From 9df5202145471522bb170de4911eaa513797d6e8 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 16 May 2026 14:38:13 +0900 Subject: [PATCH 01/26] feat(gql): align platform purchase API schema Update the GraphQL API schema for the cross-platform purchase and verification work before committing generated outputs and implementations. --- packages/gql/src/api-android.graphql | 16 ++++----- packages/gql/src/api-ios.graphql | 50 ++++++++++++++-------------- packages/gql/src/api.graphql | 28 ++++++++-------- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/packages/gql/src/api-android.graphql b/packages/gql/src/api-android.graphql index 2530e2d2..a83a04d9 100644 --- a/packages/gql/src/api-android.graphql +++ b/packages/gql/src/api-android.graphql @@ -3,13 +3,13 @@ extend type Mutation { """ Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android """ # Future acknowledgePurchaseAndroid(purchaseToken: String!): Boolean! """ Consume a consumable purchase so it can be re-bought. - See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + See: https://openiap.dev/docs/apis/android/consume-purchase-android """ # Future consumePurchaseAndroid(purchaseToken: String!): Boolean! @@ -20,7 +20,7 @@ extend type Mutation { Returns true if available, false otherwise. Throws OpenIapError.NotPrepared if billing client not ready. - See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android """ # Future checkAlternativeBillingAvailabilityAndroid: Boolean! @@ -30,7 +30,7 @@ extend type Mutation { Returns true if user accepted, false if user canceled. Throws OpenIapError.NotPrepared if billing client not ready. - See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android """ # Future showAlternativeBillingDialogAndroid: Boolean! @@ -41,7 +41,7 @@ extend type Mutation { Returns token string, or null if creation failed. Throws OpenIapError.NotPrepared if billing client not ready. - See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android """ # Future createAlternativeBillingTokenAndroid: String @@ -54,7 +54,7 @@ extend type Mutation { Available in Google Play Billing Library 8.2.0+. Returns availability result with isAvailable flag. Throws OpenIapError.NotPrepared if billing client not ready. - See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + See: https://openiap.dev/docs/apis/android/is-billing-program-available-android """ # Future isBillingProgramAvailableAndroid(program: BillingProgramAndroid!): BillingProgramAvailabilityResultAndroid! @@ -65,7 +65,7 @@ extend type Mutation { Returns external transaction token needed for reporting external transactions. Throws OpenIapError.NotPrepared if billing client not ready. - See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android """ # Future createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid!): BillingProgramReportingDetailsAndroid! @@ -76,7 +76,7 @@ extend type Mutation { Shows Play Store dialog and optionally launches external URL. Throws OpenIapError.NotPrepared if billing client not ready. - See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + See: https://openiap.dev/docs/apis/android/launch-external-link-android """ # Future launchExternalLinkAndroid(params: LaunchExternalLinkParamsAndroid!): Boolean! diff --git a/packages/gql/src/api-ios.graphql b/packages/gql/src/api-ios.graphql index a9494f2e..5492c9fb 100644 --- a/packages/gql/src/api-ios.graphql +++ b/packages/gql/src/api-ios.graphql @@ -3,20 +3,20 @@ extend type Query { """ Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + See: https://openiap.dev/docs/apis/ios/get-storefront-ios """ # Future getStorefrontIOS: String! @deprecated(reason: "Use getStorefront") """ Read the App Store-promoted product, if any (iOS 11+). - See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios """ # Future getPromotedProductIOS: ProductIOS """ Check eligibility for the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.canPresent. - See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios """ # Future canPresentExternalPurchaseNoticeIOS: Boolean! @@ -24,7 +24,7 @@ extend type Query { Check eligibility for the custom-link variant of external purchase (iOS 18.1+). Returns true if the app can use custom external purchase links. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios """ # Future isEligibleForExternalPurchaseCustomLinkIOS: Boolean! @@ -32,7 +32,7 @@ extend type Query { Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). Use this token to report transactions made through ExternalPurchaseCustomLink. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios """ # Future getExternalPurchaseCustomLinkTokenIOS( @@ -43,55 +43,55 @@ extend type Query { ): ExternalPurchaseCustomLinkTokenResultIOS! """ List unfinished StoreKit transactions in the queue. - See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios """ # Future getPendingTransactionsIOS: [PurchaseIOS!]! """ Check intro-offer eligibility for a subscription group. - See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios """ # Future isEligibleForIntroOfferIOS(groupID: String!): Boolean! """ Get subscription status objects from StoreKit 2 (iOS 15+). - See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + See: https://openiap.dev/docs/apis/ios/subscription-status-ios """ # Future subscriptionStatusIOS(sku: String!): [SubscriptionStatusIOS!]! """ Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + See: https://openiap.dev/docs/apis/ios/current-entitlement-ios """ # Future currentEntitlementIOS(sku: String!): PurchaseIOS """ Get the latest verified transaction for a product, using StoreKit 2. - See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + See: https://openiap.dev/docs/apis/ios/latest-transaction-ios """ # Future latestTransactionIOS(sku: String!): PurchaseIOS """ Check whether a transaction's JWS verification passed (StoreKit 2). - See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios """ # Future isTransactionVerifiedIOS(sku: String!): Boolean! """ Return the JWS string for a transaction (StoreKit 2). - See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios """ # Future getTransactionJwsIOS(sku: String!): String """ Get base64-encoded receipt data (legacy validation). - See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios """ # Future getReceiptDataIOS: String """ Fetch the app transaction (iOS 16+). - See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios """ # Future getAppTransactionIOS: AppTransaction @@ -100,13 +100,13 @@ extend type Query { Requires the SK2ConsumableTransactionHistory Info.plist key in the host app for finished consumables to be included (iOS 18+). Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios """ # Future getAllTransactionsIOS: [PurchaseIOS!]! """ Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + See: https://openiap.dev/docs/apis/ios/validate-receipt-ios """ # Future validateReceiptIOS(options: VerifyPurchaseProps!): VerifyPurchaseResultIOS! @deprecated(reason: "Use verifyPurchase") @@ -115,7 +115,7 @@ extend type Query { extend type Mutation { """ Clear pending transactions in the queue (sandbox helper). - See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + See: https://openiap.dev/docs/apis/ios/clear-transaction-ios """ # Future clearTransactionIOS: Boolean! @@ -125,31 +125,31 @@ extend type Mutation { @deprecated Use promotedProductListenerIOS to receive the productId, then call requestPurchase with that SKU instead. In StoreKit 2, promoted products can be purchased directly via the standard purchase flow. - See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios """ # Future requestPurchaseOnPromotedProductIOS: Boolean! @deprecated(reason: "Use promotedProductListenerIOS + requestPurchase instead") """ Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios """ # Future showManageSubscriptionsIOS: [PurchaseIOS!]! """ Present the refund request sheet (iOS 15+). See also Features → Refund. - See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios """ # Future beginRefundRequestIOS(sku: String!): String """ Force sync transactions with the App Store (iOS 15+). - See: https://www.openiap.dev/docs/apis/ios/sync-ios + See: https://openiap.dev/docs/apis/ios/sync-ios """ # Future syncIOS: Boolean! """ Show the App Store offer code redemption sheet. - See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios """ # Future presentCodeRedemptionSheetIOS: Boolean! @@ -157,13 +157,13 @@ extend type Mutation { Present the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios """ # Future presentExternalPurchaseNoticeSheetIOS: ExternalPurchaseNoticeResultIOS! """ Present an external purchase link, StoreKit External (iOS 16+). - See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios """ # Future presentExternalPurchaseLinkIOS(url: String!): ExternalPurchaseLinkResultIOS! @@ -171,7 +171,7 @@ extend type Mutation { Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). Call this after a deliberate customer interaction before linking out to external purchases. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios """ # Future showExternalPurchaseCustomLinkNoticeIOS( diff --git a/packages/gql/src/api.graphql b/packages/gql/src/api.graphql index 8c7e357b..e7f1f6a4 100644 --- a/packages/gql/src/api.graphql +++ b/packages/gql/src/api.graphql @@ -4,31 +4,31 @@ extend type Query { """ Fetch products or subscriptions from the store. - See: https://www.openiap.dev/docs/apis/fetch-products + See: https://openiap.dev/docs/apis/fetch-products """ # Future fetchProducts(params: ProductRequest!): FetchProductsResult! """ List active purchases for the current user. - See: https://www.openiap.dev/docs/apis/get-available-purchases + See: https://openiap.dev/docs/apis/get-available-purchases """ # Future getAvailablePurchases(options: PurchaseOptions): [Purchase!]! """ Get details of all currently active subscriptions (filters by subscriptionIds when provided). - See: https://www.openiap.dev/docs/apis/get-active-subscriptions + See: https://openiap.dev/docs/apis/get-active-subscriptions """ # Future getActiveSubscriptions(subscriptionIds: [String!]): [ActiveSubscription!]! """ Check whether the user has any active subscription. - See: https://www.openiap.dev/docs/apis/has-active-subscriptions + See: https://openiap.dev/docs/apis/has-active-subscriptions """ # Future hasActiveSubscriptions(subscriptionIds: [String!]): Boolean! """ Return the user's storefront country code. - See: https://www.openiap.dev/docs/apis/get-storefront + See: https://openiap.dev/docs/apis/get-storefront """ # Future getStorefront: String! @@ -38,25 +38,25 @@ extend type Query { extend type Mutation { """ Initialize the store connection. Call before any IAP API. - See: https://www.openiap.dev/docs/apis/init-connection + See: https://openiap.dev/docs/apis/init-connection """ # Future initConnection(config: InitConnectionConfig): Boolean! """ Close the store connection and release resources. - See: https://www.openiap.dev/docs/apis/end-connection + See: https://openiap.dev/docs/apis/end-connection """ # Future endConnection: Boolean! """ Initiate a purchase or subscription flow; rely on events for final state. - See: https://www.openiap.dev/docs/apis/request-purchase + See: https://openiap.dev/docs/apis/request-purchase """ # Future requestPurchase(params: RequestPurchaseProps!): RequestPurchaseResult """ Complete a transaction after server-side verification. Required on Android within 3 days. - See: https://www.openiap.dev/docs/apis/finish-transaction + See: https://openiap.dev/docs/apis/finish-transaction """ # Future finishTransaction( @@ -65,19 +65,19 @@ extend type Mutation { ): VoidResult! """ Restore non-consumable and active subscription purchases. - See: https://www.openiap.dev/docs/apis/restore-purchases + See: https://openiap.dev/docs/apis/restore-purchases """ # Future restorePurchases: VoidResult! """ Open the platform's subscription management UI. - See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + See: https://openiap.dev/docs/apis/deep-link-to-subscriptions """ # Future deepLinkToSubscriptions(options: DeepLinkOptions): VoidResult! """ Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - See: https://www.openiap.dev/docs/features/validation#verify-purchase + See: https://openiap.dev/docs/features/validation#verify-purchase """ # Future validateReceipt(options: VerifyPurchaseProps!): VerifyPurchaseResult! @deprecated(reason: "Use verifyPurchase") @@ -87,7 +87,7 @@ extend type Mutation { + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. Inspect the concrete variant before reading fields. - See: https://www.openiap.dev/docs/features/validation#verify-purchase + See: https://openiap.dev/docs/features/validation#verify-purchase """ # Future verifyPurchase(options: VerifyPurchaseProps!): VerifyPurchaseResult! @@ -95,7 +95,7 @@ extend type Mutation { Verify via a managed provider without standing up your own server. The PurchaseVerificationProvider enum currently exposes only IAPKit; platform availability may differ by implementation. - See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider """ # Future verifyPurchaseWithProvider( From b1ffaaaf716642574996604921dd97252678e2ca Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 16 May 2026 14:38:41 +0900 Subject: [PATCH 02/26] chore(gql): sync generated platform types Update codegen metadata and regenerated platform type outputs after the schema changes. --- libraries/expo-iap/src/types.ts | 94 +- .../flutter_inapp_purchase/lib/types.dart | 94 +- .../io/github/hyochan/kmpiap/openiap/Types.kt | 94 +- libraries/maui-iap/src/OpenIap.Maui/Types.cs | 94 +- libraries/react-native-iap/src/types.ts | 94 +- packages/apple/Sources/Models/Types.swift | 94 +- .../src/main/java/dev/hyo/openiap/Types.kt | 94 +- packages/gql/README.md | 2 +- packages/gql/codegen/core/parser.ts | 2 +- packages/gql/codegen/core/schema-linter.ts | 43 +- packages/gql/package-lock.json | 4646 ----------------- packages/gql/package.json | 7 +- packages/gql/src/generated/Types.cs | 94 +- packages/gql/src/generated/Types.kt | 94 +- packages/gql/src/generated/Types.swift | 94 +- packages/gql/src/generated/types.dart | 94 +- packages/gql/src/generated/types.ts | 94 +- 17 files changed, 598 insertions(+), 5230 deletions(-) delete mode 100644 packages/gql/package-lock.json diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index fc6fef35..fcce2ade 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -587,12 +587,12 @@ export interface LimitedQuantityInfoAndroid { export interface Mutation { /** * Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - * See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + * See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android */ acknowledgePurchaseAndroid: Promise; /** * Present the refund request sheet (iOS 15+). See also Features → Refund. - * See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + * See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios */ beginRefundRequestIOS?: Promise<(string | null)>; /** @@ -600,17 +600,17 @@ export interface Mutation { * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android */ checkAlternativeBillingAvailabilityAndroid: Promise; /** * Clear pending transactions in the queue (sandbox helper). - * See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + * See: https://openiap.dev/docs/apis/ios/clear-transaction-ios */ clearTransactionIOS: Promise; /** * Consume a consumable purchase so it can be re-bought. - * See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + * See: https://openiap.dev/docs/apis/android/consume-purchase-android */ consumePurchaseAndroid: Promise; /** @@ -620,7 +620,7 @@ export interface Mutation { * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android */ createAlternativeBillingTokenAndroid?: Promise<(string | null)>; /** @@ -629,27 +629,27 @@ export interface Mutation { * * Returns external transaction token needed for reporting external transactions. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ createBillingProgramReportingDetailsAndroid: Promise; /** * Open the platform's subscription management UI. - * See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + * See: https://openiap.dev/docs/apis/deep-link-to-subscriptions */ deepLinkToSubscriptions: Promise; /** * Close the store connection and release resources. - * See: https://www.openiap.dev/docs/apis/end-connection + * See: https://openiap.dev/docs/apis/end-connection */ endConnection: Promise; /** * Complete a transaction after server-side verification. Required on Android within 3 days. - * See: https://www.openiap.dev/docs/apis/finish-transaction + * See: https://openiap.dev/docs/apis/finish-transaction */ finishTransaction: Promise; /** * Initialize the store connection. Call before any IAP API. - * See: https://www.openiap.dev/docs/apis/init-connection + * See: https://openiap.dev/docs/apis/init-connection */ initConnection: Promise; /** @@ -659,7 +659,7 @@ export interface Mutation { * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + * See: https://openiap.dev/docs/apis/android/is-billing-program-available-android */ isBillingProgramAvailableAndroid: Promise; /** @@ -668,29 +668,29 @@ export interface Mutation { * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + * See: https://openiap.dev/docs/apis/android/launch-external-link-android */ launchExternalLinkAndroid: Promise; /** * Show the App Store offer code redemption sheet. - * See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios */ presentCodeRedemptionSheetIOS: Promise; /** * Present an external purchase link, StoreKit External (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios */ presentExternalPurchaseLinkIOS: Promise; /** * Present the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. * Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios */ presentExternalPurchaseNoticeSheetIOS: Promise; /** * Initiate a purchase or subscription flow; rely on events for final state. - * See: https://www.openiap.dev/docs/apis/request-purchase + * See: https://openiap.dev/docs/apis/request-purchase */ requestPurchase?: Promise<(Purchase | Purchase[] | null)>; /** @@ -699,13 +699,13 @@ export interface Mutation { * @deprecated Use promotedProductListenerIOS to receive the productId, * then call requestPurchase with that SKU instead. In StoreKit 2, * promoted products can be purchased directly via the standard purchase flow. - * See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios * @deprecated Use promotedProductListenerIOS + requestPurchase instead */ requestPurchaseOnPromotedProductIOS: Promise; /** * Restore non-consumable and active subscription purchases. - * See: https://www.openiap.dev/docs/apis/restore-purchases + * See: https://openiap.dev/docs/apis/restore-purchases */ restorePurchases: Promise; /** @@ -714,29 +714,29 @@ export interface Mutation { * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ showAlternativeBillingDialogAndroid: Promise; /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - * See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ showExternalPurchaseCustomLinkNoticeIOS: Promise; /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios */ showManageSubscriptionsIOS: Promise; /** * Force sync transactions with the App Store (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/sync-ios + * See: https://openiap.dev/docs/apis/ios/sync-ios */ syncIOS: Promise; /** * Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase * @deprecated Use verifyPurchase */ validateReceipt: Promise; @@ -746,14 +746,14 @@ export interface Mutation { * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. * Inspect the concrete variant before reading fields. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase */ verifyPurchase: Promise; /** * Verify via a managed provider without standing up your own server. The * PurchaseVerificationProvider enum currently exposes only IAPKit; platform * availability may differ by implementation. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + * See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider */ verifyPurchaseWithProvider: Promise; } @@ -1307,22 +1307,22 @@ export interface Query { /** * Check eligibility for the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.canPresent. - * See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + * See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios */ canPresentExternalPurchaseNoticeIOS: Promise; /** * Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + * See: https://openiap.dev/docs/apis/ios/current-entitlement-ios */ currentEntitlementIOS?: Promise<(PurchaseIOS | null)>; /** * Fetch products or subscriptions from the store. - * See: https://www.openiap.dev/docs/apis/fetch-products + * See: https://openiap.dev/docs/apis/fetch-products */ fetchProducts: Promise<(ProductOrSubscription[] | Product[] | ProductSubscription[] | null)>; /** * Get details of all currently active subscriptions (filters by subscriptionIds when provided). - * See: https://www.openiap.dev/docs/apis/get-active-subscriptions + * See: https://openiap.dev/docs/apis/get-active-subscriptions */ getActiveSubscriptions: Promise; /** @@ -1330,92 +1330,92 @@ export interface Query { * Requires the SK2ConsumableTransactionHistory Info.plist key in the host app * for finished consumables to be included (iOS 18+). * Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - * See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios */ getAllTransactionsIOS: Promise; /** * Fetch the app transaction (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + * See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios */ getAppTransactionIOS?: Promise<(AppTransaction | null)>; /** * List active purchases for the current user. - * See: https://www.openiap.dev/docs/apis/get-available-purchases + * See: https://openiap.dev/docs/apis/get-available-purchases */ getAvailablePurchases: Promise; /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - * See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + * See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios */ getExternalPurchaseCustomLinkTokenIOS: Promise; /** * List unfinished StoreKit transactions in the queue. - * See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios */ getPendingTransactionsIOS: Promise; /** * Read the App Store-promoted product, if any (iOS 11+). - * See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios */ getPromotedProductIOS?: Promise<(ProductIOS | null)>; /** * Get base64-encoded receipt data (legacy validation). - * See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + * See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios */ getReceiptDataIOS?: Promise<(string | null)>; /** * Return the user's storefront country code. - * See: https://www.openiap.dev/docs/apis/get-storefront + * See: https://openiap.dev/docs/apis/get-storefront */ getStorefront: Promise; /** * Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - * See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + * See: https://openiap.dev/docs/apis/ios/get-storefront-ios * @deprecated Use getStorefront */ getStorefrontIOS: Promise; /** * Return the JWS string for a transaction (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + * See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios */ getTransactionJwsIOS?: Promise<(string | null)>; /** * Check whether the user has any active subscription. - * See: https://www.openiap.dev/docs/apis/has-active-subscriptions + * See: https://openiap.dev/docs/apis/has-active-subscriptions */ hasActiveSubscriptions: Promise; /** * Check eligibility for the custom-link variant of external purchase (iOS 18.1+). * Returns true if the app can use custom external purchase links. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios */ isEligibleForExternalPurchaseCustomLinkIOS: Promise; /** * Check intro-offer eligibility for a subscription group. - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios */ isEligibleForIntroOfferIOS: Promise; /** * Check whether a transaction's JWS verification passed (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + * See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios */ isTransactionVerifiedIOS: Promise; /** * Get the latest verified transaction for a product, using StoreKit 2. - * See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + * See: https://openiap.dev/docs/apis/ios/latest-transaction-ios */ latestTransactionIOS?: Promise<(PurchaseIOS | null)>; /** * Get subscription status objects from StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + * See: https://openiap.dev/docs/apis/ios/subscription-status-ios */ subscriptionStatusIOS: Promise; /** * Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + * See: https://openiap.dev/docs/apis/ios/validate-receipt-ios * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 2558620f..2e08ac0a 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -5233,22 +5233,22 @@ sealed class VerifyPurchaseResult { /// GraphQL root mutation operations. abstract class MutationResolver { /// Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - /// See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + /// See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android Future acknowledgePurchaseAndroid(String purchaseToken); /// Present the refund request sheet (iOS 15+). See also Features → Refund. - /// See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios Future beginRefundRequestIOS(String sku); /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android Future checkAlternativeBillingAvailabilityAndroid(); /// Clear pending transactions in the queue (sandbox helper). - /// See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/clear-transaction-ios Future clearTransactionIOS(); /// Consume a consumable purchase so it can be re-bought. - /// See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + /// See: https://openiap.dev/docs/apis/android/consume-purchase-android Future consumePurchaseAndroid(String purchaseToken); /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. /// Must be called AFTER successful payment in your payment system. @@ -5256,32 +5256,32 @@ abstract class MutationResolver { /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android Future createAlternativeBillingTokenAndroid(); /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). /// Replaces the deprecated createExternalOfferReportingDetailsAsync API. /// /// Returns external transaction token needed for reporting external transactions. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android Future createBillingProgramReportingDetailsAndroid(BillingProgramAndroid program); /// Open the platform's subscription management UI. - /// See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions Future deepLinkToSubscriptions({ String? packageNameAndroid, String? skuAndroid, }); /// Close the store connection and release resources. - /// See: https://www.openiap.dev/docs/apis/end-connection + /// See: https://openiap.dev/docs/apis/end-connection Future endConnection(); /// Complete a transaction after server-side verification. Required on Android within 3 days. - /// See: https://www.openiap.dev/docs/apis/finish-transaction + /// See: https://openiap.dev/docs/apis/finish-transaction Future finishTransaction({ required PurchaseInput purchase, bool? isConsumable, }); /// Initialize the store connection. Call before any IAP API. - /// See: https://www.openiap.dev/docs/apis/init-connection + /// See: https://openiap.dev/docs/apis/init-connection Future initConnection({ AlternativeBillingModeAndroid? alternativeBillingModeAndroid, BillingProgramAndroid? enableBillingProgramAndroid, @@ -5292,14 +5292,14 @@ abstract class MutationResolver { /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android Future isBillingProgramAvailableAndroid(BillingProgramAndroid program); /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). /// Replaces the deprecated showExternalOfferInformationDialog API. /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + /// See: https://openiap.dev/docs/apis/android/launch-external-link-android Future launchExternalLinkAndroid({ required BillingProgramAndroid billingProgram, required ExternalLinkLaunchModeAndroid launchMode, @@ -5307,49 +5307,49 @@ abstract class MutationResolver { required String linkUri, }); /// Show the App Store offer code redemption sheet. - /// See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios Future presentCodeRedemptionSheetIOS(); /// Present an external purchase link, StoreKit External (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios Future presentExternalPurchaseLinkIOS(String url); /// Present the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios Future presentExternalPurchaseNoticeSheetIOS(); /// Initiate a purchase or subscription flow; rely on events for final state. - /// See: https://www.openiap.dev/docs/apis/request-purchase + /// See: https://openiap.dev/docs/apis/request-purchase Future requestPurchase(RequestPurchaseProps params); /// Buy the currently promoted product. /// /// @deprecated Use promotedProductListenerIOS to receive the productId, /// then call requestPurchase with that SKU instead. In StoreKit 2, /// promoted products can be purchased directly via the standard purchase flow. - /// See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios Future requestPurchaseOnPromotedProductIOS(); /// Restore non-consumable and active subscription purchases. - /// See: https://www.openiap.dev/docs/apis/restore-purchases + /// See: https://openiap.dev/docs/apis/restore-purchases Future restorePurchases(); /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. /// Must be called BEFORE processing payment in your payment system. /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android Future showAlternativeBillingDialogAndroid(); /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - /// See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios Future showExternalPurchaseCustomLinkNoticeIOS(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios Future> showManageSubscriptionsIOS(); /// Force sync transactions with the App Store (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/sync-ios + /// See: https://openiap.dev/docs/apis/ios/sync-ios Future syncIOS(); /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase Future validateReceipt({ VerifyPurchaseAppleOptions? apple, VerifyPurchaseGoogleOptions? google, @@ -5360,7 +5360,7 @@ abstract class MutationResolver { /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. /// Inspect the concrete variant before reading fields. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase Future verifyPurchase({ VerifyPurchaseAppleOptions? apple, VerifyPurchaseGoogleOptions? google, @@ -5369,7 +5369,7 @@ abstract class MutationResolver { /// Verify via a managed provider without standing up your own server. The /// PurchaseVerificationProvider enum currently exposes only IAPKit; platform /// availability may differ by implementation. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + /// See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider Future verifyPurchaseWithProvider({ RequestVerifyPurchaseWithIapkitProps? iapkit, required PurchaseVerificationProvider provider, @@ -5380,31 +5380,31 @@ abstract class MutationResolver { abstract class QueryResolver { /// Check eligibility for the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.canPresent. - /// See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios Future canPresentExternalPurchaseNoticeIOS(); /// Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + /// See: https://openiap.dev/docs/apis/ios/current-entitlement-ios Future currentEntitlementIOS(String sku); /// Fetch products or subscriptions from the store. - /// See: https://www.openiap.dev/docs/apis/fetch-products + /// See: https://openiap.dev/docs/apis/fetch-products Future fetchProducts({ required List skus, ProductQueryType? type, }); /// Get details of all currently active subscriptions (filters by subscriptionIds when provided). - /// See: https://www.openiap.dev/docs/apis/get-active-subscriptions + /// See: https://openiap.dev/docs/apis/get-active-subscriptions Future> getActiveSubscriptions([List? subscriptionIds]); /// List every StoreKit transaction (finished + unfinished) for the current user. /// Requires the SK2ConsumableTransactionHistory Info.plist key in the host app /// for finished consumables to be included (iOS 18+). /// Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - /// See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios Future> getAllTransactionsIOS(); /// Fetch the app transaction (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios Future getAppTransactionIOS(); /// List active purchases for the current user. - /// See: https://www.openiap.dev/docs/apis/get-available-purchases + /// See: https://openiap.dev/docs/apis/get-available-purchases Future> getAvailablePurchases({ bool? alsoPublishToEventListenerIOS, bool? includeSuspendedAndroid, @@ -5413,48 +5413,48 @@ abstract class QueryResolver { /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - /// See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios Future getExternalPurchaseCustomLinkTokenIOS(ExternalPurchaseCustomLinkTokenTypeIOS tokenType); /// List unfinished StoreKit transactions in the queue. - /// See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios Future> getPendingTransactionsIOS(); /// Read the App Store-promoted product, if any (iOS 11+). - /// See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios Future getPromotedProductIOS(); /// Get base64-encoded receipt data (legacy validation). - /// See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + /// See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios Future getReceiptDataIOS(); /// Return the user's storefront country code. - /// See: https://www.openiap.dev/docs/apis/get-storefront + /// See: https://openiap.dev/docs/apis/get-storefront Future getStorefront(); /// Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - /// See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios Future getStorefrontIOS(); /// Return the JWS string for a transaction (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios Future getTransactionJwsIOS(String sku); /// Check whether the user has any active subscription. - /// See: https://www.openiap.dev/docs/apis/has-active-subscriptions + /// See: https://openiap.dev/docs/apis/has-active-subscriptions Future hasActiveSubscriptions([List? subscriptionIds]); /// Check eligibility for the custom-link variant of external purchase (iOS 18.1+). /// Returns true if the app can use custom external purchase links. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios Future isEligibleForExternalPurchaseCustomLinkIOS(); /// Check intro-offer eligibility for a subscription group. - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios Future isEligibleForIntroOfferIOS(String groupID); /// Check whether a transaction's JWS verification passed (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios Future isTransactionVerifiedIOS(String sku); /// Get the latest verified transaction for a product, using StoreKit 2. - /// See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/latest-transaction-ios Future latestTransactionIOS(String sku); /// Get subscription status objects from StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + /// See: https://openiap.dev/docs/apis/ios/subscription-status-ios Future> subscriptionStatusIOS(String sku); /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios Future validateReceiptIOS({ VerifyPurchaseAppleOptions? apple, VerifyPurchaseGoogleOptions? google, 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 49f8de1d..92447dfe 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 @@ -5273,12 +5273,12 @@ public sealed interface VerifyPurchaseResult { public interface MutationResolver { /** * Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - * See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + * See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android */ suspend fun acknowledgePurchaseAndroid(purchaseToken: String): Boolean /** * Present the refund request sheet (iOS 15+). See also Features → Refund. - * See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + * See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios */ suspend fun beginRefundRequestIOS(sku: String): String? /** @@ -5286,17 +5286,17 @@ public interface MutationResolver { * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android */ suspend fun checkAlternativeBillingAvailabilityAndroid(): Boolean /** * Clear pending transactions in the queue (sandbox helper). - * See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + * See: https://openiap.dev/docs/apis/ios/clear-transaction-ios */ suspend fun clearTransactionIOS(): Boolean /** * Consume a consumable purchase so it can be re-bought. - * See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + * See: https://openiap.dev/docs/apis/android/consume-purchase-android */ suspend fun consumePurchaseAndroid(purchaseToken: String): Boolean /** @@ -5306,7 +5306,7 @@ public interface MutationResolver { * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android */ suspend fun createAlternativeBillingTokenAndroid(): String? /** @@ -5315,27 +5315,27 @@ public interface MutationResolver { * * Returns external transaction token needed for reporting external transactions. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ suspend fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid /** * Open the platform's subscription management UI. - * See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + * See: https://openiap.dev/docs/apis/deep-link-to-subscriptions */ suspend fun deepLinkToSubscriptions(options: DeepLinkOptions? = null): Unit /** * Close the store connection and release resources. - * See: https://www.openiap.dev/docs/apis/end-connection + * See: https://openiap.dev/docs/apis/end-connection */ suspend fun endConnection(): Boolean /** * Complete a transaction after server-side verification. Required on Android within 3 days. - * See: https://www.openiap.dev/docs/apis/finish-transaction + * See: https://openiap.dev/docs/apis/finish-transaction */ suspend fun finishTransaction(purchase: PurchaseInput, isConsumable: Boolean? = null): Unit /** * Initialize the store connection. Call before any IAP API. - * See: https://www.openiap.dev/docs/apis/init-connection + * See: https://openiap.dev/docs/apis/init-connection */ suspend fun initConnection(config: InitConnectionConfig? = null): Boolean /** @@ -5345,7 +5345,7 @@ public interface MutationResolver { * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + * See: https://openiap.dev/docs/apis/android/is-billing-program-available-android */ suspend fun isBillingProgramAvailableAndroid(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid /** @@ -5354,29 +5354,29 @@ public interface MutationResolver { * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + * See: https://openiap.dev/docs/apis/android/launch-external-link-android */ suspend fun launchExternalLinkAndroid(params: LaunchExternalLinkParamsAndroid): Boolean /** * Show the App Store offer code redemption sheet. - * See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios */ suspend fun presentCodeRedemptionSheetIOS(): Boolean /** * Present an external purchase link, StoreKit External (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios */ suspend fun presentExternalPurchaseLinkIOS(url: String): ExternalPurchaseLinkResultIOS /** * Present the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. * Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios */ suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS /** * Initiate a purchase or subscription flow; rely on events for final state. - * See: https://www.openiap.dev/docs/apis/request-purchase + * See: https://openiap.dev/docs/apis/request-purchase */ suspend fun requestPurchase(params: RequestPurchaseProps): RequestPurchaseResult? /** @@ -5385,12 +5385,12 @@ public interface MutationResolver { * @deprecated Use promotedProductListenerIOS to receive the productId, * then call requestPurchase with that SKU instead. In StoreKit 2, * promoted products can be purchased directly via the standard purchase flow. - * See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios */ suspend fun requestPurchaseOnPromotedProductIOS(): Boolean /** * Restore non-consumable and active subscription purchases. - * See: https://www.openiap.dev/docs/apis/restore-purchases + * See: https://openiap.dev/docs/apis/restore-purchases */ suspend fun restorePurchases(): Unit /** @@ -5399,29 +5399,29 @@ public interface MutationResolver { * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ suspend fun showAlternativeBillingDialogAndroid(): Boolean /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - * See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ suspend fun showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS): ExternalPurchaseCustomLinkNoticeResultIOS /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios */ suspend fun showManageSubscriptionsIOS(): List /** * Force sync transactions with the App Store (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/sync-ios + * See: https://openiap.dev/docs/apis/ios/sync-ios */ suspend fun syncIOS(): Boolean /** * Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase */ suspend fun validateReceipt(options: VerifyPurchaseProps): VerifyPurchaseResult /** @@ -5430,14 +5430,14 @@ public interface MutationResolver { * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. * Inspect the concrete variant before reading fields. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase */ suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult /** * Verify via a managed provider without standing up your own server. The * PurchaseVerificationProvider enum currently exposes only IAPKit; platform * availability may differ by implementation. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + * See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider */ suspend fun verifyPurchaseWithProvider(options: VerifyPurchaseWithProviderProps): VerifyPurchaseWithProviderResult } @@ -5449,22 +5449,22 @@ public interface QueryResolver { /** * Check eligibility for the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.canPresent. - * See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + * See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios */ suspend fun canPresentExternalPurchaseNoticeIOS(): Boolean /** * Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + * See: https://openiap.dev/docs/apis/ios/current-entitlement-ios */ suspend fun currentEntitlementIOS(sku: String): PurchaseIOS? /** * Fetch products or subscriptions from the store. - * See: https://www.openiap.dev/docs/apis/fetch-products + * See: https://openiap.dev/docs/apis/fetch-products */ suspend fun fetchProducts(params: ProductRequest): FetchProductsResult /** * Get details of all currently active subscriptions (filters by subscriptionIds when provided). - * See: https://www.openiap.dev/docs/apis/get-active-subscriptions + * See: https://openiap.dev/docs/apis/get-active-subscriptions */ suspend fun getActiveSubscriptions(subscriptionIds: List? = null): List /** @@ -5472,91 +5472,91 @@ public interface QueryResolver { * Requires the SK2ConsumableTransactionHistory Info.plist key in the host app * for finished consumables to be included (iOS 18+). * Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - * See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios */ suspend fun getAllTransactionsIOS(): List /** * Fetch the app transaction (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + * See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios */ suspend fun getAppTransactionIOS(): AppTransaction? /** * List active purchases for the current user. - * See: https://www.openiap.dev/docs/apis/get-available-purchases + * See: https://openiap.dev/docs/apis/get-available-purchases */ suspend fun getAvailablePurchases(options: PurchaseOptions? = null): List /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - * See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + * See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios */ suspend fun getExternalPurchaseCustomLinkTokenIOS(tokenType: ExternalPurchaseCustomLinkTokenTypeIOS): ExternalPurchaseCustomLinkTokenResultIOS /** * List unfinished StoreKit transactions in the queue. - * See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios */ suspend fun getPendingTransactionsIOS(): List /** * Read the App Store-promoted product, if any (iOS 11+). - * See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios */ suspend fun getPromotedProductIOS(): ProductIOS? /** * Get base64-encoded receipt data (legacy validation). - * See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + * See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios */ suspend fun getReceiptDataIOS(): String? /** * Return the user's storefront country code. - * See: https://www.openiap.dev/docs/apis/get-storefront + * See: https://openiap.dev/docs/apis/get-storefront */ suspend fun getStorefront(): String /** * Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - * See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + * See: https://openiap.dev/docs/apis/ios/get-storefront-ios */ suspend fun getStorefrontIOS(): String /** * Return the JWS string for a transaction (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + * See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios */ suspend fun getTransactionJwsIOS(sku: String): String? /** * Check whether the user has any active subscription. - * See: https://www.openiap.dev/docs/apis/has-active-subscriptions + * See: https://openiap.dev/docs/apis/has-active-subscriptions */ suspend fun hasActiveSubscriptions(subscriptionIds: List? = null): Boolean /** * Check eligibility for the custom-link variant of external purchase (iOS 18.1+). * Returns true if the app can use custom external purchase links. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios */ suspend fun isEligibleForExternalPurchaseCustomLinkIOS(): Boolean /** * Check intro-offer eligibility for a subscription group. - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios */ suspend fun isEligibleForIntroOfferIOS(groupID: String): Boolean /** * Check whether a transaction's JWS verification passed (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + * See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios */ suspend fun isTransactionVerifiedIOS(sku: String): Boolean /** * Get the latest verified transaction for a product, using StoreKit 2. - * See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + * See: https://openiap.dev/docs/apis/ios/latest-transaction-ios */ suspend fun latestTransactionIOS(sku: String): PurchaseIOS? /** * Get subscription status objects from StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + * See: https://openiap.dev/docs/apis/ios/subscription-status-ios */ suspend fun subscriptionStatusIOS(sku: String): List /** * Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + * See: https://openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS } diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index e89111dd..6c5ea212 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -4100,26 +4100,26 @@ public sealed record WinBackOfferInputIOS public interface MutationResolver { /// Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - /// See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + /// See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android Task AcknowledgePurchaseAndroidAsync(string purchaseToken); /// Present the refund request sheet (iOS 15+). See also Features → Refund. - /// See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios Task BeginRefundRequestIOSAsync(string sku); /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android Task CheckAlternativeBillingAvailabilityAndroidAsync(); /// Clear pending transactions in the queue (sandbox helper). - /// See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/clear-transaction-ios Task ClearTransactionIOSAsync(); /// Consume a consumable purchase so it can be re-bought. - /// See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + /// See: https://openiap.dev/docs/apis/android/consume-purchase-android Task ConsumePurchaseAndroidAsync(string purchaseToken); /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. @@ -4128,7 +4128,7 @@ public interface MutationResolver /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android Task CreateAlternativeBillingTokenAndroidAsync(); /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). @@ -4136,23 +4136,23 @@ public interface MutationResolver /// /// Returns external transaction token needed for reporting external transactions. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android Task CreateBillingProgramReportingDetailsAndroidAsync(BillingProgramAndroid program); /// Open the platform's subscription management UI. - /// See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions Task DeepLinkToSubscriptionsAsync(DeepLinkOptions? options = null); /// Close the store connection and release resources. - /// See: https://www.openiap.dev/docs/apis/end-connection + /// See: https://openiap.dev/docs/apis/end-connection Task EndConnectionAsync(); /// Complete a transaction after server-side verification. Required on Android within 3 days. - /// See: https://www.openiap.dev/docs/apis/finish-transaction + /// See: https://openiap.dev/docs/apis/finish-transaction Task FinishTransactionAsync(PurchaseInput purchase, bool? isConsumable = null); /// Initialize the store connection. Call before any IAP API. - /// See: https://www.openiap.dev/docs/apis/init-connection + /// See: https://openiap.dev/docs/apis/init-connection Task InitConnectionAsync(InitConnectionConfig? config = null); /// Check whether a billing program (e.g., External Payments) is available for the current user. @@ -4161,7 +4161,7 @@ public interface MutationResolver /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android Task IsBillingProgramAvailableAndroidAsync(BillingProgramAndroid program); /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). @@ -4169,25 +4169,25 @@ public interface MutationResolver /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + /// See: https://openiap.dev/docs/apis/android/launch-external-link-android Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid @params); /// Show the App Store offer code redemption sheet. - /// See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios Task PresentCodeRedemptionSheetIOSAsync(); /// Present an external purchase link, StoreKit External (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios Task PresentExternalPurchaseLinkIOSAsync(string url); /// Present the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios Task PresentExternalPurchaseNoticeSheetIOSAsync(); /// Initiate a purchase or subscription flow; rely on events for final state. - /// See: https://www.openiap.dev/docs/apis/request-purchase + /// See: https://openiap.dev/docs/apis/request-purchase Task RequestPurchaseAsync(RequestPurchaseProps @params); /// Buy the currently promoted product. @@ -4195,11 +4195,11 @@ public interface MutationResolver /// @deprecated Use promotedProductListenerIOS to receive the productId, /// then call requestPurchase with that SKU instead. In StoreKit 2, /// promoted products can be purchased directly via the standard purchase flow. - /// See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios Task RequestPurchaseOnPromotedProductIOSAsync(); /// Restore non-consumable and active subscription purchases. - /// See: https://www.openiap.dev/docs/apis/restore-purchases + /// See: https://openiap.dev/docs/apis/restore-purchases Task RestorePurchasesAsync(); /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. @@ -4207,25 +4207,25 @@ public interface MutationResolver /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android Task ShowAlternativeBillingDialogAndroidAsync(); /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - /// See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios Task ShowExternalPurchaseCustomLinkNoticeIOSAsync(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios Task> ShowManageSubscriptionsIOSAsync(); /// Force sync transactions with the App Store (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/sync-ios + /// See: https://openiap.dev/docs/apis/ios/sync-ios Task SyncIOSAsync(); /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase Task ValidateReceiptAsync(VerifyPurchaseProps options); /// Verify a purchase against your own backend. Returns a platform-specific @@ -4233,13 +4233,13 @@ public interface MutationResolver /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. /// Inspect the concrete variant before reading fields. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase Task VerifyPurchaseAsync(VerifyPurchaseProps options); /// Verify via a managed provider without standing up your own server. The /// PurchaseVerificationProvider enum currently exposes only IAPKit; platform /// availability may differ by implementation. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + /// See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider Task VerifyPurchaseWithProviderAsync(VerifyPurchaseWithProviderProps options); } @@ -4248,94 +4248,94 @@ public interface QueryResolver { /// Check eligibility for the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.canPresent. - /// See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios Task CanPresentExternalPurchaseNoticeIOSAsync(); /// Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + /// See: https://openiap.dev/docs/apis/ios/current-entitlement-ios Task CurrentEntitlementIOSAsync(string sku); /// Fetch products or subscriptions from the store. - /// See: https://www.openiap.dev/docs/apis/fetch-products + /// See: https://openiap.dev/docs/apis/fetch-products Task FetchProductsAsync(ProductRequest @params); /// Get details of all currently active subscriptions (filters by subscriptionIds when provided). - /// See: https://www.openiap.dev/docs/apis/get-active-subscriptions + /// See: https://openiap.dev/docs/apis/get-active-subscriptions Task> GetActiveSubscriptionsAsync(IReadOnlyList? subscriptionIds = null); /// List every StoreKit transaction (finished + unfinished) for the current user. /// Requires the SK2ConsumableTransactionHistory Info.plist key in the host app /// for finished consumables to be included (iOS 18+). /// Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - /// See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios Task> GetAllTransactionsIOSAsync(); /// Fetch the app transaction (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios Task GetAppTransactionIOSAsync(); /// List active purchases for the current user. - /// See: https://www.openiap.dev/docs/apis/get-available-purchases + /// See: https://openiap.dev/docs/apis/get-available-purchases Task> GetAvailablePurchasesAsync(PurchaseOptions? options = null); /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - /// See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios Task GetExternalPurchaseCustomLinkTokenIOSAsync(ExternalPurchaseCustomLinkTokenTypeIOS tokenType); /// List unfinished StoreKit transactions in the queue. - /// See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios Task> GetPendingTransactionsIOSAsync(); /// Read the App Store-promoted product, if any (iOS 11+). - /// See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios Task GetPromotedProductIOSAsync(); /// Get base64-encoded receipt data (legacy validation). - /// See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + /// See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios Task GetReceiptDataIOSAsync(); /// Return the user's storefront country code. - /// See: https://www.openiap.dev/docs/apis/get-storefront + /// See: https://openiap.dev/docs/apis/get-storefront Task GetStorefrontAsync(); /// Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - /// See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios Task GetStorefrontIOSAsync(); /// Return the JWS string for a transaction (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios Task GetTransactionJwsIOSAsync(string sku); /// Check whether the user has any active subscription. - /// See: https://www.openiap.dev/docs/apis/has-active-subscriptions + /// See: https://openiap.dev/docs/apis/has-active-subscriptions Task HasActiveSubscriptionsAsync(IReadOnlyList? subscriptionIds = null); /// Check eligibility for the custom-link variant of external purchase (iOS 18.1+). /// Returns true if the app can use custom external purchase links. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios Task IsEligibleForExternalPurchaseCustomLinkIOSAsync(); /// Check intro-offer eligibility for a subscription group. - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios Task IsEligibleForIntroOfferIOSAsync(string groupId); /// Check whether a transaction's JWS verification passed (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios Task IsTransactionVerifiedIOSAsync(string sku); /// Get the latest verified transaction for a product, using StoreKit 2. - /// See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/latest-transaction-ios Task LatestTransactionIOSAsync(string sku); /// Get subscription status objects from StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + /// See: https://openiap.dev/docs/apis/ios/subscription-status-ios Task> SubscriptionStatusIOSAsync(string sku); /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios Task ValidateReceiptIOSAsync(VerifyPurchaseProps options); } diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index fc6fef35..fcce2ade 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -587,12 +587,12 @@ export interface LimitedQuantityInfoAndroid { export interface Mutation { /** * Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - * See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + * See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android */ acknowledgePurchaseAndroid: Promise; /** * Present the refund request sheet (iOS 15+). See also Features → Refund. - * See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + * See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios */ beginRefundRequestIOS?: Promise<(string | null)>; /** @@ -600,17 +600,17 @@ export interface Mutation { * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android */ checkAlternativeBillingAvailabilityAndroid: Promise; /** * Clear pending transactions in the queue (sandbox helper). - * See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + * See: https://openiap.dev/docs/apis/ios/clear-transaction-ios */ clearTransactionIOS: Promise; /** * Consume a consumable purchase so it can be re-bought. - * See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + * See: https://openiap.dev/docs/apis/android/consume-purchase-android */ consumePurchaseAndroid: Promise; /** @@ -620,7 +620,7 @@ export interface Mutation { * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android */ createAlternativeBillingTokenAndroid?: Promise<(string | null)>; /** @@ -629,27 +629,27 @@ export interface Mutation { * * Returns external transaction token needed for reporting external transactions. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ createBillingProgramReportingDetailsAndroid: Promise; /** * Open the platform's subscription management UI. - * See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + * See: https://openiap.dev/docs/apis/deep-link-to-subscriptions */ deepLinkToSubscriptions: Promise; /** * Close the store connection and release resources. - * See: https://www.openiap.dev/docs/apis/end-connection + * See: https://openiap.dev/docs/apis/end-connection */ endConnection: Promise; /** * Complete a transaction after server-side verification. Required on Android within 3 days. - * See: https://www.openiap.dev/docs/apis/finish-transaction + * See: https://openiap.dev/docs/apis/finish-transaction */ finishTransaction: Promise; /** * Initialize the store connection. Call before any IAP API. - * See: https://www.openiap.dev/docs/apis/init-connection + * See: https://openiap.dev/docs/apis/init-connection */ initConnection: Promise; /** @@ -659,7 +659,7 @@ export interface Mutation { * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + * See: https://openiap.dev/docs/apis/android/is-billing-program-available-android */ isBillingProgramAvailableAndroid: Promise; /** @@ -668,29 +668,29 @@ export interface Mutation { * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + * See: https://openiap.dev/docs/apis/android/launch-external-link-android */ launchExternalLinkAndroid: Promise; /** * Show the App Store offer code redemption sheet. - * See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios */ presentCodeRedemptionSheetIOS: Promise; /** * Present an external purchase link, StoreKit External (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios */ presentExternalPurchaseLinkIOS: Promise; /** * Present the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. * Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios */ presentExternalPurchaseNoticeSheetIOS: Promise; /** * Initiate a purchase or subscription flow; rely on events for final state. - * See: https://www.openiap.dev/docs/apis/request-purchase + * See: https://openiap.dev/docs/apis/request-purchase */ requestPurchase?: Promise<(Purchase | Purchase[] | null)>; /** @@ -699,13 +699,13 @@ export interface Mutation { * @deprecated Use promotedProductListenerIOS to receive the productId, * then call requestPurchase with that SKU instead. In StoreKit 2, * promoted products can be purchased directly via the standard purchase flow. - * See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios * @deprecated Use promotedProductListenerIOS + requestPurchase instead */ requestPurchaseOnPromotedProductIOS: Promise; /** * Restore non-consumable and active subscription purchases. - * See: https://www.openiap.dev/docs/apis/restore-purchases + * See: https://openiap.dev/docs/apis/restore-purchases */ restorePurchases: Promise; /** @@ -714,29 +714,29 @@ export interface Mutation { * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ showAlternativeBillingDialogAndroid: Promise; /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - * See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ showExternalPurchaseCustomLinkNoticeIOS: Promise; /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios */ showManageSubscriptionsIOS: Promise; /** * Force sync transactions with the App Store (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/sync-ios + * See: https://openiap.dev/docs/apis/ios/sync-ios */ syncIOS: Promise; /** * Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase * @deprecated Use verifyPurchase */ validateReceipt: Promise; @@ -746,14 +746,14 @@ export interface Mutation { * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. * Inspect the concrete variant before reading fields. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase */ verifyPurchase: Promise; /** * Verify via a managed provider without standing up your own server. The * PurchaseVerificationProvider enum currently exposes only IAPKit; platform * availability may differ by implementation. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + * See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider */ verifyPurchaseWithProvider: Promise; } @@ -1307,22 +1307,22 @@ export interface Query { /** * Check eligibility for the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.canPresent. - * See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + * See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios */ canPresentExternalPurchaseNoticeIOS: Promise; /** * Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + * See: https://openiap.dev/docs/apis/ios/current-entitlement-ios */ currentEntitlementIOS?: Promise<(PurchaseIOS | null)>; /** * Fetch products or subscriptions from the store. - * See: https://www.openiap.dev/docs/apis/fetch-products + * See: https://openiap.dev/docs/apis/fetch-products */ fetchProducts: Promise<(ProductOrSubscription[] | Product[] | ProductSubscription[] | null)>; /** * Get details of all currently active subscriptions (filters by subscriptionIds when provided). - * See: https://www.openiap.dev/docs/apis/get-active-subscriptions + * See: https://openiap.dev/docs/apis/get-active-subscriptions */ getActiveSubscriptions: Promise; /** @@ -1330,92 +1330,92 @@ export interface Query { * Requires the SK2ConsumableTransactionHistory Info.plist key in the host app * for finished consumables to be included (iOS 18+). * Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - * See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios */ getAllTransactionsIOS: Promise; /** * Fetch the app transaction (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + * See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios */ getAppTransactionIOS?: Promise<(AppTransaction | null)>; /** * List active purchases for the current user. - * See: https://www.openiap.dev/docs/apis/get-available-purchases + * See: https://openiap.dev/docs/apis/get-available-purchases */ getAvailablePurchases: Promise; /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - * See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + * See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios */ getExternalPurchaseCustomLinkTokenIOS: Promise; /** * List unfinished StoreKit transactions in the queue. - * See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios */ getPendingTransactionsIOS: Promise; /** * Read the App Store-promoted product, if any (iOS 11+). - * See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios */ getPromotedProductIOS?: Promise<(ProductIOS | null)>; /** * Get base64-encoded receipt data (legacy validation). - * See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + * See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios */ getReceiptDataIOS?: Promise<(string | null)>; /** * Return the user's storefront country code. - * See: https://www.openiap.dev/docs/apis/get-storefront + * See: https://openiap.dev/docs/apis/get-storefront */ getStorefront: Promise; /** * Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - * See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + * See: https://openiap.dev/docs/apis/ios/get-storefront-ios * @deprecated Use getStorefront */ getStorefrontIOS: Promise; /** * Return the JWS string for a transaction (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + * See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios */ getTransactionJwsIOS?: Promise<(string | null)>; /** * Check whether the user has any active subscription. - * See: https://www.openiap.dev/docs/apis/has-active-subscriptions + * See: https://openiap.dev/docs/apis/has-active-subscriptions */ hasActiveSubscriptions: Promise; /** * Check eligibility for the custom-link variant of external purchase (iOS 18.1+). * Returns true if the app can use custom external purchase links. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios */ isEligibleForExternalPurchaseCustomLinkIOS: Promise; /** * Check intro-offer eligibility for a subscription group. - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios */ isEligibleForIntroOfferIOS: Promise; /** * Check whether a transaction's JWS verification passed (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + * See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios */ isTransactionVerifiedIOS: Promise; /** * Get the latest verified transaction for a product, using StoreKit 2. - * See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + * See: https://openiap.dev/docs/apis/ios/latest-transaction-ios */ latestTransactionIOS?: Promise<(PurchaseIOS | null)>; /** * Get subscription status objects from StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + * See: https://openiap.dev/docs/apis/ios/subscription-status-ios */ subscriptionStatusIOS: Promise; /** * Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + * See: https://openiap.dev/docs/apis/ios/validate-receipt-ios * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 8cde48d8..b1ac4d36 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -2502,22 +2502,22 @@ public enum VerifyPurchaseResult: Codable { /// GraphQL root mutation operations. public protocol MutationResolver { /// Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - /// See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + /// See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android func acknowledgePurchaseAndroid(_ purchaseToken: String) async throws -> Bool /// Present the refund request sheet (iOS 15+). See also Features → Refund. - /// See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios func beginRefundRequestIOS(_ sku: String) async throws -> String? /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android func checkAlternativeBillingAvailabilityAndroid() async throws -> Bool /// Clear pending transactions in the queue (sandbox helper). - /// See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/clear-transaction-ios func clearTransactionIOS() async throws -> Bool /// Consume a consumable purchase so it can be re-bought. - /// See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + /// See: https://openiap.dev/docs/apis/android/consume-purchase-android func consumePurchaseAndroid(_ purchaseToken: String) async throws -> Bool /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. /// Must be called AFTER successful payment in your payment system. @@ -2525,26 +2525,26 @@ public protocol MutationResolver { /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android func createAlternativeBillingTokenAndroid() async throws -> String? /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). /// Replaces the deprecated createExternalOfferReportingDetailsAsync API. /// /// Returns external transaction token needed for reporting external transactions. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android func createBillingProgramReportingDetailsAndroid(_ program: BillingProgramAndroid) async throws -> BillingProgramReportingDetailsAndroid /// Open the platform's subscription management UI. - /// See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void /// Close the store connection and release resources. - /// See: https://www.openiap.dev/docs/apis/end-connection + /// See: https://openiap.dev/docs/apis/end-connection func endConnection() async throws -> Bool /// Complete a transaction after server-side verification. Required on Android within 3 days. - /// See: https://www.openiap.dev/docs/apis/finish-transaction + /// See: https://openiap.dev/docs/apis/finish-transaction func finishTransaction(purchase: PurchaseInput, isConsumable: Bool?) async throws -> Void /// Initialize the store connection. Call before any IAP API. - /// See: https://www.openiap.dev/docs/apis/init-connection + /// See: https://openiap.dev/docs/apis/init-connection func initConnection(_ config: InitConnectionConfig?) async throws -> Bool /// Check whether a billing program (e.g., External Payments) is available for the current user. /// Replaces the deprecated isExternalOfferAvailableAsync API. @@ -2552,71 +2552,71 @@ public protocol MutationResolver { /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android func isBillingProgramAvailableAndroid(_ program: BillingProgramAndroid) async throws -> BillingProgramAvailabilityResultAndroid /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). /// Replaces the deprecated showExternalOfferInformationDialog API. /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + /// See: https://openiap.dev/docs/apis/android/launch-external-link-android func launchExternalLinkAndroid(_ params: LaunchExternalLinkParamsAndroid) async throws -> Bool /// Show the App Store offer code redemption sheet. - /// See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios func presentCodeRedemptionSheetIOS() async throws -> Bool /// Present an external purchase link, StoreKit External (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS /// Present the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS /// Initiate a purchase or subscription flow; rely on events for final state. - /// See: https://www.openiap.dev/docs/apis/request-purchase + /// See: https://openiap.dev/docs/apis/request-purchase func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? /// Buy the currently promoted product. /// /// @deprecated Use promotedProductListenerIOS to receive the productId, /// then call requestPurchase with that SKU instead. In StoreKit 2, /// promoted products can be purchased directly via the standard purchase flow. - /// See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios func requestPurchaseOnPromotedProductIOS() async throws -> Bool /// Restore non-consumable and active subscription purchases. - /// See: https://www.openiap.dev/docs/apis/restore-purchases + /// See: https://openiap.dev/docs/apis/restore-purchases func restorePurchases() async throws -> Void /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. /// Must be called BEFORE processing payment in your payment system. /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android func showAlternativeBillingDialogAndroid() async throws -> Bool /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - /// See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios func showExternalPurchaseCustomLinkNoticeIOS(_ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios func showManageSubscriptionsIOS() async throws -> [PurchaseIOS] /// Force sync transactions with the App Store (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/sync-ios + /// See: https://openiap.dev/docs/apis/ios/sync-ios func syncIOS() async throws -> Bool /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase func validateReceipt(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult /// Verify a purchase against your own backend. Returns a platform-specific /// variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. /// Inspect the concrete variant before reading fields. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase func verifyPurchase(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult /// Verify via a managed provider without standing up your own server. The /// PurchaseVerificationProvider enum currently exposes only IAPKit; platform /// availability may differ by implementation. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + /// See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider func verifyPurchaseWithProvider(_ options: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult } @@ -2624,74 +2624,74 @@ public protocol MutationResolver { public protocol QueryResolver { /// Check eligibility for the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.canPresent. - /// See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios func canPresentExternalPurchaseNoticeIOS() async throws -> Bool /// Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + /// See: https://openiap.dev/docs/apis/ios/current-entitlement-ios func currentEntitlementIOS(_ sku: String) async throws -> PurchaseIOS? /// Fetch products or subscriptions from the store. - /// See: https://www.openiap.dev/docs/apis/fetch-products + /// See: https://openiap.dev/docs/apis/fetch-products func fetchProducts(_ params: ProductRequest) async throws -> FetchProductsResult /// Get details of all currently active subscriptions (filters by subscriptionIds when provided). - /// See: https://www.openiap.dev/docs/apis/get-active-subscriptions + /// See: https://openiap.dev/docs/apis/get-active-subscriptions func getActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> [ActiveSubscription] /// List every StoreKit transaction (finished + unfinished) for the current user. /// Requires the SK2ConsumableTransactionHistory Info.plist key in the host app /// for finished consumables to be included (iOS 18+). /// Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - /// See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios func getAllTransactionsIOS() async throws -> [PurchaseIOS] /// Fetch the app transaction (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios func getAppTransactionIOS() async throws -> AppTransaction? /// List active purchases for the current user. - /// See: https://www.openiap.dev/docs/apis/get-available-purchases + /// See: https://openiap.dev/docs/apis/get-available-purchases func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [Purchase] /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - /// See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios func getExternalPurchaseCustomLinkTokenIOS(_ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) async throws -> ExternalPurchaseCustomLinkTokenResultIOS /// List unfinished StoreKit transactions in the queue. - /// See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios func getPendingTransactionsIOS() async throws -> [PurchaseIOS] /// Read the App Store-promoted product, if any (iOS 11+). - /// See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios func getPromotedProductIOS() async throws -> ProductIOS? /// Get base64-encoded receipt data (legacy validation). - /// See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + /// See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios func getReceiptDataIOS() async throws -> String? /// Return the user's storefront country code. - /// See: https://www.openiap.dev/docs/apis/get-storefront + /// See: https://openiap.dev/docs/apis/get-storefront func getStorefront() async throws -> String /// Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - /// See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios func getStorefrontIOS() async throws -> String /// Return the JWS string for a transaction (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios func getTransactionJwsIOS(_ sku: String) async throws -> String? /// Check whether the user has any active subscription. - /// See: https://www.openiap.dev/docs/apis/has-active-subscriptions + /// See: https://openiap.dev/docs/apis/has-active-subscriptions func hasActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> Bool /// Check eligibility for the custom-link variant of external purchase (iOS 18.1+). /// Returns true if the app can use custom external purchase links. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool /// Check intro-offer eligibility for a subscription group. - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios func isEligibleForIntroOfferIOS(_ groupID: String) async throws -> Bool /// Check whether a transaction's JWS verification passed (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios func isTransactionVerifiedIOS(_ sku: String) async throws -> Bool /// Get the latest verified transaction for a product, using StoreKit 2. - /// See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/latest-transaction-ios func latestTransactionIOS(_ sku: String) async throws -> PurchaseIOS? /// Get subscription status objects from StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + /// See: https://openiap.dev/docs/apis/ios/subscription-status-ios func subscriptionStatusIOS(_ sku: String) async throws -> [SubscriptionStatusIOS] /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios func validateReceiptIOS(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS } 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 58192905..2e95f006 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 @@ -5152,12 +5152,12 @@ public sealed interface VerifyPurchaseResult { public interface MutationResolver { /** * Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - * See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + * See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android */ suspend fun acknowledgePurchaseAndroid(purchaseToken: String): Boolean /** * Present the refund request sheet (iOS 15+). See also Features → Refund. - * See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + * See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios */ suspend fun beginRefundRequestIOS(sku: String): String? /** @@ -5165,17 +5165,17 @@ public interface MutationResolver { * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android */ suspend fun checkAlternativeBillingAvailabilityAndroid(): Boolean /** * Clear pending transactions in the queue (sandbox helper). - * See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + * See: https://openiap.dev/docs/apis/ios/clear-transaction-ios */ suspend fun clearTransactionIOS(): Boolean /** * Consume a consumable purchase so it can be re-bought. - * See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + * See: https://openiap.dev/docs/apis/android/consume-purchase-android */ suspend fun consumePurchaseAndroid(purchaseToken: String): Boolean /** @@ -5185,7 +5185,7 @@ public interface MutationResolver { * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android */ suspend fun createAlternativeBillingTokenAndroid(): String? /** @@ -5194,27 +5194,27 @@ public interface MutationResolver { * * Returns external transaction token needed for reporting external transactions. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ suspend fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid /** * Open the platform's subscription management UI. - * See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + * See: https://openiap.dev/docs/apis/deep-link-to-subscriptions */ suspend fun deepLinkToSubscriptions(options: DeepLinkOptions? = null): Unit /** * Close the store connection and release resources. - * See: https://www.openiap.dev/docs/apis/end-connection + * See: https://openiap.dev/docs/apis/end-connection */ suspend fun endConnection(): Boolean /** * Complete a transaction after server-side verification. Required on Android within 3 days. - * See: https://www.openiap.dev/docs/apis/finish-transaction + * See: https://openiap.dev/docs/apis/finish-transaction */ suspend fun finishTransaction(purchase: PurchaseInput, isConsumable: Boolean? = null): Unit /** * Initialize the store connection. Call before any IAP API. - * See: https://www.openiap.dev/docs/apis/init-connection + * See: https://openiap.dev/docs/apis/init-connection */ suspend fun initConnection(config: InitConnectionConfig? = null): Boolean /** @@ -5224,7 +5224,7 @@ public interface MutationResolver { * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + * See: https://openiap.dev/docs/apis/android/is-billing-program-available-android */ suspend fun isBillingProgramAvailableAndroid(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid /** @@ -5233,29 +5233,29 @@ public interface MutationResolver { * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + * See: https://openiap.dev/docs/apis/android/launch-external-link-android */ suspend fun launchExternalLinkAndroid(params: LaunchExternalLinkParamsAndroid): Boolean /** * Show the App Store offer code redemption sheet. - * See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios */ suspend fun presentCodeRedemptionSheetIOS(): Boolean /** * Present an external purchase link, StoreKit External (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios */ suspend fun presentExternalPurchaseLinkIOS(url: String): ExternalPurchaseLinkResultIOS /** * Present the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. * Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios */ suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS /** * Initiate a purchase or subscription flow; rely on events for final state. - * See: https://www.openiap.dev/docs/apis/request-purchase + * See: https://openiap.dev/docs/apis/request-purchase */ suspend fun requestPurchase(params: RequestPurchaseProps): RequestPurchaseResult? /** @@ -5264,12 +5264,12 @@ public interface MutationResolver { * @deprecated Use promotedProductListenerIOS to receive the productId, * then call requestPurchase with that SKU instead. In StoreKit 2, * promoted products can be purchased directly via the standard purchase flow. - * See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios */ suspend fun requestPurchaseOnPromotedProductIOS(): Boolean /** * Restore non-consumable and active subscription purchases. - * See: https://www.openiap.dev/docs/apis/restore-purchases + * See: https://openiap.dev/docs/apis/restore-purchases */ suspend fun restorePurchases(): Unit /** @@ -5278,29 +5278,29 @@ public interface MutationResolver { * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ suspend fun showAlternativeBillingDialogAndroid(): Boolean /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - * See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ suspend fun showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS): ExternalPurchaseCustomLinkNoticeResultIOS /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios */ suspend fun showManageSubscriptionsIOS(): List /** * Force sync transactions with the App Store (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/sync-ios + * See: https://openiap.dev/docs/apis/ios/sync-ios */ suspend fun syncIOS(): Boolean /** * Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase */ suspend fun validateReceipt(options: VerifyPurchaseProps): VerifyPurchaseResult /** @@ -5309,14 +5309,14 @@ public interface MutationResolver { * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. * Inspect the concrete variant before reading fields. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase */ suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult /** * Verify via a managed provider without standing up your own server. The * PurchaseVerificationProvider enum currently exposes only IAPKit; platform * availability may differ by implementation. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + * See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider */ suspend fun verifyPurchaseWithProvider(options: VerifyPurchaseWithProviderProps): VerifyPurchaseWithProviderResult } @@ -5328,22 +5328,22 @@ public interface QueryResolver { /** * Check eligibility for the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.canPresent. - * See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + * See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios */ suspend fun canPresentExternalPurchaseNoticeIOS(): Boolean /** * Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + * See: https://openiap.dev/docs/apis/ios/current-entitlement-ios */ suspend fun currentEntitlementIOS(sku: String): PurchaseIOS? /** * Fetch products or subscriptions from the store. - * See: https://www.openiap.dev/docs/apis/fetch-products + * See: https://openiap.dev/docs/apis/fetch-products */ suspend fun fetchProducts(params: ProductRequest): FetchProductsResult /** * Get details of all currently active subscriptions (filters by subscriptionIds when provided). - * See: https://www.openiap.dev/docs/apis/get-active-subscriptions + * See: https://openiap.dev/docs/apis/get-active-subscriptions */ suspend fun getActiveSubscriptions(subscriptionIds: List? = null): List /** @@ -5351,91 +5351,91 @@ public interface QueryResolver { * Requires the SK2ConsumableTransactionHistory Info.plist key in the host app * for finished consumables to be included (iOS 18+). * Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - * See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios */ suspend fun getAllTransactionsIOS(): List /** * Fetch the app transaction (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + * See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios */ suspend fun getAppTransactionIOS(): AppTransaction? /** * List active purchases for the current user. - * See: https://www.openiap.dev/docs/apis/get-available-purchases + * See: https://openiap.dev/docs/apis/get-available-purchases */ suspend fun getAvailablePurchases(options: PurchaseOptions? = null): List /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - * See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + * See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios */ suspend fun getExternalPurchaseCustomLinkTokenIOS(tokenType: ExternalPurchaseCustomLinkTokenTypeIOS): ExternalPurchaseCustomLinkTokenResultIOS /** * List unfinished StoreKit transactions in the queue. - * See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios */ suspend fun getPendingTransactionsIOS(): List /** * Read the App Store-promoted product, if any (iOS 11+). - * See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios */ suspend fun getPromotedProductIOS(): ProductIOS? /** * Get base64-encoded receipt data (legacy validation). - * See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + * See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios */ suspend fun getReceiptDataIOS(): String? /** * Return the user's storefront country code. - * See: https://www.openiap.dev/docs/apis/get-storefront + * See: https://openiap.dev/docs/apis/get-storefront */ suspend fun getStorefront(): String /** * Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - * See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + * See: https://openiap.dev/docs/apis/ios/get-storefront-ios */ suspend fun getStorefrontIOS(): String /** * Return the JWS string for a transaction (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + * See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios */ suspend fun getTransactionJwsIOS(sku: String): String? /** * Check whether the user has any active subscription. - * See: https://www.openiap.dev/docs/apis/has-active-subscriptions + * See: https://openiap.dev/docs/apis/has-active-subscriptions */ suspend fun hasActiveSubscriptions(subscriptionIds: List? = null): Boolean /** * Check eligibility for the custom-link variant of external purchase (iOS 18.1+). * Returns true if the app can use custom external purchase links. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios */ suspend fun isEligibleForExternalPurchaseCustomLinkIOS(): Boolean /** * Check intro-offer eligibility for a subscription group. - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios */ suspend fun isEligibleForIntroOfferIOS(groupID: String): Boolean /** * Check whether a transaction's JWS verification passed (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + * See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios */ suspend fun isTransactionVerifiedIOS(sku: String): Boolean /** * Get the latest verified transaction for a product, using StoreKit 2. - * See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + * See: https://openiap.dev/docs/apis/ios/latest-transaction-ios */ suspend fun latestTransactionIOS(sku: String): PurchaseIOS? /** * Get subscription status objects from StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + * See: https://openiap.dev/docs/apis/ios/subscription-status-ios */ suspend fun subscriptionStatusIOS(sku: String): List /** * Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + * See: https://openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS } diff --git a/packages/gql/README.md b/packages/gql/README.md index 935554f6..8905a056 100644 --- a/packages/gql/README.md +++ b/packages/gql/README.md @@ -39,7 +39,7 @@ Generated outputs: Uses [`@graphql-codegen/cli`](https://www.the-guild.dev/graphql/codegen). 1. Ensure Node 18+ is installed. -2. Install dependencies once: `npm install` +2. Install dependencies once from the monorepo root: `bun install --frozen-lockfile` 3. Generate types: `bun run generate:ts` 4. Generated output: `src/generated/types.ts` diff --git a/packages/gql/codegen/core/parser.ts b/packages/gql/codegen/core/parser.ts index 48c64433..671fc5f4 100644 --- a/packages/gql/codegen/core/parser.ts +++ b/packages/gql/codegen/core/parser.ts @@ -124,7 +124,7 @@ export class SchemaParser { const trimmed = line.trim(); // Track current type context - const typeMatch = trimmed.match(/^type\s+([A-Za-z0-9_]+)/); + const typeMatch = trimmed.match(/^(?:extend\s+)?type\s+([A-Za-z0-9_]+)/); if (typeMatch) { currentTypeName = typeMatch[1]; if (expectUnionType) { diff --git a/packages/gql/codegen/core/schema-linter.ts b/packages/gql/codegen/core/schema-linter.ts index def52fc6..6423f7bf 100644 --- a/packages/gql/codegen/core/schema-linter.ts +++ b/packages/gql/codegen/core/schema-linter.ts @@ -24,6 +24,15 @@ export interface LintOptions { strict?: boolean; } +const PLATFORM_TYPE_SUFFIX_EXCEPTIONS = new Set([ + // Public API names kept for source/binary compatibility. + 'AppTransaction', + 'ProductAndroidOneTimePurchaseOfferDetail', + 'ProductSubscriptionAndroidOfferDetails', + 'UserChoiceBillingDetails', + 'VerifyPurchaseResultHorizon', +]); + /** * Lint schema conventions and return findings. */ @@ -51,7 +60,7 @@ export function lintSchema( const lineNum = i + 1; // Track type definitions - const typeMatch = trimmed.match(/^type\s+([A-Za-z0-9_]+)/); + const typeMatch = trimmed.match(/^(?:extend\s+)?type\s+([A-Za-z0-9_]+)/); if (typeMatch) { const typeName = typeMatch[1]; currentTypeName = typeName; @@ -63,23 +72,27 @@ export function lintSchema( // Platform suffix checks for types in platform-specific files if (isIOSFile && !typeName.endsWith('IOS') && !typeName.startsWith('Query') && !typeName.startsWith('Mutation')) { - results.push({ - level: 'warning', - file: fileName, - line: lineNum, - message: `Type "${typeName}" in iOS file should end with "IOS" suffix`, - rule: 'ios-type-suffix', - }); + if (!PLATFORM_TYPE_SUFFIX_EXCEPTIONS.has(typeName)) { + results.push({ + level: 'warning', + file: fileName, + line: lineNum, + message: `Type "${typeName}" in iOS file should end with "IOS" suffix`, + rule: 'ios-type-suffix', + }); + } } if (isAndroidFile && !typeName.endsWith('Android') && !typeName.startsWith('Query') && !typeName.startsWith('Mutation')) { - results.push({ - level: 'warning', - file: fileName, - line: lineNum, - message: `Type "${typeName}" in Android file should end with "Android" suffix`, - rule: 'android-type-suffix', - }); + if (!PLATFORM_TYPE_SUFFIX_EXCEPTIONS.has(typeName)) { + results.push({ + level: 'warning', + file: fileName, + line: lineNum, + message: `Type "${typeName}" in Android file should end with "Android" suffix`, + rule: 'android-type-suffix', + }); + } } continue; diff --git a/packages/gql/package-lock.json b/packages/gql/package-lock.json deleted file mode 100644 index 2820059b..00000000 --- a/packages/gql/package-lock.json +++ /dev/null @@ -1,4646 +0,0 @@ -{ - "name": "openiap-gql", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "openiap-gql", - "version": "1.0.0", - "license": "ISC", - "devDependencies": { - "@graphql-codegen/add": "^6.0.0", - "@graphql-codegen/cli": "^6.0.0", - "@graphql-codegen/typescript": "^5.0.0", - "graphql": "^16.11.0", - "ts-node": "^10.9.2", - "typescript": "^5.9.2" - } - }, - "node_modules/@ardatan/relay-compiler": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/@ardatan/relay-compiler/-/relay-compiler-12.0.3.tgz", - "integrity": "sha512-mBDFOGvAoVlWaWqs3hm1AciGHSQE1rqFc/liZTyYz/Oek9yZdT5H26pH2zAFuEiTiBVPPyMuqf5VjOFPI2DGsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/runtime": "^7.26.10", - "chalk": "^4.0.0", - "fb-watchman": "^2.0.0", - "immutable": "~3.7.6", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "relay-runtime": "12.0.0", - "signedsource": "^1.0.0" - }, - "bin": { - "relay-compiler": "bin/relay-compiler" - }, - "peerDependencies": { - "graphql": "*" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", - "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@envelop/core": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.3.1.tgz", - "integrity": "sha512-n29V3vRqXvPcG76C8zE482LQykk0P66zv1mjpk7aHeGe9qnh8AzB/RvoX5SVFwApJQPp0ixob8NoYXg4FHKMGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@envelop/instrumentation": "^1.0.0", - "@envelop/types": "^5.2.1", - "@whatwg-node/promise-helpers": "^1.2.4", - "tslib": "^2.5.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@envelop/instrumentation": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz", - "integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/promise-helpers": "^1.2.1", - "tslib": "^2.5.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@envelop/types": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", - "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/promise-helpers": "^1.0.0", - "tslib": "^2.5.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@fastify/busboy": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", - "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@graphql-codegen/add": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-6.0.0.tgz", - "integrity": "sha512-biFdaURX0KTwEJPQ1wkT6BRgNasqgQ5KbCI1a3zwtLtO7XTo7/vKITPylmiU27K5DSOWYnY/1jfSqUAEBuhZrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", - "tslib": "~2.6.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/@graphql-codegen/cli": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.0.0.tgz", - "integrity": "sha512-tvchLVCMtorDE+UwgQbrjyaQK16GCZA+QomTxZazRx64ixtgmbEiQV7GhCBy0y0Bo7/tcTJb6sy9G/TL/BgiOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.18.13", - "@babel/template": "^7.18.10", - "@babel/types": "^7.18.13", - "@graphql-codegen/client-preset": "^5.0.0", - "@graphql-codegen/core": "^5.0.0", - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-tools/apollo-engine-loader": "^8.0.0", - "@graphql-tools/code-file-loader": "^8.0.0", - "@graphql-tools/git-loader": "^8.0.0", - "@graphql-tools/github-loader": "^8.0.0", - "@graphql-tools/graphql-file-loader": "^8.0.0", - "@graphql-tools/json-file-loader": "^8.0.0", - "@graphql-tools/load": "^8.1.0", - "@graphql-tools/url-loader": "^8.0.0", - "@graphql-tools/utils": "^10.0.0", - "@inquirer/prompts": "^7.8.2", - "@whatwg-node/fetch": "^0.10.0", - "chalk": "^4.1.0", - "cosmiconfig": "^9.0.0", - "debounce": "^2.0.0", - "detect-indent": "^6.0.0", - "graphql-config": "^5.1.1", - "is-glob": "^4.0.1", - "jiti": "^2.3.0", - "json-to-pretty-yaml": "^1.2.2", - "listr2": "^9.0.0", - "log-symbols": "^4.0.0", - "micromatch": "^4.0.5", - "shell-quote": "^1.7.3", - "string-env-interpolation": "^1.0.1", - "ts-log": "^2.2.3", - "tslib": "^2.4.0", - "yaml": "^2.3.1", - "yargs": "^17.0.0" - }, - "bin": { - "gql-gen": "cjs/bin.js", - "graphql-code-generator": "cjs/bin.js", - "graphql-codegen": "cjs/bin.js", - "graphql-codegen-esm": "esm/bin.js" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "@parcel/watcher": "^2.1.0", - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - }, - "peerDependenciesMeta": { - "@parcel/watcher": { - "optional": true - } - } - }, - "node_modules/@graphql-codegen/client-preset": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-5.0.1.tgz", - "integrity": "sha512-3dXS7Sh/AkV+Ewq/HB1DSCb0tZBOIdTL8zkGQjRKWaf14x21h2f/xKl2zhRh6KlXjcCrIpX+AxHAhQxs6cXwVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.20.2", - "@babel/template": "^7.20.7", - "@graphql-codegen/add": "^6.0.0", - "@graphql-codegen/gql-tag-operations": "5.0.0", - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/typed-document-node": "^6.0.0", - "@graphql-codegen/typescript": "^5.0.0", - "@graphql-codegen/typescript-operations": "^5.0.0", - "@graphql-codegen/visitor-plugin-common": "^6.0.0", - "@graphql-tools/documents": "^1.0.0", - "@graphql-tools/utils": "^10.0.0", - "@graphql-typed-document-node/core": "3.2.0", - "tslib": "~2.6.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", - "graphql-sock": "^1.0.0" - }, - "peerDependenciesMeta": { - "graphql-sock": { - "optional": true - } - } - }, - "node_modules/@graphql-codegen/core": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-5.0.0.tgz", - "integrity": "sha512-vLTEW0m8LbE4xgRwbFwCdYxVkJ1dBlVJbQyLb9Q7bHnVFgHAP982Xo8Uv7FuPBmON+2IbTjkCqhFLHVZbqpvjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-tools/schema": "^10.0.0", - "@graphql-tools/utils": "^10.0.0", - "tslib": "~2.6.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/@graphql-codegen/gql-tag-operations": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-5.0.0.tgz", - "integrity": "sha512-kC2pc/tyzVc1laZtlfuQHqYxF4UqB4YXzAboFfeY1cxrxCh/+H70jHnfA1O4vhPndiRd+XZA8wxPv0hIqDXYaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/visitor-plugin-common": "6.0.0", - "@graphql-tools/utils": "^10.0.0", - "auto-bind": "~4.0.0", - "tslib": "~2.6.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/@graphql-codegen/plugin-helpers": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.0.0.tgz", - "integrity": "sha512-Z7P89vViJvQakRyMbq/JF2iPLruRFOwOB6IXsuSvV/BptuuEd7fsGPuEf8bdjjDxUY0pJZnFN8oC7jIQ8p9GKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^10.0.0", - "change-case-all": "1.0.15", - "common-tags": "1.8.2", - "import-from": "4.0.0", - "lodash": "~4.17.0", - "tslib": "~2.6.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/@graphql-codegen/schema-ast": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-5.0.0.tgz", - "integrity": "sha512-jn7Q3PKQc0FxXjbpo9trxzlz/GSFQWxL042l0iC8iSbM/Ar+M7uyBwMtXPsev/3Razk+osQyreghIz0d2+6F7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-tools/utils": "^10.0.0", - "tslib": "~2.6.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/@graphql-codegen/typed-document-node": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-6.0.0.tgz", - "integrity": "sha512-OYmbadwvjq19yCZjioy901pLI9YV6i7A0fP3MpcJlo2uQVY27RJPcN2NeLfFzXdHr6f5bm9exqB6X1iKimfA2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/visitor-plugin-common": "6.0.0", - "auto-bind": "~4.0.0", - "change-case-all": "1.0.15", - "tslib": "~2.6.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/@graphql-codegen/typescript": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-5.0.0.tgz", - "integrity": "sha512-u90SGM6+Rdc3Je1EmVQOrGk5fl7hK1cLR4y5Q1MeUenj0aZFxKno65DCW7RcQpcfebvkPsVGA6y3oS02wPFj6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/schema-ast": "^5.0.0", - "@graphql-codegen/visitor-plugin-common": "6.0.0", - "auto-bind": "~4.0.0", - "tslib": "~2.6.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/@graphql-codegen/typescript-operations": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-5.0.0.tgz", - "integrity": "sha512-mqgp/lp5v7w+RYj5AJ/BVquP+sgje3EAgg++62ciolOB5zzWT8en09cRdNq4UZfszCYTOtlhCG7NQAAcSae37A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/typescript": "^5.0.0", - "@graphql-codegen/visitor-plugin-common": "6.0.0", - "auto-bind": "~4.0.0", - "tslib": "~2.6.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", - "graphql-sock": "^1.0.0" - }, - "peerDependenciesMeta": { - "graphql-sock": { - "optional": true - } - } - }, - "node_modules/@graphql-codegen/visitor-plugin-common": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.0.0.tgz", - "integrity": "sha512-K05Jv2elOeFstH3i+Ah0Pi9do6NYUvrbdhEkP+UvP9fmIro1hCKwcIEP7j4VFz8mt3gAC3dB5KVJDoyaPUgi4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-tools/optimize": "^2.0.0", - "@graphql-tools/relay-operation-optimizer": "^7.0.0", - "@graphql-tools/utils": "^10.0.0", - "auto-bind": "~4.0.0", - "change-case-all": "1.0.15", - "dependency-graph": "^1.0.0", - "graphql-tag": "^2.11.0", - "parse-filepath": "^1.0.2", - "tslib": "~2.6.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/@graphql-hive/signal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@graphql-hive/signal/-/signal-1.0.0.tgz", - "integrity": "sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@graphql-tools/apollo-engine-loader": { - "version": "8.0.22", - "resolved": "https://registry.npmjs.org/@graphql-tools/apollo-engine-loader/-/apollo-engine-loader-8.0.22.tgz", - "integrity": "sha512-ssD2wNxeOTRcUEkuGcp0KfZAGstL9YLTe/y3erTDZtOs2wL1TJESw8NVAp+3oUHPeHKBZQB4Z6RFEbPgMdT2wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "@whatwg-node/fetch": "^0.10.0", - "sync-fetch": "0.6.0-2", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/batch-execute": { - "version": "9.0.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-9.0.19.tgz", - "integrity": "sha512-VGamgY4PLzSx48IHPoblRw0oTaBa7S26RpZXt0Y4NN90ytoE0LutlpB2484RbkfcTjv9wa64QD474+YP1kEgGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "@whatwg-node/promise-helpers": "^1.3.0", - "dataloader": "^2.2.3", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/batch-execute/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/@graphql-tools/code-file-loader": { - "version": "8.1.22", - "resolved": "https://registry.npmjs.org/@graphql-tools/code-file-loader/-/code-file-loader-8.1.22.tgz", - "integrity": "sha512-FSka29kqFkfFmw36CwoQ+4iyhchxfEzPbXOi37lCEjWLHudGaPkXc3RyB9LdmBxx3g3GHEu43a5n5W8gfcrMdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/graphql-tag-pluck": "8.3.21", - "@graphql-tools/utils": "^10.9.1", - "globby": "^11.0.3", - "tslib": "^2.4.0", - "unixify": "^1.0.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/delegate": { - "version": "10.2.23", - "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-10.2.23.tgz", - "integrity": "sha512-xrPtl7f1LxS+B6o+W7ueuQh67CwRkfl+UKJncaslnqYdkxKmNBB4wnzVcW8ZsRdwbsla/v43PtwAvSlzxCzq2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/batch-execute": "^9.0.19", - "@graphql-tools/executor": "^1.4.9", - "@graphql-tools/schema": "^10.0.25", - "@graphql-tools/utils": "^10.9.1", - "@repeaterjs/repeater": "^3.0.6", - "@whatwg-node/promise-helpers": "^1.3.0", - "dataloader": "^2.2.3", - "dset": "^3.1.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/delegate/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/@graphql-tools/documents": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/documents/-/documents-1.0.1.tgz", - "integrity": "sha512-aweoMH15wNJ8g7b2r4C4WRuJxZ0ca8HtNO54rkye/3duxTkW4fGBEutCx03jCIr5+a1l+4vFJNP859QnAVBVCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/executor": { - "version": "1.4.9", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.4.9.tgz", - "integrity": "sha512-SAUlDT70JAvXeqV87gGzvDzUGofn39nvaVcVhNf12Dt+GfWHtNNO/RCn/Ea4VJaSLGzraUd41ObnN3i80EBU7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "@graphql-typed-document-node/core": "^3.2.0", - "@repeaterjs/repeater": "^3.0.4", - "@whatwg-node/disposablestack": "^0.0.6", - "@whatwg-node/promise-helpers": "^1.0.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/executor-common": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-0.0.4.tgz", - "integrity": "sha512-SEH/OWR+sHbknqZyROCFHcRrbZeUAyjCsgpVWCRjqjqRbiJiXq6TxNIIOmpXgkrXWW/2Ev4Wms6YSGJXjdCs6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@envelop/core": "^5.2.3", - "@graphql-tools/utils": "^10.8.1" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/executor-graphql-ws": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-2.0.7.tgz", - "integrity": "sha512-J27za7sKF6RjhmvSOwOQFeNhNHyP4f4niqPnerJmq73OtLx9Y2PGOhkXOEB0PjhvPJceuttkD2O1yMgEkTGs3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/executor-common": "^0.0.6", - "@graphql-tools/utils": "^10.9.1", - "@whatwg-node/disposablestack": "^0.0.6", - "graphql-ws": "^6.0.6", - "isomorphic-ws": "^5.0.0", - "tslib": "^2.8.1", - "ws": "^8.18.3" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/executor-graphql-ws/node_modules/@graphql-tools/executor-common": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-0.0.6.tgz", - "integrity": "sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@envelop/core": "^5.3.0", - "@graphql-tools/utils": "^10.9.1" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/executor-graphql-ws/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/@graphql-tools/executor-http": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-1.3.3.tgz", - "integrity": "sha512-LIy+l08/Ivl8f8sMiHW2ebyck59JzyzO/yF9SFS4NH6MJZUezA1xThUXCDIKhHiD56h/gPojbkpcFvM2CbNE7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-hive/signal": "^1.0.0", - "@graphql-tools/executor-common": "^0.0.4", - "@graphql-tools/utils": "^10.8.1", - "@repeaterjs/repeater": "^3.0.4", - "@whatwg-node/disposablestack": "^0.0.6", - "@whatwg-node/fetch": "^0.10.4", - "@whatwg-node/promise-helpers": "^1.3.0", - "meros": "^1.2.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/executor-http/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/@graphql-tools/executor-legacy-ws": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.1.19.tgz", - "integrity": "sha512-bEbv/SlEdhWQD0WZLUX1kOenEdVZk1yYtilrAWjRUgfHRZoEkY9s+oiqOxnth3z68wC2MWYx7ykkS5hhDamixg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "@types/ws": "^8.0.0", - "isomorphic-ws": "^5.0.0", - "tslib": "^2.4.0", - "ws": "^8.17.1" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/git-loader": { - "version": "8.0.26", - "resolved": "https://registry.npmjs.org/@graphql-tools/git-loader/-/git-loader-8.0.26.tgz", - "integrity": "sha512-0g+9eng8DaT4ZmZvUmPgjLTgesUa6M8xrDjNBltRldZkB055rOeUgJiKmL6u8PjzI5VxkkVsn0wtAHXhDI2UXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/graphql-tag-pluck": "8.3.21", - "@graphql-tools/utils": "^10.9.1", - "is-glob": "4.0.3", - "micromatch": "^4.0.8", - "tslib": "^2.4.0", - "unixify": "^1.0.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/github-loader": { - "version": "8.0.22", - "resolved": "https://registry.npmjs.org/@graphql-tools/github-loader/-/github-loader-8.0.22.tgz", - "integrity": "sha512-uQ4JNcNPsyMkTIgzeSbsoT9hogLjYrZooLUYd173l5eUGUi49EAcsGdiBCKaKfEjanv410FE8hjaHr7fjSRkJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/executor-http": "^1.1.9", - "@graphql-tools/graphql-tag-pluck": "^8.3.21", - "@graphql-tools/utils": "^10.9.1", - "@whatwg-node/fetch": "^0.10.0", - "@whatwg-node/promise-helpers": "^1.0.0", - "sync-fetch": "0.6.0-2", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/graphql-file-loader": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.1.1.tgz", - "integrity": "sha512-5JaUE3zMHW21Oh3bGSNKcr/Mi6oZ9/QWlBCNYbGy+09U23EOZmhPn9a44zP3gXcnnj0C+YVEr8dsMaoaB3UVGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/import": "7.1.1", - "@graphql-tools/utils": "^10.9.1", - "globby": "^11.0.3", - "tslib": "^2.4.0", - "unixify": "^1.0.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/graphql-tag-pluck": { - "version": "8.3.21", - "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-8.3.21.tgz", - "integrity": "sha512-TJhELNvR1tmghXMi6HVKp/Swxbx1rcSp/zdkuJZT0DCM3vOY11FXY6NW3aoxumcuYDNN3jqXcCPKstYGFPi5GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/plugin-syntax-import-assertions": "^7.26.0", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", - "@graphql-tools/utils": "^10.9.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/import": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/import/-/import-7.1.1.tgz", - "integrity": "sha512-zhlhaUmeTfV76vMoLRn9xCVMVc7sLf10ve5GKEhXFFDcWA6+vEZGk9CCm1VlPf2kyKGlF7bwLVzfepb3ZoOU9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "@theguild/federation-composition": "^0.19.0", - "resolve-from": "5.0.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/json-file-loader": { - "version": "8.0.20", - "resolved": "https://registry.npmjs.org/@graphql-tools/json-file-loader/-/json-file-loader-8.0.20.tgz", - "integrity": "sha512-5v6W+ZLBBML5SgntuBDLsYoqUvwfNboAwL6BwPHi3z/hH1f8BS9/0+MCW9OGY712g7E4pc3y9KqS67mWF753eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "globby": "^11.0.3", - "tslib": "^2.4.0", - "unixify": "^1.0.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/load": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@graphql-tools/load/-/load-8.1.2.tgz", - "integrity": "sha512-WhDPv25/jRND+0uripofMX0IEwo6mrv+tJg6HifRmDu8USCD7nZhufT0PP7lIcuutqjIQFyogqT70BQsy6wOgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/schema": "^10.0.25", - "@graphql-tools/utils": "^10.9.1", - "p-limit": "3.1.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/merge": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.1.tgz", - "integrity": "sha512-BJ5/7Y7GOhTuvzzO5tSBFL4NGr7PVqTJY3KeIDlVTT8YLcTXtBR+hlrC3uyEym7Ragn+zyWdHeJ9ev+nRX1X2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/utils": "^10.9.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/optimize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/optimize/-/optimize-2.0.0.tgz", - "integrity": "sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/relay-operation-optimizer": { - "version": "7.0.21", - "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.21.tgz", - "integrity": "sha512-vMdU0+XfeBh9RCwPqRsr3A05hPA3MsahFn/7OAwXzMySA5EVnSH5R4poWNs3h1a0yT0tDPLhxORhK7qJdSWj2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ardatan/relay-compiler": "^12.0.3", - "@graphql-tools/utils": "^10.9.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/schema": { - "version": "10.0.25", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.25.tgz", - "integrity": "sha512-/PqE8US8kdQ7lB9M5+jlW8AyVjRGCKU7TSktuW3WNKSKmDO0MK1wakvb5gGdyT49MjAIb4a3LWxIpwo5VygZuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/merge": "^9.1.1", - "@graphql-tools/utils": "^10.9.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/url-loader": { - "version": "8.0.33", - "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-8.0.33.tgz", - "integrity": "sha512-Fu626qcNHcqAj8uYd7QRarcJn5XZ863kmxsg1sm0fyjyfBJnsvC7ddFt6Hayz5kxVKfsnjxiDfPMXanvsQVBKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/executor-graphql-ws": "^2.0.1", - "@graphql-tools/executor-http": "^1.1.9", - "@graphql-tools/executor-legacy-ws": "^1.1.19", - "@graphql-tools/utils": "^10.9.1", - "@graphql-tools/wrap": "^10.0.16", - "@types/ws": "^8.0.0", - "@whatwg-node/fetch": "^0.10.0", - "@whatwg-node/promise-helpers": "^1.0.0", - "isomorphic-ws": "^5.0.0", - "sync-fetch": "0.6.0-2", - "tslib": "^2.4.0", - "ws": "^8.17.1" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/utils": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.9.1.tgz", - "integrity": "sha512-B1wwkXk9UvU7LCBkPs8513WxOQ2H8Fo5p8HR1+Id9WmYE5+bd51vqN+MbrqvWczHCH2gwkREgHJN88tE0n1FCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", - "cross-inspect": "1.0.1", - "dset": "^3.1.4", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/wrap": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-10.1.4.tgz", - "integrity": "sha512-7pyNKqXProRjlSdqOtrbnFRMQAVamCmEREilOXtZujxY6kYit3tvWWSjUrcIOheltTffoRh7EQSjpy2JDCzasg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/delegate": "^10.2.23", - "@graphql-tools/schema": "^10.0.25", - "@graphql-tools/utils": "^10.9.1", - "@whatwg-node/promise-helpers": "^1.3.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@graphql-tools/wrap/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@inquirer/ansi": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz", - "integrity": "sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.2.4.tgz", - "integrity": "sha512-2n9Vgf4HSciFq8ttKXk+qy+GsyTXPV1An6QAwe/8bkbbqvG4VW1I/ZY1pNu2rf+h9bdzMLPbRSfcNxkHBy/Ydw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.0", - "@inquirer/core": "^10.2.2", - "@inquirer/figures": "^1.0.13", - "@inquirer/type": "^3.0.8", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.18", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.18.tgz", - "integrity": "sha512-MilmWOzHa3Ks11tzvuAmFoAd/wRuaP3SwlT1IZhyMke31FKLxPiuDWcGXhU+PKveNOpAc4axzAgrgxuIJJRmLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.2.2", - "@inquirer/type": "^3.0.8" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.2.tgz", - "integrity": "sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.0", - "@inquirer/figures": "^1.0.13", - "@inquirer/type": "^3.0.8", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "4.2.20", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.20.tgz", - "integrity": "sha512-7omh5y5bK672Q+Brk4HBbnHNowOZwrb/78IFXdrEB9PfdxL3GudQyDk8O9vQ188wj3xrEebS2M9n18BjJoI83g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.2.2", - "@inquirer/external-editor": "^1.0.2", - "@inquirer/type": "^3.0.8" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "4.0.20", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.20.tgz", - "integrity": "sha512-Dt9S+6qUg94fEvgn54F2Syf0Z3U8xmnBI9ATq2f5h9xt09fs2IJXSCIXyyVHwvggKWFXEY/7jATRo2K6Dkn6Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.2.2", - "@inquirer/type": "^3.0.8", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", - "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^2.1.0", - "iconv-lite": "^0.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", - "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/input": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.2.4.tgz", - "integrity": "sha512-cwSGpLBMwpwcZZsc6s1gThm0J+it/KIJ+1qFL2euLmSKUMGumJ5TcbMgxEjMjNHRGadouIYbiIgruKoDZk7klw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.2.2", - "@inquirer/type": "^3.0.8" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "3.0.20", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.20.tgz", - "integrity": "sha512-bbooay64VD1Z6uMfNehED2A2YOPHSJnQLs9/4WNiV/EK+vXczf/R988itL2XLDGTgmhMF2KkiWZo+iEZmc4jqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.2.2", - "@inquirer/type": "^3.0.8" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "4.0.20", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.20.tgz", - "integrity": "sha512-nxSaPV2cPvvoOmRygQR+h0B+Av73B01cqYLcr7NXcGXhbmsYfUb8fDdw2Us1bI2YsX+VvY7I7upgFYsyf8+Nug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.0", - "@inquirer/core": "^10.2.2", - "@inquirer/type": "^3.0.8" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "7.8.6", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.6.tgz", - "integrity": "sha512-68JhkiojicX9SBUD8FE/pSKbOKtwoyaVj1kwqLfvjlVXZvOy3iaSWX4dCLsZyYx/5Ur07Fq+yuDNOen+5ce6ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^4.2.4", - "@inquirer/confirm": "^5.1.18", - "@inquirer/editor": "^4.2.20", - "@inquirer/expand": "^4.0.20", - "@inquirer/input": "^4.2.4", - "@inquirer/number": "^3.0.20", - "@inquirer/password": "^4.0.20", - "@inquirer/rawlist": "^4.1.8", - "@inquirer/search": "^3.1.3", - "@inquirer/select": "^4.3.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.8.tgz", - "integrity": "sha512-CQ2VkIASbgI2PxdzlkeeieLRmniaUU1Aoi5ggEdm6BIyqopE9GuDXdDOj9XiwOqK5qm72oI2i6J+Gnjaa26ejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.2.2", - "@inquirer/type": "^3.0.8", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.1.3.tgz", - "integrity": "sha512-D5T6ioybJJH0IiSUK/JXcoRrrm8sXwzrVMjibuPs+AgxmogKslaafy1oxFiorNI4s3ElSkeQZbhYQgLqiL8h6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.2.2", - "@inquirer/figures": "^1.0.13", - "@inquirer/type": "^3.0.8", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.3.4.tgz", - "integrity": "sha512-Qp20nySRmfbuJBBsgPU7E/cL62Hf250vMZRzYDcBHty2zdD1kKCnoDFWRr0WO2ZzaXp3R7a4esaVGJUx0E6zvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.0", - "@inquirer/core": "^10.2.2", - "@inquirer/figures": "^1.0.13", - "@inquirer/type": "^3.0.8", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", - "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@repeaterjs/repeater": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", - "integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@theguild/federation-composition": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@theguild/federation-composition/-/federation-composition-0.19.1.tgz", - "integrity": "sha512-E4kllHSRYh+FsY0VR+fwl0rmWhDV8xUgWawLZTXmy15nCWQwj0BDsoEpdEXjPh7xes+75cRaeJcSbZ4jkBuSdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "constant-case": "^3.0.4", - "debug": "4.4.1", - "json5": "^2.2.3", - "lodash.sortby": "^4.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "graphql": "^16.0.0" - } - }, - "node_modules/@theguild/federation-composition/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.0.tgz", - "integrity": "sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.12.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@whatwg-node/disposablestack": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", - "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/promise-helpers": "^1.0.0", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@whatwg-node/fetch": { - "version": "0.10.10", - "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.10.tgz", - "integrity": "sha512-watz4i/Vv4HpoJ+GranJ7HH75Pf+OkPQ63NoVmru6Srgc8VezTArB00i/oQlnn0KWh14gM42F22Qcc9SU9mo/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@whatwg-node/node-fetch": "^0.7.25", - "urlpattern-polyfill": "^10.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@whatwg-node/node-fetch": { - "version": "0.7.25", - "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.7.25.tgz", - "integrity": "sha512-szCTESNJV+Xd56zU6ShOi/JWROxE9IwCic8o5D9z5QECZloas6Ez5tUuKqXTAdu6fHFx1t6C+5gwj8smzOLjtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^3.1.1", - "@whatwg-node/disposablestack": "^0.0.6", - "@whatwg-node/promise-helpers": "^1.3.2", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@whatwg-node/promise-helpers": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", - "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-escapes": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.0.tgz", - "integrity": "sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/auto-bind": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz", - "integrity": "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.4.tgz", - "integrity": "sha512-L+YvJwGAgwJBV1p6ffpSTa2KRc69EeeYGYjRVWKs0GKrK+LON0GC0gV+rKSNtALEDvMDqkvCFq9r1r94/Gjwxw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.0.tgz", - "integrity": "sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.8.2", - "caniuse-lite": "^1.0.30001741", - "electron-to-chromium": "^1.5.218", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001741", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", - "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/capital-case": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", - "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case-first": "^2.0.2" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/change-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", - "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "camel-case": "^4.1.2", - "capital-case": "^1.0.4", - "constant-case": "^3.0.4", - "dot-case": "^3.0.4", - "header-case": "^2.0.4", - "no-case": "^3.0.4", - "param-case": "^3.0.4", - "pascal-case": "^3.1.2", - "path-case": "^3.0.4", - "sentence-case": "^3.0.4", - "snake-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/change-case-all": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.15.tgz", - "integrity": "sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "change-case": "^4.1.2", - "is-lower-case": "^2.0.2", - "is-upper-case": "^2.0.2", - "lower-case": "^2.0.2", - "lower-case-first": "^2.0.2", - "sponge-case": "^1.0.1", - "swap-case": "^2.0.2", - "title-case": "^3.0.3", - "upper-case": "^2.0.2", - "upper-case-first": "^2.0.2" - } - }, - "node_modules/chardet": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", - "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.0.tgz", - "integrity": "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/common-tags": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/constant-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", - "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case": "^2.0.2" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-fetch": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", - "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-fetch": "^2.7.0" - } - }, - "node_modules/cross-inspect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", - "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/dataloader": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", - "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", - "dev": true, - "license": "MIT" - }, - "node_modules/debounce": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", - "integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dependency-graph": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", - "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/detect-indent": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", - "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/dset": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", - "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.218", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz", - "integrity": "sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/fbjs": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", - "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-fetch": "^3.1.5", - "fbjs-css-vars": "^1.0.0", - "loose-envify": "^1.0.0", - "object-assign": "^4.1.0", - "promise": "^7.1.1", - "setimmediate": "^1.0.5", - "ua-parser-js": "^1.0.35" - } - }, - "node_modules/fbjs-css-vars": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", - "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graphql": { - "version": "16.11.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", - "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/graphql-config": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/graphql-config/-/graphql-config-5.1.5.tgz", - "integrity": "sha512-mG2LL1HccpU8qg5ajLROgdsBzx/o2M6kgI3uAmoaXiSH9PCUbtIyLomLqUtCFaAeG2YCFsl0M5cfQ9rKmDoMVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/graphql-file-loader": "^8.0.0", - "@graphql-tools/json-file-loader": "^8.0.0", - "@graphql-tools/load": "^8.1.0", - "@graphql-tools/merge": "^9.0.0", - "@graphql-tools/url-loader": "^8.0.0", - "@graphql-tools/utils": "^10.0.0", - "cosmiconfig": "^8.1.0", - "jiti": "^2.0.0", - "minimatch": "^9.0.5", - "string-env-interpolation": "^1.0.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">= 16.0.0" - }, - "peerDependencies": { - "cosmiconfig-toml-loader": "^1.0.0", - "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - }, - "peerDependenciesMeta": { - "cosmiconfig-toml-loader": { - "optional": true - } - } - }, - "node_modules/graphql-config/node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/graphql-tag": { - "version": "2.12.6", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", - "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, - "node_modules/graphql-ws": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.6.tgz", - "integrity": "sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@fastify/websocket": "^10 || ^11", - "crossws": "~0.3", - "graphql": "^15.10.1 || ^16", - "uWebSockets.js": "^20", - "ws": "^8" - }, - "peerDependenciesMeta": { - "@fastify/websocket": { - "optional": true - }, - "crossws": { - "optional": true - }, - "uWebSockets.js": { - "optional": true - }, - "ws": { - "optional": true - } - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/header-case": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", - "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "capital-case": "^1.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/immutable": { - "version": "3.7.6", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", - "integrity": "sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/import-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-from/-/import-from-4.0.0.tgz", - "integrity": "sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/is-absolute": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", - "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-relative": "^1.0.0", - "is-windows": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz", - "integrity": "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-relative": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", - "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-unc-path": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-unc-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", - "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "unc-path-regex": "^0.1.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-upper-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz", - "integrity": "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isomorphic-ws": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", - "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ws": "*" - } - }, - "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-to-pretty-yaml": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/json-to-pretty-yaml/-/json-to-pretty-yaml-1.2.2.tgz", - "integrity": "sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "remedial": "^1.0.7", - "remove-trailing-spaces": "^1.0.6" - }, - "engines": { - "node": ">= 0.2.0" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.4.tgz", - "integrity": "sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/lower-case-first": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case-first/-/lower-case-first-2.0.2.tgz", - "integrity": "sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/map-cache": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", - "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/meros": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/meros/-/meros-1.3.2.tgz", - "integrity": "sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=13" - }, - "peerDependencies": { - "@types/node": ">=13" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", - "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "remove-trailing-separator": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nullthrows": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", - "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-filepath": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", - "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-absolute": "^1.0.0", - "map-cache": "^0.2.0", - "path-root": "^0.1.1" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/path-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", - "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/path-root": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", - "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-root-regex": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-root-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", - "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/promise": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", - "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "asap": "~2.0.3" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/relay-runtime": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-12.0.0.tgz", - "integrity": "sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.0.0", - "fbjs": "^3.0.0", - "invariant": "^2.2.4" - } - }, - "node_modules/remedial": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/remedial/-/remedial-1.0.8.tgz", - "integrity": "sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==", - "dev": true, - "license": "(MIT OR Apache-2.0)", - "engines": { - "node": "*" - } - }, - "node_modules/remove-trailing-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", - "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", - "dev": true, - "license": "ISC" - }, - "node_modules/remove-trailing-spaces": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/remove-trailing-spaces/-/remove-trailing-spaces-1.0.9.tgz", - "integrity": "sha512-xzG7w5IRijvIkHIjDk65URsJJ7k4J95wmcArY5PRcmjldIOl7oTvG8+X2Ag690R7SfwiOcHrWZKVc1Pp5WIOzA==", - "dev": true, - "license": "MIT" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/sentence-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", - "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case-first": "^2.0.2" - } - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true, - "license": "MIT" - }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/signedsource": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/signedsource/-/signedsource-1.0.0.tgz", - "integrity": "sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/sponge-case": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-1.0.1.tgz", - "integrity": "sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/string-env-interpolation": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz", - "integrity": "sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/swap-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-2.0.2.tgz", - "integrity": "sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/sync-fetch": { - "version": "0.6.0-2", - "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.6.0-2.tgz", - "integrity": "sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-fetch": "^3.3.2", - "timeout-signal": "^2.0.0", - "whatwg-mimetype": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/sync-fetch/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/timeout-signal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/timeout-signal/-/timeout-signal-2.0.0.tgz", - "integrity": "sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/title-case": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", - "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ts-log": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-2.2.7.tgz", - "integrity": "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true, - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ua-parser-js": { - "version": "1.0.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", - "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, - "node_modules/unc-path-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", - "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unixify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unixify/-/unixify-1.0.0.tgz", - "integrity": "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "normalize-path": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/upper-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", - "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/upper-case-first": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", - "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/urlpattern-polyfill": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", - "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/packages/gql/package.json b/packages/gql/package.json index 402a5048..d1f09623 100644 --- a/packages/gql/package.json +++ b/packages/gql/package.json @@ -1,10 +1,11 @@ { "name": "@hyodotdev/openiap-gql", - "version": "2.0.0", + "version": "2.0.2", "type": "module", "main": "src/generated/types.ts", "exports": { ".": "./src/generated/types.ts", + "./kit-api": "./src/kit-api.ts", "./webhook-client": "./src/webhook-client.ts", "./swift": "./src/generated/Types.swift", "./kotlin": "./src/generated/Types.kt", @@ -40,7 +41,7 @@ "handlebars": "^4.7.8", "ts-node": "^10.9.2", "typescript": "^5.9.2", - "vitest": "^4" + "vitest": "^4.1.5" }, - "packageManager": "bun@1.1.0" + "packageManager": "bun@1.3.13" } diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index e89111dd..6c5ea212 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -4100,26 +4100,26 @@ public sealed record WinBackOfferInputIOS public interface MutationResolver { /// Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - /// See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + /// See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android Task AcknowledgePurchaseAndroidAsync(string purchaseToken); /// Present the refund request sheet (iOS 15+). See also Features → Refund. - /// See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios Task BeginRefundRequestIOSAsync(string sku); /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android Task CheckAlternativeBillingAvailabilityAndroidAsync(); /// Clear pending transactions in the queue (sandbox helper). - /// See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/clear-transaction-ios Task ClearTransactionIOSAsync(); /// Consume a consumable purchase so it can be re-bought. - /// See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + /// See: https://openiap.dev/docs/apis/android/consume-purchase-android Task ConsumePurchaseAndroidAsync(string purchaseToken); /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. @@ -4128,7 +4128,7 @@ public interface MutationResolver /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android Task CreateAlternativeBillingTokenAndroidAsync(); /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). @@ -4136,23 +4136,23 @@ public interface MutationResolver /// /// Returns external transaction token needed for reporting external transactions. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android Task CreateBillingProgramReportingDetailsAndroidAsync(BillingProgramAndroid program); /// Open the platform's subscription management UI. - /// See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions Task DeepLinkToSubscriptionsAsync(DeepLinkOptions? options = null); /// Close the store connection and release resources. - /// See: https://www.openiap.dev/docs/apis/end-connection + /// See: https://openiap.dev/docs/apis/end-connection Task EndConnectionAsync(); /// Complete a transaction after server-side verification. Required on Android within 3 days. - /// See: https://www.openiap.dev/docs/apis/finish-transaction + /// See: https://openiap.dev/docs/apis/finish-transaction Task FinishTransactionAsync(PurchaseInput purchase, bool? isConsumable = null); /// Initialize the store connection. Call before any IAP API. - /// See: https://www.openiap.dev/docs/apis/init-connection + /// See: https://openiap.dev/docs/apis/init-connection Task InitConnectionAsync(InitConnectionConfig? config = null); /// Check whether a billing program (e.g., External Payments) is available for the current user. @@ -4161,7 +4161,7 @@ public interface MutationResolver /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android Task IsBillingProgramAvailableAndroidAsync(BillingProgramAndroid program); /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). @@ -4169,25 +4169,25 @@ public interface MutationResolver /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + /// See: https://openiap.dev/docs/apis/android/launch-external-link-android Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid @params); /// Show the App Store offer code redemption sheet. - /// See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios Task PresentCodeRedemptionSheetIOSAsync(); /// Present an external purchase link, StoreKit External (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios Task PresentExternalPurchaseLinkIOSAsync(string url); /// Present the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios Task PresentExternalPurchaseNoticeSheetIOSAsync(); /// Initiate a purchase or subscription flow; rely on events for final state. - /// See: https://www.openiap.dev/docs/apis/request-purchase + /// See: https://openiap.dev/docs/apis/request-purchase Task RequestPurchaseAsync(RequestPurchaseProps @params); /// Buy the currently promoted product. @@ -4195,11 +4195,11 @@ public interface MutationResolver /// @deprecated Use promotedProductListenerIOS to receive the productId, /// then call requestPurchase with that SKU instead. In StoreKit 2, /// promoted products can be purchased directly via the standard purchase flow. - /// See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios Task RequestPurchaseOnPromotedProductIOSAsync(); /// Restore non-consumable and active subscription purchases. - /// See: https://www.openiap.dev/docs/apis/restore-purchases + /// See: https://openiap.dev/docs/apis/restore-purchases Task RestorePurchasesAsync(); /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. @@ -4207,25 +4207,25 @@ public interface MutationResolver /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android Task ShowAlternativeBillingDialogAndroidAsync(); /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - /// See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios Task ShowExternalPurchaseCustomLinkNoticeIOSAsync(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios Task> ShowManageSubscriptionsIOSAsync(); /// Force sync transactions with the App Store (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/sync-ios + /// See: https://openiap.dev/docs/apis/ios/sync-ios Task SyncIOSAsync(); /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase Task ValidateReceiptAsync(VerifyPurchaseProps options); /// Verify a purchase against your own backend. Returns a platform-specific @@ -4233,13 +4233,13 @@ public interface MutationResolver /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. /// Inspect the concrete variant before reading fields. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase Task VerifyPurchaseAsync(VerifyPurchaseProps options); /// Verify via a managed provider without standing up your own server. The /// PurchaseVerificationProvider enum currently exposes only IAPKit; platform /// availability may differ by implementation. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + /// See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider Task VerifyPurchaseWithProviderAsync(VerifyPurchaseWithProviderProps options); } @@ -4248,94 +4248,94 @@ public interface QueryResolver { /// Check eligibility for the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.canPresent. - /// See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios Task CanPresentExternalPurchaseNoticeIOSAsync(); /// Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + /// See: https://openiap.dev/docs/apis/ios/current-entitlement-ios Task CurrentEntitlementIOSAsync(string sku); /// Fetch products or subscriptions from the store. - /// See: https://www.openiap.dev/docs/apis/fetch-products + /// See: https://openiap.dev/docs/apis/fetch-products Task FetchProductsAsync(ProductRequest @params); /// Get details of all currently active subscriptions (filters by subscriptionIds when provided). - /// See: https://www.openiap.dev/docs/apis/get-active-subscriptions + /// See: https://openiap.dev/docs/apis/get-active-subscriptions Task> GetActiveSubscriptionsAsync(IReadOnlyList? subscriptionIds = null); /// List every StoreKit transaction (finished + unfinished) for the current user. /// Requires the SK2ConsumableTransactionHistory Info.plist key in the host app /// for finished consumables to be included (iOS 18+). /// Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - /// See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios Task> GetAllTransactionsIOSAsync(); /// Fetch the app transaction (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios Task GetAppTransactionIOSAsync(); /// List active purchases for the current user. - /// See: https://www.openiap.dev/docs/apis/get-available-purchases + /// See: https://openiap.dev/docs/apis/get-available-purchases Task> GetAvailablePurchasesAsync(PurchaseOptions? options = null); /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - /// See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios Task GetExternalPurchaseCustomLinkTokenIOSAsync(ExternalPurchaseCustomLinkTokenTypeIOS tokenType); /// List unfinished StoreKit transactions in the queue. - /// See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios Task> GetPendingTransactionsIOSAsync(); /// Read the App Store-promoted product, if any (iOS 11+). - /// See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios Task GetPromotedProductIOSAsync(); /// Get base64-encoded receipt data (legacy validation). - /// See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + /// See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios Task GetReceiptDataIOSAsync(); /// Return the user's storefront country code. - /// See: https://www.openiap.dev/docs/apis/get-storefront + /// See: https://openiap.dev/docs/apis/get-storefront Task GetStorefrontAsync(); /// Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - /// See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios Task GetStorefrontIOSAsync(); /// Return the JWS string for a transaction (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios Task GetTransactionJwsIOSAsync(string sku); /// Check whether the user has any active subscription. - /// See: https://www.openiap.dev/docs/apis/has-active-subscriptions + /// See: https://openiap.dev/docs/apis/has-active-subscriptions Task HasActiveSubscriptionsAsync(IReadOnlyList? subscriptionIds = null); /// Check eligibility for the custom-link variant of external purchase (iOS 18.1+). /// Returns true if the app can use custom external purchase links. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios Task IsEligibleForExternalPurchaseCustomLinkIOSAsync(); /// Check intro-offer eligibility for a subscription group. - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios Task IsEligibleForIntroOfferIOSAsync(string groupId); /// Check whether a transaction's JWS verification passed (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios Task IsTransactionVerifiedIOSAsync(string sku); /// Get the latest verified transaction for a product, using StoreKit 2. - /// See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/latest-transaction-ios Task LatestTransactionIOSAsync(string sku); /// Get subscription status objects from StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + /// See: https://openiap.dev/docs/apis/ios/subscription-status-ios Task> SubscriptionStatusIOSAsync(string sku); /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios Task ValidateReceiptIOSAsync(VerifyPurchaseProps options); } diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 7e1e6572..7455c2c6 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -5271,12 +5271,12 @@ public sealed interface VerifyPurchaseResult { public interface MutationResolver { /** * Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - * See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + * See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android */ suspend fun acknowledgePurchaseAndroid(purchaseToken: String): Boolean /** * Present the refund request sheet (iOS 15+). See also Features → Refund. - * See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + * See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios */ suspend fun beginRefundRequestIOS(sku: String): String? /** @@ -5284,17 +5284,17 @@ public interface MutationResolver { * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android */ suspend fun checkAlternativeBillingAvailabilityAndroid(): Boolean /** * Clear pending transactions in the queue (sandbox helper). - * See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + * See: https://openiap.dev/docs/apis/ios/clear-transaction-ios */ suspend fun clearTransactionIOS(): Boolean /** * Consume a consumable purchase so it can be re-bought. - * See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + * See: https://openiap.dev/docs/apis/android/consume-purchase-android */ suspend fun consumePurchaseAndroid(purchaseToken: String): Boolean /** @@ -5304,7 +5304,7 @@ public interface MutationResolver { * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android */ suspend fun createAlternativeBillingTokenAndroid(): String? /** @@ -5313,27 +5313,27 @@ public interface MutationResolver { * * Returns external transaction token needed for reporting external transactions. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ suspend fun createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid /** * Open the platform's subscription management UI. - * See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + * See: https://openiap.dev/docs/apis/deep-link-to-subscriptions */ suspend fun deepLinkToSubscriptions(options: DeepLinkOptions? = null): Unit /** * Close the store connection and release resources. - * See: https://www.openiap.dev/docs/apis/end-connection + * See: https://openiap.dev/docs/apis/end-connection */ suspend fun endConnection(): Boolean /** * Complete a transaction after server-side verification. Required on Android within 3 days. - * See: https://www.openiap.dev/docs/apis/finish-transaction + * See: https://openiap.dev/docs/apis/finish-transaction */ suspend fun finishTransaction(purchase: PurchaseInput, isConsumable: Boolean? = null): Unit /** * Initialize the store connection. Call before any IAP API. - * See: https://www.openiap.dev/docs/apis/init-connection + * See: https://openiap.dev/docs/apis/init-connection */ suspend fun initConnection(config: InitConnectionConfig? = null): Boolean /** @@ -5343,7 +5343,7 @@ public interface MutationResolver { * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + * See: https://openiap.dev/docs/apis/android/is-billing-program-available-android */ suspend fun isBillingProgramAvailableAndroid(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid /** @@ -5352,29 +5352,29 @@ public interface MutationResolver { * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + * See: https://openiap.dev/docs/apis/android/launch-external-link-android */ suspend fun launchExternalLinkAndroid(params: LaunchExternalLinkParamsAndroid): Boolean /** * Show the App Store offer code redemption sheet. - * See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios */ suspend fun presentCodeRedemptionSheetIOS(): Boolean /** * Present an external purchase link, StoreKit External (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios */ suspend fun presentExternalPurchaseLinkIOS(url: String): ExternalPurchaseLinkResultIOS /** * Present the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. * Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios */ suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS /** * Initiate a purchase or subscription flow; rely on events for final state. - * See: https://www.openiap.dev/docs/apis/request-purchase + * See: https://openiap.dev/docs/apis/request-purchase */ suspend fun requestPurchase(params: RequestPurchaseProps): RequestPurchaseResult? /** @@ -5383,12 +5383,12 @@ public interface MutationResolver { * @deprecated Use promotedProductListenerIOS to receive the productId, * then call requestPurchase with that SKU instead. In StoreKit 2, * promoted products can be purchased directly via the standard purchase flow. - * See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios */ suspend fun requestPurchaseOnPromotedProductIOS(): Boolean /** * Restore non-consumable and active subscription purchases. - * See: https://www.openiap.dev/docs/apis/restore-purchases + * See: https://openiap.dev/docs/apis/restore-purchases */ suspend fun restorePurchases(): Unit /** @@ -5397,29 +5397,29 @@ public interface MutationResolver { * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ suspend fun showAlternativeBillingDialogAndroid(): Boolean /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - * See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ suspend fun showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS): ExternalPurchaseCustomLinkNoticeResultIOS /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios */ suspend fun showManageSubscriptionsIOS(): List /** * Force sync transactions with the App Store (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/sync-ios + * See: https://openiap.dev/docs/apis/ios/sync-ios */ suspend fun syncIOS(): Boolean /** * Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase */ suspend fun validateReceipt(options: VerifyPurchaseProps): VerifyPurchaseResult /** @@ -5428,14 +5428,14 @@ public interface MutationResolver { * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. * Inspect the concrete variant before reading fields. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase */ suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult /** * Verify via a managed provider without standing up your own server. The * PurchaseVerificationProvider enum currently exposes only IAPKit; platform * availability may differ by implementation. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + * See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider */ suspend fun verifyPurchaseWithProvider(options: VerifyPurchaseWithProviderProps): VerifyPurchaseWithProviderResult } @@ -5447,22 +5447,22 @@ public interface QueryResolver { /** * Check eligibility for the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.canPresent. - * See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + * See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios */ suspend fun canPresentExternalPurchaseNoticeIOS(): Boolean /** * Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + * See: https://openiap.dev/docs/apis/ios/current-entitlement-ios */ suspend fun currentEntitlementIOS(sku: String): PurchaseIOS? /** * Fetch products or subscriptions from the store. - * See: https://www.openiap.dev/docs/apis/fetch-products + * See: https://openiap.dev/docs/apis/fetch-products */ suspend fun fetchProducts(params: ProductRequest): FetchProductsResult /** * Get details of all currently active subscriptions (filters by subscriptionIds when provided). - * See: https://www.openiap.dev/docs/apis/get-active-subscriptions + * See: https://openiap.dev/docs/apis/get-active-subscriptions */ suspend fun getActiveSubscriptions(subscriptionIds: List? = null): List /** @@ -5470,91 +5470,91 @@ public interface QueryResolver { * Requires the SK2ConsumableTransactionHistory Info.plist key in the host app * for finished consumables to be included (iOS 18+). * Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - * See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios */ suspend fun getAllTransactionsIOS(): List /** * Fetch the app transaction (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + * See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios */ suspend fun getAppTransactionIOS(): AppTransaction? /** * List active purchases for the current user. - * See: https://www.openiap.dev/docs/apis/get-available-purchases + * See: https://openiap.dev/docs/apis/get-available-purchases */ suspend fun getAvailablePurchases(options: PurchaseOptions? = null): List /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - * See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + * See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios */ suspend fun getExternalPurchaseCustomLinkTokenIOS(tokenType: ExternalPurchaseCustomLinkTokenTypeIOS): ExternalPurchaseCustomLinkTokenResultIOS /** * List unfinished StoreKit transactions in the queue. - * See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios */ suspend fun getPendingTransactionsIOS(): List /** * Read the App Store-promoted product, if any (iOS 11+). - * See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios */ suspend fun getPromotedProductIOS(): ProductIOS? /** * Get base64-encoded receipt data (legacy validation). - * See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + * See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios */ suspend fun getReceiptDataIOS(): String? /** * Return the user's storefront country code. - * See: https://www.openiap.dev/docs/apis/get-storefront + * See: https://openiap.dev/docs/apis/get-storefront */ suspend fun getStorefront(): String /** * Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - * See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + * See: https://openiap.dev/docs/apis/ios/get-storefront-ios */ suspend fun getStorefrontIOS(): String /** * Return the JWS string for a transaction (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + * See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios */ suspend fun getTransactionJwsIOS(sku: String): String? /** * Check whether the user has any active subscription. - * See: https://www.openiap.dev/docs/apis/has-active-subscriptions + * See: https://openiap.dev/docs/apis/has-active-subscriptions */ suspend fun hasActiveSubscriptions(subscriptionIds: List? = null): Boolean /** * Check eligibility for the custom-link variant of external purchase (iOS 18.1+). * Returns true if the app can use custom external purchase links. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios */ suspend fun isEligibleForExternalPurchaseCustomLinkIOS(): Boolean /** * Check intro-offer eligibility for a subscription group. - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios */ suspend fun isEligibleForIntroOfferIOS(groupID: String): Boolean /** * Check whether a transaction's JWS verification passed (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + * See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios */ suspend fun isTransactionVerifiedIOS(sku: String): Boolean /** * Get the latest verified transaction for a product, using StoreKit 2. - * See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + * See: https://openiap.dev/docs/apis/ios/latest-transaction-ios */ suspend fun latestTransactionIOS(sku: String): PurchaseIOS? /** * Get subscription status objects from StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + * See: https://openiap.dev/docs/apis/ios/subscription-status-ios */ suspend fun subscriptionStatusIOS(sku: String): List /** * Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + * See: https://openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS } diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 8cde48d8..b1ac4d36 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -2502,22 +2502,22 @@ public enum VerifyPurchaseResult: Codable { /// GraphQL root mutation operations. public protocol MutationResolver { /// Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - /// See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + /// See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android func acknowledgePurchaseAndroid(_ purchaseToken: String) async throws -> Bool /// Present the refund request sheet (iOS 15+). See also Features → Refund. - /// See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios func beginRefundRequestIOS(_ sku: String) async throws -> String? /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android func checkAlternativeBillingAvailabilityAndroid() async throws -> Bool /// Clear pending transactions in the queue (sandbox helper). - /// See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/clear-transaction-ios func clearTransactionIOS() async throws -> Bool /// Consume a consumable purchase so it can be re-bought. - /// See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + /// See: https://openiap.dev/docs/apis/android/consume-purchase-android func consumePurchaseAndroid(_ purchaseToken: String) async throws -> Bool /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. /// Must be called AFTER successful payment in your payment system. @@ -2525,26 +2525,26 @@ public protocol MutationResolver { /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android func createAlternativeBillingTokenAndroid() async throws -> String? /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). /// Replaces the deprecated createExternalOfferReportingDetailsAsync API. /// /// Returns external transaction token needed for reporting external transactions. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android func createBillingProgramReportingDetailsAndroid(_ program: BillingProgramAndroid) async throws -> BillingProgramReportingDetailsAndroid /// Open the platform's subscription management UI. - /// See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void /// Close the store connection and release resources. - /// See: https://www.openiap.dev/docs/apis/end-connection + /// See: https://openiap.dev/docs/apis/end-connection func endConnection() async throws -> Bool /// Complete a transaction after server-side verification. Required on Android within 3 days. - /// See: https://www.openiap.dev/docs/apis/finish-transaction + /// See: https://openiap.dev/docs/apis/finish-transaction func finishTransaction(purchase: PurchaseInput, isConsumable: Bool?) async throws -> Void /// Initialize the store connection. Call before any IAP API. - /// See: https://www.openiap.dev/docs/apis/init-connection + /// See: https://openiap.dev/docs/apis/init-connection func initConnection(_ config: InitConnectionConfig?) async throws -> Bool /// Check whether a billing program (e.g., External Payments) is available for the current user. /// Replaces the deprecated isExternalOfferAvailableAsync API. @@ -2552,71 +2552,71 @@ public protocol MutationResolver { /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android func isBillingProgramAvailableAndroid(_ program: BillingProgramAndroid) async throws -> BillingProgramAvailabilityResultAndroid /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). /// Replaces the deprecated showExternalOfferInformationDialog API. /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + /// See: https://openiap.dev/docs/apis/android/launch-external-link-android func launchExternalLinkAndroid(_ params: LaunchExternalLinkParamsAndroid) async throws -> Bool /// Show the App Store offer code redemption sheet. - /// See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios func presentCodeRedemptionSheetIOS() async throws -> Bool /// Present an external purchase link, StoreKit External (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS /// Present the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS /// Initiate a purchase or subscription flow; rely on events for final state. - /// See: https://www.openiap.dev/docs/apis/request-purchase + /// See: https://openiap.dev/docs/apis/request-purchase func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? /// Buy the currently promoted product. /// /// @deprecated Use promotedProductListenerIOS to receive the productId, /// then call requestPurchase with that SKU instead. In StoreKit 2, /// promoted products can be purchased directly via the standard purchase flow. - /// See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios func requestPurchaseOnPromotedProductIOS() async throws -> Bool /// Restore non-consumable and active subscription purchases. - /// See: https://www.openiap.dev/docs/apis/restore-purchases + /// See: https://openiap.dev/docs/apis/restore-purchases func restorePurchases() async throws -> Void /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. /// Must be called BEFORE processing payment in your payment system. /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android func showAlternativeBillingDialogAndroid() async throws -> Bool /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - /// See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios func showExternalPurchaseCustomLinkNoticeIOS(_ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios func showManageSubscriptionsIOS() async throws -> [PurchaseIOS] /// Force sync transactions with the App Store (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/sync-ios + /// See: https://openiap.dev/docs/apis/ios/sync-ios func syncIOS() async throws -> Bool /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase func validateReceipt(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult /// Verify a purchase against your own backend. Returns a platform-specific /// variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. /// Inspect the concrete variant before reading fields. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase func verifyPurchase(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult /// Verify via a managed provider without standing up your own server. The /// PurchaseVerificationProvider enum currently exposes only IAPKit; platform /// availability may differ by implementation. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + /// See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider func verifyPurchaseWithProvider(_ options: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult } @@ -2624,74 +2624,74 @@ public protocol MutationResolver { public protocol QueryResolver { /// Check eligibility for the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.canPresent. - /// See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios func canPresentExternalPurchaseNoticeIOS() async throws -> Bool /// Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + /// See: https://openiap.dev/docs/apis/ios/current-entitlement-ios func currentEntitlementIOS(_ sku: String) async throws -> PurchaseIOS? /// Fetch products or subscriptions from the store. - /// See: https://www.openiap.dev/docs/apis/fetch-products + /// See: https://openiap.dev/docs/apis/fetch-products func fetchProducts(_ params: ProductRequest) async throws -> FetchProductsResult /// Get details of all currently active subscriptions (filters by subscriptionIds when provided). - /// See: https://www.openiap.dev/docs/apis/get-active-subscriptions + /// See: https://openiap.dev/docs/apis/get-active-subscriptions func getActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> [ActiveSubscription] /// List every StoreKit transaction (finished + unfinished) for the current user. /// Requires the SK2ConsumableTransactionHistory Info.plist key in the host app /// for finished consumables to be included (iOS 18+). /// Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - /// See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios func getAllTransactionsIOS() async throws -> [PurchaseIOS] /// Fetch the app transaction (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios func getAppTransactionIOS() async throws -> AppTransaction? /// List active purchases for the current user. - /// See: https://www.openiap.dev/docs/apis/get-available-purchases + /// See: https://openiap.dev/docs/apis/get-available-purchases func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [Purchase] /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - /// See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios func getExternalPurchaseCustomLinkTokenIOS(_ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) async throws -> ExternalPurchaseCustomLinkTokenResultIOS /// List unfinished StoreKit transactions in the queue. - /// See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios func getPendingTransactionsIOS() async throws -> [PurchaseIOS] /// Read the App Store-promoted product, if any (iOS 11+). - /// See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios func getPromotedProductIOS() async throws -> ProductIOS? /// Get base64-encoded receipt data (legacy validation). - /// See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + /// See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios func getReceiptDataIOS() async throws -> String? /// Return the user's storefront country code. - /// See: https://www.openiap.dev/docs/apis/get-storefront + /// See: https://openiap.dev/docs/apis/get-storefront func getStorefront() async throws -> String /// Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - /// See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios func getStorefrontIOS() async throws -> String /// Return the JWS string for a transaction (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios func getTransactionJwsIOS(_ sku: String) async throws -> String? /// Check whether the user has any active subscription. - /// See: https://www.openiap.dev/docs/apis/has-active-subscriptions + /// See: https://openiap.dev/docs/apis/has-active-subscriptions func hasActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> Bool /// Check eligibility for the custom-link variant of external purchase (iOS 18.1+). /// Returns true if the app can use custom external purchase links. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool /// Check intro-offer eligibility for a subscription group. - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios func isEligibleForIntroOfferIOS(_ groupID: String) async throws -> Bool /// Check whether a transaction's JWS verification passed (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios func isTransactionVerifiedIOS(_ sku: String) async throws -> Bool /// Get the latest verified transaction for a product, using StoreKit 2. - /// See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/latest-transaction-ios func latestTransactionIOS(_ sku: String) async throws -> PurchaseIOS? /// Get subscription status objects from StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + /// See: https://openiap.dev/docs/apis/ios/subscription-status-ios func subscriptionStatusIOS(_ sku: String) async throws -> [SubscriptionStatusIOS] /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios func validateReceiptIOS(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS } diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 2558620f..2e08ac0a 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -5233,22 +5233,22 @@ sealed class VerifyPurchaseResult { /// GraphQL root mutation operations. abstract class MutationResolver { /// Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - /// See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + /// See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android Future acknowledgePurchaseAndroid(String purchaseToken); /// Present the refund request sheet (iOS 15+). See also Features → Refund. - /// See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios Future beginRefundRequestIOS(String sku); /// Check whether alternative billing is available for the user. Step 1 of the alternative billing flow. /// /// Returns true if available, false otherwise. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + /// See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android Future checkAlternativeBillingAvailabilityAndroid(); /// Clear pending transactions in the queue (sandbox helper). - /// See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/clear-transaction-ios Future clearTransactionIOS(); /// Consume a consumable purchase so it can be re-bought. - /// See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + /// See: https://openiap.dev/docs/apis/android/consume-purchase-android Future consumePurchaseAndroid(String purchaseToken); /// Create a reporting token for an alternative billing flow. Step 3 of the alternative billing flow. /// Must be called AFTER successful payment in your payment system. @@ -5256,32 +5256,32 @@ abstract class MutationResolver { /// /// Returns token string, or null if creation failed. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + /// See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android Future createAlternativeBillingTokenAndroid(); /// Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). /// Replaces the deprecated createExternalOfferReportingDetailsAsync API. /// /// Returns external transaction token needed for reporting external transactions. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + /// See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android Future createBillingProgramReportingDetailsAndroid(BillingProgramAndroid program); /// Open the platform's subscription management UI. - /// See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions Future deepLinkToSubscriptions({ String? packageNameAndroid, String? skuAndroid, }); /// Close the store connection and release resources. - /// See: https://www.openiap.dev/docs/apis/end-connection + /// See: https://openiap.dev/docs/apis/end-connection Future endConnection(); /// Complete a transaction after server-side verification. Required on Android within 3 days. - /// See: https://www.openiap.dev/docs/apis/finish-transaction + /// See: https://openiap.dev/docs/apis/finish-transaction Future finishTransaction({ required PurchaseInput purchase, bool? isConsumable, }); /// Initialize the store connection. Call before any IAP API. - /// See: https://www.openiap.dev/docs/apis/init-connection + /// See: https://openiap.dev/docs/apis/init-connection Future initConnection({ AlternativeBillingModeAndroid? alternativeBillingModeAndroid, BillingProgramAndroid? enableBillingProgramAndroid, @@ -5292,14 +5292,14 @@ abstract class MutationResolver { /// Available in Google Play Billing Library 8.2.0+. /// Returns availability result with isAvailable flag. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + /// See: https://openiap.dev/docs/apis/android/is-billing-program-available-android Future isBillingProgramAvailableAndroid(BillingProgramAndroid program); /// Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). /// Replaces the deprecated showExternalOfferInformationDialog API. /// /// Shows Play Store dialog and optionally launches external URL. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + /// See: https://openiap.dev/docs/apis/android/launch-external-link-android Future launchExternalLinkAndroid({ required BillingProgramAndroid billingProgram, required ExternalLinkLaunchModeAndroid launchMode, @@ -5307,49 +5307,49 @@ abstract class MutationResolver { required String linkUri, }); /// Show the App Store offer code redemption sheet. - /// See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios Future presentCodeRedemptionSheetIOS(); /// Present an external purchase link, StoreKit External (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios Future presentExternalPurchaseLinkIOS(String url); /// Present the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios Future presentExternalPurchaseNoticeSheetIOS(); /// Initiate a purchase or subscription flow; rely on events for final state. - /// See: https://www.openiap.dev/docs/apis/request-purchase + /// See: https://openiap.dev/docs/apis/request-purchase Future requestPurchase(RequestPurchaseProps params); /// Buy the currently promoted product. /// /// @deprecated Use promotedProductListenerIOS to receive the productId, /// then call requestPurchase with that SKU instead. In StoreKit 2, /// promoted products can be purchased directly via the standard purchase flow. - /// See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios Future requestPurchaseOnPromotedProductIOS(); /// Restore non-consumable and active subscription purchases. - /// See: https://www.openiap.dev/docs/apis/restore-purchases + /// See: https://openiap.dev/docs/apis/restore-purchases Future restorePurchases(); /// Display Google's alternative billing information dialog. Step 2 of the alternative billing flow. /// Must be called BEFORE processing payment in your payment system. /// /// Returns true if user accepted, false if user canceled. /// Throws OpenIapError.NotPrepared if billing client not ready. - /// See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + /// See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android Future showAlternativeBillingDialogAndroid(); /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). /// Call this after a deliberate customer interaction before linking out to external purchases. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - /// See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios Future showExternalPurchaseCustomLinkNoticeIOS(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType); /// Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios Future> showManageSubscriptionsIOS(); /// Force sync transactions with the App Store (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/sync-ios + /// See: https://openiap.dev/docs/apis/ios/sync-ios Future syncIOS(); /// Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase Future validateReceipt({ VerifyPurchaseAppleOptions? apple, VerifyPurchaseGoogleOptions? google, @@ -5360,7 +5360,7 @@ abstract class MutationResolver { /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. /// Inspect the concrete variant before reading fields. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase Future verifyPurchase({ VerifyPurchaseAppleOptions? apple, VerifyPurchaseGoogleOptions? google, @@ -5369,7 +5369,7 @@ abstract class MutationResolver { /// Verify via a managed provider without standing up your own server. The /// PurchaseVerificationProvider enum currently exposes only IAPKit; platform /// availability may differ by implementation. - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + /// See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider Future verifyPurchaseWithProvider({ RequestVerifyPurchaseWithIapkitProps? iapkit, required PurchaseVerificationProvider provider, @@ -5380,31 +5380,31 @@ abstract class MutationResolver { abstract class QueryResolver { /// Check eligibility for the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.canPresent. - /// See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios Future canPresentExternalPurchaseNoticeIOS(); /// Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + /// See: https://openiap.dev/docs/apis/ios/current-entitlement-ios Future currentEntitlementIOS(String sku); /// Fetch products or subscriptions from the store. - /// See: https://www.openiap.dev/docs/apis/fetch-products + /// See: https://openiap.dev/docs/apis/fetch-products Future fetchProducts({ required List skus, ProductQueryType? type, }); /// Get details of all currently active subscriptions (filters by subscriptionIds when provided). - /// See: https://www.openiap.dev/docs/apis/get-active-subscriptions + /// See: https://openiap.dev/docs/apis/get-active-subscriptions Future> getActiveSubscriptions([List? subscriptionIds]); /// List every StoreKit transaction (finished + unfinished) for the current user. /// Requires the SK2ConsumableTransactionHistory Info.plist key in the host app /// for finished consumables to be included (iOS 18+). /// Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - /// See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios Future> getAllTransactionsIOS(); /// Fetch the app transaction (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios Future getAppTransactionIOS(); /// List active purchases for the current user. - /// See: https://www.openiap.dev/docs/apis/get-available-purchases + /// See: https://openiap.dev/docs/apis/get-available-purchases Future> getAvailablePurchases({ bool? alsoPublishToEventListenerIOS, bool? includeSuspendedAndroid, @@ -5413,48 +5413,48 @@ abstract class QueryResolver { /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). /// Use this token to report transactions made through ExternalPurchaseCustomLink. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - /// See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios Future getExternalPurchaseCustomLinkTokenIOS(ExternalPurchaseCustomLinkTokenTypeIOS tokenType); /// List unfinished StoreKit transactions in the queue. - /// See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios Future> getPendingTransactionsIOS(); /// Read the App Store-promoted product, if any (iOS 11+). - /// See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios Future getPromotedProductIOS(); /// Get base64-encoded receipt data (legacy validation). - /// See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + /// See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios Future getReceiptDataIOS(); /// Return the user's storefront country code. - /// See: https://www.openiap.dev/docs/apis/get-storefront + /// See: https://openiap.dev/docs/apis/get-storefront Future getStorefront(); /// Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - /// See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios Future getStorefrontIOS(); /// Return the JWS string for a transaction (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios Future getTransactionJwsIOS(String sku); /// Check whether the user has any active subscription. - /// See: https://www.openiap.dev/docs/apis/has-active-subscriptions + /// See: https://openiap.dev/docs/apis/has-active-subscriptions Future hasActiveSubscriptions([List? subscriptionIds]); /// Check eligibility for the custom-link variant of external purchase (iOS 18.1+). /// Returns true if the app can use custom external purchase links. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios Future isEligibleForExternalPurchaseCustomLinkIOS(); /// Check intro-offer eligibility for a subscription group. - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios Future isEligibleForIntroOfferIOS(String groupID); /// Check whether a transaction's JWS verification passed (StoreKit 2). - /// See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios Future isTransactionVerifiedIOS(String sku); /// Get the latest verified transaction for a product, using StoreKit 2. - /// See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/latest-transaction-ios Future latestTransactionIOS(String sku); /// Get subscription status objects from StoreKit 2 (iOS 15+). - /// See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + /// See: https://openiap.dev/docs/apis/ios/subscription-status-ios Future> subscriptionStatusIOS(String sku); /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios Future validateReceiptIOS({ VerifyPurchaseAppleOptions? apple, VerifyPurchaseGoogleOptions? google, diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index fc6fef35..fcce2ade 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -587,12 +587,12 @@ export interface LimitedQuantityInfoAndroid { export interface Mutation { /** * Acknowledge a non-consumable purchase. Required within 3 days or Google auto-refunds. - * See: https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android + * See: https://openiap.dev/docs/apis/android/acknowledge-purchase-android */ acknowledgePurchaseAndroid: Promise; /** * Present the refund request sheet (iOS 15+). See also Features → Refund. - * See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + * See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios */ beginRefundRequestIOS?: Promise<(string | null)>; /** @@ -600,17 +600,17 @@ export interface Mutation { * * Returns true if available, false otherwise. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + * See: https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android */ checkAlternativeBillingAvailabilityAndroid: Promise; /** * Clear pending transactions in the queue (sandbox helper). - * See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + * See: https://openiap.dev/docs/apis/ios/clear-transaction-ios */ clearTransactionIOS: Promise; /** * Consume a consumable purchase so it can be re-bought. - * See: https://www.openiap.dev/docs/apis/android/consume-purchase-android + * See: https://openiap.dev/docs/apis/android/consume-purchase-android */ consumePurchaseAndroid: Promise; /** @@ -620,7 +620,7 @@ export interface Mutation { * * Returns token string, or null if creation failed. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + * See: https://openiap.dev/docs/apis/android/create-alternative-billing-token-android */ createAlternativeBillingTokenAndroid?: Promise<(string | null)>; /** @@ -629,27 +629,27 @@ export interface Mutation { * * Returns external transaction token needed for reporting external transactions. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + * See: https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ createBillingProgramReportingDetailsAndroid: Promise; /** * Open the platform's subscription management UI. - * See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + * See: https://openiap.dev/docs/apis/deep-link-to-subscriptions */ deepLinkToSubscriptions: Promise; /** * Close the store connection and release resources. - * See: https://www.openiap.dev/docs/apis/end-connection + * See: https://openiap.dev/docs/apis/end-connection */ endConnection: Promise; /** * Complete a transaction after server-side verification. Required on Android within 3 days. - * See: https://www.openiap.dev/docs/apis/finish-transaction + * See: https://openiap.dev/docs/apis/finish-transaction */ finishTransaction: Promise; /** * Initialize the store connection. Call before any IAP API. - * See: https://www.openiap.dev/docs/apis/init-connection + * See: https://openiap.dev/docs/apis/init-connection */ initConnection: Promise; /** @@ -659,7 +659,7 @@ export interface Mutation { * Available in Google Play Billing Library 8.2.0+. * Returns availability result with isAvailable flag. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + * See: https://openiap.dev/docs/apis/android/is-billing-program-available-android */ isBillingProgramAvailableAndroid: Promise; /** @@ -668,29 +668,29 @@ export interface Mutation { * * Shows Play Store dialog and optionally launches external URL. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/launch-external-link-android + * See: https://openiap.dev/docs/apis/android/launch-external-link-android */ launchExternalLinkAndroid: Promise; /** * Show the App Store offer code redemption sheet. - * See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios */ presentCodeRedemptionSheetIOS: Promise; /** * Present an external purchase link, StoreKit External (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios */ presentExternalPurchaseLinkIOS: Promise; /** * Present the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. * Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() - * See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + * See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios */ presentExternalPurchaseNoticeSheetIOS: Promise; /** * Initiate a purchase or subscription flow; rely on events for final state. - * See: https://www.openiap.dev/docs/apis/request-purchase + * See: https://openiap.dev/docs/apis/request-purchase */ requestPurchase?: Promise<(Purchase | Purchase[] | null)>; /** @@ -699,13 +699,13 @@ export interface Mutation { * @deprecated Use promotedProductListenerIOS to receive the productId, * then call requestPurchase with that SKU instead. In StoreKit 2, * promoted products can be purchased directly via the standard purchase flow. - * See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios * @deprecated Use promotedProductListenerIOS + requestPurchase instead */ requestPurchaseOnPromotedProductIOS: Promise; /** * Restore non-consumable and active subscription purchases. - * See: https://www.openiap.dev/docs/apis/restore-purchases + * See: https://openiap.dev/docs/apis/restore-purchases */ restorePurchases: Promise; /** @@ -714,29 +714,29 @@ export interface Mutation { * * Returns true if user accepted, false if user canceled. * Throws OpenIapError.NotPrepared if billing client not ready. - * See: https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + * See: https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ showAlternativeBillingDialogAndroid: Promise; /** * Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). * Call this after a deliberate customer interaction before linking out to external purchases. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) - * See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + * See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios */ showExternalPurchaseCustomLinkNoticeIOS: Promise; /** * Present the manage-subscriptions sheet and return changed purchases (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + * See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios */ showManageSubscriptionsIOS: Promise; /** * Force sync transactions with the App Store (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/sync-ios + * See: https://openiap.dev/docs/apis/ios/sync-ios */ syncIOS: Promise; /** * Deprecated. Validate purchase receipts with the configured providers — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase * @deprecated Use verifyPurchase */ validateReceipt: Promise; @@ -746,14 +746,14 @@ export interface Mutation { * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. * Inspect the concrete variant before reading fields. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase + * See: https://openiap.dev/docs/features/validation#verify-purchase */ verifyPurchase: Promise; /** * Verify via a managed provider without standing up your own server. The * PurchaseVerificationProvider enum currently exposes only IAPKit; platform * availability may differ by implementation. - * See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + * See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider */ verifyPurchaseWithProvider: Promise; } @@ -1307,22 +1307,22 @@ export interface Query { /** * Check eligibility for the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.canPresent. - * See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + * See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios */ canPresentExternalPurchaseNoticeIOS: Promise; /** * Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + * See: https://openiap.dev/docs/apis/ios/current-entitlement-ios */ currentEntitlementIOS?: Promise<(PurchaseIOS | null)>; /** * Fetch products or subscriptions from the store. - * See: https://www.openiap.dev/docs/apis/fetch-products + * See: https://openiap.dev/docs/apis/fetch-products */ fetchProducts: Promise<(ProductOrSubscription[] | Product[] | ProductSubscription[] | null)>; /** * Get details of all currently active subscriptions (filters by subscriptionIds when provided). - * See: https://www.openiap.dev/docs/apis/get-active-subscriptions + * See: https://openiap.dev/docs/apis/get-active-subscriptions */ getActiveSubscriptions: Promise; /** @@ -1330,92 +1330,92 @@ export interface Query { * Requires the SK2ConsumableTransactionHistory Info.plist key in the host app * for finished consumables to be included (iOS 18+). * Unlike getAvailablePurchases, always returns the iOS-specific PurchaseIOS shape. - * See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios */ getAllTransactionsIOS: Promise; /** * Fetch the app transaction (iOS 16+). - * See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + * See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios */ getAppTransactionIOS?: Promise<(AppTransaction | null)>; /** * List active purchases for the current user. - * See: https://www.openiap.dev/docs/apis/get-available-purchases + * See: https://openiap.dev/docs/apis/get-available-purchases */ getAvailablePurchases: Promise; /** * Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). * Use this token to report transactions made through ExternalPurchaseCustomLink. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) - * See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + * See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios */ getExternalPurchaseCustomLinkTokenIOS: Promise; /** * List unfinished StoreKit transactions in the queue. - * See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + * See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios */ getPendingTransactionsIOS: Promise; /** * Read the App Store-promoted product, if any (iOS 11+). - * See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + * See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios */ getPromotedProductIOS?: Promise<(ProductIOS | null)>; /** * Get base64-encoded receipt data (legacy validation). - * See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + * See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios */ getReceiptDataIOS?: Promise<(string | null)>; /** * Return the user's storefront country code. - * See: https://www.openiap.dev/docs/apis/get-storefront + * See: https://openiap.dev/docs/apis/get-storefront */ getStorefront: Promise; /** * Deprecated. Get the current App Store storefront country code — use cross-platform getStorefront instead. - * See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + * See: https://openiap.dev/docs/apis/ios/get-storefront-ios * @deprecated Use getStorefront */ getStorefrontIOS: Promise; /** * Return the JWS string for a transaction (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + * See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios */ getTransactionJwsIOS?: Promise<(string | null)>; /** * Check whether the user has any active subscription. - * See: https://www.openiap.dev/docs/apis/has-active-subscriptions + * See: https://openiap.dev/docs/apis/has-active-subscriptions */ hasActiveSubscriptions: Promise; /** * Check eligibility for the custom-link variant of external purchase (iOS 18.1+). * Returns true if the app can use custom external purchase links. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios */ isEligibleForExternalPurchaseCustomLinkIOS: Promise; /** * Check intro-offer eligibility for a subscription group. - * See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + * See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios */ isEligibleForIntroOfferIOS: Promise; /** * Check whether a transaction's JWS verification passed (StoreKit 2). - * See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + * See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios */ isTransactionVerifiedIOS: Promise; /** * Get the latest verified transaction for a product, using StoreKit 2. - * See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + * See: https://openiap.dev/docs/apis/ios/latest-transaction-ios */ latestTransactionIOS?: Promise<(PurchaseIOS | null)>; /** * Get subscription status objects from StoreKit 2 (iOS 15+). - * See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + * See: https://openiap.dev/docs/apis/ios/subscription-status-ios */ subscriptionStatusIOS: Promise; /** * Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. - * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + * See: https://openiap.dev/docs/apis/ios/validate-receipt-ios * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; From 01136dc8772d135e0653b7ca59a0ab8269b43987 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 16 May 2026 14:39:04 +0900 Subject: [PATCH 03/26] feat(apple): align native purchase APIs Update Apple package implementation, wrappers, examples, and release scripts for the shared API changes. --- packages/apple/.gitignore | 3 +- packages/apple/CONTRIBUTING.md | 6 +- .../Screens/WebhookStreamScreen.swift | 2 +- .../Screens/uis/PurchaseDetailSheet.swift | 4 +- packages/apple/Example/workspace-state.json | 27 ----- packages/apple/README.md | 6 +- .../apple/Sources/OpenIapModule+ObjC.swift | 46 ++++++-- packages/apple/Sources/OpenIapModule.swift | 95 ++++++++-------- packages/apple/Sources/OpenIapProtocol.swift | 56 ++++++++++ packages/apple/Sources/OpenIapStore.swift | 35 +++++- packages/apple/Sources/OpenIapVersion.swift | 63 ++++++++++- packages/apple/openiap.podspec | 5 + packages/apple/package.json | 4 +- packages/apple/scripts/build-xcframework.sh | 27 ++++- packages/apple/scripts/bump-version.sh | 103 +++++++++--------- packages/apple/wrapper/project.yml | 2 +- 16 files changed, 328 insertions(+), 156 deletions(-) delete mode 100644 packages/apple/Example/workspace-state.json diff --git a/packages/apple/.gitignore b/packages/apple/.gitignore index 8ff2363d..68a17815 100644 --- a/packages/apple/.gitignore +++ b/packages/apple/.gitignore @@ -5,6 +5,7 @@ .build/ .swiftpm/ Package.resolved +workspace-state.json # xcodegen output for the wrapper project that produces OpenIAP.xcframework. # project.yml is the SoT; the .xcodeproj is regenerated by build-xcframework.sh. @@ -41,4 +42,4 @@ Thumbs.db *.temp *~.nib *.swp -*.log \ No newline at end of file +*.log diff --git a/packages/apple/CONTRIBUTING.md b/packages/apple/CONTRIBUTING.md index 03a11aef..40700cf0 100644 --- a/packages/apple/CONTRIBUTING.md +++ b/packages/apple/CONTRIBUTING.md @@ -8,7 +8,7 @@ Thank you for your interest in contributing! We love your input and appreciate y 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 3. Make your changes 4. Run tests (`swift test`) -5. Commit your changes (`git commit -m 'Add amazing feature'`) +5. Commit your changes (`git commit -m 'feat(apple): add amazing feature'`) 6. Push to your branch (`git push origin feature/amazing-feature`) 7. Open a Pull Request @@ -16,8 +16,8 @@ Thank you for your interest in contributing! We love your input and appreciate y ```bash # Clone your fork -git clone https://github.com/YOUR_USERNAME/openiap-apple.git -cd openiap-apple +git clone https://github.com/YOUR_USERNAME/openiap.git +cd openiap/packages/apple # Open in Xcode open Package.swift diff --git a/packages/apple/Example/OpenIapExample/Screens/WebhookStreamScreen.swift b/packages/apple/Example/OpenIapExample/Screens/WebhookStreamScreen.swift index d7c20aa8..b1cd8383 100644 --- a/packages/apple/Example/OpenIapExample/Screens/WebhookStreamScreen.swift +++ b/packages/apple/Example/OpenIapExample/Screens/WebhookStreamScreen.swift @@ -30,7 +30,7 @@ struct WebhookStreamScreen: View { VStack(alignment: .leading, spacing: 8) { Text("SSE /v1/webhooks/stream/{apiKey}") .font(.headline) - Text("api key: \(apiKey.isEmpty ? "MISSING" : "\(apiKey.prefix(8))...")") + Text("api key: \(apiKey.isEmpty ? "MISSING" : "CONFIGURED")") .font(.caption) .foregroundColor(.secondary) } diff --git a/packages/apple/Example/OpenIapExample/Screens/uis/PurchaseDetailSheet.swift b/packages/apple/Example/OpenIapExample/Screens/uis/PurchaseDetailSheet.swift index 550e5491..bab2ba25 100644 --- a/packages/apple/Example/OpenIapExample/Screens/uis/PurchaseDetailSheet.swift +++ b/packages/apple/Example/OpenIapExample/Screens/uis/PurchaseDetailSheet.swift @@ -29,7 +29,7 @@ struct PurchaseDetailSheet: View { items.append(DetailItem(label: "Transaction Date", value: formattedDate(purchase.transactionDate))) if let token = purchase.purchaseToken, token.isEmpty == false { - items.append(DetailItem(label: "Purchase Token", value: token)) + items.append(DetailItem(label: "Purchase Token", value: "")) } return items @@ -48,7 +48,7 @@ struct PurchaseDetailSheet: View { items.append(DetailItem(label: "Original Transaction ID", value: originalId)) } if let token = purchase.appAccountToken, token.isEmpty == false { - items.append(DetailItem(label: "App Account Token", value: token)) + items.append(DetailItem(label: "App Account Token", value: "")) } if let expiration = purchase.expirationDateIOS { items.append(DetailItem(label: "Expiration Date", value: formattedDate(expiration))) diff --git a/packages/apple/Example/workspace-state.json b/packages/apple/Example/workspace-state.json deleted file mode 100644 index 5b0ae04d..00000000 --- a/packages/apple/Example/workspace-state.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "object" : { - "artifacts" : [ - - ], - "dependencies" : [ - { - "basedOn" : null, - "packageRef" : { - "identity" : "openiap-apple", - "kind" : "fileSystem", - "location" : "/Users/hyo/Github/hyodotdev/openiap-apple", - "name" : "OpenIAP" - }, - "state" : { - "name" : "fileSystem", - "path" : "/Users/hyo/Github/hyodotdev/openiap-apple" - }, - "subpath" : "openiap-apple" - } - ], - "prebuilts" : [ - - ] - }, - "version" : 7 -} \ No newline at end of file diff --git a/packages/apple/README.md b/packages/apple/README.md index e24a3e45..6266fbcb 100644 --- a/packages/apple/README.md +++ b/packages/apple/README.md @@ -50,7 +50,7 @@ Add to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/hyodotdev/openiap.git", from: "$version") + .package(url: "https://github.com/hyodotdev/openiap.git", from: "") ] ``` @@ -59,10 +59,10 @@ dependencies: [ Add to your `Podfile`: ```ruby -pod 'openiap', '~> $version' +pod 'openiap', '~> ' ``` -> Check [`openiap-versions.json`](../../openiap-versions.json) for the current version. +Use the latest version from the Swift Package / CocoaPods badges above. ## Quick Start diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index c69d94aa..0777c3ca 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -159,6 +159,37 @@ import StoreKit } } + @objc func requestPurchaseWithPayload( + _ payload: [String: Any], + completion: @escaping (Any?, Error?) -> Void + ) { + Task { + do { + let props = try OpenIapSerialization.requestPurchaseProps(from: payload) + let result = try await requestPurchase(props) + + switch result { + case .purchase(let purchase): + if let purchase = purchase { + completion(OpenIapSerialization.purchase(purchase), nil) + } else { + completion(nil, nil) + } + case .purchases(let purchases): + if let firstPurchase = purchases?.first { + completion(OpenIapSerialization.purchase(firstPurchase), nil) + } else { + completion(nil, nil) + } + case .none: + completion(nil, nil) + } + } catch { + completion(nil, error) + } + } + } + @objc func requestSubscriptionWithSku( _ sku: String, offer: [String: Any]?, @@ -333,14 +364,13 @@ import StoreKit @available(*, deprecated, message: "Use promotedProductListenerIOS + requestPurchase instead") @objc func requestPurchaseOnPromotedProductIOSWithCompletion(_ completion: @escaping (Bool, Error?) -> Void) { - Task { - do { - let result = try await requestPurchaseOnPromotedProductIOS() - completion(result, nil) - } catch { - completion(false, error) - } - } + completion( + false, + PurchaseError.make( + code: .featureNotSupported, + message: "Use promotedProductListenerIOS + requestPurchase instead" + ) + ) } @objc func deepLinkToSubscriptionsWithCompletion(_ completion: @escaping (Error?) -> Void) { diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 66fc792f..7ff15a3f 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -56,7 +56,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - Note: This wraps `OpenIapStoreKit2.initialize()`. Safe to call multiple times — the /// second call is a no-op. /// - /// See: https://www.openiap.dev/docs/apis/init-connection + /// See: https://openiap.dev/docs/apis/init-connection public func initConnection() async throws -> Bool { while true { if let endTask = connection.currentEndTask() { @@ -95,7 +95,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Close the store connection and release resources. - /// See: https://www.openiap.dev/docs/apis/end-connection + /// See: https://openiap.dev/docs/apis/end-connection public func endConnection() async throws -> Bool { let task = connection.makeEndTask { [weak self] in guard let self else { return } @@ -118,7 +118,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - Note: This is a regular promise-based call. Do not confuse with `request*` APIs, /// which are event-based. /// - /// See: https://www.openiap.dev/docs/apis/fetch-products + /// See: https://openiap.dev/docs/apis/fetch-products public func fetchProducts(_ params: ProductRequest) async throws -> FetchProductsResult { guard !params.skus.isEmpty else { let error = makePurchaseError(code: .emptySkuList) @@ -218,7 +218,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Read the App Store-promoted product, if any. - /// See: https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios public func getPromotedProductIOS() async throws -> ProductIOS? { // iOS-only: Promoted in-app purchases (App Store promotional purchases) only available on iOS // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases @@ -267,7 +267,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - Throws: Synchronous rejections from StoreKit (e.g. user cancel before sheet, not prepared). /// - Warning: Event-based. Listen via `purchaseUpdatedListener` / `purchaseErrorListener`. /// - /// See: https://www.openiap.dev/docs/apis/request-purchase + /// See: https://openiap.dev/docs/apis/request-purchase public func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? { try await ensureConnection() let iosProps = try resolveIOSPurchaseProps(from: params) @@ -417,14 +417,14 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Buy the currently promoted product. - /// See: https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios + /// See: https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios @available(*, deprecated, message: "Use promotedProductListenerIOS + requestPurchase instead") public func requestPurchaseOnPromotedProductIOS() async throws -> Bool { throw makePurchaseError(code: .featureNotSupported) } /// Restore non-consumable and active subscription purchases. - /// See: https://www.openiap.dev/docs/apis/restore-purchases + /// See: https://openiap.dev/docs/apis/restore-purchases public func restorePurchases() async throws -> Void { _ = try await syncIOS() } @@ -440,7 +440,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - Returns: An array of `Purchase` values matching the selected scope. /// - Throws: When the StoreKit query fails. /// - /// See: https://www.openiap.dev/docs/apis/get-available-purchases + /// See: https://openiap.dev/docs/apis/get-available-purchases public func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [Purchase] { try await ensureConnection() let onlyActive = options?.onlyIncludeActiveItemsIOS ?? false @@ -476,7 +476,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// `Transaction.all` and returns the iOS-specific `PurchaseIOS` shape rather than /// the cross-platform `Purchase` type. /// - /// See: https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-all-transactions-ios public func getAllTransactionsIOS() async throws -> [PurchaseIOS] { try await ensureConnection() var transactions: [PurchaseIOS] = [] @@ -513,7 +513,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - Important: iOS unfinished transactions replay on every app launch. (Android purchases /// must be acknowledged within 3 days, but that path lives in the Android module.) /// - /// See: https://www.openiap.dev/docs/apis/finish-transaction + /// See: https://openiap.dev/docs/apis/finish-transaction public func finishTransaction(purchase: PurchaseInput, isConsumable: Bool?) async throws -> Void { try await ensureConnection() let identifier = purchase.id @@ -560,7 +560,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// List unfinished StoreKit transactions. - /// See: https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios + /// See: https://openiap.dev/docs/apis/ios/get-pending-transactions-ios public func getPendingTransactionsIOS() async throws -> [PurchaseIOS] { try await ensureConnection() let snapshot = await state.pendingSnapshot() @@ -572,7 +572,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Clear pending transactions in the queue (sandbox helper). - /// See: https://www.openiap.dev/docs/apis/ios/clear-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/clear-transaction-ios public func clearTransactionIOS() async throws -> Bool { try await ensureConnection() for await result in Transaction.unfinished { @@ -588,7 +588,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Check whether a transaction's JWS verification passed. - /// See: https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios + /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios public func isTransactionVerifiedIOS(sku: String) async throws -> Bool { try await ensureConnection() let product = try await storeProduct(for: sku) @@ -602,7 +602,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Return the JWS string for a transaction. - /// See: https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios + /// See: https://openiap.dev/docs/apis/ios/get-transaction-jws-ios public func getTransactionJwsIOS(sku: String) async throws -> String? { try await ensureConnection() let product = try await storeProduct(for: sku) @@ -617,7 +617,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Validation /// Get base64 receipt data (legacy validation). - /// See: https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios + /// See: https://openiap.dev/docs/apis/ios/get-receipt-data-ios public func getReceiptDataIOS() async throws -> String? { guard let receiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: receiptURL.path) else { @@ -628,7 +628,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Deprecated. Legacy App Store receipt validation. Use `verifyPurchase` instead. - /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios + /// See: https://openiap.dev/docs/apis/ios/validate-receipt-ios @available(*, deprecated, message: "Use verifyPurchase") public func validateReceiptIOS(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS { try await performVerifyPurchaseIOS(props) @@ -669,14 +669,14 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Deprecated. Use verifyPurchase instead — same input/output shape. - /// See: https://www.openiap.dev/docs/apis/validate-receipt + /// See: https://openiap.dev/docs/apis/validate-receipt @available(*, deprecated, message: "Use verifyPurchase") public func validateReceipt(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { try await verifyPurchase(props) } /// Verify a purchase against your own backend (returns isValid + raw store metadata). - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase + /// See: https://openiap.dev/docs/features/validation#verify-purchase public func verifyPurchase(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { try await ensureConnection() let iosResult = try await performVerifyPurchaseIOS(props) @@ -684,7 +684,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Verify via a managed provider (currently IAPKit; the PurchaseVerificationProvider enum exposes only Iapkit today). - /// See: https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider + /// See: https://openiap.dev/docs/features/validation#verify-purchase-with-provider public func verifyPurchaseWithProvider(_ props: VerifyPurchaseWithProviderProps) async throws -> VerifyPurchaseWithProviderResult { try await ensureConnection() guard props.provider == .iapkit else { @@ -724,13 +724,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // Log request details for debugging OpenIapLog.debug("IAPKit request URL: \(url.absoluteString)") - if let requestBody = String(data: body, encoding: .utf8) { - // Truncate JWS for readability (keep first/last 50 chars) - let truncatedBody = requestBody.count > 200 - ? String(requestBody.prefix(100)) + "..." + String(requestBody.suffix(50)) - : requestBody - OpenIapLog.debug("IAPKit request body: \(truncatedBody)") - } + OpenIapLog.debug("IAPKit request body: , bytes=\(body.count)") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { @@ -738,7 +732,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } guard (200...299).contains(httpResponse.statusCode) else { let responseBody = String(data: data, encoding: .utf8) ?? "" - OpenIapLog.warn("verifyPurchaseWithProvider failed (HTTP \(httpResponse.statusCode)): \(responseBody)") + OpenIapLog.warn("verifyPurchaseWithProvider failed (HTTP \(httpResponse.statusCode))") // Extract concise error message from IAPKit response var errorMessage = "HTTP \(httpResponse.statusCode)" if let jsonData = responseBody.data(using: .utf8), @@ -748,14 +742,13 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { throw makePurchaseError(code: .receiptFailed, message: errorMessage) } - // Log raw response for debugging - let jsonString = String(data: data, encoding: .utf8) ?? "" - OpenIapLog.info("IAPKit raw response: \(jsonString)") + // Log only response metadata; the body can contain receipt details. + OpenIapLog.debug("IAPKit verification response received: bytes=\(data.count)") // Parse manually to handle extra fields from IAPKit // API response format: { "store": "apple", "isValid": true, "state": "PURCHASED" } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - OpenIapLog.warn("Failed to parse IAPKit verification response. Raw: \(jsonString)") + OpenIapLog.warn("Failed to parse IAPKit verification response") throw makePurchaseError(code: .receiptFailed, message: "Unable to parse verification response") } @@ -853,7 +846,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Store Information /// Return the user's storefront country code. - /// See: https://www.openiap.dev/docs/apis/get-storefront + /// See: https://openiap.dev/docs/apis/get-storefront public func getStorefront() async throws -> String { try await ensureConnection() guard let storefront = await Storefront.current else { @@ -865,7 +858,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Deprecated. Use cross-platform `getStorefront` instead. - /// See: https://www.openiap.dev/docs/apis/ios/get-storefront-ios + /// See: https://openiap.dev/docs/apis/ios/get-storefront-ios public func getStorefrontIOS() async throws -> String { try await getStorefront() } @@ -874,7 +867,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - Note: Available on iOS 16.0+, macOS 14.0+, tvOS 16.0+, watchOS 9.0+ /// - SeeAlso: https://developer.apple.com/documentation/storekit/apptransaction /// - /// See: https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/get-app-transaction-ios @available(iOS 16.0, macOS 14.0, tvOS 16.0, watchOS 9.0, *) public func getAppTransactionIOS() async throws -> AppTransaction? { try await ensureConnection() @@ -890,7 +883,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Subscription Management /// Get details of all currently active subscriptions. - /// See: https://www.openiap.dev/docs/apis/get-active-subscriptions + /// See: https://openiap.dev/docs/apis/get-active-subscriptions public func getActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> [ActiveSubscription] { try await ensureConnection() var allSubscriptions: [ActiveSubscription] = [] @@ -952,7 +945,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Check whether the user has any active subscription. - /// See: https://www.openiap.dev/docs/apis/has-active-subscriptions + /// See: https://openiap.dev/docs/apis/has-active-subscriptions public func hasActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> Bool { let subscriptions = try await getActiveSubscriptions(subscriptionIds) return subscriptions.contains { $0.isActive } @@ -962,7 +955,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - Note: Available on iOS 15.0+, iPadOS 15.0+, Mac Catalyst 15.0+, macOS 14.0+, visionOS 1.0+. Not available on tvOS (subscriptions are managed in Settings > Accounts) or watchOS. /// - SeeAlso: https://developer.apple.com/documentation/storekit/appstore/showmanagesubscriptions(in:) /// - /// See: https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + /// See: https://openiap.dev/docs/apis/deep-link-to-subscriptions public func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void { try await ensureConnection() // tvOS: AppStore.showManageSubscriptions not available on tvOS (subscriptions managed in Settings > Accounts) @@ -987,7 +980,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Get subscription status objects from StoreKit 2. - /// See: https://www.openiap.dev/docs/apis/ios/subscription-status-ios + /// See: https://openiap.dev/docs/apis/ios/subscription-status-ios public func subscriptionStatusIOS(sku: String) async throws -> [SubscriptionStatusIOS] { try await ensureConnection() let product = try await storeProduct(for: sku) @@ -1025,7 +1018,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Get the user's current entitlement for a product. - /// See: https://www.openiap.dev/docs/apis/ios/current-entitlement-ios + /// See: https://openiap.dev/docs/apis/ios/current-entitlement-ios public func currentEntitlementIOS(sku: String) async throws -> PurchaseIOS? { try await ensureConnection() let product = try await storeProduct(for: sku) @@ -1041,7 +1034,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Get the latest verified transaction for a product. - /// See: https://www.openiap.dev/docs/apis/ios/latest-transaction-ios + /// See: https://openiap.dev/docs/apis/ios/latest-transaction-ios public func latestTransactionIOS(sku: String) async throws -> PurchaseIOS? { try await ensureConnection() let product = try await storeProduct(for: sku) @@ -1062,7 +1055,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - Note: Available on iOS 15.0+, iPadOS 15.0+, Mac Catalyst 15.0+, macOS 12.0+, visionOS 1.0+. Not available on tvOS or watchOS. /// - SeeAlso: https://developer.apple.com/documentation/storekit/transaction/3803220-beginrefundrequest /// - /// See: https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios + /// See: https://openiap.dev/docs/apis/ios/begin-refund-request-ios public func beginRefundRequestIOS(sku: String) async throws -> String? { try await ensureConnection() // tvOS: Transaction.beginRefundRequest not available on tvOS @@ -1110,7 +1103,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// Check if the user is eligible for an introductory offer for a subscription group /// - SeeAlso: https://developer.apple.com/documentation/storekit/product/subscriptioninfo/iseligibleforintrooffer(for:) /// - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios public func isEligibleForIntroOfferIOS(groupID: String) async throws -> Bool { try await ensureConnection() return await StoreKit.Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID) @@ -1119,7 +1112,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// Sync the user's in-app purchases with the App Store /// - SeeAlso: https://developer.apple.com/documentation/storekit/appstore/sync() /// - /// See: https://www.openiap.dev/docs/apis/ios/sync-ios + /// See: https://openiap.dev/docs/apis/ios/sync-ios public func syncIOS() async throws -> Bool { try await ensureConnection() do { @@ -1134,7 +1127,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - Note: Only available on iOS 14.0+ and Mac Catalyst. Not available on tvOS, macOS, or watchOS /// - SeeAlso: https://developer.apple.com/documentation/storekit/skpaymentqueue/3566726-presentcoderedemptionsheet /// - /// See: https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios public func presentCodeRedemptionSheetIOS() async throws -> Bool { try await ensureConnection() // presentCodeRedemptionSheet is only available on iOS, not tvOS/watchOS/macOS @@ -1149,7 +1142,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Present the manage-subscriptions sheet. - /// See: https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios + /// See: https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios public func showManageSubscriptionsIOS() async throws -> [PurchaseIOS] { try await deepLinkToSubscriptions(nil) return [] @@ -1158,7 +1151,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - External Purchase (iOS 17.4+, macOS 14.4+, tvOS 17.4+, visionOS 1.1+) /// Check eligibility for the external purchase notice sheet (iOS 17.4+). - /// See: https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios public func canPresentExternalPurchaseNoticeIOS() async throws -> Bool { try await ensureConnection() // iOS 17.4+, macOS 14.4+, tvOS 17.4+, watchOS 10.4+, visionOS 1.1+: ExternalPurchase.canPresent @@ -1171,7 +1164,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Present the external purchase notice sheet (iOS 17.4+). - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios public func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS { try await ensureConnection() // iOS 17.4+, macOS 14.4+, tvOS 17.4+, watchOS 10.4+, visionOS 1.1+: ExternalPurchase.presentNoticeSheet @@ -1235,7 +1228,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Present an external purchase link, StoreKit External (iOS 16+). - /// See: https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios + /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios public func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS { try await ensureConnection() // UIApplication.open is available on iOS/tvOS/visionOS but not watchOS/macOS @@ -1269,7 +1262,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) /// Check eligibility for the custom-link variant of external purchase (iOS 18.1+). - /// See: https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios public func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool { try await ensureConnection() // iOS 18.1+: ExternalPurchaseCustomLink.isEligible @@ -1282,7 +1275,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). - /// See: https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios + /// See: https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios public func getExternalPurchaseCustomLinkTokenIOS( _ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS ) async throws -> ExternalPurchaseCustomLinkTokenResultIOS { @@ -1324,7 +1317,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } /// Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). - /// See: https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios + /// See: https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios public func showExternalPurchaseCustomLinkNoticeIOS( _ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS ) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS { diff --git a/packages/apple/Sources/OpenIapProtocol.swift b/packages/apple/Sources/OpenIapProtocol.swift index 1a67292c..4d8a0768 100644 --- a/packages/apple/Sources/OpenIapProtocol.swift +++ b/packages/apple/Sources/OpenIapProtocol.swift @@ -91,6 +91,16 @@ public protocol OpenIapModuleProtocol { func presentCodeRedemptionSheetIOS() async throws -> Bool func showManageSubscriptionsIOS() async throws -> [PurchaseIOS] func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void + func canPresentExternalPurchaseNoticeIOS() async throws -> Bool + func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS + func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS + func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool + func getExternalPurchaseCustomLinkTokenIOS( + _ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS + ) async throws -> ExternalPurchaseCustomLinkTokenResultIOS + func showExternalPurchaseCustomLinkNoticeIOS( + _ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS + ) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS // Event Listeners func purchaseUpdatedListener( @@ -131,4 +141,50 @@ public extension OpenIapModuleProtocol { func validateReceipt(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { try await verifyPurchase(props) } + + func canPresentExternalPurchaseNoticeIOS() async throws -> Bool { + throw PurchaseError( + code: .featureNotSupported, + message: "canPresentExternalPurchaseNoticeIOS not supported" + ) + } + + func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS { + throw PurchaseError( + code: .featureNotSupported, + message: "presentExternalPurchaseNoticeSheetIOS not supported" + ) + } + + func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS { + throw PurchaseError( + code: .featureNotSupported, + message: "presentExternalPurchaseLinkIOS not supported" + ) + } + + func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool { + throw PurchaseError( + code: .featureNotSupported, + message: "isEligibleForExternalPurchaseCustomLinkIOS not supported" + ) + } + + func getExternalPurchaseCustomLinkTokenIOS( + _ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS + ) async throws -> ExternalPurchaseCustomLinkTokenResultIOS { + throw PurchaseError( + code: .featureNotSupported, + message: "getExternalPurchaseCustomLinkTokenIOS not supported" + ) + } + + func showExternalPurchaseCustomLinkNoticeIOS( + _ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS + ) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS { + throw PurchaseError( + code: .featureNotSupported, + message: "showExternalPurchaseCustomLinkNoticeIOS not supported" + ) + } } diff --git a/packages/apple/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift index c7229a69..f710f5e5 100644 --- a/packages/apple/Sources/OpenIapStore.swift +++ b/packages/apple/Sources/OpenIapStore.swift @@ -55,11 +55,15 @@ public final class OpenIapStore: ObservableObject { setupListeners() } - deinit { listenerTokens.removeAll() } + deinit { + for token in listenerTokens { module.removeListener(token) } + } // MARK: - Listener Management private func setupListeners() { + guard listenerTokens.isEmpty else { return } + let purchaseUpdate = module.purchaseUpdatedListener({ [weak self] purchase in Task { @MainActor in self?.handlePurchaseUpdate(purchase) } }, options: nil) @@ -89,6 +93,7 @@ public final class OpenIapStore: ObservableObject { status.loadings.initConnection = true defer { status.loadings.initConnection = false } isConnected = try await module.initConnection() + setupListeners() } public func endConnection() async throws { @@ -472,6 +477,34 @@ public final class OpenIapStore: ObservableObject { } #endif // !os(tvOS) + public func canPresentExternalPurchaseNoticeIOS() async throws -> Bool { + try await module.canPresentExternalPurchaseNoticeIOS() + } + + public func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS { + try await module.presentExternalPurchaseNoticeSheetIOS() + } + + public func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS { + try await module.presentExternalPurchaseLinkIOS(url) + } + + public func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool { + try await module.isEligibleForExternalPurchaseCustomLinkIOS() + } + + public func getExternalPurchaseCustomLinkTokenIOS( + _ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS + ) async throws -> ExternalPurchaseCustomLinkTokenResultIOS { + try await module.getExternalPurchaseCustomLinkTokenIOS(tokenType) + } + + public func showExternalPurchaseCustomLinkNoticeIOS( + _ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS + ) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS { + try await module.showExternalPurchaseCustomLinkNoticeIOS(noticeType) + } + public func clearTransactionIOS() async throws { _ = try await module.clearTransactionIOS() } diff --git a/packages/apple/Sources/OpenIapVersion.swift b/packages/apple/Sources/OpenIapVersion.swift index a4e1686d..e8355105 100644 --- a/packages/apple/Sources/OpenIapVersion.swift +++ b/packages/apple/Sources/OpenIapVersion.swift @@ -1,15 +1,65 @@ import Foundation +private final class OpenIapVersionBundleToken {} + /// OpenIAP version management public struct OpenIapVersion { /// Current OpenIAP Apple SDK version - /// This version is managed in monorepo root versions.json - public static let current: String = "1.2.23" + public static var current: String { + version(for: "apple") + } + + /// Current OpenIAP specification version + public static var specVersion: String { + version(for: "spec") + } /// OpenIAP GraphQL version for reference - /// This version is managed in monorepo root versions.json - public static let gqlVersion: String = "1.2.2" + @available(*, deprecated, renamed: "specVersion") + public static var gqlVersion: String { + specVersion + } + + private static func version(for key: String) -> String { + let versionURL: URL? + + #if SWIFT_PACKAGE + versionURL = Bundle.module.url(forResource: "openiap-versions", withExtension: "json") + #else + versionURL = cocoaPodsVersionURL() + #endif + guard + let url = versionURL, + let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = json[key] as? String, + !version.isEmpty + else { + fatalError("OpenIAP: missing \(key) version in openiap-versions.json") + } + return version + } + + private static func cocoaPodsVersionURL() -> URL? { + let bundles = [Bundle(for: OpenIapVersionBundleToken.self), Bundle.main] + Bundle.allBundles + + for bundle in bundles { + if let url = bundle.url(forResource: "openiap-versions", withExtension: "json") { + return url + } + + if + let bundleURL = bundle.url(forResource: "OpenIAP", withExtension: "bundle"), + let resourceBundle = Bundle(url: bundleURL), + let url = resourceBundle.url(forResource: "openiap-versions", withExtension: "json") + { + return url + } + } + + return nil + } } // MARK: - Version Info @@ -22,7 +72,8 @@ public enum OpenIapVersionInfo { } /// OpenIAP GraphQL version for reference + @available(*, deprecated, renamed: "specVersion") public static var gqlVersion: String { - OpenIapVersion.gqlVersion + OpenIapVersion.specVersion } -} \ No newline at end of file +} diff --git a/packages/apple/openiap.podspec b/packages/apple/openiap.podspec index b855c173..89044445 100644 --- a/packages/apple/openiap.podspec +++ b/packages/apple/openiap.podspec @@ -38,6 +38,11 @@ Pod::Spec.new do |s| # When podspec is in repo root (git distribution), use 'packages/apple/Sources/**/*.swift' sources_dir = File.join(File.dirname(__FILE__), 'Sources') s.source_files = File.directory?(sources_dir) ? 'Sources/**/*.swift' : 'packages/apple/Sources/**/*.swift' + s.resource_bundles = { + 'OpenIAP' => [ + File.directory?(sources_dir) ? 'Sources/openiap-versions.json' : 'packages/apple/Sources/openiap-versions.json' + ] + } s.frameworks = 'StoreKit' s.requires_arc = true diff --git a/packages/apple/package.json b/packages/apple/package.json index 6c8f12f3..7b209ec0 100644 --- a/packages/apple/package.json +++ b/packages/apple/package.json @@ -1,6 +1,6 @@ { "name": "@hyodotdev/openiap-ios", - "version": "1.2.23", + "version": "2.1.9", "private": true, "description": "OpenIAP iOS/Swift implementation", "scripts": { @@ -12,5 +12,5 @@ "dependencies": { "@hyodotdev/openiap-gql": "workspace:*" }, - "packageManager": "bun@1.1.0" + "packageManager": "bun@1.3.13" } diff --git a/packages/apple/scripts/build-xcframework.sh b/packages/apple/scripts/build-xcframework.sh index 92f84f74..a8c309bb 100755 --- a/packages/apple/scripts/build-xcframework.sh +++ b/packages/apple/scripts/build-xcframework.sh @@ -13,15 +13,39 @@ BUILD_DIR="${PACKAGE_DIR}/.build/xcframework" DERIVED="${BUILD_DIR}/derived" ARCHIVES="${BUILD_DIR}/archives" OUT="${BUILD_DIR}/OpenIAP.xcframework" +VERSIONS_FILE="${PACKAGE_DIR}/openiap-versions.json" if [[ ! -d "${WRAPPER_DIR}" ]] || [[ ! -f "${WRAPPER_DIR}/project.yml" ]]; then echo "error: wrapper project not found at ${WRAPPER_DIR}" exit 1 fi +if [[ ! -f "${VERSIONS_FILE}" ]]; then + echo "error: openiap-versions.json not found at ${VERSIONS_FILE}" + exit 1 +fi + +read_openiap_version() { + python3 - "$VERSIONS_FILE" "$1" <<'PY' +import json +import sys + +path, key = sys.argv[1], sys.argv[2] +with open(path, encoding="utf-8") as file: + value = json.load(file).get(key) + +if not isinstance(value, str) or not value.strip(): + raise SystemExit(f"missing {key} in {path}") + +print(value.strip()) +PY +} + +APPLE_VERSION="$(read_openiap_version apple)" + # Regenerate the wrapper Xcode project (xcodegen) so source-file changes are picked up. if ! command -v xcodegen >/dev/null 2>&1; then - echo "error: xcodegen not installed (brew install xcodegen)" + echo "error: xcodegen not installed (run scripts/install-xcodegen.sh )" exit 1 fi @@ -43,6 +67,7 @@ archive() { -archivePath "${archive_path}" \ -derivedDataPath "${DERIVED}" \ -configuration Release \ + OPENIAP_MARKETING_VERSION="${APPLE_VERSION}" \ SKIP_INSTALL=NO \ BUILD_LIBRARY_FOR_DISTRIBUTION=YES \ -quiet diff --git a/packages/apple/scripts/bump-version.sh b/packages/apple/scripts/bump-version.sh index 53c68370..557563fe 100755 --- a/packages/apple/scripts/bump-version.sh +++ b/packages/apple/scripts/bump-version.sh @@ -1,17 +1,32 @@ -#!/bin/bash +#!/usr/bin/env bash # Usage: ./scripts/bump-version.sh [major|minor|patch|x.x.x] -set -e +set -euo pipefail -VERSIONS_FILE="openiap-versions.json" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +VERSIONS_FILE="${REPO_ROOT}/openiap-versions.json" # Get current version from openiap-versions.json -if [ -f "${VERSIONS_FILE}" ]; then +if [[ -f "${VERSIONS_FILE}" ]]; then if command -v jq &> /dev/null; then - CURRENT_VERSION=$(jq -r '.apple' "${VERSIONS_FILE}") + CURRENT_VERSION=$(jq -er '.apple | select(type == "string" and length > 0)' "${VERSIONS_FILE}") elif command -v python3 &> /dev/null; then - CURRENT_VERSION=$(python3 -c "import json; print(json.load(open('${VERSIONS_FILE}'))['apple'])") + CURRENT_VERSION=$(python3 - "${VERSIONS_FILE}" <<'PY' +import json +import sys + +path = sys.argv[1] +with open(path, encoding="utf-8") as file: + value = json.load(file).get("apple") + +if not isinstance(value, str) or not value.strip(): + raise SystemExit(f"missing apple in {path}") + +print(value.strip()) +PY +) else echo "❌ Error: jq or python3 is required to read openiap-versions.json" exit 1 @@ -30,7 +45,7 @@ MINOR="${VERSION_PARTS[1]}" PATCH="${VERSION_PARTS[2]}" # Determine new version -if [ -z "$1" ]; then +if [[ -z "${1:-}" ]]; then echo "Usage: $0 [major|minor|patch|x.x.x]" exit 1 fi @@ -54,64 +69,54 @@ esac echo "New version: $NEW_VERSION" # Update openiap-versions.json -if [ -f "openiap-versions.json" ]; then - if command -v jq &> /dev/null; then - # Use jq to update JSON - jq --arg version "$NEW_VERSION" '.apple = $version' openiap-versions.json > openiap-versions.json.tmp && \ - mv openiap-versions.json.tmp openiap-versions.json - echo "✅ Updated openiap-versions.json" - elif command -v python3 &> /dev/null; then - # Use python3 as fallback - python3 -c " +if command -v jq &> /dev/null; then + tmp_file="${VERSIONS_FILE}.tmp" + jq --arg version "$NEW_VERSION" '.apple = $version' "$VERSIONS_FILE" > "$tmp_file" + mv "$tmp_file" "$VERSIONS_FILE" + echo "✅ Updated openiap-versions.json" +elif command -v python3 &> /dev/null; then + VERSION="$NEW_VERSION" VERSIONS_FILE="$VERSIONS_FILE" python3 - <<'PY' import json -with open('openiap-versions.json', 'r') as f: +import os + +versions_file = os.environ["VERSIONS_FILE"] +with open(versions_file, 'r', encoding='utf-8') as f: data = json.load(f) -data['apple'] = '$NEW_VERSION' -with open('openiap-versions.json', 'w') as f: +data['apple'] = os.environ["VERSION"] +with open(versions_file, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2) f.write('\n') -" - echo "✅ Updated openiap-versions.json (using python3)" - else - echo "⚠️ Warning: jq and python3 not available. Skipping openiap-versions.json update" - fi -fi - -# Update OpenIapVersion.swift fallback version -if [ -f "Sources/OpenIapVersion.swift" ]; then - sed -i '' "s/return \"[0-9.]*\"/return \"$NEW_VERSION\"/" Sources/OpenIapVersion.swift - echo "✅ Updated OpenIapVersion.swift fallback" +PY + echo "✅ Updated openiap-versions.json (using python3)" +else + echo "❌ Error: jq or python3 is required to update openiap-versions.json" + exit 1 fi -# Note: openiap.podspec now reads version from openiap-versions.json automatically +"$REPO_ROOT/scripts/sync-versions.sh" -# Update README.md - CocoaPods installation -sed -i '' "s/pod 'openiap', '~> [0-9.]*'/pod 'openiap', '~> $NEW_VERSION'/" README.md - -# Update README.md - Swift Package Manager -sed -i '' "s/.package(url: \"https:\/\/github.com\/hyodotdev\/openiap-apple.git\", from: \"[0-9.]*\")/.package(url: \"https:\/\/github.com\/hyodotdev\/openiap-apple.git\", from: \"$NEW_VERSION\")/" README.md +# openiap.podspec reads the Apple version from openiap-versions.json. # Commit changes -git add README.md openiap-versions.json Sources/OpenIapVersion.swift -git commit -m "chore: bump version to $NEW_VERSION" +cd "$REPO_ROOT" +git add openiap-versions.json packages/*/openiap-versions.json +git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json +git commit -m "chore(apple): bump version to $NEW_VERSION" # Push commits -git push origin main +git pull --rebase origin main +git push origin HEAD:main # Create and push tag (with check) if git rev-parse "refs/tags/$NEW_VERSION" >/dev/null 2>&1; then - echo "⚠️ Tag $NEW_VERSION already exists locally, deleting and recreating..." - git tag -d "$NEW_VERSION" -fi - -git tag "$NEW_VERSION" - -# Try to push tag, ignore error if already exists -if ! git push origin "$NEW_VERSION" 2>/dev/null; then - echo "ℹ️ Tag $NEW_VERSION already exists on remote (probably from CocoaPods release)" + echo "ℹ️ Tag $NEW_VERSION already exists locally. Reusing existing tag." +elif git ls-remote --exit-code --tags origin "refs/tags/$NEW_VERSION" >/dev/null 2>&1; then + echo "ℹ️ Tag $NEW_VERSION already exists on remote. Reusing existing tag." else + git tag "$NEW_VERSION" + git push origin "$NEW_VERSION" echo "✅ Tag $NEW_VERSION pushed successfully" fi echo "✅ Version bumped to $NEW_VERSION and pushed!" -echo "📦 Ready to create a GitHub Release with tag $NEW_VERSION" \ No newline at end of file +echo "📦 Ready to create a GitHub Release with tag $NEW_VERSION" diff --git a/packages/apple/wrapper/project.yml b/packages/apple/wrapper/project.yml index f27d7df8..f7a04a90 100644 --- a/packages/apple/wrapper/project.yml +++ b/packages/apple/wrapper/project.yml @@ -18,7 +18,7 @@ settings: DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER: NO GENERATE_INFOPLIST_FILE: YES PRODUCT_BUNDLE_IDENTIFIER: dev.hyo.openiap.OpenIAP - MARKETING_VERSION: "1.2.5" + MARKETING_VERSION: "$(OPENIAP_MARKETING_VERSION)" CURRENT_PROJECT_VERSION: "1" targets: OpenIAP: From 13c98e01fd143d503e0ab403b94858494ada2eb7 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 16 May 2026 14:39:18 +0900 Subject: [PATCH 04/26] feat(google): align billing API handling Update Google package implementation, examples, build scripts, and release helpers for the shared API changes. --- packages/google/CONTRIBUTING.md | 6 +- packages/google/Example/build.gradle.kts | 56 ++++++++++++--- .../screens/AlternativeBillingScreen.kt | 72 +++++++++++-------- .../hyo/martie/screens/PurchaseFlowScreen.kt | 18 ++++- .../martie/screens/SubscriptionFlowScreen.kt | 30 ++++---- .../hyo/martie/screens/WebhookStreamScreen.kt | 2 +- .../java/dev/hyo/martie/screens/uis/Modals.kt | 26 +++++-- packages/google/README.md | 16 ++--- packages/google/build.gradle.kts | 29 +++----- .../gradle/wrapper/gradle-wrapper.properties | 2 +- packages/google/openiap/build.gradle.kts | 46 +++++++----- .../java/dev/hyo/openiap/OpenIapModule.kt | 65 +++++++++-------- .../java/dev/hyo/openiap/helpers/Helpers.kt | 15 +--- .../dev/hyo/openiap/helpers/ProductManager.kt | 2 +- .../dev/hyo/openiap/store/OpenIapStore.kt | 68 +++++++++--------- .../java/dev/hyo/openiap/OpenIapModule.kt | 62 ++++++++-------- .../java/dev/hyo/openiap/OpenIapErrorTest.kt | 1 + packages/google/package.json | 4 +- .../google/scripts/open-android-studio.sh | 3 +- packages/google/scripts/publish-local.sh | 41 +---------- packages/google/scripts/update-version.sh | 56 ++++++++------- 21 files changed, 336 insertions(+), 284 deletions(-) diff --git a/packages/google/CONTRIBUTING.md b/packages/google/CONTRIBUTING.md index 7773e54b..e40c02ba 100644 --- a/packages/google/CONTRIBUTING.md +++ b/packages/google/CONTRIBUTING.md @@ -8,7 +8,7 @@ Thank you for your interest in contributing! We love your input and appreciate y 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 3. Make your changes 4. Run tests (`./gradlew :openiap:test`) -5. Commit your changes (`git commit -m 'Add amazing feature'`) +5. Commit your changes (`git commit -m 'feat(google): add amazing feature'`) 6. Push to your branch (`git push origin feature/amazing-feature`) 7. Open a Pull Request @@ -16,8 +16,8 @@ Thank you for your interest in contributing! We love your input and appreciate y ```bash # Clone your fork -git clone https://github.com/YOUR_USERNAME/openiap-google.git -cd openiap-google +git clone https://github.com/YOUR_USERNAME/openiap.git +cd openiap/packages/google # Open in Android Studio (recommended) ./scripts/open-android-studio.sh diff --git a/packages/google/Example/build.gradle.kts b/packages/google/Example/build.gradle.kts index 7005bb8c..58964f7f 100644 --- a/packages/google/Example/build.gradle.kts +++ b/packages/google/Example/build.gradle.kts @@ -1,4 +1,5 @@ import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -13,14 +14,45 @@ if (localPropertiesFile.exists()) { localPropertiesFile.inputStream().use { localProperties.load(it) } } +val openIapBuildFile = rootProject.file("openiap/build.gradle.kts") +if (!openIapBuildFile.isFile) { + error("Google Example: missing openiap/build.gradle.kts") +} +val openIapBuild = openIapBuildFile.readText() + +fun readOpenIapAndroidInt(name: String): Int { + return Regex("""$name\s*=\s*(\d+)""") + .find(openIapBuild) + ?.groupValues + ?.get(1) + ?.toInt() + ?: error("Google Example: missing $name in ${openIapBuildFile.path}") +} + +fun readOpenIapDependencyVersion(coordinate: String): String { + return Regex("""${Regex.escape(coordinate)}:([^"$]+)""") + .find(openIapBuild) + ?.groupValues + ?.get(1) + ?: error("Google Example: missing $coordinate in ${openIapBuildFile.path}") +} + +val openIapCompileSdk = readOpenIapAndroidInt("compileSdk") +val openIapMinSdk = readOpenIapAndroidInt("minSdk") +val openIapTargetSdk = openIapCompileSdk +val openIapCoreKtxVersion = readOpenIapDependencyVersion("androidx.core:core-ktx") +val openIapLifecycleRuntimeVersion = readOpenIapDependencyVersion("androidx.lifecycle:lifecycle-runtime-ktx") +val openIapLifecycleViewModelVersion = readOpenIapDependencyVersion("androidx.lifecycle:lifecycle-viewmodel-ktx") +val openIapJunitVersion = readOpenIapDependencyVersion("junit:junit") + android { namespace = "dev.hyo.martie" - compileSdk = 35 + compileSdk = openIapCompileSdk defaultConfig { applicationId = "dev.hyo.martie" - minSdk = 24 - targetSdk = 35 + minSdk = openIapMinSdk + targetSdk = openIapTargetSdk versionCode = 1 versionName = "1.0" @@ -85,10 +117,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - buildFeatures { compose = true buildConfig = true @@ -101,13 +129,19 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + dependencies { implementation(project(":openiap")) val composeUiVersion = (project.findProperty("COMPOSE_UI_VERSION") as String?) ?: "1.6.8" - implementation("androidx.core:core-ktx:1.13.1") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.core:core-ktx:$openIapCoreKtxVersion") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:$openIapLifecycleRuntimeVersion") implementation("androidx.activity:activity-compose:1.9.0") implementation("androidx.compose.ui:ui:$composeUiVersion") @@ -115,12 +149,12 @@ dependencies { implementation("androidx.compose.material3:material3:1.2.1") implementation("androidx.compose.material:material-icons-extended:$composeUiVersion") implementation("androidx.navigation:navigation-compose:2.7.7") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$openIapLifecycleViewModelVersion") debugImplementation("androidx.compose.ui:ui-tooling:$composeUiVersion") debugImplementation("androidx.compose.ui:ui-test-manifest:$composeUiVersion") - testImplementation("junit:junit:4.13.2") + testImplementation("junit:junit:$openIapJunitVersion") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation("androidx.compose.ui:ui-test-junit4:$composeUiVersion") diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt index 9b703cb5..90bdb187 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt @@ -40,9 +40,11 @@ import dev.hyo.openiap.ExternalLinkTypeAndroid import dev.hyo.openiap.DeveloperBillingOptionParamsAndroid import dev.hyo.openiap.DeveloperBillingLaunchModeAndroid import dev.hyo.openiap.DeveloperProvidedBillingDetailsAndroid +import dev.hyo.openiap.OpenIapLog import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener import dev.hyo.martie.util.findActivity import kotlinx.coroutines.delay +import java.security.MessageDigest // Billing mode options including new 8.2.0+ Billing Programs private enum class BillingModeOption { @@ -52,6 +54,16 @@ private enum class BillingModeOption { EXTERNAL_PAYMENTS // New 8.3.0+ API (Japan only) } +private fun maskToken(token: String?): String { + val value = token?.takeIf { it.isNotBlank() } ?: return "none" + val fingerprint = MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + .take(12) + return "" +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AlternativeBillingScreen(navController: NavController) { @@ -68,8 +80,8 @@ fun AlternativeBillingScreen(navController: NavController) { // Initialize store - use default constructor for auto-detection (compatible with both Play and Horizon) val iapStore = remember { - android.util.Log.d("AlternativeBillingScreen", "Creating OpenIapStore with auto-detection") - dev.hyo.openiap.OpenIapLog.isEnabled = true + OpenIapLog.isEnabled = true + OpenIapLog.debug("Creating OpenIapStore with auto-detection", tag = "AlternativeBillingScreen") // Use default constructor which auto-detects platform (Play or Horizon) // Alternative billing mode will be set via initConnection config @@ -79,14 +91,14 @@ fun AlternativeBillingScreen(navController: NavController) { // User Choice Billing listener (remembered to properly add/remove) val userChoiceListener = remember { dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener { details -> - android.util.Log.d("UserChoiceEvent", "=== User Choice Billing Event ===") - android.util.Log.d("UserChoiceEvent", "External Token: ${details.externalTransactionToken}") - android.util.Log.d("UserChoiceEvent", "Products: ${details.products}") - android.util.Log.d("UserChoiceEvent", "==============================") + OpenIapLog.debug("=== User Choice Billing Event ===", tag = "UserChoiceEvent") + OpenIapLog.debug("External Token: ${maskToken(details.externalTransactionToken)}", tag = "UserChoiceEvent") + OpenIapLog.debug("Products: ${details.products}", tag = "UserChoiceEvent") + OpenIapLog.debug("==============================", tag = "UserChoiceEvent") // Show result in UI iapStore.postStatusMessage( - message = "User selected alternative billing\nToken: ${details.externalTransactionToken.take(20)}...\nProducts: ${details.products.joinToString()}", + message = "User selected alternative billing\nToken: ${maskToken(details.externalTransactionToken)}\nProducts: ${details.products.joinToString()}", status = dev.hyo.openiap.store.PurchaseResultStatus.Info, productId = details.products.firstOrNull() ) @@ -99,14 +111,14 @@ fun AlternativeBillingScreen(navController: NavController) { // Developer Provided Billing listener (remembered to properly add/remove) val developerBillingListener = remember { dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener { details -> - android.util.Log.d("DeveloperBillingEvent", "=== Developer Provided Billing Event ===") - android.util.Log.d("DeveloperBillingEvent", "External Token: ${details.externalTransactionToken}") - android.util.Log.d("DeveloperBillingEvent", "========================================") + OpenIapLog.debug("=== Developer Provided Billing Event ===", tag = "DeveloperBillingEvent") + OpenIapLog.debug("External Token: ${maskToken(details.externalTransactionToken)}", tag = "DeveloperBillingEvent") + OpenIapLog.debug("========================================", tag = "DeveloperBillingEvent") // Show result in UI iapStore.postStatusMessage( message = "User selected developer billing (External Payments)\n\n" + - "Token: ${details.externalTransactionToken.take(30)}...\n\n" + + "Token: ${maskToken(details.externalTransactionToken)}\n\n" + "⚠️ Next steps:\n" + "1. Process payment with your payment gateway\n" + "2. Report token to Google within 24 hours", @@ -157,11 +169,11 @@ fun AlternativeBillingScreen(navController: NavController) { try { val purchaseAndroid = purchase as? PurchaseAndroid if (purchaseAndroid != null) { - android.util.Log.d("AlternativeBilling", "Auto-finishing transaction for testing") + OpenIapLog.debug("Auto-finishing transaction for testing", tag = "AlternativeBilling") iapStore.finishTransaction(purchaseAndroid, true) } } catch (e: Exception) { - android.util.Log.e("AlternativeBilling", "Auto-finish failed: ${e.message}") + OpenIapLog.error("Auto-finish failed: ${e.message}", tag = "AlternativeBilling") } } } @@ -169,10 +181,10 @@ fun AlternativeBillingScreen(navController: NavController) { // Initialize connection when mode changes LaunchedEffect(selectedMode, selectedBillingProgram) { try { - android.util.Log.d("AlternativeBillingScreen", "Initializing with mode: $selectedMode") + OpenIapLog.debug("Initializing with mode: $selectedMode", tag = "AlternativeBillingScreen") // IMPORTANT: End existing connection first before creating new one - android.util.Log.d("AlternativeBillingScreen", "Ending existing connection...") + OpenIapLog.debug("Ending existing connection...", tag = "AlternativeBillingScreen") iapStore.endConnection() delay(500) // Give it time to fully disconnect @@ -196,22 +208,22 @@ fun AlternativeBillingScreen(navController: NavController) { ) } - android.util.Log.d("AlternativeBillingScreen", "Reconnecting with config: $config") + OpenIapLog.debug("Reconnecting with config: $config", tag = "AlternativeBillingScreen") val connected = iapStore.initConnection(config) - android.util.Log.d("AlternativeBillingScreen", "Connection result: $connected") + OpenIapLog.debug("Connection result: $connected", tag = "AlternativeBillingScreen") if (connected) { - android.util.Log.d("AlternativeBillingScreen", "Fetching products...") + OpenIapLog.debug("Fetching products...", tag = "AlternativeBillingScreen") val request = ProductRequest( skus = IapConstants.INAPP_SKUS, type = ProductQueryType.InApp ) iapStore.fetchProducts(request) } else { - android.util.Log.e("AlternativeBillingScreen", "Failed to connect to billing service") + OpenIapLog.error("Failed to connect to billing service", tag = "AlternativeBillingScreen") } } catch (e: Exception) { - android.util.Log.e("AlternativeBillingScreen", "Connection error: ${e.message}", e) + OpenIapLog.error("Connection error: ${e.message}", e, tag = "AlternativeBillingScreen") } } @@ -758,14 +770,14 @@ fun AlternativeBillingScreen(navController: NavController) { } // Step 3: Process payment (DEMO - not implemented) - android.util.Log.d("BillingPrograms", "⚠️ Payment processing not implemented - this is a demo") + OpenIapLog.debug("⚠️ Payment processing not implemented - this is a demo", tag = "BillingPrograms") // Step 4: Create reporting details val reportingDetails = iapStore.createBillingProgramReportingDetails(selectedBillingProgram) iapStore.postStatusMessage( "✅ Billing Programs flow completed (DEMO)\n\n" + "Program: ${reportingDetails.billingProgram}\n" + - "Token: ${reportingDetails.externalTransactionToken.take(20)}...\n\n" + + "Token: ${maskToken(reportingDetails.externalTransactionToken)}\n\n" + "⚠️ Next steps:\n" + "1. Process payment in your system\n" + "2. Report token to Google within 24h", @@ -773,7 +785,7 @@ fun AlternativeBillingScreen(navController: NavController) { selectedProduct!!.id ) } catch (e: Exception) { - android.util.Log.e("BillingPrograms", "Error: ${e.message}", e) + OpenIapLog.error("Error: ${e.message}", e, tag = "BillingPrograms") iapStore.postStatusMessage( "Error: ${e.message}", PurchaseResultStatus.Error @@ -837,14 +849,14 @@ fun AlternativeBillingScreen(navController: NavController) { } // Step 2.5: Process payment (DEMO - not implemented) - android.util.Log.d("AlternativeBilling", "⚠️ Payment processing not implemented") + OpenIapLog.debug("⚠️ Payment processing not implemented", tag = "AlternativeBilling") // Step 3: Create token @Suppress("DEPRECATION") val token = iapStore.createAlternativeBillingReportingToken() if (token != null) { iapStore.postStatusMessage( - "Alternative billing completed (DEMO)\nToken: ${token.take(20)}...\n⚠️ Backend reporting required", + "Alternative billing completed (DEMO)\nToken: ${maskToken(token)}\n⚠️ Backend reporting required", PurchaseResultStatus.Info, selectedProduct!!.id ) @@ -855,7 +867,7 @@ fun AlternativeBillingScreen(navController: NavController) { ) } } catch (e: Exception) { - android.util.Log.e("AlternativeBilling", "Legacy alternative billing error: ${e.message}", e) + OpenIapLog.error("Legacy alternative billing error: ${e.message}", e, tag = "AlternativeBilling") iapStore.postStatusMessage( "Alternative billing failed: ${e.message}", PurchaseResultStatus.Error @@ -905,7 +917,7 @@ fun AlternativeBillingScreen(navController: NavController) { // If user selects Google Play → onPurchaseUpdated callback // If user selects alternative → UserChoiceBillingListener callback } catch (e: Exception) { - android.util.Log.e("AlternativeBilling", "User choice billing error: ${e.message}", e) + OpenIapLog.error("User choice billing error: ${e.message}", e, tag = "AlternativeBilling") iapStore.postStatusMessage( "User choice billing failed: ${e.message}", PurchaseResultStatus.Error @@ -971,7 +983,7 @@ fun AlternativeBillingScreen(navController: NavController) { type = ProductQueryType.InApp ) - android.util.Log.d("ExternalPayments", "Launching purchase with External Payments option") + OpenIapLog.debug("Launching purchase with External Payments option", tag = "ExternalPayments") iapStore.requestPurchase(props) // If user selects Google Play → onPurchaseSuccess callback @@ -984,7 +996,7 @@ fun AlternativeBillingScreen(navController: NavController) { selectedProduct!!.id ) } catch (e: Exception) { - android.util.Log.e("ExternalPayments", "External Payments error: ${e.message}", e) + OpenIapLog.error("External Payments error: ${e.message}", e, tag = "ExternalPayments") iapStore.postStatusMessage( "External Payments failed: ${e.message}", PurchaseResultStatus.Error @@ -1043,7 +1055,7 @@ fun AlternativeBillingScreen(navController: NavController) { style = MaterialTheme.typography.bodySmall ) Text( - "Token: ${purchase.purchaseToken?.take(20)}...", + "Token: ${maskToken(purchase.purchaseToken)}", style = MaterialTheme.typography.bodySmall ) diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt index 769e67f8..59dac1dc 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt @@ -59,6 +59,19 @@ enum class VerificationMethod(val displayName: String) { IAPKit("☁️ IAPKit (Server)") } +private fun redactedPurchaseJson(purchase: PurchaseAndroid): String = + purchase.toJson() + .mapValues { (key, value) -> + if (key in sensitivePurchaseJsonKeys && value != null) "" else value + } + .toString() + +private val sensitivePurchaseJsonKeys = setOf( + "dataAndroid", + "purchaseToken", + "signatureAndroid" +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PurchaseFlowScreen( @@ -421,7 +434,7 @@ fun PurchaseFlowScreen( OutlinedButton( onClick = { lastPurchaseAndroid?.let { p -> - val json = p.toJson().toString() + val json = redactedPurchaseJson(p) clipboard.setText(AnnotatedString(json)) } }, @@ -563,7 +576,7 @@ fun PurchaseFlowScreen( ?: throw IllegalStateException("Purchase token is required for IAPKit verification") println("PurchaseFlow: IAPKit verification params:") - println(" - purchaseToken: ${token.take(6)}… (redacted)") + println(" - purchaseToken: ") val props = RequestVerifyPurchaseWithIapkitProps( apiKey = apiKey, @@ -651,7 +664,6 @@ fun PurchaseFlowScreen( result } catch (e: Exception) { println("PurchaseFlow: IAPKit verification error: ${e.message}") - e.printStackTrace() verificationResultMessage = "❌ IAPKit verification error: ${e.message}" iapStore.postStatusMessage( message = "Verification error: ${e.message}. Finishing transaction anyway for testing.", diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt index 3499df6c..e9d2319c 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt @@ -52,6 +52,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.security.MessageDigest import dev.hyo.martie.util.findActivity import dev.hyo.martie.util.PREMIUM_SUBSCRIPTION_PRODUCT_ID import dev.hyo.martie.util.SUBSCRIPTION_PREFS_NAME @@ -70,6 +71,16 @@ private object ReplacementMode { const val KEEP_EXISTING = 7 // Keep existing payment schedule (8.1.0+) } +private fun maskPurchaseToken(token: String?): String { + val value = token?.takeIf { it.isNotBlank() } ?: return "none" + val fingerprint = MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + .take(12) + return "" +} + // Helper to format remaining time like "3d 4h" / "2h 12m" / "35m" private fun formatRemaining(deltaMillis: Long): String { if (deltaMillis <= 0) return "0m" @@ -187,7 +198,6 @@ fun SubscriptionFlowScreen( } } catch (e: Exception) { println("SubscriptionFlow: getActiveSubscriptions FAILED: ${e.message}") - e.printStackTrace() } delay(500) @@ -220,7 +230,7 @@ fun SubscriptionFlowScreen( println(" Offer $index:") println(" Base Plan: ${offer.basePlanId}") println(" Offer ID: ${offer.offerId}") - println(" Offer Token: ${offer.offerToken.take(20)}...") + println(" Offer Token: ") offer.pricingPhases.pricingPhaseList.forEachIndexed { phaseIndex, phase -> println(" Phase $phaseIndex: ${phase.formattedPrice} for ${phase.billingPeriod}") } @@ -235,7 +245,6 @@ fun SubscriptionFlowScreen( } } catch (e: Exception) { println("SubscriptionFlow: Initialization error: ${e.message}") - e.printStackTrace() iapStore.postStatusMessage( message = "Failed to initialize: ${e.message}", status = PurchaseResultStatus.Error @@ -859,7 +868,7 @@ fun SubscriptionFlowScreen( return@launch } - println("SubscriptionFlow [Horizon/Play]: Changing from ${currentOffer.basePlanId} to ${targetOffer.basePlanId} with token: ${purchaseToken.take(10)}...") + println("SubscriptionFlow [Horizon/Play]: Changing from ${currentOffer.basePlanId} to ${targetOffer.basePlanId} with token: ${maskPurchaseToken(purchaseToken)}") // Request subscription offer change (same product, different offer) // Using new subscriptionProductReplacementParams API (8.1.0+) @@ -908,7 +917,6 @@ fun SubscriptionFlowScreen( } } catch (e: Exception) { println("SubscriptionFlow: Error changing subscription: ${e.message}") - e.printStackTrace() iapStore.postStatusMessage( message = "Subscription change failed: ${e.message}", @@ -953,7 +961,7 @@ fun SubscriptionFlowScreen( } // Log purchase details for debugging - println("SubscriptionFlow: Current purchase details - productId: ${subscription.productId}, token: ${subscription.purchaseToken?.take(10)}") + println("SubscriptionFlow: Current purchase details - productId: ${subscription.productId}, token: ${maskPurchaseToken(subscription.purchaseToken)}") println("SubscriptionFlow: Purchase state: ${subscription.purchaseState}") // Resolve the active offer for this subscription @@ -1087,7 +1095,7 @@ fun SubscriptionFlowScreen( return@launch } - println("SubscriptionFlow: Changing from ${currentOffer.basePlanId} to ${targetOffer.basePlanId} with token: ${purchaseToken.take(10)}...") + println("SubscriptionFlow: Changing from ${currentOffer.basePlanId} to ${targetOffer.basePlanId} with token: ${maskPurchaseToken(purchaseToken)}") // For same subscription with different offers, use CHARGE_FULL_PRICE // This is often the only supported mode for offer changes @@ -1137,7 +1145,6 @@ fun SubscriptionFlowScreen( } } catch (e: Exception) { println("SubscriptionFlow: Error changing subscription: ${e.message}") - e.printStackTrace() // If upgrade fails, show more helpful message val errorMessage = when { @@ -1228,7 +1235,7 @@ fun SubscriptionFlowScreen( } // Platform-specific offer selection - val subscriptionOffers = if (isHorizon && product.id == "dev.hyo.martie.premium" && product is ProductAndroid) { + val subscriptionOffers = if (isHorizon && product.id == "dev.hyo.martie.premium") { // HORIZON ONLY: Premium product has multiple offers (MONTHLY and ANNUAL) // We default to MONTHLY offer for initial purchase val monthlyOffer = product.subscriptionOfferDetailsAndroid?.find { offer -> @@ -1237,7 +1244,7 @@ fun SubscriptionFlowScreen( } } if (monthlyOffer != null) { - println("SubscriptionFlow: Using MONTHLY offer token: ${monthlyOffer.offerToken}") + println("SubscriptionFlow: Using MONTHLY offer token: ") listOf(AndroidSubscriptionOfferInput( offerToken = monthlyOffer.offerToken, sku = product.id @@ -1367,7 +1374,7 @@ fun SubscriptionFlowScreen( ?: throw IllegalStateException("Purchase token is required for IAPKit verification") println("SubscriptionFlow: IAPKit verification params:") - println(" - purchaseToken: $token") + println(" - purchaseToken: ${maskPurchaseToken(token)}") val props = RequestVerifyPurchaseWithIapkitProps( apiKey = apiKey, @@ -1454,7 +1461,6 @@ fun SubscriptionFlowScreen( result } catch (e: Exception) { println("SubscriptionFlow: IAPKit verification error: ${e.message}") - e.printStackTrace() verificationResultMessage = "❌ IAPKit verification error: ${e.message}" iapStore.postStatusMessage( message = "Verification error: ${e.message}. Finishing transaction anyway for testing.", diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/WebhookStreamScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/WebhookStreamScreen.kt index f41510a2..3d40680d 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/WebhookStreamScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/WebhookStreamScreen.kt @@ -110,7 +110,7 @@ fun WebhookStreamScreen(navController: NavController) { ) { Text("SSE /v1/webhooks/stream/{apiKey}", style = MaterialTheme.typography.titleMedium) Text( - "api key: ${BuildConfig.IAPKIT_API_KEY.take(8).ifEmpty { "MISSING" }}", + "api key: ${if (BuildConfig.IAPKIT_API_KEY.isBlank()) "MISSING" else "CONFIGURED"}", color = AppColors.textSecondary ) diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/Modals.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/Modals.kt index 0abf369c..ebbea16e 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/Modals.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/Modals.kt @@ -167,7 +167,7 @@ fun ProductDetailModal( DetailRow("Formatted Price", offer.formattedPrice) DetailRow("Price (micros)", offer.priceAmountMicros) offer.offerId?.let { DetailRow("Offer ID", it) } - DetailRow("Offer Token", offer.offerToken) + DetailRow("Offer Token", redactedIfPresent(offer.offerToken)) if (offer.offerTags.isNotEmpty()) { DetailRow("Tags", offer.offerTags.joinToString(", ")) } @@ -236,7 +236,7 @@ fun ProductDetailModal( ) { DetailRow("Base Plan", offer.basePlanId) offer.offerId?.let { DetailRow("Offer ID", it) } - DetailRow("Offer Token", offer.offerToken) + DetailRow("Offer Token", redactedIfPresent(offer.offerToken)) if (offer.offerTags.isNotEmpty()) { DetailRow("Tags", offer.offerTags.joinToString(", ")) } @@ -364,7 +364,7 @@ fun PurchaseDetailModal( } add("id" to purchase.id) add("transactionId" to (purchase.transactionId ?: "-")) - add("purchaseToken" to (purchase.purchaseToken ?: "-")) + add("purchaseToken" to redactedIfPresent(purchase.purchaseToken)) add("purchaseState" to purchase.purchaseState.rawValue) add("productId" to purchase.productId) add("transactionDate" to purchase.transactionDate.toString()) @@ -373,7 +373,7 @@ fun PurchaseDetailModal( purchase.isAcknowledgedAndroid?.let { add("isAcknowledgedAndroid" to it.toString()) } purchase.obfuscatedAccountIdAndroid?.let { add("obfuscatedAccountIdAndroid" to it) } purchase.obfuscatedProfileIdAndroid?.let { add("obfuscatedProfileIdAndroid" to it) } - purchase.signatureAndroid?.let { add("signatureAndroid" to it) } + purchase.signatureAndroid?.let { add("signatureAndroid" to redactedIfPresent(it)) } } detailRows.forEach { (label, value) -> DetailRow(label, value) } } @@ -386,7 +386,7 @@ fun PurchaseDetailModal( ) { OutlinedButton( onClick = { - val json = purchase.toJson().toString() + val json = redactedPurchaseJson(purchase) clipboard.setText(AnnotatedString(json)) }, modifier = Modifier.weight(1f) @@ -412,3 +412,19 @@ private fun DetailRow(label: String, value: String) { Text(value, style = MaterialTheme.typography.bodyMedium) } } + +private fun redactedIfPresent(value: String?): String = + if (value.isNullOrEmpty()) "-" else "" + +private fun redactedPurchaseJson(purchase: PurchaseAndroid): String = + purchase.toJson() + .mapValues { (key, value) -> + if (key in sensitivePurchaseJsonKeys && value != null) "" else value + } + .toString() + +private val sensitivePurchaseJsonKeys = setOf( + "dataAndroid", + "purchaseToken", + "signatureAndroid" +) diff --git a/packages/google/README.md b/packages/google/README.md index 07870bd9..09c92b89 100644 --- a/packages/google/README.md +++ b/packages/google/README.md @@ -3,13 +3,13 @@
OpenIAP Google Logo -

Android implementation of the OpenIAP specification using Google Play Billing.

+

Android implementation of the OpenIAP specification using Google Play Billing.


[![Maven Central](https://img.shields.io/maven-central/v/io.github.hyochan.openiap/openiap-google)](https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google) -[![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=21) +[![API](https://img.shields.io/badge/API-23%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=23) [![Google Release](https://github.com/hyodotdev/openiap/actions/workflows/release-google.yml/badge.svg)](https://github.com/hyodotdev/openiap/actions/workflows/release-google.yml) [![CI](https://github.com/hyodotdev/openiap/actions/workflows/ci.yml/badge.svg)](https://github.com/hyodotdev/openiap/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) @@ -31,10 +31,10 @@ Visit [**openiap.dev**](https://openiap.dev) for complete documentation, API ref ## Requirements -- **Minimum SDK**: 21 (Android 5.0) -- **Compile SDK**: 34+ -- **Google Play Billing**: v8.0.0 -- **Kotlin**: 1.9.20+ +- **Minimum SDK**: 23 (Android 6.0) +- **Compile SDK**: 35 +- **Google Play Billing**: v8.3.0 +- **Kotlin**: 2.2.0+ ## Installation @@ -42,11 +42,11 @@ Add to your module's `build.gradle.kts`: ```kotlin dependencies { - implementation("io.github.hyochan.openiap:openiap-google:$version") + implementation("io.github.hyochan.openiap:openiap-google:") } ``` -> Check [`openiap-versions.json`](../../openiap-versions.json) for the current version. +Use the latest version from [Maven Central](https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google) or the badge above. ## Quick Start diff --git a/packages/google/build.gradle.kts b/packages/google/build.gradle.kts index 79992dbb..4c0fa773 100644 --- a/packages/google/build.gradle.kts +++ b/packages/google/build.gradle.kts @@ -1,36 +1,29 @@ plugins { - id("com.android.library") version "8.7.3" apply false - id("com.android.application") version "8.7.3" apply false + id("com.android.library") version "8.13.2" apply false + id("com.android.application") version "8.13.2" apply false id("org.jetbrains.kotlin.android") version "2.2.0" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" apply false - id("com.vanniktech.maven.publish") version "0.29.0" apply false + id("com.vanniktech.maven.publish") version "0.35.0" apply false } +import groovy.json.JsonSlurper import java.io.File // Read version from monorepo root or environment variable val androidVersion = System.getenv("ORG_GRADLE_PROJECT_openIapVersion") ?: run { - // Fallback: read from openiap-versions.json val versionsFile = File(rootDir.parentFile.parentFile, "openiap-versions.json") - val jsonText = versionsFile.readText() - jsonText.substringAfter("\"google\": \"").substringBefore("\"") -} - -val gqlVersion = run { - val versionsFile = File(rootDir.parentFile.parentFile, "openiap-versions.json") - if (versionsFile.exists()) { - val jsonText = versionsFile.readText() - jsonText.substringAfter("\"gql\": \"").substringBefore("\"") - } else { - "1.2.2" // Fallback + if (!versionsFile.isFile) { + error("packages/google: missing openiap-versions.json at ${versionsFile.path}") } + val versionsJson = JsonSlurper().parseText(versionsFile.readText()) as Map<*, *> + versionsJson["google"]?.toString() + ?: error("packages/google: 'google' version missing in openiap-versions.json") } extra["OPENIAP_VERSION"] = androidVersion -extra["GQL_VERSION"] = gqlVersion -// Configure Sonatype (OSSRH) publishing at the root -// Credentials are sourced from env or gradle.properties (OSSRH_USERNAME/OSSRH_PASSWORD) +// Configure Maven Central publishing at the root. +// Credentials are sourced from env or gradle.properties. // Maven Central publishing is configured per-module via Vanniktech plugin. tasks.register("clean", Delete::class) { diff --git a/packages/google/gradle/wrapper/gradle-wrapper.properties b/packages/google/gradle/wrapper/gradle-wrapper.properties index 79eb9d00..ed4c299a 100644 --- a/packages/google/gradle/wrapper/gradle-wrapper.properties +++ b/packages/google/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index 9ceb3b9d..ba1b36ff 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -1,4 +1,5 @@ import groovy.json.JsonSlurper +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") @@ -9,8 +10,12 @@ plugins { // Read version from monorepo root openiap-versions.json val versionsFile = File(rootDir.parentFile.parentFile, "openiap-versions.json") +if (!versionsFile.isFile) { + error("packages/google: missing openiap-versions.json at ${versionsFile.path}") +} val versionsJson = JsonSlurper().parseText(versionsFile.readText()) as Map<*, *> -val openIapVersion: String = versionsJson["google"]?.toString() ?: "1.0.0" +val openIapVersion: String = versionsJson["google"]?.toString() + ?: error("packages/google: 'google' version missing in openiap-versions.json") android { namespace = "io.github.hyochan.openiap" @@ -53,10 +58,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - // Enable Compose for composables in this library (IapContext) buildFeatures { compose = true @@ -89,7 +90,18 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + dependencies { + val playBillingVersion = "8.3.0" + val coroutinesVersion = "1.9.0" + val horizonPlatformVersion = "77.0.1" + val horizonBillingCompatibilityVersion = "1.1.1" + implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") @@ -99,18 +111,18 @@ dependencies { // Play flavor: Google Play Billing API (compile + runtime) // Version 8.3.0 adds External Payments Program support (Japan only) - add("playCompileOnly", "com.android.billingclient:billing-ktx:8.3.0") - add("playApi", "com.android.billingclient:billing-ktx:8.3.0") + add("playCompileOnly", "com.android.billingclient:billing-ktx:$playBillingVersion") + add("playApi", "com.android.billingclient:billing-ktx:$playBillingVersion") // Horizon flavor: Meta Horizon Platform SDK and Billing Compatibility Library (compile + runtime) - add("horizonCompileOnly", "com.meta.horizon.platform.ovr:android-platform-sdk:77.0.1") - add("horizonApi", "com.meta.horizon.platform.ovr:android-platform-sdk:77.0.1") - add("horizonCompileOnly", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1") - add("horizonApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1") + add("horizonCompileOnly", "com.meta.horizon.platform.ovr:android-platform-sdk:$horizonPlatformVersion") + add("horizonApi", "com.meta.horizon.platform.ovr:android-platform-sdk:$horizonPlatformVersion") + add("horizonCompileOnly", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:$horizonBillingCompatibilityVersion") + add("horizonApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:$horizonBillingCompatibilityVersion") // Kotlin Coroutines - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") // JSON handling @@ -123,9 +135,9 @@ dependencies { // Testing dependencies testImplementation("junit:junit:4.13.2") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") // Add Google Play Billing for tests (all flavors need it for OpenIapErrorTest) - testImplementation("com.android.billingclient:billing-ktx:8.3.0") + testImplementation("com.android.billingclient:billing-ktx:$playBillingVersion") // Robolectric for lightweight Android JVM tests (e.g. Horizon no-op listener) testImplementation("org.robolectric:robolectric:4.13") testImplementation("androidx.test:core:1.5.0") @@ -176,8 +188,8 @@ mavenPublishing { } } - // Use the new Central Portal publishing which avoids Nexus staging profile lookups. - publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL) + // Central Portal is the default Maven Central target on Vanniktech 0.33+. + publishToMavenCentral() signAllPublications() pom { 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 56778879..d691cb20 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 @@ -2,7 +2,6 @@ package dev.hyo.openiap import android.app.Activity import android.content.Context -import android.util.Log import com.meta.horizon.billingclient.api.AcknowledgePurchaseParams import com.meta.horizon.billingclient.api.AlternativeBillingOnlyInformationDialogListener import com.meta.horizon.billingclient.api.AlternativeBillingOnlyReportingDetails @@ -49,12 +48,23 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.lang.ref.WeakReference +import java.security.MessageDigest import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException private const val TAG = "OpenIapModule" +private fun redactSensitiveToken(token: String?): String { + val value = token?.takeIf { it.isNotBlank() } ?: return "none" + val fingerprint = MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + .take(12) + return "" +} + /** * OpenIapModule for Meta Horizon Billing * @@ -406,7 +416,7 @@ class OpenIapModule( if (androidArgs.type == ProductQueryType.Subs) { androidArgs.subscriptionOffers.orEmpty().forEach { offer -> if (offer.offerToken.isNotEmpty()) { - OpenIapLog.d("Adding offer token for SKU ${offer.sku}: ${offer.offerToken}", TAG) + OpenIapLog.d("Adding offer token for SKU ${offer.sku}: ", TAG) val queue = requestedOffersBySku.getOrPut(offer.sku) { mutableListOf() } queue.add(offer.offerToken) } @@ -419,9 +429,9 @@ class OpenIapModule( if (androidArgs.type == ProductQueryType.Subs) { val availableOffers = productDetails.subscriptionOfferDetails?.map { - "${it.basePlanId}:${it.offerToken}" + it.basePlanId } ?: emptyList() - OpenIapLog.d("Available offers for ${productDetails.productId}: $availableOffers", TAG) + OpenIapLog.d("Available offer base plans for ${productDetails.productId}: $availableOffers", TAG) val availableTokens = productDetails.subscriptionOfferDetails?.map { it.offerToken } ?: emptyList() val fromQueue = requestedOffersBySku[productDetails.productId]?.let { queue -> @@ -430,11 +440,10 @@ class OpenIapModule( val fromIndex = androidArgs.subscriptionOffers?.getOrNull(index)?.takeIf { it.sku == productDetails.productId }?.offerToken val resolved = fromQueue ?: fromIndex ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken - OpenIapLog.d("Resolved offer token for ${productDetails.productId}: $resolved", TAG) - android.util.Log.i(TAG, "BILLING_FLOW_PARAM: SKU=${productDetails.productId}, resolvedOfferToken=$resolved") + OpenIapLog.d("Resolved offer token for ${productDetails.productId}: ${redactSensitiveToken(resolved)}", TAG) if (resolved.isNullOrEmpty() || (availableTokens.isNotEmpty() && !availableTokens.contains(resolved))) { - OpenIapLog.w("Invalid offer token: $resolved not in $availableTokens", TAG) + OpenIapLog.w("Invalid offer token: ${redactSensitiveToken(resolved)} not in available offer tokens", TAG) val err = OpenIapError.SkuOfferMismatch purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } consumePurchaseCallback(Result.success(emptyList())) @@ -446,7 +455,7 @@ class OpenIapModule( // Handle one-time purchase discount offers // Note: Horizon SDK doesn't currently support one-time purchase discount offers, // but we pass the offer token through in case future SDK versions add support. - OpenIapLog.d("Setting offer token for one-time product ${productDetails.productId}: ${androidArgs.offerToken}", TAG) + OpenIapLog.d("Setting offer token for one-time product ${productDetails.productId}: ", TAG) // Validate offerToken format (basic sanity check) if (androidArgs.offerToken.isBlank()) { @@ -474,7 +483,7 @@ class OpenIapModule( if (androidArgs.type == ProductQueryType.Subs && !androidArgs.purchaseToken.isNullOrBlank()) { // This is a subscription upgrade/downgrade - do not set obfuscatedProfileId OpenIapLog.d("=== Subscription Upgrade Flow ===", TAG) - OpenIapLog.d(" - Old Token: ${androidArgs.purchaseToken.take(10)}...", TAG) + OpenIapLog.d(" - Old Token: ${redactSensitiveToken(androidArgs.purchaseToken)}", TAG) OpenIapLog.d(" - Target SKUs: ${androidArgs.skus}", TAG) OpenIapLog.d(" - Replacement mode: ${androidArgs.replacementMode}", TAG) OpenIapLog.d(" - Product Details Count: ${paramsList.size}", TAG) @@ -843,8 +852,8 @@ class OpenIapModule( OpenIapLog.i("Purchases count: ${purchases?.size ?: 0}", TAG) purchases?.forEachIndexed { index, purchase -> - val redactedToken = purchase.purchaseToken?.take(8)?.plus("…") - val redactedOrder = purchase.orderId?.take(8)?.plus("…") + val redactedToken = redactSensitiveToken(purchase.purchaseToken) + val redactedOrder = redactSensitiveToken(purchase.orderId) OpenIapLog.i( "[HorizonPurchase $index] productIds=${purchase.products} token=$redactedToken orderId=$redactedOrder " + "acknowledged=${purchase.isAcknowledged()} autoRenew=${purchase.isAutoRenewing()}", @@ -1005,7 +1014,7 @@ class OpenIapModule( OpenIapLog.w("Alternative Billing not supported by Horizon library", TAG) cont.resumeWithException(Exception("Feature not supported")) } catch (e: Exception) { - Log.e(TAG, "Error checking alternative billing: ${e.message}") + OpenIapLog.e("Error checking alternative billing: ${e.message}", e, TAG) cont.resumeWithException(e) } } @@ -1015,7 +1024,7 @@ class OpenIapModule( } catch (e: OpenIapError) { throw e } catch (e: Exception) { - Log.e(TAG, "Error in checkAlternativeBillingAvailability: ${e.message}") + OpenIapLog.e("Error in checkAlternativeBillingAvailability: ${e.message}", e, TAG) false } } @@ -1043,7 +1052,7 @@ class OpenIapModule( OpenIapLog.w("showAlternativeBillingOnlyInformationDialog not supported", TAG) cont.resumeWithException(Exception("Feature not supported")) } catch (e: Exception) { - Log.e(TAG, "Error showing alternative billing dialog: ${e.message}") + OpenIapLog.e("Error showing alternative billing dialog: ${e.message}", e, TAG) cont.resumeWithException(e) } } @@ -1053,7 +1062,7 @@ class OpenIapModule( } catch (e: OpenIapError) { throw e } catch (e: Exception) { - Log.e(TAG, "Error in showAlternativeBillingInformationDialog: ${e.message}") + OpenIapLog.e("Error in showAlternativeBillingInformationDialog: ${e.message}", e, TAG) false } } @@ -1072,7 +1081,7 @@ class OpenIapModule( OpenIapLog.w("createAlternativeBillingOnlyReportingDetails not supported", TAG) cont.resumeWithException(Exception("Feature not supported")) } catch (e: Exception) { - Log.e(TAG, "Error creating alternative billing token: ${e.message}") + OpenIapLog.e("Error creating alternative billing token: ${e.message}", e, TAG) cont.resumeWithException(e) } } @@ -1086,57 +1095,57 @@ class OpenIapModule( } catch (e: OpenIapError) { throw e } catch (e: Exception) { - Log.e(TAG, "Error in createAlternativeBillingReportingToken: ${e.message}") + OpenIapLog.e("Error in createAlternativeBillingReportingToken: ${e.message}", e, TAG) null } } override fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { // No-op: User Choice Billing is a Google Play feature, not supported on Meta Horizon - Log.w(TAG, "setUserChoiceBillingListener is not supported on Meta Horizon (no-op)") + OpenIapLog.w("setUserChoiceBillingListener is not supported on Meta Horizon (no-op)", TAG) } override fun setDeveloperProvidedBillingListener(listener: dev.hyo.openiap.listener.DeveloperProvidedBillingListener?) { // No-op: External Payments is a Google Play 8.3.0+ feature, not supported on Meta Horizon - Log.w(TAG, "setDeveloperProvidedBillingListener is not supported on Meta Horizon (no-op)") + OpenIapLog.w("setDeveloperProvidedBillingListener is not supported on Meta Horizon (no-op)", TAG) } override fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { // No-op: User Choice Billing is a Google Play feature, not supported on Meta Horizon - Log.w(TAG, "addUserChoiceBillingListener is not supported on Meta Horizon (no-op)") + OpenIapLog.w("addUserChoiceBillingListener is not supported on Meta Horizon (no-op)", TAG) } override fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { // No-op: User Choice Billing is a Google Play feature, not supported on Meta Horizon - Log.w(TAG, "removeUserChoiceBillingListener is not supported on Meta Horizon (no-op)") + OpenIapLog.w("removeUserChoiceBillingListener is not supported on Meta Horizon (no-op)", TAG) } override fun addDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) { // No-op: External Payments is a Google Play 8.3.0+ feature, not supported on Meta Horizon - Log.w(TAG, "addDeveloperProvidedBillingListener is not supported on Meta Horizon (no-op)") + OpenIapLog.w("addDeveloperProvidedBillingListener is not supported on Meta Horizon (no-op)", TAG) } override fun removeDeveloperProvidedBillingListener(listener: OpenIapDeveloperProvidedBillingListener) { // No-op: External Payments is a Google Play 8.3.0+ feature, not supported on Meta Horizon - Log.w(TAG, "removeDeveloperProvidedBillingListener is not supported on Meta Horizon (no-op)") + OpenIapLog.w("removeDeveloperProvidedBillingListener is not supported on Meta Horizon (no-op)", TAG) } override fun addSubscriptionBillingIssueListener(listener: dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener) { // No-op: Suspended-subscription detection (Purchase.isSuspended) requires Google Play // Billing Library 8.1+. The Meta Horizon Billing Compatibility SDK targets Play Billing 7.0 // and does not expose this signal. - Log.w(TAG, "addSubscriptionBillingIssueListener is not supported on Meta Horizon (no-op); requires Play Billing 8.1+") + OpenIapLog.w("addSubscriptionBillingIssueListener is not supported on Meta Horizon (no-op); requires Play Billing 8.1+", TAG) } override fun removeSubscriptionBillingIssueListener(listener: dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener) { // No-op: see addSubscriptionBillingIssueListener - Log.w(TAG, "removeSubscriptionBillingIssueListener is not supported on Meta Horizon (no-op)") + OpenIapLog.w("removeSubscriptionBillingIssueListener is not supported on Meta Horizon (no-op)", TAG) } // Billing Programs (8.2.0+, EXTERNAL_PAYMENTS 8.3.0+) - Not supported on Horizon override suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid { // No-op: Billing Programs is a Google Play 8.2.0+ feature, not supported on Meta Horizon - Log.w(TAG, "isBillingProgramAvailable is not supported on Meta Horizon (no-op)") + OpenIapLog.w("isBillingProgramAvailable is not supported on Meta Horizon (no-op)", TAG) return BillingProgramAvailabilityResultAndroid( billingProgram = program, isAvailable = false @@ -1145,7 +1154,7 @@ class OpenIapModule( override suspend fun createBillingProgramReportingDetails(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid { // No-op: Billing Programs is a Google Play 8.2.0+ feature, not supported on Meta Horizon - Log.w(TAG, "createBillingProgramReportingDetails is not supported on Meta Horizon (no-op)") + OpenIapLog.w("createBillingProgramReportingDetails is not supported on Meta Horizon (no-op)", TAG) return BillingProgramReportingDetailsAndroid( billingProgram = program, externalTransactionToken = "" @@ -1154,7 +1163,7 @@ class OpenIapModule( override suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean { // No-op: Billing Programs is a Google Play 8.2.0+ feature, not supported on Meta Horizon - Log.w(TAG, "launchExternalLink is not supported on Meta Horizon (no-op)") + OpenIapLog.w("launchExternalLink is not supported on Meta Horizon (no-op)", TAG) return false } } diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/Helpers.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/Helpers.kt index 2df94910..dd09dfeb 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/Helpers.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/Helpers.kt @@ -17,26 +17,21 @@ private const val TAG = "Helpers" * Query and restore all purchases (both INAPP and SUBS) for Horizon */ internal suspend fun restorePurchasesHorizon(client: BillingClient?): List { - android.util.Log.i("HORIZON_QUERY", "restorePurchasesHorizon: Starting") OpenIapLog.d("restorePurchasesHorizon: Starting", TAG) if (client == null) { - android.util.Log.w("HORIZON_QUERY", "restorePurchasesHorizon: BillingClient is null") OpenIapLog.w("restorePurchasesHorizon: BillingClient is null", TAG) return emptyList() } val purchases = mutableListOf() val inapp = queryPurchasesHorizon(client, BillingClient.ProductType.INAPP) - android.util.Log.i("HORIZON_QUERY", "restorePurchasesHorizon: INAPP purchases = ${inapp.size}") OpenIapLog.d("restorePurchasesHorizon: INAPP purchases = ${inapp.size}", TAG) purchases += inapp val subs = queryPurchasesHorizon(client, BillingClient.ProductType.SUBS) - android.util.Log.i("HORIZON_QUERY", "restorePurchasesHorizon: SUBS purchases = ${subs.size}") OpenIapLog.d("restorePurchasesHorizon: SUBS purchases = ${subs.size}", TAG) purchases += subs - android.util.Log.i("HORIZON_QUERY", "restorePurchasesHorizon: Total = ${purchases.size}") OpenIapLog.d("restorePurchasesHorizon: Total = ${purchases.size}", TAG) return purchases } @@ -48,11 +43,9 @@ internal suspend fun queryPurchasesHorizon( client: BillingClient?, productType: String ): List = suspendCancellableCoroutine { continuation -> - android.util.Log.i("HORIZON_QUERY", "queryPurchasesHorizon: type=$productType") OpenIapLog.d("queryPurchasesHorizon: type=$productType", TAG) val billingClient = client ?: run { - android.util.Log.w("HORIZON_QUERY", "queryPurchasesHorizon: BillingClient is null") OpenIapLog.w("queryPurchasesHorizon: BillingClient is null", TAG) continuation.resume(emptyList()) return@suspendCancellableCoroutine @@ -60,16 +53,14 @@ internal suspend fun queryPurchasesHorizon( // CRITICAL FIX: Check if BillingClient is ready before querying if (!billingClient.isReady()) { - android.util.Log.w("HORIZON_QUERY", "queryPurchasesHorizon: BillingClient is not ready, returning empty list") OpenIapLog.w("queryPurchasesHorizon: BillingClient is not ready", TAG) continuation.resume(emptyList()) return@suspendCancellableCoroutine } - android.util.Log.i("HORIZON_QUERY", "queryPurchasesHorizon: BillingClient is ready, querying purchases") + OpenIapLog.d("queryPurchasesHorizon: BillingClient is ready, querying purchases", TAG) val params = QueryPurchasesParams.newBuilder().setProductType(productType).build() billingClient.queryPurchasesAsync(params) { result, purchaseList -> - android.util.Log.i("HORIZON_QUERY", "queryPurchasesHorizon: type=$productType responseCode=${result.responseCode} count=${purchaseList?.size ?: 0}") OpenIapLog.d( "queryPurchasesHorizon: type=$productType responseCode=${result.responseCode} " + "count=${purchaseList?.size ?: 0}", @@ -78,14 +69,12 @@ internal suspend fun queryPurchasesHorizon( if (result.responseCode == BillingClient.BillingResponseCode.OK) { val mapped = purchaseList?.map { - android.util.Log.d("HORIZON_QUERY", " - Purchase: productIds=${it.products}") OpenIapLog.d(" - Purchase: productIds=${it.products}", TAG) it.toPurchase() } ?: emptyList() - android.util.Log.i("HORIZON_QUERY", "queryPurchasesHorizon: Returning ${mapped.size} mapped purchases") + OpenIapLog.d("queryPurchasesHorizon: Returning ${mapped.size} mapped purchases", TAG) continuation.resume(mapped) } else { - android.util.Log.w("HORIZON_QUERY", "queryPurchasesHorizon: Failed with code=${result.responseCode}") OpenIapLog.w("queryPurchasesHorizon: Failed with code=${result.responseCode}", TAG) continuation.resume(emptyList()) } diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/ProductManager.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/ProductManager.kt index 2b868328..16d377ff 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/ProductManager.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/ProductManager.kt @@ -128,7 +128,7 @@ internal class ProductManager { // Log subscription offer details product.subscriptionOfferDetails?.forEachIndexed { index, offer -> - OpenIapLog.d(" Offer[$index]: token=${offer.offerToken}", TAG) + OpenIapLog.d(" Offer[$index]: token=", TAG) offer.pricingPhases?.pricingPhaseList?.forEachIndexed { phaseIndex, phase -> OpenIapLog.d( " Phase[$phaseIndex]: period=${phase.billingPeriod}, " + diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt index 4cddc759..927063a8 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -62,7 +62,7 @@ import kotlinx.coroutines.launch */ class OpenIapStore(private val module: OpenIapProtocol) { init { - android.util.Log.i("OpenIapStore", "Initialized with module: ${module.javaClass.simpleName}") + OpenIapLog.i("Initialized with module: ${module.javaClass.simpleName}", "OpenIapStore") } constructor(context: Context) : this(buildModule(context, null, null)) @@ -116,30 +116,29 @@ class OpenIapStore(private val module: OpenIapProtocol) { // This ensures the purchase list reflects the new purchase immediately storeScope.launch { try { - android.util.Log.i("OpenIapStore", "Purchase update received, refreshing available purchases") + OpenIapLog.i("Purchase update received, refreshing available purchases", "OpenIapStore") // Wait a bit for the purchase to be fully processed by Horizon kotlinx.coroutines.delay(500) // Ensure connection is ready if (!isConnected.value) { - android.util.Log.w("OpenIapStore", "Not connected, skipping purchase refresh (connection will be restored on next app start)") + OpenIapLog.w("Not connected, skipping purchase refresh (connection will be restored on next app start)", "OpenIapStore") // Don't attempt to reconnect here as it may cause issues // The purchase will be available on next app launch return@launch } - android.util.Log.i("OpenIapStore", "About to call module.getAvailablePurchases(null)") + OpenIapLog.i("About to call module.getAvailablePurchases(null)", "OpenIapStore") val result = module.getAvailablePurchases(null) - android.util.Log.i("OpenIapStore", "module.getAvailablePurchases returned: ${result.size} purchases") + OpenIapLog.i("module.getAvailablePurchases returned: ${result.size} purchases", "OpenIapStore") result.forEachIndexed { index, purchase -> - android.util.Log.i("OpenIapStore", " Purchase[$index]: ${purchase.productId}") + OpenIapLog.i(" Purchase[$index]: ${purchase.productId}", "OpenIapStore") } _availablePurchases.value = result - android.util.Log.i("OpenIapStore", "Available purchases updated: ${result.size} purchases") + OpenIapLog.i("Available purchases updated: ${result.size} purchases", "OpenIapStore") } catch (e: Exception) { - android.util.Log.e("OpenIapStore", "Failed to refresh purchases after update", e) - e.printStackTrace() + OpenIapLog.e("Failed to refresh purchases after update", e, "OpenIapStore") } } } @@ -228,7 +227,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { * @throws OpenIapError.InitConnection when the billing client fails to initialize * (e.g. Play Store missing, version too old). * - * @see init-connection + * @see init-connection */ val initConnection: MutationInitConnectionHandler = { config -> setLoading { it.initConnection = true } @@ -256,7 +255,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { * @throws OpenIapError.InitConnection when the billing client fails to initialize * (e.g. Play Store missing, version too old). * - * @see init-connection + * @see init-connection */ suspend fun initConnection(): Boolean { OpenIapLog.i("OpenIapStore.initConnection(): Calling initConnection(null)...", "OpenIapStore") @@ -266,7 +265,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { /** * Close the store connection and release resources. * - * @see https://www.openiap.dev/docs/apis/end-connection + * @see https://openiap.dev/docs/apis/end-connection */ val endConnection: MutationEndConnectionHandler = { removePurchaseUpdateListener(purchaseUpdateListener) @@ -296,15 +295,15 @@ class OpenIapStore(private val module: OpenIapProtocol) { * `Subscriptions` for Subs, mixed list for All. * @throws OpenIapError on store rejection (unknown SKU, network failure, not connected). * - * @see fetch-products + * @see fetch-products */ val fetchProducts: QueryFetchProductsHandler = { request -> - android.util.Log.i("OpenIapStore", "fetchProducts called with SKUs: ${request.skus}, type: ${request.type}") + OpenIapLog.i("fetchProducts called with SKUs: ${request.skus}, type: ${request.type}", "OpenIapStore") setLoading { it.fetchProducts = true } try { - android.util.Log.i("OpenIapStore", "Calling module.fetchProducts") + OpenIapLog.i("Calling module.fetchProducts", "OpenIapStore") val result = module.fetchProducts(request) - android.util.Log.i("OpenIapStore", "module.fetchProducts returned: $result") + OpenIapLog.i("module.fetchProducts returned: $result", "OpenIapStore") when (result) { is FetchProductsResultProducts -> { // Merge new products with existing ones @@ -390,19 +389,19 @@ class OpenIapStore(private val module: OpenIapProtocol) { * @return List of [Purchase] currently owned according to Play Billing. * @throws OpenIapError when the Play Billing query fails. * - * @see get-available-purchases + * @see get-available-purchases */ val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { options -> - android.util.Log.i("OpenIapStore", "getAvailablePurchases called, module type: ${module.javaClass.simpleName}") + OpenIapLog.i("getAvailablePurchases called, module type: ${module.javaClass.simpleName}", "OpenIapStore") setLoading { it.restorePurchases = true } try { - android.util.Log.i("OpenIapStore", "Calling module.getAvailablePurchases(options)") + OpenIapLog.i("Calling module.getAvailablePurchases(options)", "OpenIapStore") val result = module.getAvailablePurchases(options) - android.util.Log.i("OpenIapStore", "module.getAvailablePurchases returned ${result.size} purchases") + OpenIapLog.i("module.getAvailablePurchases returned ${result.size} purchases", "OpenIapStore") _availablePurchases.value = result result } catch (e: Exception) { - android.util.Log.e("OpenIapStore", "getAvailablePurchases exception: ${e.message}", e) + OpenIapLog.e("getAvailablePurchases exception: ${e.message}", e, "OpenIapStore") setError(e.message) throw e } finally { @@ -428,7 +427,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { * (or `OpenIapStore.currentPurchase` and `OpenIapStore.status.lastError` flows) for the * final state — there is no `currentError` field; errors live on `status.lastError`. * - * @see request-purchase + * @see request-purchase */ val requestPurchase: MutationRequestPurchaseHandler = { props -> val skuForStatus = when (val request = props.request) { @@ -460,7 +459,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { * * Important: Google auto-refunds Android purchases NOT acknowledged/consumed within 3 days. * - * @see finish-transaction + * @see finish-transaction */ val finishTransaction: MutationFinishTransactionHandler = { purchaseInput, isConsumable -> val token = purchaseInput.purchaseToken @@ -483,7 +482,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { /** * Get details of all currently active subscriptions. * - * @see https://www.openiap.dev/docs/apis/get-active-subscriptions + * @see https://openiap.dev/docs/apis/get-active-subscriptions */ suspend fun getActiveSubscriptions(subscriptionIds: List? = null): List = module.queryHandlers.getActiveSubscriptions?.invoke(subscriptionIds) ?: emptyList() @@ -491,7 +490,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { /** * Check whether the user has any active subscription. * - * @see https://www.openiap.dev/docs/apis/has-active-subscriptions + * @see https://openiap.dev/docs/apis/has-active-subscriptions */ suspend fun hasActiveSubscriptions(subscriptionIds: List? = null): Boolean = module.queryHandlers.hasActiveSubscriptions?.invoke(subscriptionIds) ?: false @@ -499,7 +498,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { /** * Open the platform's subscription management UI. * - * @see https://www.openiap.dev/docs/apis/deep-link-to-subscriptions + * @see https://openiap.dev/docs/apis/deep-link-to-subscriptions */ suspend fun deepLinkToSubscriptions(options: DeepLinkOptions) = module.mutationHandlers.deepLinkToSubscriptions?.invoke(options) @@ -509,7 +508,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { /** * Check whether alternative billing is available for the user. * - * @see https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android + * @see https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android */ @Deprecated("Use isBillingProgramAvailable with BillingProgramAndroid.ExternalOffer instead") @Suppress("DEPRECATION") @@ -518,7 +517,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { /** * Display Google's alternative billing information dialog. * - * @see https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android + * @see https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android */ @Deprecated("Use launchExternalLink instead") @Suppress("DEPRECATION") @@ -528,7 +527,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { /** * Create a reporting token for an alternative billing flow. * - * @see https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android + * @see https://openiap.dev/docs/apis/android/create-alternative-billing-token-android */ @Deprecated("Use createBillingProgramReportingDetails with BillingProgramAndroid.ExternalOffer instead") @Suppress("DEPRECATION") @@ -541,7 +540,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { /** * Check whether a billing program (e.g., External Payments) is available. * - * @see https://www.openiap.dev/docs/apis/android/is-billing-program-available-android + * @see https://openiap.dev/docs/apis/android/is-billing-program-available-android */ suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid = module.isBillingProgramAvailable(program) @@ -549,7 +548,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { /** * Create the reporting payload Google requires after a Developer-Provided Billing transaction (Play Billing 8.3.0+). * - * @see https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android + * @see https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android */ suspend fun createBillingProgramReportingDetails(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid = module.createBillingProgramReportingDetails(program) @@ -557,7 +556,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { /** * Launch an external content/offer link from inside the Billing Programs flow (Play Billing 8.2.0+). * - * @see https://www.openiap.dev/docs/apis/android/launch-external-link-android + * @see https://openiap.dev/docs/apis/android/launch-external-link-android */ suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean = module.launchExternalLink(activity, params) @@ -733,16 +732,15 @@ private fun buildModule(context: Context, store: String?, appId: String?): OpenI val defaultStore = try { val buildConfig = Class.forName("io.github.hyochan.openiap.BuildConfig") val storeValue = buildConfig.getField("OPENIAP_STORE").get(null) as? String ?: "play" - android.util.Log.i("OpenIapStore", "BuildConfig.OPENIAP_STORE = $storeValue") + OpenIapLog.i("BuildConfig.OPENIAP_STORE = $storeValue", "OpenIapStore") storeValue } catch (e: Throwable) { - android.util.Log.w("OpenIapStore", "Failed to read BuildConfig.OPENIAP_STORE: ${e.message}") + OpenIapLog.w("Failed to read BuildConfig.OPENIAP_STORE: ${e.message}", "OpenIapStore") "play" } val selected = (store ?: defaultStore).lowercase() - android.util.Log.i("OpenIapStore", "buildModule: selected=$selected, defaultStore=$defaultStore") OpenIapLog.d("buildModule: selected=$selected, defaultStore=$defaultStore", "OpenIapStore") return when (selected) { diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 83d7685d..676afaf4 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -72,10 +72,21 @@ import kotlinx.coroutines.withContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import java.lang.ref.WeakReference +import java.security.MessageDigest import java.util.concurrent.atomic.AtomicReference // AlternativeBillingMode moved to main source set (shared between Play and Horizon) +private fun redactSensitiveToken(token: String?): String { + val value = token?.takeIf { it.isNotBlank() } ?: return "none" + val fingerprint = MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + .take(12) + return "" +} + /** * Main OpenIapModule implementation for Android * @@ -456,7 +467,7 @@ class OpenIapModule( try { val tokenMethod = details.javaClass.getMethod("getExternalTransactionToken") val token = tokenMethod.invoke(details) as? String - OpenIapLog.d("✓ External transaction token created: $token", TAG) + OpenIapLog.d("✓ External transaction token created: ${redactSensitiveToken(token)}", TAG) if (continuation.isActive) continuation.resume(token) } catch (e: Exception) { OpenIapLog.e("Failed to extract token: ${e.message}", e, TAG) @@ -583,7 +594,7 @@ class OpenIapModule( try { val tokenMethod = details.javaClass.getMethod("getExternalTransactionToken") val token = tokenMethod.invoke(details) as? String - OpenIapLog.d("Billing program reporting token created: $token", TAG) + OpenIapLog.d("Billing program reporting token created: ${redactSensitiveToken(token)}", TAG) if (continuation.isActive && token != null) { continuation.resume(BillingProgramReportingDetailsAndroid( @@ -837,7 +848,7 @@ class OpenIapModule( val tokenResult = createAlternativeBillingReportingToken() if (tokenResult != null) { - OpenIapLog.d("✓ Alternative billing token created: $tokenResult", TAG) + OpenIapLog.d("✓ Alternative billing token created: ${redactSensitiveToken(tokenResult)}", TAG) OpenIapLog.d("", TAG) OpenIapLog.d("============================================================", TAG) OpenIapLog.d("NEXT STEPS (PRODUCTION IMPLEMENTATION REQUIRED)", TAG) @@ -846,26 +857,17 @@ class OpenIapModule( OpenIapLog.d("", TAG) OpenIapLog.d("Required implementation:", TAG) OpenIapLog.d("1. Process payment through YOUR alternative payment system", TAG) - OpenIapLog.d("2. After successful payment, send this token to your backend:", TAG) - OpenIapLog.d(" Token: $tokenResult", TAG) + OpenIapLog.d("2. After successful payment, send this token to your backend without logging it", TAG) + OpenIapLog.d(" Token: ${redactSensitiveToken(tokenResult)}", TAG) OpenIapLog.d("3. Backend reports to Google Play Developer API within 24 hours:", TAG) OpenIapLog.d(" POST https://androidpublisher.googleapis.com/androidpublisher/v3/", TAG) OpenIapLog.d(" applications/{packageName}/externalTransactions", TAG) - OpenIapLog.d(" Body: { externalTransactionToken: \"$tokenResult\", ... }", TAG) + OpenIapLog.d(" Body: { externalTransactionToken: \"\", ... }", TAG) OpenIapLog.d("", TAG) OpenIapLog.d("See: https://developer.android.com/google/play/billing/alternative/reporting", TAG) OpenIapLog.d("============================================================", TAG) OpenIapLog.d("=== END ALTERNATIVE BILLING ONLY MODE ===", TAG) - // TODO: In production, emit this token via callback for payment processing - // alternativeBillingCallback?.onTokenCreated( - // token = tokenResult, - // productId = props.skus.first(), - // onPaymentComplete = { transactionId -> - // // App reports to backend after payment success - // } - // ) - // Return empty list - app should handle purchase via alternative billing return@withContext emptyList() } else { @@ -949,7 +951,7 @@ class OpenIapModule( if (androidArgs.type == ProductQueryType.Subs) { for (offer in androidArgs.subscriptionOffers.orEmpty()) { if (offer.offerToken.isNotEmpty()) { - OpenIapLog.d("Adding offer token for SKU ${offer.sku}: ${offer.offerToken}", TAG) + OpenIapLog.d("Adding offer token for SKU ${offer.sku}: ", TAG) val queue = requestedOffersBySku.getOrPut(offer.sku) { mutableListOf() } queue.add(offer.offerToken) } @@ -962,9 +964,9 @@ class OpenIapModule( if (androidArgs.type == ProductQueryType.Subs) { val availableOffers = productDetails.subscriptionOfferDetails?.map { - "${it.basePlanId}:${it.offerToken}" + it.basePlanId } ?: emptyList() - OpenIapLog.d("Available offers for ${productDetails.productId}: $availableOffers", TAG) + OpenIapLog.d("Available offer base plans for ${productDetails.productId}: $availableOffers", TAG) val availableTokens = productDetails.subscriptionOfferDetails?.map { it.offerToken } ?: emptyList() val fromQueue = requestedOffersBySku[productDetails.productId]?.let { queue -> @@ -973,10 +975,10 @@ class OpenIapModule( val fromIndex = androidArgs.subscriptionOffers?.getOrNull(index)?.takeIf { it.sku == productDetails.productId }?.offerToken val resolved = fromQueue ?: fromIndex ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken - OpenIapLog.d("Resolved offer token for ${productDetails.productId}: $resolved", TAG) + OpenIapLog.d("Resolved offer token for ${productDetails.productId}: ${redactSensitiveToken(resolved)}", TAG) if (resolved.isNullOrEmpty() || (availableTokens.isNotEmpty() && !availableTokens.contains(resolved))) { - OpenIapLog.w("Invalid offer token: $resolved not in $availableTokens", TAG) + OpenIapLog.w("Invalid offer token: ${redactSensitiveToken(resolved)} not in available offer tokens", TAG) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } consumePurchaseCallback(Result.success(emptyList())) @@ -995,7 +997,7 @@ class OpenIapModule( } } else if (androidArgs.type == ProductQueryType.InApp && !androidArgs.offerToken.isNullOrEmpty()) { // Handle one-time purchase discount offers (Android 7.0+) - OpenIapLog.d("Setting offer token for one-time product ${productDetails.productId}: ${androidArgs.offerToken}", TAG) + OpenIapLog.d("Setting offer token for one-time product ${productDetails.productId}: ", TAG) // Validate offer token exists in available one-time purchase offers // Use oneTimePurchaseOfferDetailsList (Billing Library 7.0+) for discount offers @@ -1003,7 +1005,7 @@ class OpenIapModule( val availableTokens = oneTimePurchaseOffers?.map { it.offerToken } ?: emptyList() if (availableTokens.isEmpty()) { - OpenIapLog.w("No one-time purchase offers available for ${productDetails.productId}, but offerToken was provided: ${androidArgs.offerToken}", TAG) + OpenIapLog.w("No one-time purchase offers available for ${productDetails.productId}, but offerToken was provided: ", TAG) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } consumePurchaseCallback(Result.success(emptyList())) @@ -1011,7 +1013,7 @@ class OpenIapModule( } if (!availableTokens.contains(androidArgs.offerToken)) { - OpenIapLog.w("Invalid one-time offer token: ${androidArgs.offerToken} not in $availableTokens", TAG) + OpenIapLog.w("Invalid one-time offer token: not in available offer tokens", TAG) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } consumePurchaseCallback(Result.success(emptyList())) @@ -1050,7 +1052,7 @@ class OpenIapModule( if (androidArgs.type == ProductQueryType.Subs && !androidArgs.purchaseToken.isNullOrBlank()) { // This is a subscription upgrade/downgrade - do not set obfuscatedProfileId OpenIapLog.d("=== Subscription Upgrade Flow ===", TAG) - OpenIapLog.d(" - Old Token: ${androidArgs.purchaseToken.take(10)}...", TAG) + OpenIapLog.d(" - Old Token: ${redactSensitiveToken(androidArgs.purchaseToken)}", TAG) OpenIapLog.d(" - Target SKUs: ${androidArgs.skus}", TAG) OpenIapLog.d(" - Replacement mode: ${androidArgs.replacementMode}", TAG) OpenIapLog.d(" - Product Details Count: ${paramsList.size}", TAG) @@ -1462,7 +1464,7 @@ class OpenIapModule( OpenIapLog.d("Mapping purchase products=${purchase.products} to type=$productType basePlanId=$basePlanId (cached=${cached != null})", TAG) purchase.toPurchase(productType, basePlanId) } - OpenIapLog.d("Mapped purchases=${gson.toJson(mapped)}", TAG) + OpenIapLog.d("Mapped purchases count=${mapped.size}", TAG) notifySuspendedSubscriptions(mapped) for (converted in mapped) { for (listener in purchaseUpdateListeners) { @@ -1540,7 +1542,7 @@ class OpenIapModule( if (externalToken != null && products != null) { val productIds = products.mapNotNull { it?.toString() } - OpenIapLog.d("External transaction token: $externalToken", TAG) + OpenIapLog.d("External transaction token: ${redactSensitiveToken(externalToken)}", TAG) OpenIapLog.d("Products: $productIds", TAG) // Create UserChoiceBillingDetails for the event @@ -1575,8 +1577,7 @@ class OpenIapModule( OpenIapLog.w("Failed to extract user choice details", TAG) } } catch (e: Exception) { - OpenIapLog.w("Error processing user choice details: ${e.message}", TAG) - e.printStackTrace() + OpenIapLog.e("Error processing user choice details", e, TAG) } OpenIapLog.d("==========================================", TAG) } @@ -1830,7 +1831,7 @@ class OpenIapModule( val externalToken = tokenMethod?.invoke(billingDetails) as? String if (externalToken != null) { - OpenIapLog.d("External transaction token: $externalToken", TAG) + OpenIapLog.d("External transaction token: ${redactSensitiveToken(externalToken)}", TAG) // Create DeveloperProvidedBillingDetailsAndroid for the event val details = DeveloperProvidedBillingDetailsAndroid( @@ -1862,8 +1863,7 @@ class OpenIapModule( OpenIapLog.w("Failed to extract external transaction token", TAG) } } catch (e: Exception) { - OpenIapLog.w("Error processing developer billing details: ${e.message}", TAG) - e.printStackTrace() + OpenIapLog.e("Error processing developer billing details", e, TAG) } OpenIapLog.d("==========================================", TAG) } diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt index 8beb7a94..d9d691e3 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt @@ -327,6 +327,7 @@ class OpenIapErrorTest { } @Test + @Suppress("DEPRECATION") fun `fromBillingResponseCode forwards debugMessage for every response code`() { val debug = "offerToken does not match any product details" val codesToAssert = listOf( diff --git a/packages/google/package.json b/packages/google/package.json index 3ca23430..6e3c4213 100644 --- a/packages/google/package.json +++ b/packages/google/package.json @@ -1,6 +1,6 @@ { "name": "@hyodotdev/openiap-android", - "version": "1.2.12", + "version": "2.1.5", "private": true, "description": "OpenIAP Android/Kotlin implementation", "scripts": { @@ -12,5 +12,5 @@ "dependencies": { "@hyodotdev/openiap-gql": "workspace:*" }, - "packageManager": "bun@1.1.0" + "packageManager": "bun@1.3.13" } diff --git a/packages/google/scripts/open-android-studio.sh b/packages/google/scripts/open-android-studio.sh index d4cd21a0..ab713409 100755 --- a/packages/google/scripts/open-android-studio.sh +++ b/packages/google/scripts/open-android-studio.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -euo pipefail # OpenIAP Google - Open in Android Studio # This script opens the Google (Android) package in Android Studio @@ -39,4 +40,4 @@ echo "📱 Running from Terminal:" echo " ./gradlew :Example:installDebug && adb shell am start -n dev.hyo.martie/.MainActivity" echo "" echo "🔍 View Logs:" -echo " adb logcat -s OpenIAP:V MainActivity:V" \ No newline at end of file +echo " adb logcat -s OpenIAP:V MainActivity:V" diff --git a/packages/google/scripts/publish-local.sh b/packages/google/scripts/publish-local.sh index f556b314..712e5714 100755 --- a/packages/google/scripts/publish-local.sh +++ b/packages/google/scripts/publish-local.sh @@ -38,10 +38,6 @@ signingInMemoryKeyFile="$(read_prop signingInMemoryKeyFile)" signingInMemoryKey="$(read_prop signingInMemoryKey)" openIapVersion="$(read_prop openIapVersion)" openIapGroupId="$(read_prop OPENIAP_GROUP_ID)" -# Staging profile id (either key is accepted) -sonatypeStagingProfileId="$(read_prop sonatypeStagingProfileId)" -mavenCentralStagingProfileId="$(read_prop mavenCentralStagingProfileId)" -sonatypeHostProp="$(read_prop sonatypeHost)" if [[ -z "$mavenCentralUsername" || -z "$mavenCentralPassword" ]]; then echo "Missing required keys in local.properties. Required: mavenCentralUsername, mavenCentralPassword" @@ -85,27 +81,11 @@ if [[ -n "$openIapVersion" ]]; then export ORG_GRADLE_PROJECT_openIapVersion="$openIapVersion" fi -# Optional: select Sonatype host via env SONATYPE_HOST=(S01|DEFAULT) -if [[ -n "${SONATYPE_HOST:-}" ]]; then - export ORG_GRADLE_PROJECT_sonatypeHost="$SONATYPE_HOST" -elif [[ -n "$sonatypeHostProp" ]]; then - # Allow specifying the host via local.properties (sonatypeHost=DEFAULT|S01) - export ORG_GRADLE_PROJECT_sonatypeHost="$sonatypeHostProp" -fi - # Optional: override Maven groupId via local.properties OPENIAP_GROUP_ID if [[ -n "$openIapGroupId" ]]; then export ORG_GRADLE_PROJECT_OPENIAP_GROUP_ID="$openIapGroupId" fi -# Optional: set explicit staging profile id to bypass lookup -if [[ -n "$sonatypeStagingProfileId" ]]; then - export ORG_GRADLE_PROJECT_mavenCentralStagingProfileId="$sonatypeStagingProfileId" -fi -if [[ -n "$mavenCentralStagingProfileId" ]]; then - export ORG_GRADLE_PROJECT_mavenCentralStagingProfileId="$mavenCentralStagingProfileId" -fi - # Optional first argument can be "local" to publish to Maven Local for testing MODE=${1:-central} @@ -116,31 +96,12 @@ if [[ "$MODE" == "local" ]]; then echo "Publishing to Maven Local (for local testing)..." ./gradlew :openiap:publishToMavenLocal --no-daemon --stacktrace echo "Published to Maven Local." - echo "Use dependency: ${openIapGroupId:-io.github.hyochan}:openiap-google:${openIapVersion:-}" + echo "Use dependency: ${openIapGroupId:-io.github.hyochan.openiap}:openiap-google:${openIapVersion:-}" exit 0 fi echo "Building and publishing to Maven Central..." - -# Try with configured host (default S01 from Gradle config). If it fails with -# a common stagingProfiles error, retry using the alternate host automatically. -set +e ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace -rc=$? -set -e - -if [[ $rc -ne 0 ]]; then - echo "Initial publish failed (exit $rc). Attempting host fallback..." - currentHost="${ORG_GRADLE_PROJECT_sonatypeHost:-S01}" - if [[ "$currentHost" =~ ^(?i)s01$ ]]; then - fallbackHost="DEFAULT" - else - fallbackHost="S01" - fi - echo "Retrying with SONATYPE_HOST=$fallbackHost" - export ORG_GRADLE_PROJECT_sonatypeHost="$fallbackHost" - ./gradlew :openiap:publishAndReleaseToMavenCentral --no-daemon --no-parallel --stacktrace -fi echo "Publishing completed." echo "Check https://central.sonatype.com/publishing/deployments" diff --git a/packages/google/scripts/update-version.sh b/packages/google/scripts/update-version.sh index 01454d30..4d150fd0 100755 --- a/packages/google/scripts/update-version.sh +++ b/packages/google/scripts/update-version.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# This script updates the version in README.md and openiap-versions.json +# This script updates the Google version in openiap-versions.json and synced metadata. # Usage: ./scripts/update-version.sh if [ $# -ne 1 ]; then @@ -15,41 +15,49 @@ VERSION="$1" VERSION="${VERSION#v}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -README_FILE="${REPO_ROOT}/README.md" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" VERSIONS_FILE="${REPO_ROOT}/openiap-versions.json" echo "Updating version to $VERSION" -# Update README.md -if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS uses different sed syntax - sed -i '' "s/openiap-google:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*/openiap-google:$VERSION/g" "$README_FILE" -else - # Linux - sed -i "s/openiap-google:[0-9]\+\.[0-9]\+\.[0-9]\+/openiap-google:$VERSION/g" "$README_FILE" +if [[ ! -f "$VERSIONS_FILE" ]]; then + echo "Error: openiap-versions.json not found at $VERSIONS_FILE" >&2 + exit 1 fi -# Update openiap-versions.json (preserving spec version) -if command -v python3 &> /dev/null; then - SPEC_VERSION=$(python3 -c "import json; print(json.load(open('$VERSIONS_FILE'))['spec'])" 2>/dev/null || echo "2.0.0") +# Update openiap-versions.json without dropping other version fields +if command -v jq &> /dev/null; then + tmp_file="${VERSIONS_FILE}.tmp" + jq --arg version "$VERSION" '.google = $version' "$VERSIONS_FILE" > "$tmp_file" + mv "$tmp_file" "$VERSIONS_FILE" +elif command -v python3 &> /dev/null; then + VERSION="$VERSION" VERSIONS_FILE="$VERSIONS_FILE" python3 - <<'PY' +import json +import os + +versions_file = os.environ["VERSIONS_FILE"] +with open(versions_file, "r", encoding="utf-8") as f: + data = json.load(f) +data["google"] = os.environ["VERSION"] +with open(versions_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") +PY else - SPEC_VERSION=$(grep '"spec"' "$VERSIONS_FILE" | sed 's/.*"spec".*"\([^"]*\)".*/\1/') + echo "Error: jq or python3 is required to update openiap-versions.json" >&2 + exit 1 fi -cat > "$VERSIONS_FILE" << EOF -{ - "spec": "$SPEC_VERSION", - "google": "$VERSION" -} -EOF +"$REPO_ROOT/scripts/sync-versions.sh" -echo "✅ Updated README.md and openiap-versions.json to version $VERSION" +echo "✅ Updated openiap-versions.json to version $VERSION" echo "" echo "Files modified:" -echo " - $README_FILE" echo " - $VERSIONS_FILE" +echo " - $REPO_ROOT/packages/*/openiap-versions.json" +echo " - $REPO_ROOT/packages/{gql,docs,google,apple}/package.json" echo "" echo "To commit these changes:" -echo " git add README.md openiap-versions.json" -echo " git commit -m \"chore: update version to $VERSION\"" \ No newline at end of file +echo " git add openiap-versions.json packages/*/openiap-versions.json" +echo " git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json" +echo " git commit -m \"chore(google): update version to $VERSION\"" From 89f7a20c1f51ef93b040abb07d6724ed39f050d8 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 16 May 2026 14:39:34 +0900 Subject: [PATCH 05/26] feat(sdk): align framework purchase APIs Update framework SDK implementations, examples, tests, and build scripts for the shared purchase API changes. --- libraries/expo-iap/CLAUDE.md | 16 +- libraries/expo-iap/CONTRIBUTING.md | 4 +- libraries/expo-iap/README.md | 27 +- libraries/expo-iap/android/build.gradle | 54 ++- .../android/openiap-android-sdk.gradle | 30 ++ .../java/expo/modules/iap/ExpoIapHelper.kt | 16 +- .../main/java/expo/modules/iap/ExpoIapLog.kt | 4 +- .../java/expo/modules/iap/ExpoIapModule.kt | 40 +- .../java/expo/modules/iap/PromiseUtils.kt | 5 +- libraries/expo-iap/example/README.md | 2 +- .../example/app/alternative-billing.tsx | 60 +-- .../example/app/available-purchases.tsx | 19 +- .../expo-iap/example/app/purchase-flow.tsx | 4 +- .../example/app/subscription-flow.tsx | 30 +- .../expo-iap/example/app/webhook-stream.tsx | 2 +- .../example/src/utils/buildPurchaseRows.ts | 20 +- libraries/expo-iap/ios/ExpoIapModule.swift | 37 +- .../expo-iap/ios/onside/OnsideIapModule.swift | 169 ++++++-- .../expo-iap/plugin/src/withLocalOpenIAP.ts | 108 ++++- libraries/expo-iap/scripts/test-coverage.sh | 6 +- libraries/expo-iap/src/ExpoIapModule.ts | 79 +++- .../expo-iap/src/__mocks__/ExpoIapModule.js | 1 - .../src/__mocks__/expo-modules-core.js | 12 + .../src/__tests__/ExpoIapModule.test.ts | 187 +++++++++ .../expo-iap/src/__tests__/index.test.ts | 145 ++++++- libraries/expo-iap/src/index.ts | 172 ++++---- libraries/expo-iap/src/modules/android.ts | 16 +- libraries/expo-iap/src/modules/ios.ts | 50 +-- libraries/expo-iap/src/useIAP.ts | 22 +- libraries/flutter_inapp_purchase/CLAUDE.md | 10 +- .../flutter_inapp_purchase/CONTRIBUTING.md | 35 +- libraries/flutter_inapp_purchase/README.md | 18 +- .../android/build.gradle | 110 ++++- .../android/gradle.properties | 5 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../android/openiap-android-sdk.gradle | 60 +++ .../android/settings.gradle | 10 +- .../AmazonInappPurchasePlugin.kt | 185 +++++---- .../AndroidInappPurchasePlugin.kt | 259 ++++++------ .../FlutterInappPurchasePlugin.kt | 73 ++-- .../MethodResultWrapper.kt | 24 +- .../example/android/app/build.gradle | 53 ++- .../android/app/src/main/AndroidManifest.xml | 3 +- .../example/android/build.gradle | 38 +- .../example/android/gradle.properties | 5 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../example/android/settings.gradle | 54 ++- .../lib/src/screens/all_products_screen.dart | 130 +----- .../screens/alternative_billing_screen.dart | 4 +- .../lib/src/screens/purchase_flow_screen.dart | 82 ++-- .../src/screens/subscription_flow_screen.dart | 29 +- .../src/screens/webhook_stream_screen.dart | 2 +- .../lib/src/widgets/product_detail_modal.dart | 10 +- .../lib/src/widgets/purchase_detail_view.dart | 23 +- .../macos/Runner.xcodeproj/project.pbxproj | 1 + .../Classes/FlutterInappPurchasePlugin.swift | 51 ++- .../ios/flutter_inapp_purchase.podspec | 9 +- .../lib/flutter_inapp_purchase.dart | 152 ++++--- .../flutter_inapp_purchase/lib/utils.dart | 20 + .../Classes/FlutterInappPurchasePlugin.swift | 377 ++++++++++++++---- .../macos/flutter_inapp_purchase.podspec | 5 +- .../test/builders_unit_test.dart | 6 +- .../flutter_inapp_purchase_channel_test.dart | 131 ++++++ .../test/ios_methods_test.dart | 7 + libraries/godot-iap/CLAUDE.md | 15 +- libraries/godot-iap/CONTRIBUTING.md | 4 +- libraries/godot-iap/EXAMPLES.md | 6 +- libraries/godot-iap/Example/iap_manager.gd | 2 +- libraries/godot-iap/Makefile | 33 +- libraries/godot-iap/README.md | 16 +- .../addons/godot-iap/android/GodotIap.gdap | 2 +- .../godot-iap/addons/godot-iap/godot_iap.gd | 116 +++--- .../addons/godot-iap/godot_iap_plugin.gd | 26 +- libraries/godot-iap/android/build.gradle.kts | 55 ++- libraries/godot-iap/android/gradle.properties | 8 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../godot-iap/android/settings.gradle.kts | 36 +- .../android/src/main/AndroidManifest.xml | 3 +- .../main/java/dev/hyo/godotiap/GodotIap.kt | 6 - .../Sources/GodotIap/GodotIap.swift | 42 +- libraries/godot-iap/scripts/build_android.sh | 2 +- libraries/godot-iap/scripts/generate-types.sh | 37 +- libraries/godot-iap/scripts/pre-commit | 2 - libraries/godot-iap/scripts/sync-versions.sh | 85 ++-- libraries/godot-iap/scripts/write-gdap.sh | 83 ++++ libraries/kmp-iap/CHANGELOG.md | 2 +- libraries/kmp-iap/CLAUDE.md | 10 +- libraries/kmp-iap/CONTRIBUTING.md | 53 +-- libraries/kmp-iap/README.md | 21 +- .../example/composeApp/build.gradle.kts | 28 +- .../screens/AlternativeBillingScreen.kt | 16 +- .../hyo/martie/screens/PurchaseFlowScreen.kt | 22 +- .../martie/screens/SubscriptionFlowScreen.kt | 46 +-- .../hyo/martie/screens/WebhookStreamScreen.kt | 2 +- libraries/kmp-iap/example/gradle.properties | 3 +- .../kmp-iap/example/gradle/libs.versions.toml | 28 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../iosApp/iosApp.xcodeproj/project.pbxproj | 2 +- libraries/kmp-iap/example/run-ios.sh | 7 +- libraries/kmp-iap/example/settings.gradle.kts | 16 + libraries/kmp-iap/gradle.properties | 17 +- libraries/kmp-iap/gradle.properties.template | 15 +- libraries/kmp-iap/gradle/libs.versions.toml | 22 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- libraries/kmp-iap/library/build.gradle.kts | 323 ++++++++------- libraries/kmp-iap/library/library.podspec | 43 +- .../kotlin/io/github/hyochan/kmpiap/Helper.kt | 28 +- .../hyochan/kmpiap/InAppPurchaseAndroid.kt | 169 ++++---- .../io/github/hyochan/kmpiap/DslExtensions.kt | 2 +- .../hyochan/kmpiap/InAppPurchaseTest.kt | 2 + .../github/hyochan/kmpiap/VerificationTest.kt | 2 + .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 312 ++++++++------- .../kmpiap/openiap/WebhookTransport.ios.kt | 2 +- libraries/kmp-iap/local.properties.template | 19 +- .../native/InAppPurchaseBridge/Package.swift | 32 +- .../InAppPurchaseBridge.swift | 1 + libraries/kmp-iap/publish-local.sh | 50 ++- libraries/kmp-iap/scripts/build-all.sh | 4 +- libraries/kmp-iap/scripts/generate-types.sh | 26 +- libraries/kmp-iap/scripts/publish-local.sh | 9 +- .../kmp-iap/scripts/update-readme-version.sh | 22 +- libraries/kmp-iap/setup.sh | 6 +- libraries/maui-iap/README.md | 13 +- libraries/maui-iap/android/build.gradle.kts | 4 +- libraries/maui-iap/android/gradle.properties | 1 + .../maui-iap/android/openiap/build.gradle.kts | 70 +++- .../maui-iap/android/settings.gradle.kts | 19 + .../Pages/AllProductsPage.xaml.cs | 52 ++- .../Pages/AlternativeBillingPage.xaml.cs | 6 +- .../Pages/AvailablePurchasesPage.xaml.cs | 5 +- .../Pages/WebhookStreamPage.xaml.cs | 4 +- .../Utils/BuildPurchaseRows.cs | 12 +- libraries/maui-iap/src/Directory.Build.props | 16 + .../OpenIap.Maui.Bindings.Android.csproj | 30 +- .../ApiDefinition.cs | 8 + .../src/OpenIap.Maui/OpenIap.Maui.csproj | 10 +- .../Platforms/iOS/NSObjectJsonBridge.cs | 50 +++ .../OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs | 168 ++++---- libraries/react-native-iap/CLAUDE.md | 2 +- libraries/react-native-iap/CONTRIBUTING.md | 10 +- libraries/react-native-iap/README.md | 36 +- .../react-native-iap/android/build.gradle | 114 +++++- .../android/gradle.properties | 6 +- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 19 +- .../java/com/margelo/nitro/iap/RnIapLog.kt | 4 +- .../example-expo/app.config.ts | 2 +- .../example-expo/app/all-products.tsx | 6 +- .../example-expo/app/alternative-billing.tsx | 15 +- .../example-expo/app/available-purchases.tsx | 14 +- .../example-expo/app/purchase-flow.tsx | 6 +- .../example-expo/app/subscription-flow.tsx | 75 ++-- .../example-expo/app/webhook-stream.tsx | 2 +- .../components/AndroidOneTimeOfferDetails.tsx | 7 +- .../contexts/DataModalContext.tsx | 7 +- .../example-expo/utils/buildPurchaseRows.ts | 6 +- .../example/android/app/build.gradle | 32 +- .../example/screens/AllProducts.tsx | 13 +- .../example/screens/AlternativeBilling.tsx | 24 +- .../example/screens/AvailablePurchases.tsx | 14 +- .../example/screens/PurchaseFlow.tsx | 6 +- .../example/screens/SubscriptionFlow.tsx | 73 ++-- .../example/screens/WebhookStream.tsx | 2 +- .../components/AndroidOneTimeOfferDetails.tsx | 7 +- .../example/src/contexts/DataModalContext.tsx | 7 +- .../example/src/utils/buildPurchaseRows.ts | 20 +- .../react-native-iap/ios/HybridRnIap.swift | 12 +- .../react-native-iap/scripts/ci-check.sh | 87 ++-- .../src/__tests__/index.test.ts | 65 ++- .../react-native-iap/src/hooks/useIAP.ts | 30 +- libraries/react-native-iap/src/index.ts | 297 +++++--------- .../react-native-iap/src/specs/RnIap.nitro.ts | 17 +- 171 files changed, 4330 insertions(+), 2444 deletions(-) create mode 100644 libraries/expo-iap/android/openiap-android-sdk.gradle create mode 100644 libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts create mode 100644 libraries/flutter_inapp_purchase/android/openiap-android-sdk.gradle create mode 100755 libraries/godot-iap/scripts/write-gdap.sh create mode 100644 libraries/kmp-iap/native/InAppPurchaseBridge/Sources/InAppPurchaseBridge/InAppPurchaseBridge.swift create mode 100644 libraries/maui-iap/src/Directory.Build.props diff --git a/libraries/expo-iap/CLAUDE.md b/libraries/expo-iap/CLAUDE.md index 80bf9704..00959cb1 100644 --- a/libraries/expo-iap/CLAUDE.md +++ b/libraries/expo-iap/CLAUDE.md @@ -79,11 +79,11 @@ Before committing any changes: - **ID fields**: Use `Id` instead of `ID` (e.g., `productId`, `transactionId`, not `productID`, `transactionID`) - **Consistent naming**: This applies to functions, types, and file names -- **Deprecation**: Fields without platform suffixes will be removed in v2.9.0 +- **Deprecation**: Fields without platform suffixes are legacy and should only be removed in a planned major release. ### Type System -For complete type definitions and documentation, see: +For complete type definitions and documentation, see: The library follows the OpenIAP type specifications with platform-specific extensions using iOS/Android suffixes. @@ -109,7 +109,7 @@ The library follows the OpenIAP type specifications with platform-specific exten ### API Method Naming - Functions that depend on event results should use `request` prefix (e.g., `requestPurchase`) -- Follow OpenIAP terminology: +- Follow OpenIAP terminology: - Do not use generic prefixes like `get`, `find` - refer to the official terminology ## IAP-Specific Guidelines @@ -118,10 +118,10 @@ The library follows the OpenIAP type specifications with platform-specific exten All implementations must follow the OpenIAP specification: -- **APIs**: -- **Types**: -- **Events**: -- **Errors**: +- **APIs**: +- **Types**: +- **Events**: +- **Errors**: ### Feature Development Process @@ -251,7 +251,7 @@ const {requestPurchase} = useIAP({ For complete error handling documentation, see: -- [Error Codes Reference](https://www.openiap.dev/docs/errors) +- [Error Codes Reference](https://openiap.dev/docs/errors) - [Error Handling Guide](https://docs.expo-iap.dev/docs/guides/error-handling) ## Documentation Guidelines diff --git a/libraries/expo-iap/CONTRIBUTING.md b/libraries/expo-iap/CONTRIBUTING.md index c3492e3c..1b41b97c 100644 --- a/libraries/expo-iap/CONTRIBUTING.md +++ b/libraries/expo-iap/CONTRIBUTING.md @@ -446,8 +446,8 @@ We welcome feature requests! Please: ## 📚 Additional Resources -- [Documentation Site](https://hyochan.github.io/expo-iap) -- [API Reference](https://hyochan.github.io/expo-iap/docs/api/use-iap) +- [Documentation Site](https://openiap.dev/docs/setup/expo) +- [API Reference](https://openiap.dev/docs/apis) - [Example App](./example) Thank you for contributing to expo-iap! 🎉 diff --git a/libraries/expo-iap/README.md b/libraries/expo-iap/README.md index f1f371a9..6303b56f 100644 --- a/libraries/expo-iap/README.md +++ b/libraries/expo-iap/README.md @@ -1,13 +1,13 @@ # Expo IAP
- Expo IAP Logo + Expo IAP Logo [![Version](http://img.shields.io/npm/v/expo-iap.svg?style=flat-square)](https://npmjs.org/package/expo-iap) [![Download](http://img.shields.io/npm/dm/expo-iap.svg?style=flat-square)](https://npmjs.org/package/expo-iap) [![OpenIAP](https://img.shields.io/badge/OpenIAP-Compliant-green?style=flat-square)](https://openiap.dev) [![CI](https://github.com/hyodotdev/openiap/actions/workflows/ci.yml/badge.svg)](https://github.com/hyodotdev/openiap/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/hyodotdev/openiap/graph/badge.svg?token=47VMTY5NyM)](https://codecov.io/gh/hyodotdev/openiap) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fhyochan%2Fexpo-iap.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fhyochan%2Fexpo-iap?ref=badge_shield&issueType=license) Expo IAP is a powerful in-app purchase solution for Expo and React Native applications that conforms to the Open IAP specification. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support. -If you're shipping an app with expo-iap, we’d love to hear about it—please share your product and feedback in [Who's using Expo IAP?](https://github.com/hyochan/expo-iap/discussions/143). Community stories help us keep improving the ecosystem. +If you're shipping an app with expo-iap, we’d love to hear about it—please share your product and feedback in [expo-iap Q&A Discussions](https://github.com/hyodotdev/openiap/discussions/categories/expo-iap). Community stories help us keep improving the ecosystem. Open IAP @@ -30,19 +30,19 @@ If you're shipping an app with expo-iap, we’d love to hear about it—please s ## 📚 Documentation -**[📖 Visit our comprehensive documentation site →](https://hyochan.github.io/expo-iap)** +**[📖 Visit our comprehensive documentation site →](https://openiap.dev/docs/setup/expo)** ## Using with AI Assistants expo-iap provides AI-friendly documentation for Cursor, GitHub Copilot, Claude, and ChatGPT. -**[📖 AI Assistants Guide →](https://hyochan.github.io/expo-iap/guides/ai-assistants)** +**[📖 AI Assistants Guide →](https://openiap.dev/docs/guides/ai-assistants)** Quick links: -- [llms.txt](https://hyochan.github.io/expo-iap/llms.txt) - Quick reference -- [llms-full.txt](https://hyochan.github.io/expo-iap/llms-full.txt) - Full API reference -- [Onside Integration](https://hyochan.github.io/expo-iap/guides/onside-integration) - Using Onside marketplace payments on iOS +- [llms.txt](https://openiap.dev/llms.txt) - Quick reference +- [llms-full.txt](https://openiap.dev/llms-full.txt) - Full API reference +- [Onside Integration](https://openiap.dev/docs/features/alternative-marketplace/onside) - Using Onside marketplace payments on iOS ## Notice @@ -53,8 +53,7 @@ The `expo-iap` module has been migrated from [react-native-iap](https://github.c Both libraries will continue to be maintained in parallel going forward. -📖 See the [Future Roadmap and Discussion](https://github.com/hyochan/react-native-iap/discussions/2754) for more details. -👉 Stay updated via the [Current Project Status comment](https://github.com/hyochan/react-native-iap/discussions/2754#discussioncomment-10510249). +📖 See the [OpenIAP discussions](https://github.com/hyodotdev/openiap/discussions) for roadmap and project status updates. ## Installation @@ -62,7 +61,7 @@ Both libraries will continue to be maintained in parallel going forward. npx expo install expo-iap ``` -For platform-specific configuration (Android Kotlin version, iOS deployment target, etc.), see the [Installation Guide](https://hyochan.github.io/expo-iap/getting-started/installation#important-for-expo-managed-workflow). +For platform-specific configuration (Android Kotlin version, iOS deployment target, etc.), see the [Installation Guide](https://openiap.dev/docs/setup/expo#installation). ## Contributing @@ -74,7 +73,7 @@ We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) - Code style and conventions - Submitting pull requests -For detailed usage examples and error handling, see the [documentation](https://hyochan.github.io/expo-iap). +For detailed usage examples and error handling, see the [documentation](https://openiap.dev/docs/setup/expo). > Sharing your thoughts—any feedback would be greatly appreciated! @@ -107,7 +106,7 @@ For bug reports, please [open an issue](https://github.com/hyodotdev/openiap/iss
- Meta + Meta Meta
@@ -116,9 +115,9 @@ For bug reports, please [open an issue](https://github.com/hyodotdev/openiap/iss diff --git a/libraries/expo-iap/android/build.gradle b/libraries/expo-iap/android/build.gradle index c766915a..e4fd012b 100644 --- a/libraries/expo-iap/android/build.gradle +++ b/libraries/expo-iap/android/build.gradle @@ -1,10 +1,26 @@ import groovy.json.JsonSlurper +import org.jetbrains.kotlin.gradle.dsl.JvmTarget apply plugin: 'com.android.library' apply plugin: 'kotlin-android' group = 'expo.modules.iap' -version = '0.1.0' + +def resolvePackageJsonFile() { + def packageJsonFile = new File(projectDir.parentFile, 'package.json') + if (!packageJsonFile.isFile()) { + throw new GradleException("expo-iap: Unable to locate package.json") + } + return packageJsonFile +} + +def expoIapPackageJson = new JsonSlurper().parse(resolvePackageJsonFile()) +def expoIapPackageVersion = (expoIapPackageJson instanceof Map) ? expoIapPackageJson.version : null +if (!(expoIapPackageVersion instanceof String) || !expoIapPackageVersion.trim()) { + throw new GradleException("expo-iap: 'version' missing or invalid in package.json") +} +expoIapPackageVersion = expoIapPackageVersion.trim() +version = expoIapPackageVersion def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") apply from: expoModulesCorePlugin @@ -32,6 +48,7 @@ if (!(googleVersion instanceof String) || !googleVersion.trim()) { throw new GradleException("expo-iap: 'google' version missing or invalid in openiap-versions.json") } def googleVersionString = googleVersion.trim() +apply from: project.file('openiap-android-sdk.gradle') // If you want to use the managed Android SDK versions from expo-modules-core, set this to true. // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. @@ -40,26 +57,24 @@ def useManagedAndroidSdkVersions = false if (useManagedAndroidSdkVersions) { useDefaultAndroidSdkVersions() } else { - buildscript { - // Simple helper that allows the root project to override versions declared by this library. - ext.safeExtGet = { prop, fallback -> - rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback - } - } + def openIapCompileSdkVersion = openIapResolveAndroidSdkVersion('compileSdkVersion', 'compileSdk', 35) + def openIapMinSdkVersion = openIapResolveAndroidSdkVersion('minSdkVersion', 'minSdk', 23) + def openIapTargetSdkVersion = openIapResolveAndroidSdkVersion('targetSdkVersion', 'compileSdk', 35) + project.android { - compileSdkVersion safeExtGet("compileSdkVersion", 34) + compileSdk = openIapCompileSdkVersion defaultConfig { - minSdkVersion safeExtGet("minSdkVersion", 21) - targetSdkVersion safeExtGet("targetSdkVersion", 34) + minSdk = openIapMinSdkVersion + targetSdk = openIapTargetSdkVersion } } } android { - namespace "expo.modules.iap" + namespace = "expo.modules.iap" defaultConfig { - versionCode 1 - versionName "0.1.0" + versionCode = 1 + versionName = expoIapPackageVersion // When using local openiap-google with flavors, select the appropriate flavor // Read horizonEnabled from gradle.properties, default to play def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false @@ -67,11 +82,7 @@ android { missingDimensionStrategy "platform", flavor } lintOptions { - abortOnError false - } - kotlinOptions { - jvmTarget = "17" - freeCompilerArgs += ["-Xskip-metadata-version-check"] + abortOnError = false } compileOptions { sourceCompatibility JavaVersion.VERSION_17 @@ -79,6 +90,13 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-Xskip-metadata-version-check") + } +} + dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7" diff --git a/libraries/expo-iap/android/openiap-android-sdk.gradle b/libraries/expo-iap/android/openiap-android-sdk.gradle new file mode 100644 index 00000000..62175d68 --- /dev/null +++ b/libraries/expo-iap/android/openiap-android-sdk.gradle @@ -0,0 +1,30 @@ +ext.openIapReadGoogleAndroidSdkVersion = { String propertyName -> + File current = projectDir + while (current != null) { + File candidate = new File(current, 'packages/google/openiap/build.gradle.kts') + if (candidate.isFile()) { + def matcher = candidate.text =~ /(?m)^\s*${propertyName}\s*=\s*(\d+)\s*$/ + return matcher.find() ? matcher.group(1).toInteger() : null + } + current = current.parentFile + } + return null +} + +ext.openIapToIntegerVersion = { Object value, String label -> + if (value instanceof Number) { + return value.toInteger() + } + if (value instanceof CharSequence && value.toString() ==~ /\d+/) { + return value.toString().toInteger() + } + throw new GradleException("expo-iap: ${label} must be an integer, got ${value}") +} + +ext.openIapResolveAndroidSdkVersion = { String extName, String googlePropertyName, int fallback -> + if (rootProject.ext.has(extName)) { + return openIapToIntegerVersion(rootProject.ext.get(extName), extName) + } + def googleValue = openIapReadGoogleAndroidSdkVersion(googlePropertyName) + return googleValue ?: fallback +} diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt index 54bdb9a5..4434c307 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt @@ -1,6 +1,5 @@ package expo.modules.iap -import android.util.Log import dev.hyo.openiap.AndroidSubscriptionOfferInput import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapModule @@ -15,7 +14,6 @@ import java.util.Locale import java.util.concurrent.ConcurrentLinkedQueue object ExpoIapHelper { - private const val TAG = "ExpoIapHelper" private const val MAX_BUFFERED_EVENTS = 200 fun emitOrQueue( @@ -67,7 +65,7 @@ object ExpoIapHelper { val flat = mutableMapOf() // Carry over top-level fields like type, useAlternativeBilling for ((k, v) in params) { - if (k is String && k != "request") flat[k] = v + if (k != "request") flat[k] = v } // Overlay platform-specific fields for ((k, v) in nested) { @@ -205,7 +203,7 @@ object ExpoIapHelper { runCatching { emitOrQueue(module, scope, connectionReady, pendingEvents, eventName, payload) }.onFailure { error -> - android.util.Log.e(TAG, "Failed to buffer/send $logTag", error) + ExpoIapLog.failure("buffer/send $logTag", error) val errorPayload = mapOf( "code" to fallbackErrorCode, @@ -213,7 +211,7 @@ object ExpoIapHelper { ) runCatching { emitOrQueue(module, scope, connectionReady, pendingEvents, eventPurchaseError, errorPayload) - }.onFailure { android.util.Log.e(TAG, "Failed to send error event", it) } + }.onFailure { ExpoIapLog.failure("send error event", it) } } } @@ -240,7 +238,7 @@ object ExpoIapHelper { p.toJson(), ) }.onFailure { error -> - android.util.Log.e(TAG, "Failed to buffer/send PURCHASE_UPDATED", error) + ExpoIapLog.failure("buffer/send PURCHASE_UPDATED", error) // Emit as purchase error so user knows something went wrong val errorPayload = mapOf( @@ -256,7 +254,7 @@ object ExpoIapHelper { eventPurchaseError, errorPayload, ) - }.onFailure { android.util.Log.e(TAG, "Failed to send error event", it) } + }.onFailure { ExpoIapLog.failure("send error event", it) } } } openIap.addPurchaseErrorListener { e -> @@ -271,7 +269,7 @@ object ExpoIapHelper { errorJson, ) }.onFailure { error -> - android.util.Log.e(TAG, "Failed to buffer/send PURCHASE_ERROR", error) + ExpoIapLog.failure("buffer/send PURCHASE_ERROR", error) // Critical: if we can't emit the original error, at least try to emit a generic one val fallbackPayload = mapOf( @@ -287,7 +285,7 @@ object ExpoIapHelper { eventPurchaseError, fallbackPayload, ) - }.onFailure { android.util.Log.e(TAG, "Failed to send fallback error event", it) } + }.onFailure { ExpoIapLog.failure("send fallback error event", it) } } // Also reject any pending purchase promises to match iOS behavior val errorCode = errorJson["code"] as? String ?: OpenIapError.PurchaseFailed.CODE diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapLog.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapLog.kt index d41e948e..4c124c2b 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapLog.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapLog.kt @@ -33,7 +33,9 @@ internal object ExpoIapLog { } fun debug(message: String) { - Log.d(TAG, message) + if (BuildConfig.DEBUG || Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, message) + } } private fun stringify(value: Any?): String { diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt index a5c645f9..b8e729cc 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt @@ -1,7 +1,6 @@ package expo.modules.iap import android.content.Context -import android.util.Log import dev.hyo.openiap.AndroidSubscriptionOfferInput import dev.hyo.openiap.DeepLinkOptions import dev.hyo.openiap.FetchProductsResultProducts @@ -34,6 +33,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.security.MessageDigest import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicBoolean import dev.hyo.openiap.BillingProgramAndroid as OpenIapBillingProgram @@ -41,6 +41,16 @@ import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunc import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalLinkParams +private fun redactSensitiveToken(token: String?): String { + val value = token?.takeIf { it.isNotBlank() } ?: return "none" + val fingerprint = MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + .take(12) + return "" +} + class ExpoIapModule : Module() { companion object { const val TAG = "ExpoIapModule" @@ -143,7 +153,7 @@ class ExpoIapModule : Module() { val ev = pendingEvents.poll() ?: break // Already on main dispatcher here; emit directly runCatching { sendEvent(ev.first, ev.second) } - .onFailure { Log.e(TAG, "Failed to flush buffered event: ${ev.first}", it) } + .onFailure { ExpoIapLog.failure("flush buffered event ${ev.first}", it) } } ExpoIapLog.result("initConnection", true) @@ -354,11 +364,7 @@ class ExpoIapModule : Module() { errorMap, ) }.onFailure { ex -> - Log.e( - TAG, - "Failed to send PURCHASE_ERROR event (requestPurchase)", - ex, - ) + ExpoIapLog.failure("send PURCHASE_ERROR event requestPurchase", ex) } ExpoIapHelper.rejectPurchasePromises( errorCode, @@ -370,7 +376,7 @@ class ExpoIapModule : Module() { } AsyncFunction("acknowledgePurchaseAndroid") { token: String, promise: Promise -> - ExpoIapLog.payload("acknowledgePurchaseAndroid", mapOf("token" to token)) + ExpoIapLog.payload("acknowledgePurchaseAndroid", mapOf("token" to redactSensitiveToken(token))) scope.launch { try { openIap.acknowledgePurchaseAndroid(token) @@ -386,12 +392,15 @@ class ExpoIapModule : Module() { // New name: consumePurchaseAndroid AsyncFunction("consumePurchaseAndroid") { token: String, promise: Promise -> - ExpoIapLog.payload("consumePurchaseAndroid", mapOf("token" to token)) + ExpoIapLog.payload("consumePurchaseAndroid", mapOf("token" to redactSensitiveToken(token))) scope.launch { try { openIap.consumePurchaseAndroid(token) val response = mapOf("responseCode" to 0, "purchaseToken" to token) - ExpoIapLog.result("consumePurchaseAndroid", response) + ExpoIapLog.result( + "consumePurchaseAndroid", + response + ("purchaseToken" to redactSensitiveToken(token)), + ) promise.resolve(response) } catch (e: Exception) { ExpoIapLog.failure("consumePurchaseAndroid", e) @@ -423,7 +432,7 @@ class ExpoIapModule : Module() { val activity = runCatching { currentActivity } .onFailure { - Log.e(TAG, "showAlternativeBillingDialogAndroid: Activity missing", it) + ExpoIapLog.failure("showAlternativeBillingDialogAndroid activity", it) }.getOrNull() ?: run { promise.reject(OpenIapError.ServiceUnavailable.CODE, "Activity not available", null) return@launch @@ -447,7 +456,7 @@ class ExpoIapModule : Module() { // Note: OpenIapModule.createAlternativeBillingReportingToken() doesn't accept sku parameter // The sku parameter is ignored for now - may be used in future versions val token = openIap.createAlternativeBillingReportingToken() - ExpoIapLog.result("createAlternativeBillingTokenAndroid", token) + ExpoIapLog.result("createAlternativeBillingTokenAndroid", redactSensitiveToken(token)) promise.resolve(token) } catch (e: Exception) { ExpoIapLog.failure("createAlternativeBillingTokenAndroid", e) @@ -587,7 +596,10 @@ class ExpoIapModule : Module() { "billingProgram" to program, "externalTransactionToken" to result.externalTransactionToken, ) - ExpoIapLog.result("createBillingProgramReportingDetailsAndroid", response) + ExpoIapLog.result( + "createBillingProgramReportingDetailsAndroid", + response + ("externalTransactionToken" to redactSensitiveToken(result.externalTransactionToken)), + ) promise.resolve(response) } catch (e: Exception) { ExpoIapLog.failure("createBillingProgramReportingDetailsAndroid", e) @@ -603,7 +615,7 @@ class ExpoIapModule : Module() { val activity = runCatching { currentActivity } .onFailure { - Log.e(TAG, "launchExternalLinkAndroid: Activity missing", it) + ExpoIapLog.failure("launchExternalLinkAndroid activity", it) }.getOrNull() ?: run { promise.reject(OpenIapError.ServiceUnavailable.CODE, "Activity not available", null) return@launch diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/PromiseUtils.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/PromiseUtils.kt index 1d896c85..bc79f650 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/PromiseUtils.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/PromiseUtils.kt @@ -1,6 +1,5 @@ package expo.modules.iap -import android.util.Log import dev.hyo.openiap.OpenIapError import expo.modules.kotlin.Promise @@ -54,7 +53,7 @@ fun Promise.safeResolve(value: Any?) { try { this.resolve(value) } catch (e: RuntimeException) { - Log.d(PromiseUtils.TAG, "Already consumed ${e.message}") + ExpoIapLog.debug("Already consumed ${e.message}") } } @@ -78,6 +77,6 @@ fun Promise.safeReject( try { this.reject(code, message, throwable) } catch (e: RuntimeException) { - Log.d(PromiseUtils.TAG, "Already consumed ${e.message}") + ExpoIapLog.debug("Already consumed ${e.message}") } } diff --git a/libraries/expo-iap/example/README.md b/libraries/expo-iap/example/README.md index 102ad1ef..918fa64d 100644 --- a/libraries/expo-iap/example/README.md +++ b/libraries/expo-iap/example/README.md @@ -77,7 +77,7 @@ const result = await requestPurchase({ if (isAndroidPurchaseArray(result)) { // TypeScript knows this is ProductPurchaseAndroid[] const purchase = result[0]; - console.log('Android Token:', purchase.purchaseTokenAndroid); + console.log('Android token available:', Boolean(purchase.purchaseTokenAndroid)); } else if (isIosPurchase(result)) { // TypeScript knows this is ProductPurchaseIos console.log('iOS Transaction ID:', result.transactionId); diff --git a/libraries/expo-iap/example/app/alternative-billing.tsx b/libraries/expo-iap/example/app/alternative-billing.tsx index 94807e50..52ce7575 100644 --- a/libraries/expo-iap/example/app/alternative-billing.tsx +++ b/libraries/expo-iap/example/app/alternative-billing.tsx @@ -81,7 +81,11 @@ function AlternativeBillingScreen() { enableBillingProgramAndroid: Platform.OS === 'android' ? billingProgram : undefined, onPurchaseSuccess: async (purchase: Purchase) => { - console.log('Purchase successful:', purchase); + console.log('Purchase successful:', { + productId: purchase.productId, + transactionId: purchase.id, + platform: purchase.platform, + }); setLastPurchase(purchase); setIsProcessing(false); @@ -226,9 +230,8 @@ function AlternativeBillingScreen() { try { // Step 1: Check if billing program is available - const availability = await isBillingProgramAvailableAndroid( - billingProgram, - ); + const availability = + await isBillingProgramAvailableAndroid(billingProgram); console.log('[Android] Billing program available:', availability); if (!availability.isAvailable) { @@ -256,20 +259,21 @@ function AlternativeBillingScreen() { setPurchaseResult('Getting reporting token...'); // Step 3: Get reporting details (after payment completes externally) - const details = await createBillingProgramReportingDetailsAndroid( - billingProgram, - ); - console.log('[Android] Reporting details:', details); + const details = + await createBillingProgramReportingDetailsAndroid(billingProgram); + console.log('[Android] Reporting details:', { + billingProgram: details.billingProgram, + hasExternalTransactionToken: Boolean( + details.externalTransactionToken, + ), + }); setPurchaseResult( `✅ Billing Programs API flow completed\n\nProduct: ${ product.id }\nProgram: ${ details.billingProgram - }\nToken: ${details.externalTransactionToken.substring( - 0, - 20, - )}...\n\n⚠️ Important:\n1. Report token to Google Play within 24 hours\n2. Process payment on your external site`, + }\nToken: \n\n⚠️ Important:\n1. Report token to Google Play within 24 hours\n2. Process payment on your external site`, ); Alert.alert( @@ -423,10 +427,10 @@ function AlternativeBillingScreen() { {billingProgram === 'external-offer' ? 'External Offer' : billingProgram === 'external-payments' - ? 'External Payments' - : billingProgram === 'external-content-link' - ? 'External Content Link' - : billingProgram} + ? 'External Payments' + : billingProgram === 'external-content-link' + ? 'External Content Link' + : billingProgram} @@ -552,10 +556,10 @@ function AlternativeBillingScreen() { {isProcessing ? 'Processing...' : Platform.OS === 'ios' - ? '🛒 Buy (External URL)' - : androidBillingFlow === 'billing-programs' - ? '🛒 Buy (Billing Programs)' - : '🛒 Buy (User Choice Billing)'} + ? '🛒 Buy (External URL)' + : androidBillingFlow === 'billing-programs' + ? '🛒 Buy (Billing Programs)' + : '🛒 Buy (User Choice Billing)'} @@ -707,19 +711,19 @@ function AlternativeBillingScreen() { {program === 'external-offer' ? 'External Offer' : program === 'external-payments' - ? 'External Payments' - : program === 'external-content-link' - ? 'External Content Link' - : program} + ? 'External Payments' + : program === 'external-content-link' + ? 'External Content Link' + : program} {program === 'external-offer' ? 'For apps that offer digital content outside Google Play. Requires approval.' : program === 'external-payments' - ? 'For apps in eligible regions to use alternative payment processors.' - : program === 'external-content-link' - ? 'For linking to external content already purchased outside the app.' - : ''} + ? 'For apps in eligible regions to use alternative payment processors.' + : program === 'external-content-link' + ? 'For linking to external content already purchased outside the app.' + : ''} ))} diff --git a/libraries/expo-iap/example/app/available-purchases.tsx b/libraries/expo-iap/example/app/available-purchases.tsx index ffaa8cb7..48638c85 100644 --- a/libraries/expo-iap/example/app/available-purchases.tsx +++ b/libraries/expo-iap/example/app/available-purchases.tsx @@ -65,9 +65,11 @@ export default function AvailablePurchases() { finishTransaction, } = useIAP({ onPurchaseSuccess: async (purchase) => { - // Avoid logging sensitive token in console output - const {purchaseToken: _omit, ...safePurchase} = purchase as any; - console.log('[AVAILABLE-PURCHASES] Purchase successful:', safePurchase); + console.log('[AVAILABLE-PURCHASES] Purchase successful:', { + productId: purchase.productId, + transactionId: purchase.id, + platform: purchase.platform, + }); // Finish transaction like in subscription-flow await finishTransaction({ @@ -98,8 +100,9 @@ export default function AvailablePurchases() { try { await getActiveSubscriptions(); console.log( - '[AVAILABLE-PURCHASES] Active subscriptions result (state):', - activeSubscriptions, + '[AVAILABLE-PURCHASES] Active subscriptions result:', + activeSubscriptions.length, + 'items', ); } catch (error) { console.error( @@ -232,7 +235,7 @@ export default function AvailablePurchases() { console.log( '[AVAILABLE-PURCHASES] activeSubscriptions:', activeSubscriptions.length, - activeSubscriptions, + 'items', ); }, [activeSubscriptions]); @@ -427,9 +430,7 @@ export default function AvailablePurchases() { {selectedSubscription.purchaseToken && ( Purchase Token - - {selectedSubscription.purchaseToken} - + {''} )} diff --git a/libraries/expo-iap/example/app/purchase-flow.tsx b/libraries/expo-iap/example/app/purchase-flow.tsx index 4c29194b..9d7dff3b 100644 --- a/libraries/expo-iap/example/app/purchase-flow.tsx +++ b/libraries/expo-iap/example/app/purchase-flow.tsx @@ -880,10 +880,10 @@ function PurchaseFlowContainer() { provider: verifyRequest.provider, iapkit: { ...(Platform.OS === 'ios' - ? {apple: {jws: `${jwsOrToken.substring(0, 50)}...`}} + ? {apple: {jws: ''}} : { google: { - purchaseToken: `${jwsOrToken.substring(0, 50)}...`, + purchaseToken: '', }, }), }, diff --git a/libraries/expo-iap/example/app/subscription-flow.tsx b/libraries/expo-iap/example/app/subscription-flow.tsx index 4b141252..6bc8f61e 100644 --- a/libraries/expo-iap/example/app/subscription-flow.tsx +++ b/libraries/expo-iap/example/app/subscription-flow.tsx @@ -233,8 +233,8 @@ function SubscriptionFlow({ message: canUpgrade ? 'Upgrade available' : isDowngrade - ? 'Downgrade option' - : undefined, + ? 'Downgrade option' + : undefined, }; }, [getCurrentSubscription, isCancelled], @@ -770,8 +770,8 @@ function SubscriptionFlow({ {verificationMethod === 'ignore' ? '❌ None (Skip)' : verificationMethod === 'local' - ? '📱 Local (Device)' - : '☁️ IAPKit (Server)'} + ? '📱 Local (Device)' + : '☁️ IAPKit (Server)'} @@ -1544,11 +1544,11 @@ function SubscriptionFlowContainer() { isPurchased = hasValidToken || hasValidTransactionId; isRestoration = Boolean( 'originalTransactionIdentifierIOS' in purchase && - purchase.originalTransactionIdentifierIOS && - purchase.originalTransactionIdentifierIOS !== purchase.id && - 'transactionReasonIOS' in purchase && - purchase.transactionReasonIOS && - purchase.transactionReasonIOS !== 'PURCHASE', + purchase.originalTransactionIdentifierIOS && + purchase.originalTransactionIdentifierIOS !== purchase.id && + 'transactionReasonIOS' in purchase && + purchase.transactionReasonIOS && + purchase.transactionReasonIOS !== 'PURCHASE', ); console.log('iOS Purchase Analysis:'); @@ -1714,10 +1714,10 @@ function SubscriptionFlowContainer() { provider: verifyRequest.provider, iapkit: { ...(Platform.OS === 'ios' - ? {apple: {jws: `${jwsOrToken.substring(0, 50)}...`}} + ? {apple: {jws: ''}} : { google: { - purchaseToken: `${jwsOrToken.substring(0, 50)}...`, + purchaseToken: '', }, }), }, @@ -1888,8 +1888,12 @@ function SubscriptionFlowContainer() { if (subscriptions.length > 0) { console.log( - 'Full subscription details:', - JSON.stringify(subscriptions, null, 2), + 'Subscription product summary:', + subscriptions.map((sub) => ({ + id: sub.id, + title: sub.title, + type: sub.type, + })), ); } }, [subscriptions]); diff --git a/libraries/expo-iap/example/app/webhook-stream.tsx b/libraries/expo-iap/example/app/webhook-stream.tsx index 9c29c137..7fbf29be 100644 --- a/libraries/expo-iap/example/app/webhook-stream.tsx +++ b/libraries/expo-iap/example/app/webhook-stream.tsx @@ -160,7 +160,7 @@ export default function WebhookStreamScreen() { base: {baseUrl} {'\n'} - api key: {apiKey ? `${apiKey.slice(0, 8)}…` : 'MISSING'} + api key: {apiKey ? 'CONFIGURED' : 'MISSING'} diff --git a/libraries/expo-iap/example/src/utils/buildPurchaseRows.ts b/libraries/expo-iap/example/src/utils/buildPurchaseRows.ts index def8968e..b6310094 100644 --- a/libraries/expo-iap/example/src/utils/buildPurchaseRows.ts +++ b/libraries/expo-iap/example/src/utils/buildPurchaseRows.ts @@ -83,7 +83,11 @@ export const buildPurchaseRows = (purchase: Purchase): PurchaseDetailRow[] => { if (platform === 'ios') { const iosPurchase = purchase as PurchaseIOS; pushRow(rows, 'quantityIOS', iosPurchase.quantityIOS); - pushRow(rows, 'appAccountToken', iosPurchase.appAccountToken); + pushRow( + rows, + 'appAccountToken', + iosPurchase.appAccountToken ? '' : null, + ); pushRow(rows, 'appBundleIdIOS', iosPurchase.appBundleIdIOS); pushRow(rows, 'countryCodeIOS', iosPurchase.countryCodeIOS); pushRow(rows, 'currencyCodeIOS', iosPurchase.currencyCodeIOS); @@ -128,7 +132,11 @@ export const buildPurchaseRows = (purchase: Purchase): PurchaseDetailRow[] => { } } else if (platform === 'android') { const androidPurchase = purchase as PurchaseAndroid; - pushRow(rows, 'signatureAndroid', androidPurchase.signatureAndroid); + pushRow( + rows, + 'signatureAndroid', + androidPurchase.signatureAndroid ? '' : null, + ); pushRow(rows, 'packageNameAndroid', androidPurchase.packageNameAndroid); pushRow( rows, @@ -155,10 +163,14 @@ export const buildPurchaseRows = (purchase: Purchase): PurchaseDetailRow[] => { 'autoRenewingAndroid', formatBoolean(androidPurchase.autoRenewingAndroid), ); - pushRow(rows, 'dataAndroid', androidPurchase.dataAndroid); + pushRow( + rows, + 'dataAndroid', + androidPurchase.dataAndroid ? '' : null, + ); } - pushRow(rows, 'purchaseToken', purchase.purchaseToken); + pushRow(rows, 'purchaseToken', purchase.purchaseToken ? '' : null); return rows; }; diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index 1cbc807e..6987570b 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -174,14 +174,14 @@ public final class ExpoIapModule: Module { AsyncFunction("getReceiptIOS") { () async throws -> String in ExpoIapLog.payload("getReceiptIOS", payload: nil) let receipt = try await OpenIapModule.shared.getReceiptDataIOS() ?? "" - ExpoIapLog.result("getReceiptIOS", value: receipt) + ExpoIapLog.result("getReceiptIOS", value: "") return receipt } AsyncFunction("getReceiptDataIOS") { () async throws -> String in ExpoIapLog.payload("getReceiptDataIOS", payload: nil) let receipt = try await OpenIapModule.shared.getReceiptDataIOS() ?? "" - ExpoIapLog.result("getReceiptDataIOS", value: receipt) + ExpoIapLog.result("getReceiptDataIOS", value: "") return receipt } @@ -189,7 +189,7 @@ public final class ExpoIapModule: Module { ExpoIapLog.payload("requestReceiptRefreshIOS", payload: nil) _ = try await OpenIapModule.shared.syncIOS() let receipt = try await OpenIapModule.shared.getReceiptDataIOS() ?? "" - ExpoIapLog.result("requestReceiptRefreshIOS", value: receipt) + ExpoIapLog.result("requestReceiptRefreshIOS", value: "") return receipt } @@ -227,10 +227,15 @@ public final class ExpoIapModule: Module { return sanitized } catch let error as PurchaseError { ExpoIapLog.failure("verifyPurchase", error: error) - throw error + throw IapException.from(error) } catch { ExpoIapLog.failure("verifyPurchase", error: error) - throw PurchaseError.make(code: .receiptFailed) + throw IapException.from( + PurchaseError.make( + code: .purchaseVerificationFailed, + message: error.localizedDescription + ) + ) } } @@ -245,10 +250,15 @@ public final class ExpoIapModule: Module { return sanitized } catch let error as PurchaseError { ExpoIapLog.failure("verifyPurchaseWithProvider", error: error) - throw error + throw IapException.from(error) } catch { ExpoIapLog.failure("verifyPurchaseWithProvider", error: error) - throw PurchaseError.make(code: .receiptFailed) + throw IapException.from( + PurchaseError.make( + code: .purchaseVerificationFailed, + message: error.localizedDescription + ) + ) } } @@ -294,14 +304,17 @@ public final class ExpoIapModule: Module { AsyncFunction("requestPurchaseOnPromotedProductIOS") { () async throws -> Bool in ExpoIapLog.payload("requestPurchaseOnPromotedProductIOS", payload: nil) - let success = try await OpenIapModule.shared.requestPurchaseOnPromotedProductIOS() - ExpoIapLog.result("requestPurchaseOnPromotedProductIOS", value: success) - return success + throw IapException.from( + PurchaseError.make( + code: .featureNotSupported, + message: "Use promotedProductListenerIOS + requestPurchase instead" + ) + ) } AsyncFunction("getStorefront") { () async throws -> String in ExpoIapLog.payload("getStorefront", payload: nil) - let storefront = try await OpenIapModule.shared.getStorefrontIOS() + let storefront = try await OpenIapModule.shared.getStorefront() ExpoIapLog.result("getStorefront", value: storefront) return storefront } @@ -323,7 +336,7 @@ public final class ExpoIapModule: Module { AsyncFunction("getTransactionJwsIOS") { (sku: String) async throws -> String? in ExpoIapLog.payload("getTransactionJwsIOS", payload: ["sku": sku]) let jws = try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku) - ExpoIapLog.result("getTransactionJwsIOS", value: jws) + ExpoIapLog.result("getTransactionJwsIOS", value: jws == nil ? nil : "") return jws } diff --git a/libraries/expo-iap/ios/onside/OnsideIapModule.swift b/libraries/expo-iap/ios/onside/OnsideIapModule.swift index 5d03796b..01efb43e 100644 --- a/libraries/expo-iap/ios/onside/OnsideIapModule.swift +++ b/libraries/expo-iap/ios/onside/OnsideIapModule.swift @@ -7,6 +7,7 @@ private enum OnsideEvent: String { case purchaseUpdated = "purchase-updated" case purchaseError = "purchase-error" case promotedProductIOS = "promoted-product-ios" + case subscriptionBillingIssue = "subscription-billing-issue" } private enum OnsideBridgeError: Error, LocalizedError { @@ -39,7 +40,7 @@ private enum OnsideBridgeError: Error, LocalizedError { } #if canImport(OnsideKit) -import OnsideKit +@preconcurrency import OnsideKit @available(iOS 16.0, *) @MainActor @@ -50,18 +51,14 @@ public final class ExpoIapOnsideModule: Module { private let productFetcher = OnsideProductFetcher() private var productCache: [String: OnsideProduct] = [:] - private let encoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .millisecondsSince1970 - return encoder - }() - nonisolated public func definition() -> ModuleDefinition { Name("ExpoIapOnside") Constants { var constants: [String: Any] = [:] - OpenIapSerialization.errorCodes().forEach { key, value in + let errorCodes = OpenIapSerialization.errorCodes() + constants["ERROR_CODES"] = errorCodes + errorCodes.forEach { key, value in constants[key] = value } constants["IS_ONSIDE_KIT_INSTALLED_IOS"] = true @@ -71,7 +68,8 @@ public final class ExpoIapOnsideModule: Module { Events( OnsideEvent.purchaseUpdated.rawValue, OnsideEvent.purchaseError.rawValue, - OnsideEvent.promotedProductIOS.rawValue + OnsideEvent.promotedProductIOS.rawValue, + OnsideEvent.subscriptionBillingIssue.rawValue ) OnCreate { @@ -98,6 +96,11 @@ public final class ExpoIapOnsideModule: Module { return true } + AsyncFunction("setPurchaseUpdatedListenerOptions") { (_: [String: Any]?) async throws -> Void in + // OnsideKit does not replay StoreKit 2 transactions through OpenIAP, + // so the StoreKit dedupe option is intentionally a no-op here. + } + AsyncFunction("fetchProducts") { (params: [String: Any]) async throws -> [[String: Any]] in ExpoIapLog.payload("fetchProductsOnside", payload: params) try await ensureObserverRegistered() @@ -164,10 +167,6 @@ public final class ExpoIapOnsideModule: Module { throw OnsideBridgeError.productNotFound(response.invalidProductIdentifiers.joined(separator: ", ")) } - await MainActor.run { - response.products.forEach { productCache[$0.productIdentifier] = $0 } - } - let payload: [[String: Any]] = try await MainActor.run { for p in response.products { productCache[p.productIdentifier] = p @@ -265,7 +264,11 @@ public final class ExpoIapOnsideModule: Module { return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in Task { @MainActor [weak self] in - self?.restoreContinuation = continuation + guard let self else { + continuation.resume(returning: false) + return + } + self.restoreContinuation = continuation Onside.defaultPaymentQueue().restoreCompletedTransactions { result in Task { @MainActor [weak self] in @@ -286,15 +289,45 @@ public final class ExpoIapOnsideModule: Module { } } - AsyncFunction("getStorefrontIOS") { () async throws -> String in - ExpoIapLog.payload("getStorefrontOnside", payload: nil) + AsyncFunction("getAvailableItems") { (alsoPublish: Bool, onlyIncludeActive: Bool) async throws -> [[String: Any]] in + ExpoIapLog.payload( + "getAvailableItemsOnside", + payload: [ + "alsoPublishToEventListenerIOS": alsoPublish, + "onlyIncludeActiveItemsIOS": onlyIncludeActive, + ] + ) try await ensureObserverRegistered() - let storefront = await Onside.defaultPaymentQueue().storefront?.countryCode ?? "" - ExpoIapLog.result("getStorefrontOnside", value: storefront) - return storefront + let queue = await Onside.defaultPaymentQueue() + let payload = try queue.transactions.compactMap { transaction -> [String: Any]? in + switch transaction.transactionState { + case .purchased, .restored: + return try serialize(transaction: transaction) + default: + return nil + } + } + ExpoIapLog.result("getAvailableItemsOnside", value: payload) + return payload + } + + AsyncFunction("getStorefront") { () async throws -> String in + try await getOnsideStorefront() + } + + AsyncFunction("getStorefrontIOS") { () async throws -> String in + try await getOnsideStorefront() } } + private func getOnsideStorefront() async throws -> String { + ExpoIapLog.payload("getStorefrontOnside", payload: nil) + try await ensureObserverRegistered() + let storefront = await Onside.defaultPaymentQueue().storefront?.countryCode ?? "" + ExpoIapLog.result("getStorefrontOnside", value: storefront) + return storefront + } + private func ensureObserverRegistered() async throws { if !isInitialized { Onside.defaultPaymentQueue().add(observer: transactionObserver) @@ -315,24 +348,32 @@ public final class ExpoIapOnsideModule: Module { private func configureObserverCallbacks() { transactionObserver.onTransactionsUpdated = { [weak self] transactions in - guard let self = self else { return } - transactions.forEach { transaction in - self.handle(transaction: transaction) + Task { @MainActor [weak self] in + guard let self else { return } + transactions.forEach { transaction in + self.handle(transaction: transaction) + } } } transactionObserver.onRestoreFinished = { [weak self] in - guard let self else { return } - let cont = self.restoreContinuation - self.restoreContinuation = nil - cont?.resume(returning: true) + Task { @MainActor [weak self] in + guard let self else { return } + let cont = self.restoreContinuation + self.restoreContinuation = nil + cont?.resume(returning: true) + } } transactionObserver.onRestoreFailed = { [weak self] error in - guard let self else { return } - let cont = self.restoreContinuation - self.restoreContinuation = nil - cont?.resume(throwing: OnsideBridgeError.queueError(error.localizedDescription)) + Task { @MainActor [weak self] in + guard let self else { return } + let cont = self.restoreContinuation + self.restoreContinuation = nil + cont?.resume( + throwing: OnsideBridgeError.queueError(error.localizedDescription) + ) + } } } @@ -385,10 +426,11 @@ public final class ExpoIapOnsideModule: Module { let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = product.price.currencyCode ?? "" - let formattedPrice = formatter.string(from: NSDecimalNumber(decimal: product.price.value)) ?? "\(product.price.value)" + let priceNumber = NSDecimalNumber(decimal: product.price.value) + let formattedPrice = formatter.string(from: priceNumber) ?? "\(product.price.value)" dictionary["displayPrice"] = formattedPrice dictionary["currency"] = product.price.currencyCode ?? "" - dictionary["price"] = product.price.value + dictionary["price"] = priceNumber.doubleValue dictionary["type"] = "in-app" dictionary["typeIOS"] = "non-consumable" dictionary["isFamilyShareableIOS"] = false @@ -430,13 +472,14 @@ public final class ExpoIapOnsideModule: Module { let priceFormatter = NumberFormatter() priceFormatter.numberStyle = .currency priceFormatter.currencyCode = product.price.currencyCode ?? "" - let formattedPrice = priceFormatter.string(from: NSDecimalNumber(decimal: product.price.value)) ?? "\(product.price.value)" + let priceNumber = NSDecimalNumber(decimal: product.price.value) + let formattedPrice = priceFormatter.string(from: priceNumber) ?? "\(product.price.value)" let jsonObject: [String: Any] = [ "id": product.productIdentifier, "title": product.localizedTitle, "description": product.localizedDescription, "price": [ - "value": product.price.value, + "value": priceNumber.doubleValue, "currencyCode": product.price.currencyCode ?? "", "formatted": formattedPrice, ], @@ -552,6 +595,11 @@ private final class OnsideProductFetcher: NSObject, OnsideProductsRequestDelegat guard !identifiers.isEmpty else { throw OnsideBridgeError.emptySkuList } + guard continuation == nil else { + throw OnsideBridgeError.queueError( + "A product request is already in progress." + ) + } return try await withCheckedThrowingContinuation { continuation in let request = Onside.makeProductsRequest(productIdentifiers: identifiers) @@ -563,28 +611,54 @@ private final class OnsideProductFetcher: NSObject, OnsideProductsRequestDelegat } func onsideProductsRequest(_ request: OnsideProductsRequest, didReceive response: OnsideProductsResponse) { - continuation?.resume(returning: response) - cleanup() + Task { @MainActor [weak self] in + self?.complete(.success(response)) + } } func onsideProductsRequestRequest( _ request: OnsideProductsRequest, didFailWithError error: OnsideProductsRequestError ) { - continuation?.resume(throwing: OnsideBridgeError.queueError(error.localizedDescription)) - cleanup() + Task { @MainActor [weak self] in + self?.complete( + .failure(OnsideBridgeError.queueError(error.localizedDescription)) + ) + } } func onsideProductsRequestDidFinish(_ request: OnsideProductsRequest) { - cleanup() + Task { @MainActor [weak self] in + self?.complete( + .failure( + OnsideBridgeError.queueError( + "Product request finished without a response." + ) + ) + ) + } } @MainActor + private func complete(_ result: Result) { + guard let continuation else { + cleanup() + return + } + self.continuation = nil + cleanup() + switch result { + case .success(let response): + continuation.resume(returning: response) + case .failure(let error): + continuation.resume(throwing: error) + } + } + private func cleanup() { request?.delegate = nil request?.stop() request = nil - continuation = nil } } @@ -607,7 +681,8 @@ public final class ExpoIapOnsideModule: Module { Events( OnsideEvent.purchaseUpdated.rawValue, OnsideEvent.purchaseError.rawValue, - OnsideEvent.promotedProductIOS.rawValue + OnsideEvent.promotedProductIOS.rawValue, + OnsideEvent.subscriptionBillingIssue.rawValue ) AsyncFunction("initConnection") { (_: [String: Any]?) async throws -> Bool in @@ -618,6 +693,10 @@ public final class ExpoIapOnsideModule: Module { throw OnsideBridgeError.sdkUnavailable } + AsyncFunction("setPurchaseUpdatedListenerOptions") { (_: [String: Any]?) async throws -> Void in + throw OnsideBridgeError.sdkUnavailable + } + AsyncFunction("fetchProducts") { (_: [String: Any]) async throws -> [[String: Any]] in throw OnsideBridgeError.sdkUnavailable } @@ -634,6 +713,14 @@ public final class ExpoIapOnsideModule: Module { throw OnsideBridgeError.sdkUnavailable } + AsyncFunction("getAvailableItems") { (_: Bool, _: Bool) async throws -> [[String: Any]] in + throw OnsideBridgeError.sdkUnavailable + } + + AsyncFunction("getStorefront") { () async throws -> String in + throw OnsideBridgeError.sdkUnavailable + } + AsyncFunction("getStorefrontIOS") { () async throws -> String in throw OnsideBridgeError.sdkUnavailable } diff --git a/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts b/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts index 3e4a023c..e59b2e9d 100644 --- a/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts +++ b/libraries/expo-iap/plugin/src/withLocalOpenIAP.ts @@ -17,6 +17,64 @@ import { */ type LocalPathOption = string | {ios?: string; android?: string}; +interface AndroidGradlePluginVersions { + kotlin: string; + vanniktechMavenPublish: string; +} + +const DEFAULT_ANDROID_GRADLE_PLUGIN_VERSIONS: AndroidGradlePluginVersions = { + kotlin: '2.2.0', + vanniktechMavenPublish: '0.35.0', +}; + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const readGradlePluginVersion = ( + contents: string, + pluginId: string, +): string | null => { + const pattern = new RegExp( + `id\\("${escapeRegExp(pluginId)}"\\)\\s+version\\s+"([^"]+)"`, + ); + return pattern.exec(contents)?.[1] ?? null; +}; + +const setGradlePluginVersion = ( + contents: string, + pluginId: string, + version: string, +): string => { + const pattern = new RegExp( + `id\\("${escapeRegExp(pluginId)}"\\)\\s+version\\s+"[^"]+"`, + 'g', + ); + return contents.replace(pattern, `id("${pluginId}") version "${version}"`); +}; + +const resolveAndroidGradlePluginVersions = ( + androidModulePath: string, +): AndroidGradlePluginVersions => { + const rootBuildGradle = path.resolve( + androidModulePath, + '..', + 'build.gradle.kts', + ); + if (!fs.existsSync(rootBuildGradle)) { + return DEFAULT_ANDROID_GRADLE_PLUGIN_VERSIONS; + } + + const contents = fs.readFileSync(rootBuildGradle, 'utf8'); + const kotlin = + readGradlePluginVersion(contents, 'org.jetbrains.kotlin.android') ?? + DEFAULT_ANDROID_GRADLE_PLUGIN_VERSIONS.kotlin; + const vanniktechMavenPublish = + readGradlePluginVersion(contents, 'com.vanniktech.maven.publish') ?? + DEFAULT_ANDROID_GRADLE_PLUGIN_VERSIONS.vanniktechMavenPublish; + + return {kotlin, vanniktechMavenPublish}; +}; + // Log a message only once per Node process const logOnce = (() => { const printed = new Set(); @@ -134,14 +192,21 @@ const withLocalOpenIAP: ConfigPlugin< } return config; } + const pluginVersions = + resolveAndroidGradlePluginVersions(androidModulePath); + const settingsRoot = + ((config.modRequest as any).platformProjectRoot as string | undefined) ?? + path.join(projectRoot, 'android'); + const relativeAndroidModulePath = path + .relative(settingsRoot, androidModulePath) + .replace(/\\/g, '/'); // 1) settings.gradle: include and map projectDir const settings = config.modResults; const includeLine = "include ':openiap-google'"; - const projectDirLine = `project(':openiap-google').projectDir = new File('${androidModulePath.replace( - /\\/g, - '/', - )}')`; + const projectDirLine = `project(':openiap-google').projectDir = new File(settingsDir, '${relativeAndroidModulePath}')`; + const projectDirPattern = + /^project\(':openiap-google'\)\.projectDir\s*=.*$/gm; let contents = settings.contents ?? ''; // Ensure pluginManagement has plugin mappings required by the included module @@ -159,18 +224,34 @@ const withLocalOpenIAP: ConfigPlugin< contents, ); + contents = setGradlePluginVersion( + contents, + 'com.vanniktech.maven.publish', + pluginVersions.vanniktechMavenPublish, + ); + contents = setGradlePluginVersion( + contents, + 'org.jetbrains.kotlin.android', + pluginVersions.kotlin, + ); + contents = setGradlePluginVersion( + contents, + 'org.jetbrains.kotlin.plugin.compose', + pluginVersions.kotlin, + ); + const pluginLines: string[] = []; if (needsVannik) pluginLines.push( - ` id("com.vanniktech.maven.publish") version "0.29.0"`, + ` id("com.vanniktech.maven.publish") version "${pluginVersions.vanniktechMavenPublish}"`, ); if (needsKotlinAndroid) pluginLines.push( - ` id("org.jetbrains.kotlin.android") version "2.0.21"`, + ` id("org.jetbrains.kotlin.android") version "${pluginVersions.kotlin}"`, ); if (needsCompose) pluginLines.push( - ` id("org.jetbrains.kotlin.plugin.compose") version "2.0.21"`, + ` id("org.jetbrains.kotlin.plugin.compose") version "${pluginVersions.kotlin}"`, ); // If everything already present, skip @@ -197,14 +278,13 @@ const withLocalOpenIAP: ConfigPlugin< } }; - if ( - !/com\.vanniktech\.maven\.publish/.test(contents) || - !/org\.jetbrains\.kotlin\.android/.test(contents) - ) { - injectPluginManagement(); - } + injectPluginManagement(); if (!contents.includes(includeLine)) contents += `\n${includeLine}\n`; - if (!contents.includes(projectDirLine)) contents += `${projectDirLine}\n`; + if (projectDirPattern.test(contents)) { + contents = contents.replace(projectDirPattern, projectDirLine); + } else if (!contents.includes(projectDirLine)) { + contents += `${projectDirLine}\n`; + } settings.contents = contents; logOnce(`✅ Linked local Android module at: ${androidModulePath}`); return config; diff --git a/libraries/expo-iap/scripts/test-coverage.sh b/libraries/expo-iap/scripts/test-coverage.sh index 99c5f440..7f0cd3e1 100755 --- a/libraries/expo-iap/scripts/test-coverage.sh +++ b/libraries/expo-iap/scripts/test-coverage.sh @@ -1,4 +1,9 @@ #!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_ROOT" echo "Running tests with coverage..." @@ -10,6 +15,5 @@ bunx jest --coverage echo "Running example app tests..." cd example bunx jest --coverage --passWithNoTests -cd .. echo "Coverage reports generated in ./coverage and ./example/coverage" diff --git a/libraries/expo-iap/src/ExpoIapModule.ts b/libraries/expo-iap/src/ExpoIapModule.ts index 62a65ed8..35428b63 100644 --- a/libraries/expo-iap/src/ExpoIapModule.ts +++ b/libraries/expo-iap/src/ExpoIapModule.ts @@ -2,11 +2,34 @@ import {requireNativeModule, UnavailabilityError} from 'expo-modules-core'; import {installedFromOnside} from './onside'; type NativeIapModuleName = 'ExpoIapOnside' | 'ExpoIap'; +const ONSIDE_MARKETPLACE_ID = 'com.onside.marketplace-app'; let cached: {module: any; name: NativeIapModuleName} | null = null; +let expoIapFallback: any | null | undefined; +let onsideModuleUnavailable = false; + +function isOnsideInstallation(): boolean { + if (installedFromOnside === true) { + return true; + } + + if (typeof installedFromOnside !== 'string') { + return false; + } + + const normalized = installedFromOnside.trim().toLowerCase(); + return normalized === 'true' || normalized === ONSIDE_MARKETPLACE_ID; +} + +function shouldUseOnsideModule(): boolean { + return isOnsideInstallation() && !onsideModuleUnavailable; +} function getResolved(): {module: any; name: NativeIapModuleName} { - if (!cached) { + const expectedName: NativeIapModuleName = shouldUseOnsideModule() + ? 'ExpoIapOnside' + : 'ExpoIap'; + if (!cached || cached.name !== expectedName) { cached = resolveNativeModule(); } return cached; @@ -16,28 +39,21 @@ function resolveNativeModule(): { module: any; name: NativeIapModuleName; } { - const candidates: NativeIapModuleName[] = ['ExpoIapOnside', 'ExpoIap']; - - for (const name of candidates) { + if (isOnsideInstallation()) { try { - const module = requireNativeModule(name); - if (name === 'ExpoIapOnside' && !installedFromOnside) { - continue; - } - return {module, name}; + return { + module: requireNativeModule('ExpoIapOnside'), + name: 'ExpoIapOnside', + }; } catch (error) { - if (name === 'ExpoIapOnside' && isMissingModuleError(error, name)) { - continue; + if (!isMissingModuleError(error, 'ExpoIapOnside')) { + throw error; } - - throw error; + onsideModuleUnavailable = true; } } - throw new UnavailabilityError( - 'expo-iap', - 'ExpoIap native module is unavailable', - ); + return {module: requireNativeModule('ExpoIap'), name: 'ExpoIap'}; } function isMissingModuleError(error: unknown, moduleName: string): boolean { @@ -72,12 +88,37 @@ export function getNativeModule() { return getResolved().module; } +function getExpoIapFallbackModule(): any | null { + if (expoIapFallback !== undefined) { + return expoIapFallback; + } + + try { + expoIapFallback = requireNativeModule('ExpoIap'); + } catch (error) { + if (isMissingModuleError(error, 'ExpoIap')) { + expoIapFallback = null; + } else { + throw error; + } + } + + return expoIapFallback; +} + export default new Proxy({} as any, { get(target, prop) { if (typeof prop === 'symbol') return Reflect.get(target, prop); + const resolved = getResolved(); if (prop === 'USING_ONSIDE_SDK') { - return getResolved().name === 'ExpoIapOnside'; + return resolved.name === 'ExpoIapOnside'; } - return getResolved().module[prop]; + + const value = resolved.module[prop]; + if (value !== undefined || resolved.name !== 'ExpoIapOnside') { + return value; + } + + return getExpoIapFallbackModule()?.[prop]; }, }); diff --git a/libraries/expo-iap/src/__mocks__/ExpoIapModule.js b/libraries/expo-iap/src/__mocks__/ExpoIapModule.js index 435e3d15..915f38a5 100644 --- a/libraries/expo-iap/src/__mocks__/ExpoIapModule.js +++ b/libraries/expo-iap/src/__mocks__/ExpoIapModule.js @@ -1,4 +1,3 @@ -/* global jest */ const core = require('./expo-modules-core'); const nativeModule = core.requireNativeModule(); diff --git a/libraries/expo-iap/src/__mocks__/expo-modules-core.js b/libraries/expo-iap/src/__mocks__/expo-modules-core.js index 8fb9c953..18e7737f 100644 --- a/libraries/expo-iap/src/__mocks__/expo-modules-core.js +++ b/libraries/expo-iap/src/__mocks__/expo-modules-core.js @@ -15,9 +15,16 @@ const mockNativeModule = { getTransactionJwsIOS: jest.fn(), validateReceiptIOS: jest.fn(), presentCodeRedemptionSheetIOS: jest.fn(), + canPresentExternalPurchaseNoticeIOS: jest.fn(), + presentExternalPurchaseNoticeSheetIOS: jest.fn(), + presentExternalPurchaseLinkIOS: jest.fn(), getAppTransactionIOS: jest.fn(), + isEligibleForExternalPurchaseCustomLinkIOS: jest.fn(), + getExternalPurchaseCustomLinkTokenIOS: jest.fn(), + showExternalPurchaseCustomLinkNoticeIOS: jest.fn(), getPromotedProductIOS: jest.fn(), getPendingTransactionsIOS: jest.fn(), + getAllTransactionsIOS: jest.fn(), clearTransactionIOS: jest.fn(), // Common methods fetchProducts: jest.fn(), @@ -27,6 +34,7 @@ const mockNativeModule = { getActiveSubscriptions: jest.fn(), hasActiveSubscriptions: jest.fn(), getStorefront: jest.fn(), + restorePurchases: jest.fn(), finishTransaction: jest.fn(), verifyPurchase: jest.fn(), verifyPurchaseWithProvider: jest.fn(), @@ -35,7 +43,11 @@ const mockNativeModule = { setPurchaseUpdatedListenerOptions: jest.fn().mockResolvedValue(undefined), // Android-specific methods acknowledgePurchaseAndroid: jest.fn(), + consumePurchaseAndroid: jest.fn(), consumeProductAndroid: jest.fn(), + checkAlternativeBillingAvailabilityAndroid: jest.fn(), + showAlternativeBillingDialogAndroid: jest.fn(), + createAlternativeBillingTokenAndroid: jest.fn(), // Billing Programs API (8.2.0+) isBillingProgramAvailableAndroid: jest.fn(), launchExternalLinkAndroid: jest.fn(), diff --git a/libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts b/libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts new file mode 100644 index 00000000..532f710e --- /dev/null +++ b/libraries/expo-iap/src/__tests__/ExpoIapModule.test.ts @@ -0,0 +1,187 @@ +describe('ExpoIapModule proxy', () => { + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + const loadExpoIapModule = () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('../ExpoIapModule').default; + }; + + it('does not load ExpoIapOnside when Onside is not enabled', () => { + const expoIapModule = { + ERROR_CODES: {}, + fetchProducts: jest.fn(), + }; + const requireNativeModule = jest.fn((name: string) => { + if (name === 'ExpoIap') return expoIapModule; + throw new Error(`Unexpected native module '${name}'`); + }); + + jest.doMock('../onside', () => ({ + installedFromOnside: false, + })); + jest.doMock('expo-modules-core', () => ({ + requireNativeModule, + UnavailabilityError: class UnavailabilityError extends Error {}, + })); + + const ExpoIapModule = loadExpoIapModule(); + + expect(ExpoIapModule.USING_ONSIDE_SDK).toBe(false); + expect(ExpoIapModule.fetchProducts).toBe(expoIapModule.fetchProducts); + expect(requireNativeModule).toHaveBeenCalledTimes(1); + expect(requireNativeModule).toHaveBeenCalledWith('ExpoIap'); + }); + + it('re-resolves when Onside availability changes after initial access', () => { + let installedFromOnside: boolean | null = null; + const expoIapModule = { + ERROR_CODES: {}, + fetchProducts: jest.fn(), + }; + const onsideModule = { + ERROR_CODES: {}, + fetchProducts: jest.fn(), + }; + const requireNativeModule = jest.fn((name: string) => { + if (name === 'ExpoIap') return expoIapModule; + if (name === 'ExpoIapOnside') return onsideModule; + throw new Error(`Cannot find native module '${name}'`); + }); + + jest.doMock('../onside', () => ({ + get installedFromOnside() { + return installedFromOnside; + }, + })); + jest.doMock('expo-modules-core', () => ({ + requireNativeModule, + UnavailabilityError: class UnavailabilityError extends Error {}, + })); + + const ExpoIapModule = loadExpoIapModule(); + + expect(ExpoIapModule.fetchProducts).toBe(expoIapModule.fetchProducts); + installedFromOnside = true; + expect(ExpoIapModule.USING_ONSIDE_SDK).toBe(true); + expect(ExpoIapModule.fetchProducts).toBe(onsideModule.fetchProducts); + expect(requireNativeModule.mock.calls.map(([name]) => name)).toEqual([ + 'ExpoIap', + 'ExpoIapOnside', + ]); + }); + + it('treats the Onside marketplace id as an Onside installation', () => { + const onsideModule = { + ERROR_CODES: {}, + fetchProducts: jest.fn(), + }; + const requireNativeModule = jest.fn((name: string) => { + if (name === 'ExpoIapOnside') return onsideModule; + throw new Error(`Cannot find native module '${name}'`); + }); + + jest.doMock('../onside', () => ({ + installedFromOnside: 'com.onside.marketplace-app', + })); + jest.doMock('expo-modules-core', () => ({ + requireNativeModule, + UnavailabilityError: class UnavailabilityError extends Error {}, + })); + + const ExpoIapModule = loadExpoIapModule(); + + expect(ExpoIapModule.USING_ONSIDE_SDK).toBe(true); + expect(ExpoIapModule.fetchProducts).toBe(onsideModule.fetchProducts); + expect(requireNativeModule).toHaveBeenCalledWith('ExpoIapOnside'); + }); + + it('does not repeatedly load a missing ExpoIapOnside module', () => { + const expoIapModule = { + ERROR_CODES: {}, + fetchProducts: jest.fn(), + verifyPurchase: jest.fn(), + }; + const requireNativeModule = jest.fn((name: string) => { + if (name === 'ExpoIapOnside') { + throw new Error("Cannot find native module 'ExpoIapOnside'"); + } + if (name === 'ExpoIap') return expoIapModule; + throw new Error(`Cannot find native module '${name}'`); + }); + + jest.doMock('../onside', () => ({ + installedFromOnside: true, + })); + jest.doMock('expo-modules-core', () => ({ + requireNativeModule, + UnavailabilityError: class UnavailabilityError extends Error {}, + })); + + const ExpoIapModule = loadExpoIapModule(); + + expect(ExpoIapModule.USING_ONSIDE_SDK).toBe(false); + expect(ExpoIapModule.fetchProducts).toBe(expoIapModule.fetchProducts); + expect(ExpoIapModule.verifyPurchase).toBe(expoIapModule.verifyPurchase); + expect(requireNativeModule.mock.calls.map(([name]) => name)).toEqual([ + 'ExpoIapOnside', + 'ExpoIap', + ]); + }); + + it('falls back to ExpoIap for methods missing from ExpoIapOnside', () => { + const onsideModule = { + ERROR_CODES: {}, + requestPurchase: jest.fn(), + }; + const expoIapModule = { + ERROR_CODES: {}, + getStorefront: jest.fn(), + verifyPurchase: jest.fn(), + }; + + jest.doMock('../onside', () => ({ + installedFromOnside: true, + })); + jest.doMock('expo-modules-core', () => ({ + requireNativeModule: jest.fn((name: string) => { + if (name === 'ExpoIapOnside') return onsideModule; + if (name === 'ExpoIap') return expoIapModule; + throw new Error(`Cannot find native module '${name}'`); + }), + UnavailabilityError: class UnavailabilityError extends Error {}, + })); + + const ExpoIapModule = loadExpoIapModule(); + + expect(ExpoIapModule.USING_ONSIDE_SDK).toBe(true); + expect(ExpoIapModule.requestPurchase).toBe(onsideModule.requestPurchase); + expect(ExpoIapModule.verifyPurchase).toBe(expoIapModule.verifyPurchase); + expect(ExpoIapModule.getStorefront).toBe(expoIapModule.getStorefront); + }); + + it('surfaces non-missing ExpoIap fallback errors', () => { + const onsideModule = { + ERROR_CODES: {}, + requestPurchase: jest.fn(), + }; + + jest.doMock('../onside', () => ({ + installedFromOnside: true, + })); + jest.doMock('expo-modules-core', () => ({ + requireNativeModule: jest.fn((name: string) => { + if (name === 'ExpoIapOnside') return onsideModule; + if (name === 'ExpoIap') throw new Error('native init failed'); + throw new Error(`Cannot find native module '${name}'`); + }), + UnavailabilityError: class UnavailabilityError extends Error {}, + })); + + const ExpoIapModule = loadExpoIapModule(); + + expect(() => ExpoIapModule.verifyPurchase).toThrow('native init failed'); + }); +}); diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index a812f6e8..cb614aaf 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -27,6 +27,7 @@ import { getAvailablePurchases, restorePurchases, promotedProductListenerIOS, + subscriptionBillingIssueListener, userChoiceBillingListenerAndroid, developerProvidedBillingListenerAndroid, PurchaseInput, @@ -53,6 +54,8 @@ afterAll(() => { describe('Public API (index.ts)', () => { beforeEach(() => { jest.clearAllMocks(); + (Platform as any).OS = 'ios'; + (Platform as any).select = jest.fn((obj) => obj.ios); (ExpoIapModule.getPromotedProductIOS as jest.Mock).mockResolvedValue(null); }); @@ -360,6 +363,29 @@ describe('Public API (index.ts)', () => { 'ext-txn-token-12345', ); }); + + it('subscriptionBillingIssueListener normalizes purchase platform', () => { + const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const fn = jest.fn(); + subscriptionBillingIssueListener(fn); + + expect(addListener).toHaveBeenCalledWith( + OpenIapEvent.SubscriptionBillingIssue, + expect.any(Function), + ); + + const registeredCallback = addListener.mock.calls.find( + (call: any) => call[0] === OpenIapEvent.SubscriptionBillingIssue, + )?.[1]; + const purchase = { + id: 'billing-issue', + productId: 'sub.monthly', + platform: 'IOS', + } as any; + registeredCallback(purchase); + + expect(fn).toHaveBeenCalledWith({...purchase, platform: 'ios'}); + }); }); describe('connection', () => { @@ -432,7 +458,7 @@ describe('Public API (index.ts)', () => { expect(warnSpy).toHaveBeenCalledWith( '[Expo-IAP]', - "'inapp' product type is deprecated and will be removed in v3.1.0. Use 'in-app' instead.", + "'inapp' product type is deprecated and will be removed in a future major version. Use 'in-app' instead.", ); warnSpy.mockRestore(); }); @@ -836,6 +862,48 @@ describe('Public API (index.ts)', () => { expect(res).toEqual([{id: 'sub-123', platform: 'ios'}]); }); + it('iOS subscription passes advanced offer fields through', async () => { + (Platform as any).OS = 'ios'; + (ExpoIapModule.requestPurchase as jest.Mock) = jest + .fn() + .mockResolvedValue([{id: 'sub-advanced', platform: 'ios'}]); + + await requestPurchase({ + request: { + apple: { + sku: 'com.example.subscription.monthly', + introductoryOfferEligibility: true, + promotionalOfferJWS: { + offerId: 'promo-offer', + jws: 'compact-jws', + }, + winBackOffer: { + offerId: 'winback-offer', + }, + }, + }, + type: 'subs', + }); + + expect(ExpoIapModule.requestPurchase).toHaveBeenCalledWith({ + type: 'subs', + request: { + ios: { + sku: 'com.example.subscription.monthly', + introductoryOfferEligibility: true, + promotionalOfferJWS: { + offerId: 'promo-offer', + jws: 'compact-jws', + }, + winBackOffer: { + offerId: 'winback-offer', + }, + }, + }, + useAlternativeBilling: undefined, + }); + }); + it('iOS works without advancedCommerceData (optional field)', async () => { (Platform as any).OS = 'ios'; (ExpoIapModule.requestPurchase as jest.Mock) = jest @@ -923,18 +991,48 @@ describe('Public API (index.ts)', () => { it('restorePurchases performs iOS sync then fetches purchases', async () => { (Platform as any).OS = 'ios'; (Platform as any).select = (obj: any) => obj.ios; - jest.spyOn(iosMod as any, 'syncIOS').mockResolvedValue(undefined as any); + const syncSpy = jest + .spyOn(iosMod as any, 'syncIOS') + .mockResolvedValue(undefined as any); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() .mockResolvedValue([{id: 'legacy', transactionId: 'txn-restore'}]); await restorePurchases(); + expect(syncSpy).toHaveBeenCalledTimes(1); expect(ExpoIapModule.getAvailableItems).toHaveBeenCalledWith(false, true); }); - it('getPurchaseHistory placeholder (removed in v3)', () => { - // Removed legacy API in v3; keeping placeholder to maintain suite structure - expect(true).toBe(true); + it('restorePurchases uses native Onside restore when active', async () => { + (Platform as any).OS = 'ios'; + (Platform as any).select = (obj: any) => obj.ios; + const syncSpy = jest + .spyOn(iosMod as any, 'syncIOS') + .mockResolvedValue(undefined as any); + Object.defineProperty(ExpoIapModule, 'USING_ONSIDE_SDK', { + configurable: true, + value: true, + }); + (ExpoIapModule.restorePurchases as jest.Mock) = jest + .fn() + .mockResolvedValue(true); + (ExpoIapModule.getAvailableItems as jest.Mock) = jest + .fn() + .mockResolvedValue([{id: 'onside', transactionId: 'txn-onside'}]); + + try { + await restorePurchases(); + + expect(ExpoIapModule.restorePurchases).toHaveBeenCalledTimes(1); + expect(syncSpy).not.toHaveBeenCalled(); + expect(ExpoIapModule.getAvailableItems).toHaveBeenCalledWith( + false, + true, + ); + } finally { + delete (ExpoIapModule as any).USING_ONSIDE_SDK; + } }); + }); describe('finishTransaction', () => { @@ -1044,7 +1142,7 @@ describe('Public API (index.ts)', () => { transactionDate: Date.now(), } as any, }), - ).rejects.toThrow(/Unsupported Platform/); + ).rejects.toThrow(/Unsupported platform/); (Platform as any).OS = originalOs; }); }); @@ -1115,7 +1213,7 @@ describe('Public API (index.ts)', () => { it('validateReceipt throws on unsupported platform', async () => { (Platform as any).OS = 'web'; await expect(validateReceipt({apple: {sku: 'sku'}})).rejects.toThrow( - /Platform not supported/, + /Unsupported platform/, ); }); @@ -1163,17 +1261,17 @@ describe('Public API (index.ts)', () => { (Platform as any).OS = 'web'; await expect( requestPurchase({request: {} as any} as any), - ).rejects.toThrow(/Platform not supported/); + ).rejects.toThrow(/Unsupported platform/); }); }); - describe('getAvailablePurchases fallback', () => { - it('returns [] when Platform.select returns undefined', async () => { - const originalSelect = (Platform as any).select; - (Platform as any).select = () => undefined; - const res = await getAvailablePurchases(); - expect(res).toEqual([]); - (Platform as any).select = originalSelect; + describe('getAvailablePurchases platform support', () => { + it('rejects on unsupported platform', async () => { + (Platform as any).OS = 'web'; + + await expect(getAvailablePurchases()).rejects.toThrow( + /Unsupported platform: web/, + ); }); }); @@ -1289,6 +1387,7 @@ describe('Public API (index.ts)', () => { }); it('handles Android subscriptions with autoRenewingAndroid', async () => { + (Platform as any).OS = 'android'; const mockAndroidSubscription = [ { productId: 'premium_monthly', @@ -1309,6 +1408,14 @@ describe('Public API (index.ts)', () => { expect(result).toEqual(mockAndroidSubscription); expect(result[0].autoRenewingAndroid).toBe(false); }); + + it('rejects on unsupported platform', async () => { + (Platform as any).OS = 'web'; + + await expect(getActiveSubscriptions()).rejects.toThrow( + /Unsupported platform: web/, + ); + }); }); describe('hasActiveSubscriptions', () => { @@ -1405,6 +1512,14 @@ describe('Public API (index.ts)', () => { expect(result).toBe(false); }); + + it('rejects on unsupported platform', async () => { + (Platform as any).OS = 'web'; + + await expect(hasActiveSubscriptions()).rejects.toThrow( + /Unsupported platform: web/, + ); + }); }); describe('verifyPurchase', () => { diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 89b5b7d7..32a61f19 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -101,6 +101,12 @@ type NativePurchaseUpdatedOptionsModule = { ) => Promise; }; +const isStorePlatform = (): boolean => + Platform.OS === 'ios' || Platform.OS === 'android'; + +const unsupportedPlatformError = (): Error => + new Error(`Unsupported platform: ${Platform.OS}`); + // Use the raw native module for listener calls — JSI HostObjects require the // real native module as `this` when calling addListener. Using a Proxy as // `this` triggers "native state unsupported on Proxy" on New Architecture / Hermes. @@ -211,14 +217,14 @@ const configurePurchaseUpdatedListenerOptionsIOS = ( }; /** - * TODO(v3.1.0): Remove legacy 'inapp' alias once downstream apps migrate to 'in-app'. + * TODO(next-major): Remove legacy 'inapp' alias once downstream apps migrate to 'in-app'. */ export type ProductTypeInput = ProductQueryType | 'inapp'; const normalizeProductType = (type?: ProductTypeInput) => { if (type === 'inapp') { ExpoIapConsole.warn( - "'inapp' product type is deprecated and will be removed in v3.1.0. Use 'in-app' instead.", + "'inapp' product type is deprecated and will be removed in a future major version. Use 'in-app' instead.", ); } @@ -441,7 +447,7 @@ export const promotedProductListenerIOS = ( * ```typescript * const subscription = userChoiceBillingListenerAndroid((details) => { * console.log('User selected alternative billing'); - * console.log('Token:', details.externalTransactionToken); + * console.log('External transaction token received; send it to your backend without logging it.'); * console.log('Products:', details.products); * * // Process payment in your system, then report token to Google @@ -480,7 +486,7 @@ export const userChoiceBillingListenerAndroid = ( * ```typescript * const subscription = developerProvidedBillingListenerAndroid(async (details) => { * console.log('User selected developer billing'); - * console.log('Token:', details.externalTransactionToken); + * console.log('External transaction token received; send it to your backend without logging it.'); * * // Process payment with your payment gateway * await processPaymentWithYourGateway(details.externalTransactionToken); @@ -564,7 +570,7 @@ export const subscriptionBillingIssueListener = ( * @remarks When using `useIAP()`, connection is auto-managed on mount/unmount — * pass options to the hook instead of calling this directly. * - * @see {@link https://www.openiap.dev/docs/apis/init-connection} + * @see {@link https://openiap.dev/docs/apis/init-connection} */ export const initConnection: MutationField<'initConnection'> = async (config) => { const result = await ExpoIapModule.initConnection(config ?? null); @@ -581,7 +587,7 @@ export const initConnection: MutationField<'initConnection'> = async (config) => /** * Close the store connection and release resources. * - * @see {@link https://www.openiap.dev/docs/apis/end-connection} + * @see {@link https://openiap.dev/docs/apis/end-connection} */ export const endConnection: MutationField<'endConnection'> = async () => { const result = await ExpoIapModule.endConnection(); @@ -612,7 +618,7 @@ export const endConnection: MutationField<'endConnection'> = async () => { * @remarks This is a regular promise-based call. Don't confuse with `request*` APIs * (`requestPurchase`), which are event-based. * - * @see {@link https://www.openiap.dev/docs/apis/fetch-products} + * @see {@link https://openiap.dev/docs/apis/fetch-products} */ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { ExpoIapConsole.debug('fetchProducts called with:', request); @@ -680,7 +686,7 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { return castResult(filterAndroidItems(rawItems)); } - throw new Error('Unsupported platform'); + throw unsupportedPlatformError(); }; /** @@ -700,7 +706,7 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/get-available-purchases} + * @see {@link https://openiap.dev/docs/apis/get-available-purchases} */ export const getAvailablePurchases: QueryField< 'getAvailablePurchases' @@ -712,20 +718,21 @@ export const getAvailablePurchases: QueryField< includeSuspendedAndroid: options?.includeSuspendedAndroid ?? false, }; - const resolvePurchases: () => Promise = - Platform.select({ - ios: () => - ExpoIapModule.getAvailableItems( - normalizedOptions.alsoPublishToEventListenerIOS, - normalizedOptions.onlyIncludeActiveItemsIOS, - ) as Promise, - android: () => - ExpoIapModule.getAvailableItems(normalizedOptions) as Promise< - Purchase[] - >, - }) ?? (() => Promise.resolve([] as Purchase[])); - - const purchases = await resolvePurchases(); + let purchases: Purchase[]; + + if (Platform.OS === 'ios') { + purchases = (await ExpoIapModule.getAvailableItems( + normalizedOptions.alsoPublishToEventListenerIOS, + normalizedOptions.onlyIncludeActiveItemsIOS, + )) as Purchase[]; + } else if (Platform.OS === 'android') { + purchases = (await ExpoIapModule.getAvailableItems( + normalizedOptions, + )) as Purchase[]; + } else { + throw unsupportedPlatformError(); + } + return normalizePurchaseArray(purchases as Purchase[]); }; @@ -757,11 +764,15 @@ export const getAvailablePurchases: QueryField< * }); * ``` * - * @see {@link https://www.openiap.dev/docs/apis/get-active-subscriptions} + * @see {@link https://openiap.dev/docs/apis/get-active-subscriptions} */ export const getActiveSubscriptions: QueryField< 'getActiveSubscriptions' > = async (subscriptionIds) => { + if (!isStorePlatform()) { + throw unsupportedPlatformError(); + } + const result = await ExpoIapModule.getActiveSubscriptions( subscriptionIds ?? null, ); @@ -783,11 +794,15 @@ export const getActiveSubscriptions: QueryField< * const hasPremium = await hasActiveSubscriptions(['premium', 'premium_year']); * ``` * - * @see {@link https://www.openiap.dev/docs/apis/has-active-subscriptions} + * @see {@link https://openiap.dev/docs/apis/has-active-subscriptions} */ export const hasActiveSubscriptions: QueryField< 'hasActiveSubscriptions' > = async (subscriptionIds) => { + if (!isStorePlatform()) { + throw unsupportedPlatformError(); + } + return !!(await ExpoIapModule.hasActiveSubscriptions( subscriptionIds ?? null, )); @@ -796,7 +811,7 @@ export const hasActiveSubscriptions: QueryField< /** * Return the user's storefront country code. * - * @see {@link https://www.openiap.dev/docs/apis/get-storefront} + * @see {@link https://openiap.dev/docs/apis/get-storefront} */ export const getStorefront: QueryField<'getStorefront'> = async () => { if (Platform.OS !== 'ios' && Platform.OS !== 'android') { @@ -862,7 +877,7 @@ function normalizeRequestProps( * @remarks Event-based. Listen for the result via {@link purchaseUpdatedListener} / * {@link purchaseErrorListener}, or use `useIAP({ onPurchaseSuccess, onPurchaseError })`. * - * @see {@link https://www.openiap.dev/docs/apis/request-purchase} + * @see {@link https://openiap.dev/docs/apis/request-purchase} */ export const requestPurchase: MutationField<'requestPurchase'> = async ( args, @@ -885,7 +900,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( ' },\n' + ' type: "in-app"\n' + ' })\n\n' + - 'See: https://hyochan.github.io/expo-iap/docs/api/methods/core-methods#requestpurchase', + 'See: https://openiap.dev/docs/apis/request-purchase', ); } @@ -933,7 +948,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( ' },\n' + ' type: "in-app"\n' + ' })\n\n' + - 'See: https://hyochan.github.io/expo-iap/docs/api/methods/core-methods#requestpurchase', + 'See: https://openiap.dev/docs/apis/request-purchase', ); } @@ -977,7 +992,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( ' },\n' + ' type: "subs"\n' + ' })\n\n' + - 'See: https://hyochan.github.io/expo-iap/docs/api/methods/core-methods#requestpurchase', + 'See: https://openiap.dev/docs/apis/request-purchase', ); } @@ -1020,7 +1035,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( ); } - throw new Error('Platform not supported'); + throw unsupportedPlatformError(); }; /** @@ -1044,7 +1059,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( * @remarks **Critical:** Android purchases must be finalized within 3 days or Google * auto-refunds. iOS unfinished transactions replay on every app launch. * - * @see {@link https://www.openiap.dev/docs/apis/finish-transaction} + * @see {@link https://openiap.dev/docs/apis/finish-transaction} */ export const finishTransaction: MutationField<'finishTransaction'> = async ({ purchase, @@ -1076,24 +1091,33 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ({ return; } - throw new Error('Unsupported Platform'); + throw unsupportedPlatformError(); }; /** * Restore completed transactions (cross-platform behavior) * - * - iOS: perform a lightweight sync to refresh transactions and ignore sync errors, + * - iOS: perform a lightweight sync, or Onside restore when OnsideKit is active, * then fetch available purchases to surface restored items to the app. * - Android: simply fetch available purchases (restoration happens via query). * * This helper triggers the refresh flows but does not return the purchases; consumers should * call `getAvailablePurchases` or rely on hook state to inspect the latest items. * - * @see {@link https://www.openiap.dev/docs/apis/restore-purchases} + * @see {@link https://openiap.dev/docs/apis/restore-purchases} */ export const restorePurchases: MutationField<'restorePurchases'> = async () => { if (Platform.OS === 'ios') { - await syncIOS().catch(() => undefined); + const nativeModule = ExpoIapModule as any; + + if ( + nativeModule.USING_ONSIDE_SDK && + typeof nativeModule.restorePurchases === 'function' + ) { + await nativeModule.restorePurchases().catch(() => undefined); + } else { + await syncIOS().catch(() => undefined); + } } await getAvailablePurchases({ @@ -1120,7 +1144,7 @@ export const restorePurchases: MutationField<'restorePurchases'> = async () => { * packageNameAndroid: 'com.example.app' * }); * - * @see {@link https://www.openiap.dev/docs/apis/deep-link-to-subscriptions} + * @see {@link https://openiap.dev/docs/apis/deep-link-to-subscriptions} */ export const deepLinkToSubscriptions: MutationField< 'deepLinkToSubscriptions' @@ -1135,7 +1159,7 @@ export const deepLinkToSubscriptions: MutationField< return; } - throw new Error(`Unsupported platform: ${Platform.OS}`); + throw unsupportedPlatformError(); }; /** @@ -1148,7 +1172,7 @@ export const deepLinkToSubscriptions: MutationField< * * @deprecated Use verifyPurchase instead * - * @see {@link https://www.openiap.dev/docs/apis/validate-receipt} + * @see {@link https://openiap.dev/docs/apis/validate-receipt} */ export const validateReceipt: MutationField<'validateReceipt'> = async ( options, @@ -1183,7 +1207,7 @@ export const validateReceipt: MutationField<'validateReceipt'> = async ( }); } - throw new Error('Platform not supported'); + throw unsupportedPlatformError(); }; /** @@ -1195,16 +1219,16 @@ export const validateReceipt: MutationField<'validateReceipt'> = async ( * @param options - Receipt validation options containing the SKU * @returns Promise resolving to receipt validation result * - * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase} + * @see {@link https://openiap.dev/docs/features/validation#verify-purchase} */ export const verifyPurchase: MutationField<'verifyPurchase'> = async ( options, ) => { - if (Platform.OS === 'ios' || Platform.OS === 'android') { - return ExpoIapModule.verifyPurchase(options); + if (!isStorePlatform()) { + throw unsupportedPlatformError(); } - throw new Error(`Unsupported platform: ${Platform.OS}`); + return ExpoIapModule.verifyPurchase(options); }; /** @@ -1232,43 +1256,45 @@ export const verifyPurchase: MutationField<'verifyPurchase'> = async ( * }); * ``` * - * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider} + * @see {@link https://openiap.dev/docs/features/validation#verify-purchase-with-provider} */ export const verifyPurchaseWithProvider: MutationField< 'verifyPurchaseWithProvider' > = async (options) => { - if (Platform.OS === 'ios' || Platform.OS === 'android') { - // Auto-fill apiKey from config if not provided and provider is iapkit - if ( - options.provider === 'iapkit' && - options.iapkit && - !options.iapkit.apiKey - ) { - try { - // Dynamically import expo-constants to avoid hard dependency - const {default: Constants} = await import('expo-constants'); - const configApiKey = Constants.expoConfig?.extra?.iapkitApiKey; - if (configApiKey) { - options = { - ...options, - iapkit: { - ...options.iapkit, - apiKey: configApiKey, - }, - }; - } - } catch { - throw new Error( - 'expo-constants is required for auto-filling iapkitApiKey from config. ' + - 'Please install it: npx expo install expo-constants\n' + - 'Or provide apiKey directly in verifyPurchaseWithProvider options.', - ); + if (!isStorePlatform()) { + throw unsupportedPlatformError(); + } + + let resolvedOptions = options; + + if ( + resolvedOptions.provider === 'iapkit' && + resolvedOptions.iapkit && + !resolvedOptions.iapkit.apiKey + ) { + try { + // Dynamically import expo-constants to avoid hard dependency + const {default: Constants} = await import('expo-constants'); + const configApiKey = Constants.expoConfig?.extra?.iapkitApiKey; + if (typeof configApiKey === 'string' && configApiKey.length > 0) { + resolvedOptions = { + ...resolvedOptions, + iapkit: { + ...resolvedOptions.iapkit, + apiKey: configApiKey, + }, + }; } + } catch { + throw new Error( + 'expo-constants is required for auto-filling iapkitApiKey from config. ' + + 'Please install it: npx expo install expo-constants\n' + + 'Or provide apiKey directly in verifyPurchaseWithProvider options.', + ); } - return ExpoIapModule.verifyPurchaseWithProvider(options); } - throw new Error(`Unsupported platform: ${Platform.OS}`); + return ExpoIapModule.verifyPurchaseWithProvider(resolvedOptions); }; export * from './useIAP'; diff --git a/libraries/expo-iap/src/modules/android.ts b/libraries/expo-iap/src/modules/android.ts index 2bcda814..d67f12f5 100644 --- a/libraries/expo-iap/src/modules/android.ts +++ b/libraries/expo-iap/src/modules/android.ts @@ -132,7 +132,7 @@ export const validateReceiptAndroid = async ({ * (Android consumable products). Prefer using `finishTransaction` with * `isConsumable: true`, which dispatches to this under the hood. * - * @see {@link https://www.openiap.dev/docs/apis/android/consume-purchase-android} + * @see {@link https://openiap.dev/docs/apis/android/consume-purchase-android} */ export const consumePurchaseAndroid: MutationField< 'consumePurchaseAndroid' @@ -166,7 +166,7 @@ export const consumePurchaseAndroid: MutationField< * @param {string} params.token - The product's token (on Android) * @returns {Promise} * - * @see {@link https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android} + * @see {@link https://openiap.dev/docs/apis/android/acknowledge-purchase-android} */ export const acknowledgePurchaseAndroid: MutationField< 'acknowledgePurchaseAndroid' @@ -218,7 +218,7 @@ export const openRedeemOfferCodeAndroid = async (): Promise => { * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android} + * @see {@link https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android} */ export const checkAlternativeBillingAvailabilityAndroid: MutationField< 'checkAlternativeBillingAvailabilityAndroid' @@ -250,7 +250,7 @@ export const checkAlternativeBillingAvailabilityAndroid: MutationField< * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android} + * @see {@link https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android} */ export const showAlternativeBillingDialogAndroid: MutationField< 'showAlternativeBillingDialogAndroid' @@ -282,7 +282,7 @@ export const showAlternativeBillingDialogAndroid: MutationField< * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android} + * @see {@link https://openiap.dev/docs/apis/android/create-alternative-billing-token-android} */ export const createAlternativeBillingTokenAndroid: MutationField< 'createAlternativeBillingTokenAndroid' @@ -309,7 +309,7 @@ export const createAlternativeBillingTokenAndroid: MutationField< * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/is-billing-program-available-android} + * @see {@link https://openiap.dev/docs/apis/android/is-billing-program-available-android} */ export const isBillingProgramAvailableAndroid: MutationField< 'isBillingProgramAvailableAndroid' @@ -334,7 +334,7 @@ export const isBillingProgramAvailableAndroid: MutationField< * }); * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/launch-external-link-android} + * @see {@link https://openiap.dev/docs/apis/android/launch-external-link-android} */ export const launchExternalLinkAndroid: MutationField< 'launchExternalLinkAndroid' @@ -359,7 +359,7 @@ export const launchExternalLinkAndroid: MutationField< * await reportToGooglePlay(details.externalTransactionToken); * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android} + * @see {@link https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android} */ export const createBillingProgramReportingDetailsAndroid: MutationField< 'createBillingProgramReportingDetailsAndroid' diff --git a/libraries/expo-iap/src/modules/ios.ts b/libraries/expo-iap/src/modules/ios.ts index 7c0a9df8..d4670c8d 100644 --- a/libraries/expo-iap/src/modules/ios.ts +++ b/libraries/expo-iap/src/modules/ios.ts @@ -54,7 +54,7 @@ export function isProductIOS( * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/sync-ios} + * @see {@link https://openiap.dev/docs/apis/ios/sync-ios} */ export const syncIOS: MutationField<'syncIOS'> = async () => { return !!(await ExpoIapModule.syncIOS()); @@ -69,7 +69,7 @@ export const syncIOS: MutationField<'syncIOS'> = async () => { * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios} + * @see {@link https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios} */ export const isEligibleForIntroOfferIOS: QueryField< 'isEligibleForIntroOfferIOS' @@ -89,7 +89,7 @@ export const isEligibleForIntroOfferIOS: QueryField< * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/subscription-status-ios} + * @see {@link https://openiap.dev/docs/apis/ios/subscription-status-ios} */ export const subscriptionStatusIOS: QueryField< 'subscriptionStatusIOS' @@ -110,7 +110,7 @@ export const subscriptionStatusIOS: QueryField< * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/current-entitlement-ios} + * @see {@link https://openiap.dev/docs/apis/ios/current-entitlement-ios} */ export const currentEntitlementIOS: QueryField< 'currentEntitlementIOS' @@ -131,7 +131,7 @@ export const currentEntitlementIOS: QueryField< * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/latest-transaction-ios} + * @see {@link https://openiap.dev/docs/apis/ios/latest-transaction-ios} */ export const latestTransactionIOS: QueryField<'latestTransactionIOS'> = async ( sku, @@ -152,7 +152,7 @@ export const latestTransactionIOS: QueryField<'latestTransactionIOS'> = async ( * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios} + * @see {@link https://openiap.dev/docs/apis/ios/begin-refund-request-ios} */ export const beginRefundRequestIOS: MutationField< 'beginRefundRequestIOS' @@ -173,7 +173,7 @@ export const beginRefundRequestIOS: MutationField< * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios} + * @see {@link https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios} */ export const showManageSubscriptionsIOS: MutationField< 'showManageSubscriptionsIOS' @@ -192,7 +192,7 @@ export const showManageSubscriptionsIOS: MutationField< * * @returns {Promise} Base64 encoded receipt data * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-receipt-data-ios} */ export const getReceiptDataIOS: QueryField<'getReceiptDataIOS'> = async () => { return ExpoIapModule.getReceiptDataIOS(); @@ -212,7 +212,7 @@ export const getReceiptIOS = getReceiptDataIOS; * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-storefront-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-storefront-ios} */ export const getStorefrontIOS: QueryField<'getStorefrontIOS'> = async () => { return ExpoIapModule.getStorefront(); @@ -243,7 +243,7 @@ export const requestReceiptRefreshIOS = async (): Promise => { * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios} + * @see {@link https://openiap.dev/docs/apis/ios/is-transaction-verified-ios} */ export const isTransactionVerifiedIOS: QueryField< 'isTransactionVerifiedIOS' @@ -264,7 +264,7 @@ export const isTransactionVerifiedIOS: QueryField< * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-transaction-jws-ios} */ export const getTransactionJwsIOS: QueryField<'getTransactionJwsIOS'> = async ( sku, @@ -292,7 +292,7 @@ export const getTransactionJwsIOS: QueryField<'getTransactionJwsIOS'> = async ( * latestTransaction?: Purchase; * }>} * - * @see {@link https://www.openiap.dev/docs/apis/ios/validate-receipt-ios} + * @see {@link https://openiap.dev/docs/apis/ios/validate-receipt-ios} */ const validateReceiptIOSImpl = async (props: VerifyPurchaseProps | string) => { const sku = @@ -323,7 +323,7 @@ export const validateReceiptIOS = * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios} + * @see {@link https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios} */ export const presentCodeRedemptionSheetIOS: MutationField< 'presentCodeRedemptionSheetIOS' @@ -345,7 +345,7 @@ export const presentCodeRedemptionSheetIOS: MutationField< * @platform iOS * @since iOS 16.0 * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-app-transaction-ios} */ export const getAppTransactionIOS: QueryField< 'getAppTransactionIOS' @@ -363,7 +363,7 @@ export const getAppTransactionIOS: QueryField< * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-promoted-product-ios} */ export const getPromotedProductIOS: QueryField< 'getPromotedProductIOS' @@ -384,7 +384,7 @@ export const getPromotedProductIOS: QueryField< * * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios} + * @see {@link https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios} */ export const requestPurchaseOnPromotedProductIOS = async (): Promise => { @@ -398,7 +398,7 @@ export const requestPurchaseOnPromotedProductIOS = * @returns Promise resolving to array of pending transactions * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-pending-transactions-ios} */ export const getPendingTransactionsIOS: QueryField< 'getPendingTransactionsIOS' @@ -410,7 +410,7 @@ export const getPendingTransactionsIOS: QueryField< /** * List every StoreKit transaction (finished + unfinished) for the current user. * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-all-transactions-ios} */ export const getAllTransactionsIOS: QueryField< 'getAllTransactionsIOS' @@ -425,7 +425,7 @@ export const getAllTransactionsIOS: QueryField< * @returns Promise resolving when transaction is cleared * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/clear-transaction-ios} + * @see {@link https://openiap.dev/docs/apis/ios/clear-transaction-ios} */ export const clearTransactionIOS: MutationField< 'clearTransactionIOS' @@ -452,7 +452,7 @@ export const deepLinkToSubscriptionsIOS = (): Promise => * @returns Promise resolving to true if the notice sheet can be presented * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios} + * @see {@link https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios} */ export const canPresentExternalPurchaseNoticeIOS: QueryField< 'canPresentExternalPurchaseNoticeIOS' @@ -468,7 +468,7 @@ export const canPresentExternalPurchaseNoticeIOS: QueryField< * @returns Promise resolving to the result with action, token, and error if any * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios} + * @see {@link https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios} */ export const presentExternalPurchaseNoticeSheetIOS = async (): Promise => { @@ -483,7 +483,7 @@ export const presentExternalPurchaseNoticeSheetIOS = * @returns Promise resolving to the result with success status and error if any * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios} + * @see {@link https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios} */ export const presentExternalPurchaseLinkIOS: MutationField< 'presentExternalPurchaseLinkIOS' @@ -500,7 +500,7 @@ export const presentExternalPurchaseLinkIOS: MutationField< * @platform iOS * @see https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible * - * @see {@link https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios} + * @see {@link https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios} */ export const isEligibleForExternalPurchaseCustomLinkIOS = async (): Promise => { @@ -516,7 +516,7 @@ export const isEligibleForExternalPurchaseCustomLinkIOS = * @platform iOS * @see https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/token(for:) * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios} */ export const getExternalPurchaseCustomLinkTokenIOS = async ( tokenType: ExternalPurchaseCustomLinkTokenTypeIOS, @@ -542,7 +542,7 @@ export const getExternalPurchaseCustomLinkTokenIOS = async ( * @platform iOS * @see https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/shownotice(type:) * - * @see {@link https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios} + * @see {@link https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios} */ export const showExternalPurchaseCustomLinkNoticeIOS = async ( noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS, diff --git a/libraries/expo-iap/src/useIAP.ts b/libraries/expo-iap/src/useIAP.ts index 4110eefb..abe03723 100644 --- a/libraries/expo-iap/src/useIAP.ts +++ b/libraries/expo-iap/src/useIAP.ts @@ -154,7 +154,7 @@ export interface UseIAPOptions { /** * React Hook for managing In-App Purchases. - * See documentation at https://hyochan.github.io/expo-iap/docs/hooks/useIAP + * See documentation at https://openiap.dev/docs/setup/expo#useIAP-hook */ export function useIAP(options?: UseIAPOptions): UseIap { const [connected, setConnected] = useState(false); @@ -281,7 +281,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { * @remarks This is a regular promise-based call. Don't confuse with `request*` APIs * (`requestPurchase`), which are event-based. * - * @see {@link https://www.openiap.dev/docs/apis/fetch-products} + * @see {@link https://openiap.dev/docs/apis/fetch-products} */ const fetchProductsInternal = useCallback( async (params: { @@ -399,7 +399,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/get-available-purchases} + * @see {@link https://openiap.dev/docs/apis/get-available-purchases} */ const getAvailablePurchasesInternal = useCallback( async (options?: PurchaseOptions): Promise => { @@ -423,7 +423,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { /** * Get details of all currently active subscriptions. * - * @see {@link https://www.openiap.dev/docs/apis/get-active-subscriptions} + * @see {@link https://openiap.dev/docs/apis/get-active-subscriptions} */ const getActiveSubscriptionsInternal = useCallback( async (subscriptionIds?: string[]): Promise => { @@ -442,7 +442,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { /** * Check whether the user has any active subscription. * - * @see {@link https://www.openiap.dev/docs/apis/has-active-subscriptions} + * @see {@link https://openiap.dev/docs/apis/has-active-subscriptions} */ const hasActiveSubscriptionsInternal = useCallback( async (subscriptionIds?: string[]): Promise => { @@ -477,7 +477,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { * @remarks **Critical:** Android purchases must be finalized within 3 days or Google * auto-refunds. iOS unfinished transactions replay on every app launch. * - * @see {@link https://www.openiap.dev/docs/apis/finish-transaction} + * @see {@link https://openiap.dev/docs/apis/finish-transaction} */ const finishTransaction = useCallback( async ({ @@ -520,7 +520,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { * @remarks Event-based. Listen for the result via {@link purchaseUpdatedListener} / * {@link purchaseErrorListener}, or use `useIAP({ onPurchaseSuccess, onPurchaseError })`. * - * @see {@link https://www.openiap.dev/docs/apis/request-purchase} + * @see {@link https://openiap.dev/docs/apis/request-purchase} */ const requestPurchaseWithReset = useCallback( (requestObj: MutationRequestPurchaseArgs) => { @@ -551,7 +551,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { /** * Restore non-consumable and active subscription purchases. * - * @see {@link https://www.openiap.dev/docs/apis/restore-purchases} + * @see {@link https://openiap.dev/docs/apis/restore-purchases} */ const restorePurchasesInternal = useCallback( async (options?: PurchaseOptions): Promise => { @@ -580,7 +580,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { /** * Deprecated. Use verifyPurchase instead — same input/output shape. * - * @see {@link https://www.openiap.dev/docs/apis/validate-receipt} + * @see {@link https://openiap.dev/docs/apis/validate-receipt} */ const validateReceipt = useCallback(async (props: VerifyPurchaseProps) => { return validateReceiptInternal(props); @@ -589,7 +589,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { /** * Verify a purchase against your own backend (returns isValid + raw store metadata). * - * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase} + * @see {@link https://openiap.dev/docs/features/validation#verify-purchase} */ const verifyPurchase = useCallback(async (props: VerifyPurchaseProps) => { return verifyPurchaseInternal(props); @@ -598,7 +598,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { /** * Verify via a managed provider — currently only `iapkit` (IAPKit). The PurchaseVerificationProvider enum exposes no other provider literal today. * - * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider} + * @see {@link https://openiap.dev/docs/features/validation#verify-purchase-with-provider} */ const verifyPurchaseWithProvider = useCallback( async (props: VerifyPurchaseWithProviderProps) => { diff --git a/libraries/flutter_inapp_purchase/CLAUDE.md b/libraries/flutter_inapp_purchase/CLAUDE.md index f7262d65..150cca65 100644 --- a/libraries/flutter_inapp_purchase/CLAUDE.md +++ b/libraries/flutter_inapp_purchase/CLAUDE.md @@ -143,7 +143,7 @@ This project uses Codecov with two checks: **codecov/patch** (new/modified lines ### API Method Naming - Functions that depend on event results should use `request` prefix (e.g., `requestPurchase`, `requestPurchaseWithBuilder`) -- Follow OpenIAP terminology: +- Follow OpenIAP terminology: - Do not use generic prefixes like `get`, `find` - refer to the official terminology ## IAP-Specific Guidelines @@ -152,10 +152,10 @@ This project uses Codecov with two checks: **codecov/patch** (new/modified lines All implementations must follow the OpenIAP specification: -- **APIs**: -- **Types**: -- **Events**: -- **Errors**: +- **APIs**: +- **Types**: +- **Events**: +- **Errors**: ### Feature Development Process diff --git a/libraries/flutter_inapp_purchase/CONTRIBUTING.md b/libraries/flutter_inapp_purchase/CONTRIBUTING.md index f633d1e3..ba66eaaa 100644 --- a/libraries/flutter_inapp_purchase/CONTRIBUTING.md +++ b/libraries/flutter_inapp_purchase/CONTRIBUTING.md @@ -112,45 +112,30 @@ flutter run ### Android: Use local openiap-google for debugging (optional) -By default, this plugin depends on the published artifact: +By default, this plugin depends on the published artifact version from +`openiap-versions.json`: ``` -implementation "io.github.hyochan.openiap:openiap-google:1.1.12" +implementation "io.github.hyochan.openiap:openiap-google:${openiapGoogleVersion}" ``` -If you need to debug against a local checkout of the OpenIAP Android module: +If you need to debug against the monorepo OpenIAP Android module: -1. Clone the module - - ``` - git clone https://github.com/hyodotdev/openiap-google - ``` - -2. Point Gradle to the local module (uncomment/edit paths) +1. Point Gradle to the local module. Edit `android/settings.gradle` and uncomment the lines, updating the path: ``` include ':openiap' - project(':openiap').projectDir = new File('/Users/you/path/to/openiap-google/openiap') - ``` - -3. Switch the dependency for debug builds - - Edit `android/build.gradle` dependencies to use the local project in debug only: - - ``` - // implementation "io.github.hyochan.openiap:openiap-google:1.1.12" - debugImplementation project(":openiap") - releaseImplementation "io.github.hyochan.openiap:openiap-google:1.1.12" + project(':openiap').projectDir = new File(settingsDir, '../../../packages/google/openiap') ``` -4. Sync and run +2. Sync and run. Run a Gradle sync from Android Studio or rebuild the Flutter module. - To revert, comment out the include lines in `settings.gradle` and restore the single - `implementation "io.github.hyochan.openiap:openiap-google:1.1.12"` line in `android/build.gradle`. + To revert, comment out the include lines in `settings.gradle`. No + `android/build.gradle` dependency changes are needed. ### 5. Commit Your Changes @@ -206,7 +191,7 @@ Please refer to [CLAUDE.md](./CLAUDE.md) for: ## Questions or Issues? -- For new feature proposals, start a discussion at: +- For new feature proposals, start a discussion at: - For bugs, open an issue with a clear description and reproduction steps - For questions, feel free to open a discussion diff --git a/libraries/flutter_inapp_purchase/README.md b/libraries/flutter_inapp_purchase/README.md index cbf22df9..1ad1ed64 100644 --- a/libraries/flutter_inapp_purchase/README.md +++ b/libraries/flutter_inapp_purchase/README.md @@ -1,7 +1,7 @@ # flutter_inapp_purchase
- flutter_inapp_purchase logo + flutter_inapp_purchase logo [![Pub Version](https://img.shields.io/pub/v/flutter_inapp_purchase.svg?style=flat-square)](https://pub.dartlang.org/packages/flutter_inapp_purchase) [![Flutter CI](https://github.com/hyodotdev/openiap/actions/workflows/ci.yml/badge.svg)](https://github.com/hyodotdev/openiap/actions/workflows/ci.yml) [![OpenIAP](https://img.shields.io/badge/OpenIAP-Compliant-green?style=flat-square)](https://openiap.dev) [![Coverage Status](https://codecov.io/gh/hyodotdev/openiap/branch/main/graph/badge.svg?token=WXBlKvRB2G)](https://codecov.io/gh/hyodotdev/openiap) ![License](https://img.shields.io/badge/license-MIT-blue.svg) @@ -13,15 +13,17 @@ ## 📚 Documentation -**[📖 Visit our comprehensive documentation site →](https://hyochan.github.io/flutter_inapp_purchase)** +**[📖 Visit our comprehensive documentation site →](https://openiap.dev/docs/setup/flutter)** ## 📦 Installation -```yaml -dependencies: - flutter_inapp_purchase: ^8.0.0 +```bash +flutter pub add flutter_inapp_purchase ``` +For manual `pubspec.yaml` edits, copy the current dependency from the +[flutter_inapp_purchase pub.dev package page](https://pub.dev/packages/flutter_inapp_purchase). + ## 🔧 Quick Start ### Basic Usage @@ -56,11 +58,11 @@ await iap.requestPurchaseWithBuilder( flutter_inapp_purchase provides AI-friendly documentation for Cursor, GitHub Copilot, Claude, and ChatGPT. -**[AI Assistants Guide](https://hyochan.github.io/flutter_inapp_purchase/docs/guides/ai-assistants)** +**[AI Assistants Guide](https://openiap.dev/docs/guides/ai-assistants)** Quick links: -- [llms.txt](https://hyochan.github.io/flutter_inapp_purchase/llms.txt) - Quick reference -- [llms-full.txt](https://hyochan.github.io/flutter_inapp_purchase/llms-full.txt) - Full API reference +- [llms.txt](https://openiap.dev/llms.txt) - Quick reference +- [llms-full.txt](https://openiap.dev/llms-full.txt) - Full API reference ## Development diff --git a/libraries/flutter_inapp_purchase/android/build.gradle b/libraries/flutter_inapp_purchase/android/build.gradle index fe0dc3ac..96c2716f 100644 --- a/libraries/flutter_inapp_purchase/android/build.gradle +++ b/libraries/flutter_inapp_purchase/android/build.gradle @@ -1,4 +1,5 @@ import groovy.json.JsonSlurper +import org.jetbrains.kotlin.gradle.dsl.JvmTarget static File locateOpeniapVersionsFile(File pluginDir, File hostProjectRoot) { // First try to find in host app's root directory @@ -20,22 +21,89 @@ static File locateOpeniapVersionsFile(File pluginDir, File hostProjectRoot) { ) } +static String readPubspecVersion(File pubspecFile) { + if (!pubspecFile.isFile()) { + throw new GradleException("flutter_inapp_purchase: Unable to locate pubspec.yaml") + } + def matcher = pubspecFile.text =~ /(?m)^version:\s*([^\s#]+)/ + if (!matcher.find()) { + throw new GradleException("flutter_inapp_purchase: 'version' missing in pubspec.yaml") + } + return matcher.group(1).trim() +} + +static String readRequiredAndroidGradleProperty(File androidDir, String propertyName) { + File propertiesFile = new File(androidDir, 'gradle.properties') + if (!propertiesFile.isFile()) { + throw new GradleException("flutter_inapp_purchase: missing android/gradle.properties") + } + + Properties properties = new Properties() + propertiesFile.withInputStream { properties.load(it) } + String value = properties.getProperty(propertyName) + if (value == null || value.trim().isEmpty()) { + throw new GradleException("flutter_inapp_purchase: missing ${propertyName} in android/gradle.properties") + } + return value.trim() +} + def openiapVersionsFile = locateOpeniapVersionsFile(buildscript.sourceFile.parentFile.parentFile, project.rootProject.rootDir) def openiapVersions = new JsonSlurper().parse(openiapVersionsFile) def openiapGoogleVersion = openiapVersions['google'] +if (!(openiapGoogleVersion instanceof String) || !openiapGoogleVersion.trim()) { + throw new GradleException("flutter_inapp_purchase: 'google' version missing or invalid in openiap-versions.json") +} +openiapGoogleVersion = openiapGoogleVersion.trim() +def flutterPackageVersion = readPubspecVersion(new File(projectDir.parentFile, 'pubspec.yaml')) -group 'io.github.hyochan.flutter_inapp_purchase' -version '1.0-SNAPSHOT' +group = 'io.github.hyochan.flutter_inapp_purchase' +version = flutterPackageVersion buildscript { - ext.kotlin_version = '2.0.21' + def locateGoogleRootBuildFile = { File startDir -> + File current = startDir + while (current != null) { + File candidate = new File(current, 'packages/google/build.gradle.kts') + if (candidate.isFile()) { + return candidate + } + current = current.parentFile + } + return null + } + + def readGradleProperty = { String propertyName -> + File propertiesFile = new File(projectDir, 'gradle.properties') + if (!propertiesFile.isFile()) { + return null + } + Properties properties = new Properties() + propertiesFile.withInputStream { properties.load(it) } + return properties.getProperty(propertyName) + } + + def googlePluginVersion = { String pluginId -> + File googleRootBuildFile = locateGoogleRootBuildFile(projectDir) + if (googleRootBuildFile == null) { + return null + } + String escapedPluginId = java.util.regex.Pattern.quote(pluginId) + def matcher = googleRootBuildFile.text =~ /id\("${escapedPluginId}"\) version "([^"]+)"/ + return matcher.find() ? matcher.group(1) : null + } + + def androidGradlePluginVersion = googlePluginVersion('com.android.library') + ?: readRequiredAndroidGradleProperty(projectDir, 'openIapAndroidGradlePluginVersion') + ext.kotlin_version = googlePluginVersion('org.jetbrains.kotlin.android') + ?: readRequiredAndroidGradleProperty(projectDir, 'openIapKotlinVersion') + repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.1.4' + classpath "com.android.tools.build:gradle:$androidGradlePluginVersion" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -50,18 +118,25 @@ rootProject.allprojects { apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +apply from: project.file('openiap-android-sdk.gradle') + +def openIapCompileSdkVersion = openIapResolveAndroidSdkVersion('compileSdkVersion', 'compileSdk', 35) +def openIapMinSdkVersion = openIapResolveAndroidSdkVersion('minSdkVersion', 'minSdk', 23) +def openIapTargetSdkVersion = openIapResolveAndroidSdkVersion('targetSdkVersion', 'compileSdk', 35) + android { if (project.android.hasProperty('namespace')) { - namespace 'io.github.hyochan.flutter_inapp_purchase' + namespace = 'io.github.hyochan.flutter_inapp_purchase' } - compileSdkVersion 34 + compileSdk = openIapCompileSdkVersion // Read horizonEnabled from gradle.properties, default to false (play) def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false defaultConfig { - minSdkVersion 21 - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + minSdkVersion = openIapMinSdkVersion + targetSdkVersion = openIapTargetSdkVersion + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" // Use horizonEnabled to determine platform flavor def flavor = horizonEnabled ? 'horizon' : 'play' @@ -78,10 +153,6 @@ android { targetCompatibility JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = '17' - } - flavorDimensions "platform" productFlavors { // Play flavor - Google Play Billing (default) @@ -96,6 +167,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + dependencies { // In monorepo: use local packages/google source if available def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false @@ -109,10 +186,9 @@ dependencies { } // Amazon IAP for legacy Amazon Appstore support (runtime device detection) - implementation files('jars/in-app-purchasing-2.0.76.jar') - implementation 'androidx.annotation:annotation:1.6.0' + implementation files("jars/${readRequiredAndroidGradleProperty(projectDir, 'openIapAmazonIapJarFile')}") + implementation "androidx.annotation:annotation:${readRequiredAndroidGradleProperty(projectDir, 'openIapAndroidAnnotationVersion')}" - // Google Play Billing for direct API usage (only in play flavor) - // Note: This is already included in openiap, but kept for backward compatibility - add("playCompileOnly", "com.android.billingclient:billing-ktx:8.0.0") + // Google Play Billing comes transitively from openiap-google so its + // version stays centralized in packages/google. } diff --git a/libraries/flutter_inapp_purchase/android/gradle.properties b/libraries/flutter_inapp_purchase/android/gradle.properties index 53ae0ae4..f2d8f9cf 100644 --- a/libraries/flutter_inapp_purchase/android/gradle.properties +++ b/libraries/flutter_inapp_purchase/android/gradle.properties @@ -1,3 +1,8 @@ android.enableJetifier=true android.useAndroidX=true org.gradle.jvmargs=-Xmx1536M +openIapAndroidGradlePluginVersion=8.13.2 +openIapKotlinVersion=2.2.0 +openIapAndroidAnnotationVersion=1.6.0 +openIapAmazonIapJarFile=in-app-purchasing-2.0.76.jar +openIapJunitVersion=4.13.2 diff --git a/libraries/flutter_inapp_purchase/android/gradle/wrapper/gradle-wrapper.properties b/libraries/flutter_inapp_purchase/android/gradle/wrapper/gradle-wrapper.properties index bdc9a83b..c6f00302 100644 --- a/libraries/flutter_inapp_purchase/android/gradle/wrapper/gradle-wrapper.properties +++ b/libraries/flutter_inapp_purchase/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/libraries/flutter_inapp_purchase/android/openiap-android-sdk.gradle b/libraries/flutter_inapp_purchase/android/openiap-android-sdk.gradle new file mode 100644 index 00000000..7d3e5535 --- /dev/null +++ b/libraries/flutter_inapp_purchase/android/openiap-android-sdk.gradle @@ -0,0 +1,60 @@ +ext.openIapFindGoogleOpenIapBuildFile = { + File current = projectDir + while (current != null) { + File candidate = new File(current, 'packages/google/openiap/build.gradle.kts') + if (candidate.isFile()) { + return candidate + } + current = current.parentFile + } + return null +} + +ext.openIapReadGoogleAndroidSdkVersion = { String propertyName -> + File buildFile = openIapFindGoogleOpenIapBuildFile() + if (buildFile == null) { + return null + } + def matcher = buildFile.text =~ /(?m)^\s*${propertyName}\s*=\s*(\d+)\s*$/ + return matcher.find() ? matcher.group(1).toInteger() : null +} + +ext.openIapReadGoogleDependencyVersion = { String coordinate -> + File buildFile = openIapFindGoogleOpenIapBuildFile() + if (buildFile == null) { + return null + } + def matcher = buildFile.text =~ /${java.util.regex.Pattern.quote(coordinate)}:([^"$)]+)/ + return matcher.find() ? matcher.group(1) : null +} + +ext.openIapToIntegerVersion = { Object value, String label -> + if (value instanceof Number) { + return value.toInteger() + } + if (value instanceof CharSequence && value.toString() ==~ /\d+/) { + return value.toString().toInteger() + } + throw new GradleException("flutter_inapp_purchase: ${label} must be an integer, got ${value}") +} + +ext.openIapResolveAndroidSdkVersion = { String extName, String googlePropertyName, int fallback -> + if (rootProject.ext.has(extName)) { + return openIapToIntegerVersion(rootProject.ext.get(extName), extName) + } + def googleValue = openIapReadGoogleAndroidSdkVersion(googlePropertyName) + return googleValue ?: fallback +} + +ext.openIapResolveDependencyVersion = { String coordinate, String fallbackPropertyName -> + def googleValue = openIapReadGoogleDependencyVersion(coordinate) + if (googleValue) { + return googleValue + } + + def fallbackValue = rootProject.findProperty(fallbackPropertyName) ?: project.findProperty(fallbackPropertyName) + if (fallbackValue == null || fallbackValue.toString().trim().isEmpty()) { + throw new GradleException("flutter_inapp_purchase: missing ${fallbackPropertyName} in android/gradle.properties") + } + return fallbackValue.toString().trim() +} diff --git a/libraries/flutter_inapp_purchase/android/settings.gradle b/libraries/flutter_inapp_purchase/android/settings.gradle index 8884e4ba..961a10e4 100644 --- a/libraries/flutter_inapp_purchase/android/settings.gradle +++ b/libraries/flutter_inapp_purchase/android/settings.gradle @@ -1,11 +1,7 @@ rootProject.name = 'flutter_inapp_purchase' -// Optional: include local openiap-google module for debugging -// 1) git clone https://github.com/hyodotdev/openiap-google -// 2) Update path below to your local checkout -// 3) In android/build.gradle, switch dependency to: -// implementation project(":openiap") -// (and comment out the Maven Central dependency) +// Optional: include the monorepo openiap-google module for debugging. +// android/build.gradle automatically uses project(":openiap") when it exists. // // include ':openiap' -// project(':openiap').projectDir = new File('/path/to/openiap/packages/google/openiap') +// project(':openiap').projectDir = new File(settingsDir, '../../../packages/google/openiap') diff --git a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AmazonInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AmazonInappPurchasePlugin.kt index 21e027c2..86e0957c 100644 --- a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AmazonInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AmazonInappPurchasePlugin.kt @@ -15,11 +15,11 @@ import org.json.JSONObject /** AmazonInappPurchasePlugin */ class AmazonInappPurchasePlugin : MethodCallHandler { - private val TAG = "InappPurchasePlugin" private var safeResult: MethodResultWrapper? = null private var channel: MethodChannel? = null private var context: Context? = null private var activity: Activity? = null + fun setContext(context: Context?) { this.context = context } @@ -32,110 +32,136 @@ class AmazonInappPurchasePlugin : MethodCallHandler { this.channel = channel } + fun dispose() { + safeResult = null + channel = null + context = null + activity = null + } + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - if(call.method == "getStore"){ + if (call.method == "getStore") { result.success(FlutterInappPurchasePlugin.getStore()) return } val ch = channel if (ch == null) { - Log.e(TAG, "onMethodCall received for ${call.method} but channel is null. Cannot send result.") + logError("onMethodCall received for ${call.method} but channel is null. Cannot send result.") result.error("E_CHANNEL_NULL", "MethodChannel is not attached", null) return } - safeResult = MethodResultWrapper(result, ch) + val ctx = context + if (ctx == null) { + logError("onMethodCall received for ${call.method} but context is null.") + result.error("E_CONTEXT_NULL", "Context is not attached", null) + return + } + + val safe = MethodResultWrapper(result, ch) + safeResult = safe try { - PurchasingService.registerListener(context, purchasesUpdatedListener) + PurchasingService.registerListener(ctx, purchasesUpdatedListener) } catch (e: Exception) { - safeResult!!.error( + safe.error( call.method, "Call endConnection method if you want to start over.", e.message ) + return } when (call.method) { "initConnection" -> { PurchasingService.getUserData() - safeResult!!.success("Billing client ready") + safe.success("Billing client ready") } "endConnection" -> { - safeResult!!.success("Billing client has ended.") + safe.success("Billing client has ended.") + } + "setPurchaseUpdatedListenerOptions" -> { + safe.success(null) } "isReady" -> { - safeResult!!.success(true) + safe.success(true) } "showInAppMessages" -> { - safeResult!!.success("in app messages not supported for amazon") + safe.success("in app messages not supported for amazon") } "getAvailableItemsByType" -> { - val type = call.argument("type") - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "gaibt=$type") + val type = normalizeProductType(call.argument("type")) + logDebug("gaibt=$type") // NOTE: getPurchaseUpdates doesnt return Consumables which are FULFILLED if (type == "inapp") { PurchasingService.getPurchaseUpdates(true) } else if (type == "subs") { // Subscriptions are retrieved during inapp, so we just return empty list - safeResult!!.success("[]") + safe.success("[]") } else { - safeResult!!.notImplemented() + safe.notImplemented() } } "getPurchaseHistoryByType" -> { // No equivalent - safeResult!!.success("[]") + safe.success("[]") } "buyItemByType" -> { val type = call.argument("type") - //val obfuscatedAccountId = call.argument("obfuscatedAccountId") - //val obfuscatedProfileId = call.argument("obfuscatedProfileId") - val sku = call.argument("sku") + val sku: String? = call.argument("sku") + ?: call.argument("productId") + ?: call.argument>("skus")?.firstOrNull() val oldSku = call.argument("oldSku") - // TODO(v6.4.0): Remove this commented prorationMode line - //val prorationMode = call.argument("prorationMode")!! - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "type=$type||sku=$sku||oldsku=$oldSku") + logDebug("type=$type||sku=$sku||oldsku=$oldSku") + if (sku.isNullOrBlank()) { + safe.error("E_DEVELOPER_ERROR", "Missing sku", null) + return + } val requestId = PurchasingService.purchase(sku) - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "resid=$requestId") + logDebug("resid=$requestId") } "consumeProduct" -> { // consumable is a separate type in amazon - safeResult!!.success("no-ops in amazon") + safe.success("no-ops in amazon") } else -> { - safeResult!!.notImplemented() + safe.notImplemented() } } } + private fun normalizeProductType(type: String?): String { + val normalized = type?.lowercase() ?: "inapp" + return when { + normalized == "all" -> "inapp" + normalized.contains("sub") -> "subs" + else -> "inapp" + } + } + + private fun currentResult(): MethodResultWrapper? { + val result = safeResult + if (result == null) { + logWarning("Amazon IAP callback arrived without a pending method result.") + } + return result + } + private val purchasesUpdatedListener: PurchasingListener = object : PurchasingListener { override fun onUserDataResponse(userDataResponse: UserDataResponse) { - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "oudr=$userDataResponse") + logDebug("onUserDataResponse: RequestStatus (${userDataResponse.requestStatus})") } // getItemsByType override fun onProductDataResponse(response: ProductDataResponse) { - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "opdr=$response") val status = response.requestStatus - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "onProductDataResponse: RequestStatus ($status)") + logDebug("onProductDataResponse: RequestStatus ($status)") when (status) { ProductDataResponse.RequestStatus.SUCCESSFUL -> { - if (Log.isLoggable(TAG, Log.DEBUG)) { - Log.d( - TAG, - "onProductDataResponse: successful. The item data map in this response includes the valid SKUs" - ) - } + logDebug("onProductDataResponse: successful. The item data map in this response includes the valid SKUs") val productData = response.productData - //Log.d(TAG, "productData="+productData.toString()); val unavailableSkus = response.unavailableSkus - if (Log.isLoggable(TAG, Log.DEBUG)) { - Log.d( - TAG, - "onProductDataResponse: " + unavailableSkus.size + " unavailable skus" - ) - Log.d(TAG, "unavailableSkus=$unavailableSkus") - } + logDebug("onProductDataResponse: ${unavailableSkus.size} unavailable skus") + logDebug("unavailableSkus=$unavailableSkus") val items = JSONArray() try { for ((_, product) in productData) { @@ -159,31 +185,30 @@ class AmazonInappPurchasePlugin : MethodCallHandler { item.put("freeTrialPeriodAndroid", "") item.put("introductoryPriceCyclesAndroid", 0) item.put("introductoryPricePeriodAndroid", "") - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "opdr Putting $item") + logDebug("onProductDataResponse: putting sku=${product.sku}") items.put(item) } - //System.err.println("Sending "+items.toString()); - safeResult!!.success(items.toString()) + currentResult()?.success(items.toString()) } catch (e: JSONException) { - safeResult!!.error(TAG, "E_BILLING_RESPONSE_JSON_PARSE_ERROR", e.message) + currentResult()?.error(TAG, "E_BILLING_RESPONSE_JSON_PARSE_ERROR", e.message) } } ProductDataResponse.RequestStatus.FAILED -> { - safeResult!!.error(TAG, "FAILED", null) - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "onProductDataResponse: failed, should retry request") - safeResult!!.error(TAG, "NOT_SUPPORTED", null) + logDebug("onProductDataResponse: failed, should retry request") + currentResult()?.error(TAG, "FAILED", null) } ProductDataResponse.RequestStatus.NOT_SUPPORTED -> { - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "onProductDataResponse: failed, should retry request") - safeResult!!.error(TAG, "NOT_SUPPORTED", null) + logDebug("onProductDataResponse: failed, should retry request") + currentResult()?.error(TAG, "NOT_SUPPORTED", null) } } } // buyItemByType override fun onPurchaseResponse(response: PurchaseResponse) { - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "opr=$response") - when (val status = response.requestStatus) { + val status = response.requestStatus + logDebug("onPurchaseResponse: RequestStatus ($status)") + when (status) { PurchaseResponse.RequestStatus.SUCCESSFUL -> { val receipt = response.receipt PurchasingService.notifyFulfillment( @@ -199,14 +224,15 @@ class AmazonInappPurchasePlugin : MethodCallHandler { receipt.receiptId, transactionDate.toDouble() ) - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "opr Putting $item") - safeResult!!.success(item.toString()) - safeResult!!.invokeMethod("purchase-updated", item.toString()) + logDebug("onPurchaseResponse: putting sku=${receipt.sku}") + val itemJson = item.toString() + currentResult()?.success(itemJson) + currentResult()?.invokeMethod("purchase-updated", itemJson) } catch (e: JSONException) { - safeResult!!.error(TAG, "E_BILLING_RESPONSE_JSON_PARSE_ERROR", e.message) + currentResult()?.error(TAG, "E_BILLING_RESPONSE_JSON_PARSE_ERROR", e.message) } } - PurchaseResponse.RequestStatus.FAILED -> safeResult!!.error( + PurchaseResponse.RequestStatus.FAILED -> currentResult()?.error( TAG, "buyItemByType", "billingResponse is not ok: $status" @@ -217,8 +243,9 @@ class AmazonInappPurchasePlugin : MethodCallHandler { // getAvailableItemsByType override fun onPurchaseUpdatesResponse(response: PurchaseUpdatesResponse) { - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "opudr=$response") - when (response.requestStatus) { + val status = response.requestStatus + logDebug("onPurchaseUpdatesResponse: RequestStatus ($status)") + when (status) { PurchaseUpdatesResponse.RequestStatus.SUCCESSFUL -> { val items = JSONArray() try { @@ -232,22 +259,22 @@ class AmazonInappPurchasePlugin : MethodCallHandler { receipt.receiptId, transactionDate.toDouble() ) - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "opudr Putting $item") + logDebug("onPurchaseUpdatesResponse: putting sku=${receipt.sku}") items.put(item) } - safeResult!!.success(items.toString()) + currentResult()?.success(items.toString()) } catch (e: JSONException) { - safeResult!!.error(TAG, "E_BILLING_RESPONSE_JSON_PARSE_ERROR", e.message) + currentResult()?.error(TAG, "E_BILLING_RESPONSE_JSON_PARSE_ERROR", e.message) } } - PurchaseUpdatesResponse.RequestStatus.FAILED -> safeResult!!.error( + PurchaseUpdatesResponse.RequestStatus.FAILED -> currentResult()?.error( TAG, "FAILED", null ) PurchaseUpdatesResponse.RequestStatus.NOT_SUPPORTED -> { - if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "onPurchaseUpdatesResponse: failed, should retry request") - safeResult!!.error(TAG, "NOT_SUPPORTED", null) + logDebug("onPurchaseUpdatesResponse: failed, should retry request") + currentResult()?.error(TAG, "NOT_SUPPORTED", null) } } } @@ -256,16 +283,40 @@ class AmazonInappPurchasePlugin : MethodCallHandler { @Throws(JSONException::class) fun getPurchaseData( productId: String?, transactionId: String?, transactionReceipt: String?, - transactionDate: Double? + transactionDate: Double ): JSONObject { val item = JSONObject() item.put("productId", productId) item.put("transactionId", transactionId) item.put("transactionReceipt", transactionReceipt) - item.put("transactionDate", (transactionDate!!).toString()) + item.put("transactionDate", transactionDate.toString()) item.put("dataAndroid", null) item.put("signatureAndroid", null) item.put("purchaseToken", null) return item } + + companion object { + private const val TAG = "InappPurchasePlugin" + + private fun shouldLog(): Boolean = Log.isLoggable(TAG, Log.DEBUG) + + private fun logDebug(message: String) { + if (shouldLog()) { + Log.d(TAG, message) + } + } + + private fun logWarning(message: String) { + if (shouldLog()) { + Log.w(TAG, message) + } + } + + private fun logError(message: String) { + if (shouldLog()) { + Log.e(TAG, message) + } + } + } } diff --git a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt index 4926bd36..52a0a539 100644 --- a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt @@ -187,6 +187,11 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act this.error(code, resolvedMessage, null) } + private fun emitConnectionUpdated(connected: Boolean) { + val item = JSONObject().apply { put("connected", connected) } + channel?.invokeMethod("connection-updated", item.toString()) + } + fun setContext(context: Context?) { this.context = context if (context != null && openIap == null) { @@ -204,12 +209,32 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } fun onDetachedFromActivity() { + val iap = openIap + activity = null + connectionReady = false + iap?.setActivity(null) + scope.launch { + connectionMutex.withLock { + kotlin.runCatching { iap?.endConnection() } + } + } + } + + fun dispose() { + val iap = openIap + openIap = null + context = null + activity = null + channel = null + connectionReady = false + listenersAttached = false scope.launch { - kotlin.runCatching { openIap?.endConnection() } - connectionReady = false + connectionMutex.withLock { + kotlin.runCatching { iap?.endConnection() } + } + }.invokeOnCompletion { + job.cancel() } - // Cancel coroutine job to avoid leaks - job.cancel() } // ActivityLifecycleCallbacks (no-ops except for cleanup) @@ -220,8 +245,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act override fun onActivityStopped(activity: Activity) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivityDestroyed(activity: Activity) { - if (this.activity === activity && context != null) { - (context as Application).unregisterActivityLifecycleCallbacks(this) + if (this.activity === activity) { + (context as? Application)?.unregisterActivityLifecycleCallbacks(this) onDetachedFromActivity() } } @@ -246,7 +271,12 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val packageName = call.argument("packageName") scope.launch { try { - openIap?.deepLinkToSubscriptions(DeepLinkOptions(skuAndroid = sku, packageNameAndroid = packageName)) + val iap = openIap + if (iap == null) { + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") + return@launch + } + iap.deepLinkToSubscriptions(DeepLinkOptions(skuAndroid = sku, packageNameAndroid = packageName)) safe.success(true) } catch (e: Exception) { safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) @@ -257,7 +287,12 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act "openPlayStoreSubscriptions" -> { scope.launch { try { - openIap?.deepLinkToSubscriptions(DeepLinkOptions()) + val iap = openIap + if (iap == null) { + safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") + return@launch + } + iap.deepLinkToSubscriptions(DeepLinkOptions()) safe.success(true) } catch (e: Exception) { safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) @@ -287,63 +322,71 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act OpenIapLog.d(TAG, "initConnection called with config: $configMap") - attachListenersIfNeeded() - openIap?.setActivity(activity) scope.launch { - try { - // ALWAYS end connection first to reset configuration - // This ensures we start fresh regardless of current state + connectionMutex.withLock { try { - OpenIapLog.d(TAG, "Ending connection before reinitializing (current ready state: $connectionReady)") - openIap?.endConnection() - connectionReady = false + attachListenersIfNeeded() + openIap?.setActivity(activity) - // WORKAROUND: OpenIAP's endConnection() is synchronous but may trigger - // async cleanup in the background (e.g., disconnecting from Play Store). - // A small delay reduces the risk of race conditions where initConnection() - // is called before cleanup completes. This is not ideal but necessary - // until OpenIAP provides an async endConnection() or callback mechanism. - // Increase this delay if experiencing connection issues. - kotlinx.coroutines.delay(300) - } catch (e: Exception) { - OpenIapLog.w(TAG, "Error ending connection: ${e.message}") - } + // ALWAYS end connection first to reset configuration + // This ensures we start fresh regardless of current state + try { + OpenIapLog.d(TAG, "Ending connection before reinitializing (current ready state: $connectionReady)") + openIap?.endConnection() + connectionReady = false + + // WORKAROUND: OpenIAP's endConnection() is synchronous but may trigger + // async cleanup in the background (e.g., disconnecting from Play Store). + // A small delay reduces the risk of race conditions where initConnection() + // is called before cleanup completes. This is not ideal but necessary + // until OpenIAP provides an async endConnection() or callback mechanism. + // Increase this delay if experiencing connection issues. + kotlinx.coroutines.delay(300) + } catch (e: Exception) { + OpenIapLog.w(TAG, "Error ending connection: ${e.message}") + } - OpenIapLog.d(TAG, "Initializing connection with Alternative Billing mode: ${configMap.get("alternativeBillingModeAndroid") ?: "none"}") - val ok = openIap?.initConnection(newConfig) ?: false - connectionReady = ok - OpenIapLog.d(TAG, "Connection initialized: $ok") + OpenIapLog.d(TAG, "Initializing connection with Alternative Billing mode: ${configMap.get("alternativeBillingModeAndroid") ?: "none"}") + val ok = openIap?.initConnection(newConfig) ?: false + connectionReady = ok + OpenIapLog.d(TAG, "Connection initialized: $ok") - // Emit connection-updated for compatibility - val item = JSONObject().apply { put("connected", ok) } - channel?.invokeMethod("connection-updated", item.toString()) - if (ok) { - safe.success("Billing client ready") - } else { - safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "responseCode: -1") + // Emit connection-updated for compatibility + emitConnectionUpdated(ok) + if (ok) { + safe.success("Billing client ready") + } else { + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "responseCode: -1") + } + } catch (e: Exception) { + OpenIapLog.e("Error during initConnection: ${e.message}", e) + safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, e.message) } - } catch (e: Exception) { - OpenIapLog.e("Error during initConnection: ${e.message}", e) - safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, e.message) } } return } "endConnection" -> { scope.launch { - try { - OpenIapLog.d(TAG, "endConnection called") - openIap?.endConnection() - connectionReady = false - OpenIapLog.d(TAG, "Connection ended successfully") - safe.success("Billing client has ended.") - } catch (e: Exception) { - OpenIapLog.e("Error ending connection: ${e.message}", e) - safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + connectionMutex.withLock { + try { + OpenIapLog.d(TAG, "endConnection called") + openIap?.endConnection() + connectionReady = false + OpenIapLog.d(TAG, "Connection ended successfully") + safe.success("Billing client has ended.") + } catch (e: Exception) { + OpenIapLog.e("Error ending connection: ${e.message}", e) + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) + } } } return } + "setPurchaseUpdatedListenerOptions" -> { + safe.success(null) + return + } "isReady" -> { safe.success(connectionReady) return @@ -623,8 +666,10 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } } "acknowledgePurchaseAndroid" -> { - val token = call.argument("token") ?: call.argument("purchaseToken") - if (token.isNullOrBlank()) { + val token: String? = call.argument("token") + ?: call.argument("purchaseToken") + val purchaseToken = token?.takeIf { it.isNotBlank() } + if (purchaseToken == null) { safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing purchaseToken") return } @@ -635,7 +680,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } - iap.acknowledgePurchaseAndroid(token) + iap.acknowledgePurchaseAndroid(purchaseToken) val resp = JSONObject().apply { put("responseCode", 0) } safe.success(resp.toString()) } catch (e: Exception) { @@ -643,10 +688,11 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } } } - "consumePurchaseAndroid" -> { - val token = call.argument("token") ?: call.argument("purchaseToken") - if (token.isNullOrBlank()) { + val token: String? = call.argument("token") + ?: call.argument("purchaseToken") + val purchaseToken = token?.takeIf { it.isNotBlank() } + if (purchaseToken == null) { safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing purchaseToken") return } @@ -657,10 +703,10 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } - iap.consumePurchaseAndroid(token) + iap.consumePurchaseAndroid(purchaseToken) val resp = JSONObject().apply { put("responseCode", 0) - put("purchaseToken", token) + put("purchaseToken", purchaseToken) } safe.success(resp.toString()) } catch (e: Exception) { @@ -678,8 +724,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } - val isAvailable = iap.checkAlternativeBillingAvailability() - safe.success(isAvailable) + val availability = iap.isBillingProgramAvailable(BillingProgramAndroid.ExternalOffer) + safe.success(availability.isAvailable) } catch (e: Exception) { safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } @@ -698,6 +744,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, "Activity not available") return@launch } + @Suppress("DEPRECATION") val userAccepted = iap.showAlternativeBillingInformationDialog(act) safe.success(userAccepted) } catch (e: Exception) { @@ -713,8 +760,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } - val token = iap.createAlternativeBillingReportingToken() - safe.success(token) + val details = iap.createBillingProgramReportingDetails(BillingProgramAndroid.ExternalOffer) + safe.success(details.externalTransactionToken) } catch (e: Exception) { safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) } @@ -768,7 +815,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act val programStr = call.argument("billingProgram") val launchModeStr = call.argument("launchMode") val linkTypeStr = call.argument("linkType") - val linkUri = call.argument("linkUri") + val linkUri: String? = call.argument("linkUri")?.takeIf { it.isNotBlank() } scope.launch { try { @@ -782,7 +829,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, "Activity not available") return@launch } - if (linkUri.isNullOrBlank()) { + if (linkUri == null) { safe.error(OpenIapError.DeveloperError.CODE, "linkUri is required for launchExternalLinkAndroid", null) return@launch } @@ -803,8 +850,6 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act // Legacy/compat purchases queries "getAvailableItemsByType" -> { logDeprecated("getAvailableItemsByType", "Use getAvailableItems() instead") - val typeStr = call.argument("type") ?: "inapp" - val reqType = parsePurchaseType(typeStr) scope.launch { // Ensure connection for legacy path connectionMutex.withLock { @@ -814,8 +859,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act if (!connectionReady) { val ok = openIap?.initConnection(InitConnectionConfig()) ?: false connectionReady = ok - val item = JSONObject().apply { put("connected", ok) } - channel?.invokeMethod("connection-updated", item.toString()) + emitConnectionUpdated(ok) if (!ok) { safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") return@launch @@ -842,8 +886,6 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } "getPurchaseHistoryByType" -> { logDeprecated("getPurchaseHistoryByType", "Use getAvailableItems() instead") - val typeStr = call.argument("type") ?: "inapp" - val reqType = parsePurchaseType(typeStr) scope.launch { // Ensure connection for legacy path connectionMutex.withLock { @@ -853,8 +895,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act if (!connectionReady) { val ok = openIap?.initConnection(InitConnectionConfig()) ?: false connectionReady = ok - val item = JSONObject().apply { put("connected", ok) } - channel?.invokeMethod("connection-updated", item.toString()) + emitConnectionUpdated(ok) if (!ok) { safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") return@launch @@ -873,8 +914,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } // Note: As of v6.4.6+, getAvailablePurchases returns only active purchases on Android. // Purchase history (including expired/consumed items) is not supported on Android - // by the OpenIAP library. The reqType parameter is preserved for backward compatibility - // but is not used. Apps should migrate to getAvailableItems() for active purchases. + // by the OpenIAP library. Apps should migrate to getAvailableItems() for active purchases. val purchases = iap.getAvailablePurchases(null) val arr = purchasesToJsonArray(purchases) safe.success(arr.toString()) @@ -888,14 +928,15 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act "buyItemByType" -> { logDeprecated("buyItemByType", "Use requestPurchase(params) instead") val typeStr = call.argument("type") - val productId = call.argument("productId") + val productId: String? = call.argument("productId") ?: call.argument("sku") ?: call.argument>("skus")?.firstOrNull() val obfuscatedAccountId = call.argument("obfuscatedAccountId") val obfuscatedProfileId = call.argument("obfuscatedProfileId") val isOfferPersonalized = call.argument("isOfferPersonalized") ?: false - if (productId.isNullOrBlank()) { + val requestedProductId = productId?.takeIf { it.isNotBlank() } + if (requestedProductId == null) { safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing productId") return } @@ -909,16 +950,15 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act if (!connectionReady) { val ok = openIap?.initConnection(InitConnectionConfig()) ?: false connectionReady = ok - val item = JSONObject().apply { put("connected", ok) } - channel?.invokeMethod("connection-updated", item.toString()) + emitConnectionUpdated(ok) if (!ok) { safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") - return@withLock + return@launch } } } catch (e: Exception) { safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, e.message) - return@withLock + return@launch } } try { @@ -927,7 +967,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } - val skus = listOf(productId) + val skus: List = listOf(requestedProductId) val purchaseType = parsePurchaseType(typeStr) val requestProps = buildRequestPurchaseProps( type = purchaseType, @@ -951,8 +991,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act // Finish/acknowledge/consume (compat) "acknowledgePurchase" -> { logDeprecated("acknowledgePurchase", "Use acknowledgePurchaseAndroid(token) instead") - val token = call.argument("purchaseToken") - if (token.isNullOrBlank()) { + val token: String? = call.argument("purchaseToken") + val purchaseToken = token?.takeIf { it.isNotBlank() } + if (purchaseToken == null) { safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing purchaseToken") return } @@ -963,7 +1004,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } - iap.acknowledgePurchaseAndroid(token) + iap.acknowledgePurchaseAndroid(purchaseToken) val resp = JSONObject().apply { put("responseCode", 0) } safe.success(resp.toString()) } catch (e: Exception) { @@ -971,12 +1012,12 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } } } - "consumeProduct" -> { logDeprecated("consumeProduct", "Use finishTransaction(purchase, isConsumable=true) at higher-level API") - val token = call.argument("purchaseToken") - if (token.isNullOrBlank()) { + val token: String? = call.argument("purchaseToken") + val purchaseToken = token?.takeIf { it.isNotBlank() } + if (purchaseToken == null) { safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing purchaseToken") return } @@ -987,10 +1028,10 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.") return@launch } - iap.consumePurchaseAndroid(token) + iap.consumePurchaseAndroid(purchaseToken) val resp = JSONObject().apply { put("responseCode", 0) - put("purchaseToken", token) + put("purchaseToken", purchaseToken) } safe.success(resp.toString()) } catch (e: Exception) { @@ -1000,8 +1041,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } "consumePurchase" -> { logDeprecated("consumePurchase", "Use finishTransaction(purchase, isConsumable=true) at higher-level API") - val token = call.argument("purchaseToken") - if (token.isNullOrBlank()) { + val token: String? = call.argument("purchaseToken") + val purchaseToken = token?.takeIf { it.isNotBlank() } + if (purchaseToken == null) { safe.error(OpenIapError.DeveloperError.CODE, OpenIapError.DeveloperError.MESSAGE, "Missing purchaseToken") return } @@ -1012,7 +1054,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act safe.success(false) return@launch } - iap.consumePurchaseAndroid(token) + iap.consumePurchaseAndroid(purchaseToken) safe.success(true) } catch (e: Exception) { safe.success(false) @@ -1021,9 +1063,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } - // No-op legacy (Deprecated — will be removed in 7.0.0) + // No-op legacy endpoint kept for compatibility until the next major cleanup. "showInAppMessages" -> { - logDeprecated("showInAppMessages", "No-op; removed in 7.0.0") + logDeprecated("showInAppMessages", "No-op legacy endpoint.") safe.success(true) } @@ -1189,9 +1231,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } } - @Deprecated("Deprecated channel endpoint; will be removed in 7.0.0") private fun logDeprecated(name: String, message: String) { - OpenIapLog.w(TAG, "[$name] is deprecated and will be removed in 7.0.0. $message") + OpenIapLog.w(TAG, "[$name] is deprecated and will be removed in a future major version. $message") } /** @@ -1213,8 +1254,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act if (autoInit) { val ok = openIap?.initConnection(InitConnectionConfig()) ?: false connectionReady = ok - val item = JSONObject().apply { put("connected", ok) } - channel?.invokeMethod("connection-updated", item.toString()) + emitConnectionUpdated(ok) if (!ok) { safe.error(OpenIapError.InitConnection.CODE, OpenIapError.InitConnection.MESSAGE, "Failed to initialize connection") return @@ -1234,8 +1274,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act private fun attachListenersIfNeeded() { if (listenersAttached) return - listenersAttached = true - openIap?.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p -> + val iap = openIap ?: return + iap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p -> scope.launch { try { val payload = JSONObject(p.toJson()) @@ -1245,26 +1285,17 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } } }) - openIap?.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e -> + iap.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e -> scope.launch { try { - val payload = when (e) { - is OpenIapError -> JSONObject(e.toJSON()) - else -> JSONObject( - mapOf( - "code" to OpenIapError.PurchaseFailed.CODE, - "message" to (e.message ?: "Purchase error"), - "platform" to "android" - ) - ) - } + val payload = JSONObject(e.toJSON()) channel?.invokeMethod("purchase-error", payload.toString()) } catch (ex: Exception) { OpenIapLog.e("Failed to send purchase-error", ex) } } }) - openIap?.addUserChoiceBillingListener { details -> + iap.addUserChoiceBillingListener { details -> scope.launch { try { val payload = JSONObject(details.toJson()) @@ -1274,7 +1305,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } } } - openIap?.addDeveloperProvidedBillingListener(OpenIapDeveloperProvidedBillingListener { details -> + iap.addDeveloperProvidedBillingListener(OpenIapDeveloperProvidedBillingListener { details -> scope.launch { try { val payload = JSONObject(details.toJson()) @@ -1284,7 +1315,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } } }) - openIap?.addSubscriptionBillingIssueListener( + iap.addSubscriptionBillingIssueListener( dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener { purchase -> scope.launch { try { @@ -1296,11 +1327,11 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act } } ) + listenersAttached = true } companion object { private const val TAG = "InappPurchasePlugin" - private const val PLAY_STORE_URL = "https://play.google.com/store/account/subscriptions" private const val KEY_REQUEST_SUBSCRIPTION = "requestSubscription" private const val KEY_REQUEST_PURCHASE = "requestPurchase" diff --git a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/FlutterInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/FlutterInappPurchasePlugin.kt index 633b302e..18daa3ea 100644 --- a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/FlutterInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/FlutterInappPurchasePlugin.kt @@ -3,6 +3,8 @@ package io.github.hyochan.flutter_inapp_purchase import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import android.content.Context +import android.os.Build +import android.util.Log import io.flutter.plugin.common.MethodChannel import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding import io.flutter.plugin.common.BinaryMessenger @@ -35,42 +37,43 @@ class FlutterInappPurchasePlugin : FlutterPlugin, ActivityAware { // If neither Play Store nor Amazon is detected, default to Android (for Horizon and other stores) // This allows openiap to handle different billing implementations via flavors if (!isAndroid && !isAmazon) { - android.util.Log.i("FlutterInappPurchase", "No Play Store or Amazon detected - defaulting to Android plugin (supports Horizon and other stores)") + logInfo("No Play Store or Amazon detected - defaulting to Android plugin (supports Horizon and other stores)") isAndroid = true } - channel = MethodChannel(messenger, "flutter_inapp") + val methodChannel = MethodChannel(messenger, "flutter_inapp") + channel = methodChannel if (isAndroid) { - android.util.Log.i("FlutterInappPurchase", "Initializing Android IAP plugin") + logInfo("Initializing Android IAP plugin") val plugin = AndroidInappPurchasePlugin() plugin.setContext(context) - plugin.setChannel(channel) + plugin.setChannel(methodChannel) androidInappPurchasePlugin = plugin - channel!!.setMethodCallHandler(plugin) + methodChannel.setMethodCallHandler(plugin) } else if (isAmazon) { - android.util.Log.i("FlutterInappPurchase", "Initializing Amazon IAP plugin") - amazonInappPurchasePlugin = AmazonInappPurchasePlugin() - amazonInappPurchasePlugin!!.setContext(context) - amazonInappPurchasePlugin!!.setChannel(channel) - channel!!.setMethodCallHandler(amazonInappPurchasePlugin) + logInfo("Initializing Amazon IAP plugin") + val plugin = AmazonInappPurchasePlugin() + plugin.setContext(context) + plugin.setChannel(methodChannel) + amazonInappPurchasePlugin = plugin + methodChannel.setMethodCallHandler(plugin) } } override fun onDetachedFromEngine(binding: FlutterPluginBinding) { - channel!!.setMethodCallHandler(null) + channel?.setMethodCallHandler(null) + androidInappPurchasePlugin?.dispose() + amazonInappPurchasePlugin?.dispose() + androidInappPurchasePlugin = null + amazonInappPurchasePlugin = null channel = null - if (isAndroid) { - androidInappPurchasePlugin?.setChannel(null) - } else if (isAmazon) { - amazonInappPurchasePlugin!!.setChannel(null) - } } override fun onAttachedToActivity(binding: ActivityPluginBinding) { if (isAndroid) { androidInappPurchasePlugin?.setActivity(binding.activity) } else if (isAmazon) { - amazonInappPurchasePlugin!!.setActivity(binding.activity) + amazonInappPurchasePlugin?.setActivity(binding.activity) } } @@ -79,7 +82,7 @@ class FlutterInappPurchasePlugin : FlutterPlugin, ActivityAware { androidInappPurchasePlugin?.setActivity(null) androidInappPurchasePlugin?.onDetachedFromActivity() } else if (isAmazon) { - amazonInappPurchasePlugin!!.setActivity(null) + amazonInappPurchasePlugin?.setActivity(null) } } @@ -88,23 +91,30 @@ class FlutterInappPurchasePlugin : FlutterPlugin, ActivityAware { } override fun onDetachedFromActivityForConfigChanges() { - onDetachedFromActivity() - } - - private fun setAndroidInappPurchasePlugin(androidInappPurchasePlugin: AndroidInappPurchasePlugin) { - this.androidInappPurchasePlugin = androidInappPurchasePlugin - } - - private fun setAmazonInappPurchasePlugin(amazonInappPurchasePlugin: AmazonInappPurchasePlugin) { - this.amazonInappPurchasePlugin = amazonInappPurchasePlugin + if (isAndroid) { + androidInappPurchasePlugin?.setActivity(null) + } else if (isAmazon) { + amazonInappPurchasePlugin?.setActivity(null) + } } companion object { + private const val TAG = "FlutterInappPurchase" private var isAndroid = false private var isAmazon = false + private fun logInfo(message: String) { + if (Log.isLoggable(TAG, Log.INFO)) { + Log.i(TAG, message) + } + } + fun getStore(): String { - return if (!isAndroid && !isAmazon) "none" else if (isAndroid) "play_store" else "amazon" + return when { + isAndroid -> "play_store" + isAmazon -> "amazon" + else -> "none" + } } private fun isPackageInstalled(ctx: Context, packageName: String): Boolean { @@ -117,7 +127,12 @@ class FlutterInappPurchasePlugin : FlutterPlugin, ActivityAware { } fun isAppInstalledFrom(ctx: Context, installer: String?): Boolean { - val installerPackageName = ctx.packageManager.getInstallerPackageName(ctx.packageName) + val installerPackageName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ctx.packageManager.getInstallSourceInfo(ctx.packageName).installingPackageName + } else { + @Suppress("DEPRECATION") + ctx.packageManager.getInstallerPackageName(ctx.packageName) + } return installer != null && installerPackageName != null && installerPackageName.contains( installer ) diff --git a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/MethodResultWrapper.kt b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/MethodResultWrapper.kt index 5c788331..1c0ff717 100644 --- a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/MethodResultWrapper.kt +++ b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/MethodResultWrapper.kt @@ -1,8 +1,9 @@ package io.github.hyochan.flutter_inapp_purchase import android.os.Handler -import io.flutter.plugin.common.MethodChannel import android.os.Looper +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.atomic.AtomicBoolean // MethodChannel.Result wrapper that responds on the platform thread. class MethodResultWrapper internal constructor( @@ -10,33 +11,26 @@ class MethodResultWrapper internal constructor( private val safeChannel: MethodChannel ) : MethodChannel.Result { private val handler: Handler = Handler(Looper.getMainLooper()) - private var exhausted: Boolean = false + private val exhausted = AtomicBoolean(false) override fun success(result: Any?) { - if (!exhausted) { - exhausted = true - + if (exhausted.compareAndSet(false, true)) { handler.post { safeResult.success(result) } } } override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { - if (!exhausted) { - exhausted = true - + if (exhausted.compareAndSet(false, true)) { handler.post { safeResult.error(errorCode, errorMessage, errorDetails) } } } override fun notImplemented() { - if (!exhausted) { - exhausted = true - + if (exhausted.compareAndSet(false, true)) { handler.post { safeResult.notImplemented() } } } - fun invokeMethod(method: String?, arguments: Any?) { - handler.post { safeChannel.invokeMethod(method!!, arguments, null) } + fun invokeMethod(method: String, arguments: Any?) { + handler.post { safeChannel.invokeMethod(method, arguments, null) } } - -} \ No newline at end of file +} diff --git a/libraries/flutter_inapp_purchase/example/android/app/build.gradle b/libraries/flutter_inapp_purchase/example/android/app/build.gradle index d47fadb8..3e3c4168 100644 --- a/libraries/flutter_inapp_purchase/example/android/app/build.gradle +++ b/libraries/flutter_inapp_purchase/example/android/app/build.gradle @@ -1,9 +1,20 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id "com.android.application" id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" } +apply from: rootProject.file('../../android/openiap-android-sdk.gradle') + +def openIapCompileSdkVersion = openIapResolveAndroidSdkVersion('compileSdkVersion', 'compileSdk', 35) +def openIapMinSdkVersion = openIapResolveAndroidSdkVersion('minSdkVersion', 'minSdk', 23) +def openIapTargetSdkVersion = openIapResolveAndroidSdkVersion('targetSdkVersion', 'compileSdk', 35) +def openIapJunitVersion = openIapResolveDependencyVersion('junit:junit', 'openIapJunitVersion') +def openIapAndroidTestRunnerVersion = openIapResolveDependencyVersion('androidx.test:runner', 'openIapAndroidTestRunnerVersion') +def openIapEspressoCoreVersion = openIapResolveDependencyVersion('androidx.test.espresso:espresso-core', 'openIapEspressoCoreVersion') + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -23,17 +34,13 @@ if (flutterVersionName == null) { } android { - namespace 'dev.hyo.martie' - compileSdkVersion 34 - ndkVersion "27.0.12077973" + namespace = 'dev.hyo.martie' + compileSdk = openIapCompileSdkVersion + ndkVersion = "27.0.12077973" compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } lintOptions { @@ -42,12 +49,12 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "dev.hyo.martie" - minSdkVersion flutter.minSdkVersion - targetSdkVersion 34 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + applicationId = "dev.hyo.martie" + minSdkVersion = openIapMinSdkVersion + targetSdkVersion = openIapTargetSdkVersion + versionCode = flutterVersionCode.toInteger() + versionName = flutterVersionName + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" // Read horizonEnabled flag from gradle.properties (default: false) def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false @@ -68,17 +75,23 @@ android { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug + signingConfig = signingConfigs.debug } } } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + flutter { - source '../..' + source = '../..' } dependencies { - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test:runner:1.5.2' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + testImplementation "junit:junit:$openIapJunitVersion" + androidTestImplementation "androidx.test:runner:$openIapAndroidTestRunnerVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$openIapEspressoCoreVersion" } diff --git a/libraries/flutter_inapp_purchase/example/android/app/src/main/AndroidManifest.xml b/libraries/flutter_inapp_purchase/example/android/app/src/main/AndroidManifest.xml index 14615d64..a0802682 100644 --- a/libraries/flutter_inapp_purchase/example/android/app/src/main/AndroidManifest.xml +++ b/libraries/flutter_inapp_purchase/example/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - + + + 8.3.0 + 2.10.1 + 2.2.10 + 1.9.0.3 + 3.0.0 + 3.1.8 + 3.1.8 + 18.5.0 + 18.9.0 + 19.0.0 + 18.2.0 + + diff --git a/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj b/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj index 855c229e..2cb2faab 100644 --- a/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj +++ b/libraries/maui-iap/src/OpenIap.Maui.Bindings.Android/OpenIap.Maui.Bindings.Android.csproj @@ -39,7 +39,7 @@ - - - + + - - - - - - - - + + + + + + + + - - - - + + + + - - - - + + + + diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/NSObjectJsonBridge.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/NSObjectJsonBridge.cs index ef61b2ef..c35eec44 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/NSObjectJsonBridge.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/NSObjectJsonBridge.cs @@ -5,6 +5,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Text.Json.Nodes; using Foundation; @@ -50,6 +51,22 @@ internal static class NSObjectJsonBridge return json; } + public static NSDictionary JsonObjectToDictionary(JsonObject json) + { + var keys = new List(); + var values = new List(); + + foreach (var (key, node) in json) + { + var value = JsonToNSObject(node); + if (value is null) continue; + keys.Add(new NSString(key)); + values.Add(value); + } + + return NSDictionary.FromObjectsAndKeys(values.ToArray(), keys.ToArray()); + } + public static JsonArray? ArrayToArray(NSArray? array) { if (array is null) return null; @@ -66,6 +83,39 @@ internal static class NSObjectJsonBridge private static JsonNode? ArrayToNode(NSArray array) => ArrayToArray(array); + private static NSObject? JsonToNSObject(JsonNode? node) + { + return node switch + { + null => null, + JsonObject obj => JsonObjectToDictionary(obj), + JsonArray array => JsonArrayToNSArray(array), + JsonValue value => JsonValueToNSObject(value), + _ => null, + }; + } + + private static NSArray JsonArrayToNSArray(JsonArray array) + { + var values = new List(); + foreach (var item in array) + { + var value = JsonToNSObject(item); + if (value is not null) values.Add(value); + } + return NSArray.FromNSObjects(values.ToArray()); + } + + private static NSObject JsonValueToNSObject(JsonValue value) + { + if (value.TryGetValue(out var boolValue)) return NSNumber.FromBoolean(boolValue); + if (value.TryGetValue(out var intValue)) return NSNumber.FromInt32(intValue); + if (value.TryGetValue(out var longValue)) return NSNumber.FromInt64(longValue); + if (value.TryGetValue(out var doubleValue)) return NSNumber.FromDouble(doubleValue); + if (value.TryGetValue(out var stringValue)) return new NSString(stringValue); + return new NSString(value.ToJsonString()); + } + private static JsonNode? NumberToNode(NSNumber n) { // ObjCType encodes the underlying primitive: 'c' = char/BOOL, 'i' = int, diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs index d0dba461..37613607 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs @@ -274,104 +274,19 @@ public Task EndConnectionAsync() { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - // Purchase request → fetch sku/quantity from the iOS sub-prop. - if (@params.RequestPurchase is { } purchaseEnv) + try { - var iosProps = purchaseEnv.Apple ?? purchaseEnv.IOS; - if (iosProps is null) - { - tcs.TrySetException(OpenIapErrorMapper.Wrap(ErrorCode.DeveloperError, "iOS purchase request requires `apple` props")); - return tcs.Task; - } - _module.RequestPurchase( - iosProps.Sku, - iosProps.Quantity ?? 1, - ProductTypeWireString(@params.Type), - (result, err) => - { - try - { - if (err is not null) { tcs.TrySetException(MapNSError(err)); return; } - var node = result is NSDictionary d ? NSObjectJsonBridge.DictToObject(d) : null; - if (node is null) { tcs.TrySetResult(null); return; } - var purchase = node.Deserialize(JsonOptions.Default); - tcs.TrySetResult(purchase is null ? null : new RequestPurchaseResultPurchase(purchase)); - } - catch (Exception ex) { tcs.TrySetException(ex); } - }); - return tcs.Task; + ValidateIosPurchaseRequest(@params); + var payload = RequestPurchasePayload(@params); + _module.RequestPurchaseWithPayload( + payload, + (result, err) => CompleteRequestPurchaseResult(tcs, result, err)); } - - if (@params.RequestSubscription is { } subEnv) + catch (Exception ex) { - var iosProps = subEnv.Apple ?? subEnv.IOS; - if (iosProps is null) - { - tcs.TrySetException(OpenIapErrorMapper.Wrap(ErrorCode.DeveloperError, "iOS subscription request requires `apple` props")); - return tcs.Task; - } - - NSDictionary? legacyOffer = iosProps.WithOffer is null - ? null - : NSDictionary.FromObjectsAndKeys( - new NSObject[] - { - new NSString(iosProps.WithOffer.Identifier), - new NSString(iosProps.WithOffer.KeyIdentifier), - new NSString(iosProps.WithOffer.Nonce), - new NSString(iosProps.WithOffer.Signature), - NSNumber.FromDouble(iosProps.WithOffer.Timestamp), - }, - new NSObject[] - { - new NSString("identifier"), - new NSString("keyIdentifier"), - new NSString("nonce"), - new NSString("signature"), - new NSString("timestamp"), - }); - - NSDictionary? jws = iosProps.PromotionalOfferJws is null - ? null - : NSDictionary.FromObjectsAndKeys( - new NSObject[] - { - new NSString(iosProps.PromotionalOfferJws.OfferId), - new NSString(iosProps.PromotionalOfferJws.Jws), - }, - new NSObject[] - { - new NSString("offerId"), - new NSString("jws"), - }); - - NSNumber? introEligibility = iosProps.IntroductoryOfferEligibility is { } b - ? NSNumber.FromBoolean(b) - : null; - string? winBackId = iosProps.WinBackOffer?.OfferId; - - _module.RequestSubscriptionExtended( - iosProps.Sku, - legacyOffer!, - introEligibility!, - jws!, - winBackId!, - (result, err) => - { - try - { - if (err is not null) { tcs.TrySetException(MapNSError(err)); return; } - var node = result is NSDictionary d ? NSObjectJsonBridge.DictToObject(d) : null; - if (node is null) { tcs.TrySetResult(null); return; } - var purchase = node.Deserialize(JsonOptions.Default); - tcs.TrySetResult(purchase is null ? null : new RequestPurchaseResultPurchase(purchase)); - } - catch (Exception ex) { tcs.TrySetException(ex); } - }); - return tcs.Task; + tcs.TrySetException(ex); } - tcs.TrySetException(OpenIapErrorMapper.Wrap(ErrorCode.DeveloperError, "RequestPurchaseProps must set requestPurchase or requestSubscription")); return tcs.Task; } @@ -587,12 +502,19 @@ public async Task> GetActiveSubscriptionsAsync return result.Where(a => filter.Contains(a.ProductId)).ToList(); } - public Task HasActiveSubscriptionsAsync(IReadOnlyList? subscriptionIds = null) - => InvokeBool(cb => _module.HasActiveSubscriptions(cb)); + public async Task HasActiveSubscriptionsAsync(IReadOnlyList? subscriptionIds = null) + { + if (subscriptionIds is { Count: > 0 }) + { + return (await GetActiveSubscriptionsAsync(subscriptionIds)).Count > 0; + } + + return await InvokeBool(cb => _module.HasActiveSubscriptions(cb)); + } public async Task GetStorefrontAsync() { - var storefront = await InvokeNullableString(cb => _module.GetStorefrontIOS(cb)); + var storefront = await InvokeNullableString(cb => _module.GetStorefront(cb)); return storefront ?? string.Empty; } @@ -660,6 +582,57 @@ private static void Complete(TaskCompletionSource tcs, bool ok, NSError? e catch (Exception ex) { tcs.TrySetException(ex); } } + private static void ValidateIosPurchaseRequest(RequestPurchaseProps @params) + { + if (@params.RequestPurchase is { } purchaseEnv) + { + var iosProps = purchaseEnv.Apple ?? purchaseEnv.IOS; + if (iosProps is null) + throw OpenIapErrorMapper.Wrap(ErrorCode.DeveloperError, "iOS purchase request requires `apple` props"); + if (string.IsNullOrWhiteSpace(iosProps.Sku)) + throw OpenIapErrorMapper.Wrap(ErrorCode.DeveloperError, "iOS purchase request requires a SKU"); + return; + } + + if (@params.RequestSubscription is { } subEnv) + { + var iosProps = subEnv.Apple ?? subEnv.IOS; + if (iosProps is null) + throw OpenIapErrorMapper.Wrap(ErrorCode.DeveloperError, "iOS subscription request requires `apple` props"); + if (string.IsNullOrWhiteSpace(iosProps.Sku)) + throw OpenIapErrorMapper.Wrap(ErrorCode.DeveloperError, "iOS subscription request requires a SKU"); + return; + } + + throw OpenIapErrorMapper.Wrap(ErrorCode.DeveloperError, "RequestPurchaseProps must set requestPurchase or requestSubscription"); + } + + private static NSDictionary RequestPurchasePayload(RequestPurchaseProps @params) + { + var node = JsonSerializer.SerializeToNode(@params, JsonOptions.Default) as JsonObject + ?? throw OpenIapErrorMapper.Wrap(ErrorCode.DeveloperError, "Unable to serialize RequestPurchaseProps"); + return NSObjectJsonBridge.JsonObjectToDictionary(node); + } + + private static void CompleteRequestPurchaseResult( + TaskCompletionSource tcs, + NSObject? result, + NSError? err) + { + try + { + if (err is not null) { tcs.TrySetException(MapNSError(err)); return; } + var node = result is NSDictionary d ? NSObjectJsonBridge.DictToObject(d) : null; + if (node is null) { tcs.TrySetResult(null); return; } + var purchase = node.Deserialize(JsonOptions.Default); + tcs.TrySetResult(purchase is null ? null : new RequestPurchaseResultPurchase(purchase)); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + } + private static OpenIapException MapNSError(NSError err) { var message = GetNSErrorString(err, "message") @@ -684,9 +657,6 @@ private static OpenIapException MapNSError(NSError err) return err.UserInfo?.ObjectForKey(nsKey)?.ToString(); } - private static string? ProductTypeWireString(ProductQueryType type) - => type == ProductQueryType.Subs ? "subs" : type == ProductQueryType.InApp ? "in-app" : null; - private static NSDictionary ToPurchaseOptionsDictionary(PurchaseOptions? options) { var alsoPublish = options?.AlsoPublishToEventListenerIOS ?? false; diff --git a/libraries/react-native-iap/CLAUDE.md b/libraries/react-native-iap/CLAUDE.md index cebfea16..c92aeeef 100644 --- a/libraries/react-native-iap/CLAUDE.md +++ b/libraries/react-native-iap/CLAUDE.md @@ -315,7 +315,7 @@ Both platforms use the OpenIAP library's error handling: Both Android and iOS now use **OpenIAP's unified error codes** (kebab-case format): - Examples: `user-cancelled`, `item-unavailable`, `network-error`, `developer-error` -- See [OpenIAP Error Handling](https://www.openiap.dev/api/error-handling) for complete list +- See [OpenIAP Error Handling](https://openiap.dev/api/error-handling) for complete list **TypeScript Error Format:** diff --git a/libraries/react-native-iap/CONTRIBUTING.md b/libraries/react-native-iap/CONTRIBUTING.md index 9447b77f..e3380533 100644 --- a/libraries/react-native-iap/CONTRIBUTING.md +++ b/libraries/react-native-iap/CONTRIBUTING.md @@ -144,14 +144,6 @@ Follow these steps when preparing a new release (e.g., 14.2.0): - Create a GitHub Release - Publish to npm via the existing workflows -Recent highlights (14.2.0) - -- iOS: idempotent, non-blocking init; `initConnection()` now propagates failures. -- iOS: bump OpenIAP to `~> 1.1.8`. -- Android: add consumer R8 keep rules to protect Nitro HybridObjects. -- CI: use vendored Yarn to avoid Corepack 503. -- Example: stabilized Subscription/Purchase flows; tests improved. - ## Project Structure - [`android/`](android): All your `android`-specific implementations. @@ -187,4 +179,4 @@ Recent highlights (14.2.0) 4. Run tests and linting: `yarn typecheck && yarn lint --fix` 5. Submit a pull request with a clear description -For detailed usage examples and error handling, see the [documentation](https://hyochan.github.io/react-native-iap). +For detailed usage examples and error handling, see the [documentation](https://openiap.dev/docs/setup/react-native). diff --git a/libraries/react-native-iap/README.md b/libraries/react-native-iap/README.md index f1f110b1..c58a436d 100644 --- a/libraries/react-native-iap/README.md +++ b/libraries/react-native-iap/README.md @@ -1,7 +1,7 @@ # React Native IAP
- React Native IAP Logo + React Native IAP Logo [![Version](http://img.shields.io/npm/v/react-native-iap.svg?style=flat-square)](https://npmjs.org/package/react-native-iap) [![Download](http://img.shields.io/npm/dm/react-native-iap.svg?style=flat-square)](https://npmjs.org/package/react-native-iap) @@ -33,7 +33,7 @@ ## 📚 Documentation -**[📖 Visit our comprehensive documentation site →](https://hyochan.github.io/react-native-iap)** +**[📖 Visit our comprehensive documentation site →](https://openiap.dev/docs/setup/react-native)** ## ⚠️ Notice @@ -46,7 +46,7 @@ - Seeing Swift 6 C++ interop errors in Nitro (e.g., `AnyMap.swift` with `cppPart.pointee.*`)? Temporarily pin Swift to **5.10** for the `NitroModules` pod (see Installation docs) or upgrade RN and Nitro deps. - Recommended: upgrade to RN 0.79+, update `react-native-nitro-modules`/`nitro-codegen`, then `pod install` and clean build. -More details and the Podfile snippet are in the docs: https://hyochan.github.io/react-native-iap/docs/installation#ios +More details and the Podfile snippet are in the docs: https://openiap.dev/docs/setup/react-native#ios ## ✨ Features @@ -68,7 +68,7 @@ npm install react-native-iap react-native-nitro-modules yarn add react-native-iap react-native-nitro-modules ``` -**[📖 See the complete installation guide and quick start tutorial →](https://hyochan.github.io/react-native-iap/docs/installation)** +**[📖 See the complete installation guide and quick start tutorial →](https://openiap.dev/docs/setup/react-native#installation)** ## 🏗️ Architecture @@ -112,7 +112,7 @@ In your root `android/build.gradle`: ```gradle buildscript { ext { - kotlinVersion = "2.1.20" + kotlinVersion = "2.2.0" } dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" @@ -162,31 +162,31 @@ For Expo projects, add the plugin to your `app.json` or `expo.json`: React Native IAP is **OpenIAP compliant**. For detailed store configuration: -- **[iOS Setup →](https://www.openiap.dev/docs/ios-setup)** - App Store Connect configuration -- **[Android Setup →](https://www.openiap.dev/docs/android-setup)** - Google Play Console configuration +- **[iOS Setup →](https://openiap.dev/docs/ios-setup)** - App Store Connect configuration +- **[Android Setup →](https://openiap.dev/docs/android-setup)** - Google Play Console configuration ## 🤖 Using with AI Assistants React Native IAP provides AI-friendly documentation for Cursor, GitHub Copilot, Claude, and ChatGPT. -**[📖 AI Assistants Guide →](https://hyochan.github.io/react-native-iap/docs/guides/ai-assistants)** +**[📖 AI Assistants Guide →](https://openiap.dev/docs/guides/ai-assistants)** Quick links: -- [llms.txt](https://hyochan.github.io/react-native-iap/llms.txt) - Quick reference -- [llms-full.txt](https://hyochan.github.io/react-native-iap/llms-full.txt) - Full API reference +- [llms.txt](https://openiap.dev/llms.txt) - Quick reference +- [llms-full.txt](https://openiap.dev/llms-full.txt) - Full API reference ## 🎯 What's Next? -**[📖 Visit our comprehensive documentation site →](https://hyochan.github.io/react-native-iap)** +**[📖 Visit our comprehensive documentation site →](https://openiap.dev/docs/setup/react-native)** ### Key Resources -- **[Installation & Quick Start](https://hyochan.github.io/react-native-iap/docs/installation)** - Get started in minutes -- **[API Reference](https://hyochan.github.io/react-native-iap/docs/api)** - Complete useIAP hook documentation -- **[Examples](https://hyochan.github.io/react-native-iap/docs/examples/basic-store)** - Production-ready implementations -- **[Error Handling](https://hyochan.github.io/react-native-iap/docs/api/error-codes)** - OpenIAP compliant error codes -- **[Troubleshooting](https://hyochan.github.io/react-native-iap/docs/guides/troubleshooting)** - Common issues and solutions +- **[Installation & Quick Start](https://openiap.dev/docs/setup/react-native#installation)** - Get started in minutes +- **[API Reference](https://openiap.dev/docs/apis)** - Complete useIAP hook documentation +- **[Examples](https://openiap.dev/docs/example)** - Production-ready implementations +- **[Error Handling](https://openiap.dev/docs/errors)** - OpenIAP compliant error codes +- **[Troubleshooting](https://openiap.dev/docs/features/debugging)** - Common issues and solutions ## Powered by OpenIAP @@ -217,10 +217,10 @@ Other libraries built on OpenIAP: [expo-iap](https://github.com/hyodotdev/openia diff --git a/libraries/react-native-iap/android/build.gradle b/libraries/react-native-iap/android/build.gradle index bdd2c731..28c4de04 100644 --- a/libraries/react-native-iap/android/build.gradle +++ b/libraries/react-native-iap/android/build.gradle @@ -1,14 +1,50 @@ import groovy.json.JsonSlurper +import org.jetbrains.kotlin.gradle.dsl.JvmTarget buildscript { + def googleRootBuildFile = [ + new File(projectDir, '../../../packages/google/build.gradle.kts'), + new File(rootDir, '../../../packages/google/build.gradle.kts'), + new File(rootProject.projectDir, '../../../packages/google/build.gradle.kts') + ].find { it.exists() } + + def googlePluginVersion = { pluginId -> + if (googleRootBuildFile == null) { + return null + } + def marker = "id(\"${pluginId}\") version \"" + def line = googleRootBuildFile.readLines().find { it.contains(marker) } + if (line == null) { + return null + } + def matcher = line =~ /version "([^"]+)"/ + return matcher.find() ? matcher.group(1) : null + } + + def configuredVersion = { extName, propertyName -> + if (rootProject.ext.has(extName)) { + return rootProject.ext.get(extName).toString() + } + def propertyValue = project.findProperty(propertyName) + if (propertyValue == null || propertyValue.toString().trim().isEmpty()) { + throw new GradleException("react-native-iap: missing ${propertyName} in android/gradle.properties") + } + return propertyValue.toString() + } + + def androidGradlePluginVersion = googlePluginVersion('com.android.library') + ?: configuredVersion('androidGradlePluginVersion', 'NitroIap_androidGradlePluginVersion') + def kotlinGradlePluginVersion = googlePluginVersion('org.jetbrains.kotlin.android') + ?: configuredVersion('kotlinVersion', 'NitroIap_kotlinVersion') + repositories { google() mavenCentral() } dependencies { - classpath "com.android.tools.build:gradle:8.12.1" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20" + classpath "com.android.tools.build:gradle:$androidGradlePluginVersion" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinGradlePluginVersion" } } @@ -45,9 +81,6 @@ def googleVersionString = googleVersion.trim() apply plugin: "com.android.library" apply plugin: 'org.jetbrains.kotlin.android' -// Get kotlinVersion from root project or use default -def kotlinVersion = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : '2.0.21' - // Only apply Nitro autolinking if the file exists def nitroAutolinkingFile = file('../nitrogen/generated/android/NitroIap+autolinking.gradle') if (nitroAutolinkingFile.exists()) { @@ -63,25 +96,61 @@ apply from: "./fix-prefab.gradle" // } def getExtOrDefault(name) { - return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["NitroIap_" + name] + def value = rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["NitroIap_" + name] + if (value == null || value.toString().trim().isEmpty()) { + throw new GradleException("react-native-iap: missing NitroIap_${name} in android/gradle.properties") + } + return value } def getExtOrIntegerDefault(name) { - return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["NitroIap_" + name]).toInteger() + return getExtOrDefault(name).toString().toInteger() } // Read horizonEnabled from gradle.properties, default to false (play) def horizonEnabled = project.findProperty('horizonEnabled')?.toBoolean() ?: false +def resolveOpenIapGoogleBuildFile() { + def candidates = [ + new File(projectDir, '../../../packages/google/openiap/build.gradle.kts'), + new File(rootDir, '../../../packages/google/openiap/build.gradle.kts'), + new File(rootProject.projectDir, '../../../packages/google/openiap/build.gradle.kts') + ] + return candidates.find { it.exists() } +} + +def readOpenIapGoogleVariable(File buildFile, String variableName) { + if (buildFile == null) { + return null + } + def matcher = buildFile.text =~ /val\s+${variableName}\s*=\s*"([^"]+)"/ + return matcher.find() ? matcher.group(1) : null +} + +def readOpenIapGoogleDependencyVersion(File buildFile, String coordinate) { + if (buildFile == null) { + return null + } + def matcher = buildFile.text =~ /${java.util.regex.Pattern.quote(coordinate)}:([^"$)]+)/ + return matcher.find() ? matcher.group(1) : null +} + +def googleOpenIapBuildFile = resolveOpenIapGoogleBuildFile() +def coroutinesVersion = readOpenIapGoogleVariable(googleOpenIapBuildFile, 'coroutinesVersion') + ?: getExtOrDefault("coroutinesVersion") +def playServicesBaseVersion = getExtOrDefault("playServicesBaseVersion") +def junitVersion = readOpenIapGoogleDependencyVersion(googleOpenIapBuildFile, 'junit:junit') + ?: getExtOrDefault("junitVersion") + android { - namespace "com.margelo.nitro.iap" + namespace = "com.margelo.nitro.iap" - ndkVersion getExtOrDefault("ndkVersion") + ndkVersion = getExtOrDefault("ndkVersion") compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") defaultConfig { - minSdkVersion getExtOrIntegerDefault("minSdkVersion") - targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + minSdkVersion = getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion = getExtOrIntegerDefault("targetSdkVersion") buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() // Ship consumer keep rules so Nitro HybridObjects aren't stripped in app release builds consumerProguardFiles 'consumer-rules.pro' @@ -136,13 +205,13 @@ android { } buildFeatures { - buildConfig true - prefab true + buildConfig = true + prefab = true } buildTypes { release { - minifyEnabled false + minifyEnabled = false } } @@ -151,11 +220,6 @@ android { targetCompatibility JavaVersion.VERSION_17 } - // Configure Kotlin compiler to match Java compatibility - kotlinOptions { - jvmTarget = "17" - } - lintOptions { disable "GradleCompatible" } @@ -163,6 +227,12 @@ android { // Removed sourceSets configuration for codegen as it's not needed for library modules } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + repositories { mavenCentral() google() @@ -181,10 +251,10 @@ dependencies { } // Google Play Services - implementation 'com.google.android.gms:play-services-base:18.5.0' + implementation "com.google.android.gms:play-services-base:$playServicesBaseVersion" // Kotlin coroutines - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" // Determine which OpenIAP dependency to use // In monorepo: use local packages/google source if available @@ -198,7 +268,7 @@ dependencies { } // Test dependencies - testImplementation 'junit:junit:4.13.2' + testImplementation "junit:junit:$junitVersion" testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.jetbrains.kotlin:kotlin-test-junit' } diff --git a/libraries/react-native-iap/android/gradle.properties b/libraries/react-native-iap/android/gradle.properties index 6746dd37..04f6bfa2 100644 --- a/libraries/react-native-iap/android/gradle.properties +++ b/libraries/react-native-iap/android/gradle.properties @@ -1,4 +1,8 @@ -NitroIap_kotlinVersion=2.1.20 +NitroIap_kotlinVersion=2.2.0 +NitroIap_androidGradlePluginVersion=8.13.2 +NitroIap_coroutinesVersion=1.9.0 +NitroIap_playServicesBaseVersion=18.5.0 +NitroIap_junitVersion=4.13.2 NitroIap_minSdkVersion=23 NitroIap_targetSdkVersion=36 NitroIap_compileSdkVersion=36 diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index f7576cf1..efc5f772 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -49,8 +49,19 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.CompletableDeferred import org.json.JSONArray import org.json.JSONObject +import java.security.MessageDigest import java.util.Locale +private fun redactSensitiveToken(token: String?): String { + val value = token?.takeIf { it.isNotBlank() } ?: return "none" + val fingerprint = MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + .take(12) + return "" +} + /** * Custom exception for OpenIAP errors that only includes the error JSON without stack traces. * This ensures clean error messages are passed to JavaScript without Java/Kotlin stack traces. @@ -192,7 +203,7 @@ class HybridRnIap : HybridRnIapSpec() { runCatching { RnIapLog.result( "userChoiceBillingListener", - mapOf("products" to details.products, "token" to details.externalTransactionToken) + mapOf("products" to details.products, "token" to redactSensitiveToken(details.externalTransactionToken)) ) val nitroDetails = UserChoiceBillingDetails( externalTransactionToken = details.externalTransactionToken, @@ -206,7 +217,7 @@ class HybridRnIap : HybridRnIapSpec() { runCatching { RnIapLog.result( "developerProvidedBillingListener", - mapOf("token" to details.externalTransactionToken) + mapOf("token" to redactSensitiveToken(details.externalTransactionToken)) ) val nitroDetails = DeveloperProvidedBillingDetailsAndroid( externalTransactionToken = details.externalTransactionToken @@ -793,7 +804,7 @@ class HybridRnIap : HybridRnIapSpec() { // Event listener methods override fun addPurchaseUpdatedListener( listener: (purchase: NitroPurchase) -> Unit, - options: NitroPurchaseUpdatedListenerOptions? + options: PurchaseUpdatedListenerOptions? ): Double { return synchronized(purchaseUpdatedListeners) { val token = nextPurchaseUpdatedListenerToken @@ -1656,7 +1667,7 @@ class HybridRnIap : HybridRnIapSpec() { val token = withContext(Dispatchers.Main) { openIap.createAlternativeBillingReportingToken() } - RnIapLog.result("createAlternativeBillingTokenAndroid", token) + RnIapLog.result("createAlternativeBillingTokenAndroid", redactSensitiveToken(token)) token?.let { Variant_NullType_String.Second(it) } ?: Variant_NullType_String.First(NullType.NULL) } catch (err: Throwable) { RnIapLog.failure("createAlternativeBillingTokenAndroid", err) diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/RnIapLog.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/RnIapLog.kt index 7b30c67b..e71e54ff 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/RnIapLog.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/RnIapLog.kt @@ -20,7 +20,9 @@ internal object RnIapLog { } fun debug(message: String) { - Log.d(TAG, message) + if (BuildConfig.DEBUG || Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, message) + } } fun warn(message: String) { diff --git a/libraries/react-native-iap/example-expo/app.config.ts b/libraries/react-native-iap/example-expo/app.config.ts index 1b97a093..dcfe680a 100644 --- a/libraries/react-native-iap/example-expo/app.config.ts +++ b/libraries/react-native-iap/example-expo/app.config.ts @@ -67,7 +67,7 @@ export default ({config}: ConfigContext): ExpoConfig => { 'expo-build-properties', { android: { - kotlinVersion: '2.0.21', + kotlinVersion: '2.2.0', }, ios: { deploymentTarget: '15.1', diff --git a/libraries/react-native-iap/example-expo/app/all-products.tsx b/libraries/react-native-iap/example-expo/app/all-products.tsx index 26ba2154..8408b95c 100644 --- a/libraries/react-native-iap/example-expo/app/all-products.tsx +++ b/libraries/react-native-iap/example-expo/app/all-products.tsx @@ -22,7 +22,11 @@ export default function AllProductsScreen() { finishTransaction, } = useIAP({ onPurchaseSuccess: async (purchase) => { - console.log('Purchase successful:', purchase); + console.log('Purchase successful:', { + productId: purchase.productId, + transactionId: purchase.id, + platform: purchase.platform, + }); Alert.alert('Success', `Purchased: ${purchase.productId}`); try { diff --git a/libraries/react-native-iap/example-expo/app/alternative-billing.tsx b/libraries/react-native-iap/example-expo/app/alternative-billing.tsx index f84f2c31..c88816c1 100644 --- a/libraries/react-native-iap/example-expo/app/alternative-billing.tsx +++ b/libraries/react-native-iap/example-expo/app/alternative-billing.tsx @@ -37,15 +37,15 @@ export default function AlternativeBillingScreen() { const [isProcessing, setIsProcessing] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false); - const { - connected, - products, - fetchProducts, - } = useIAP({ + const {connected, products, fetchProducts} = useIAP({ enableBillingProgramAndroid: Platform.OS === 'android' ? billingProgram : undefined, onPurchaseSuccess: async (purchase: Purchase) => { - console.log('Purchase successful:', purchase); + console.log('Purchase successful:', { + productId: purchase.productId, + transactionId: purchase.id, + platform: purchase.platform, + }); setIsProcessing(false); setPurchaseResult(`✅ Purchase successful: ${purchase.productId}`); Alert.alert('Success', 'Purchase completed!'); @@ -191,9 +191,10 @@ export default function AlternativeBillingScreen() { setPurchaseResult('Creating reporting token...'); const details = await createBillingProgramReportingDetailsAndroid(billingProgram); + const hasReportingToken = Boolean(details.externalTransactionToken); setPurchaseResult( - `✅ Billing Programs API completed\n\nProgram: ${billingProgram}\nURL: ${externalUrl}\nToken: ${details.externalTransactionToken.substring(0, 30)}...\n\n⚠️ Report token to Google Play within 24h`, + `✅ Billing Programs API completed\n\nProgram: ${billingProgram}\nURL: ${externalUrl}\nToken: ${hasReportingToken ? '' : 'missing'}\n\n⚠️ Report token to Google Play within 24h`, ); Alert.alert( 'Success', diff --git a/libraries/react-native-iap/example-expo/app/available-purchases.tsx b/libraries/react-native-iap/example-expo/app/available-purchases.tsx index 7985acbb..1f04b5c2 100644 --- a/libraries/react-native-iap/example-expo/app/available-purchases.tsx +++ b/libraries/react-native-iap/example-expo/app/available-purchases.tsx @@ -41,7 +41,11 @@ export default function AvailablePurchases() { finishTransaction, } = useIAP({ onPurchaseSuccess: async (purchase) => { - console.log('[AVAILABLE-PURCHASES] Purchase successful:', purchase); + console.log('[AVAILABLE-PURCHASES] Purchase successful:', { + productId: purchase.productId, + transactionId: purchase.id, + platform: purchase.platform, + }); // Finish transaction like in subscription-flow await finishTransaction({ @@ -73,7 +77,11 @@ export default function AvailablePurchases() { setIsCheckingStatus(true); try { const subs = await getActiveSubscriptions(); - console.log('[AVAILABLE-PURCHASES] Active subscriptions result:', subs); + console.log( + '[AVAILABLE-PURCHASES] Active subscriptions result:', + subs.length, + 'items', + ); } catch (error) { console.error( '[AVAILABLE-PURCHASES] Error checking subscription status:', @@ -153,7 +161,7 @@ export default function AvailablePurchases() { console.log( '[AVAILABLE-PURCHASES] activeSubscriptions:', activeSubscriptions.length, - activeSubscriptions, + 'items', ); }, [activeSubscriptions]); diff --git a/libraries/react-native-iap/example-expo/app/purchase-flow.tsx b/libraries/react-native-iap/example-expo/app/purchase-flow.tsx index d9d44bad..c8e20b94 100644 --- a/libraries/react-native-iap/example-expo/app/purchase-flow.tsx +++ b/libraries/react-native-iap/example-expo/app/purchase-flow.tsx @@ -729,10 +729,10 @@ function PurchaseFlowContainer() { iapkit: { apiKey: '***hidden***', ...(Platform.OS === 'ios' - ? {apple: {jws: `${jwsOrToken.substring(0, 50)}...`}} + ? {apple: {jws: ''}} : { google: { - purchaseToken: `${jwsOrToken.substring(0, 50)}...`, + purchaseToken: '', }, }), }, @@ -777,7 +777,7 @@ function PurchaseFlowContainer() { // ────────────────────────────────────────────────────────────────────── // Step 5: GRANT ENTITLEMENT // ────────────────────────────────────────────────────────────────────── - // TODO: In production, update your backend here: + // Production integration point: // - Save purchase record to database // - Unlock premium features for user // - Update user's subscription status diff --git a/libraries/react-native-iap/example-expo/app/subscription-flow.tsx b/libraries/react-native-iap/example-expo/app/subscription-flow.tsx index 56d1cf15..95e1978f 100644 --- a/libraries/react-native-iap/example-expo/app/subscription-flow.tsx +++ b/libraries/react-native-iap/example-expo/app/subscription-flow.tsx @@ -28,8 +28,6 @@ import { type SubscriptionOffer, ErrorCode, } from 'react-native-iap'; -// IAPKit API Key - Set this in your environment or replace with your actual key -const IAPKIT_API_KEY = process.env.EXPO_PUBLIC_IAPKIT_API_KEY || ''; import Loading from '../components/Loading'; import {SUBSCRIPTION_PRODUCT_IDS} from '../constants/products'; import {getErrorMessage} from '../utils/errorUtils'; @@ -38,6 +36,8 @@ import { type VerificationMethod, } from '../hooks/useVerificationMethod'; import PurchaseSummaryRow from '../components/PurchaseSummaryRow'; +// IAPKit API Key - Set this in your environment or replace with your actual key +const IAPKIT_API_KEY = process.env.EXPO_PUBLIC_IAPKIT_API_KEY || ''; type ExtendedPurchase = Purchase & { purchaseTokenAndroid?: string; @@ -423,13 +423,11 @@ function SubscriptionFlow({ targetBasePlanId, offerToken: targetOffer.offerTokenAndroid, replacementMode, - purchaseToken: tokenString - ? `<${tokenString.substring(0, 10)}...>` - : 'missing', + purchaseToken: tokenString ? '' : 'missing', allOffers: androidOffers?.map((o) => ({ basePlanId: o.basePlanIdAndroid, offerId: o.id, - offerToken: o.offerTokenAndroid?.substring(0, 20) + '...', + offerToken: o.offerTokenAndroid ? '' : undefined, })), }); @@ -450,8 +448,10 @@ function SubscriptionFlow({ }, type: 'subs', }).catch((err: PurchaseError) => { - console.error('Plan change failed:', err); - console.error('Full error:', JSON.stringify(err)); + console.error('Plan change failed:', { + code: err.code, + message: err.message, + }); // More helpful error messages let errorMessage = err.message; @@ -619,7 +619,7 @@ function SubscriptionFlow({ style={[styles.offerValue, styles.offerTokenText]} numberOfLines={2} > - {offer.offerTokenAndroid} + {''} )} @@ -1673,11 +1673,10 @@ function SubscriptionFlowContainer() { // Android: Check if we have offerToken or other data to identify the plan const purchaseData = purchase as ExtendedPurchase; - // Log full purchase data to understand what's available - console.log( - 'Full purchase data for plan detection:', - JSON.stringify(purchaseData, null, 2), - ); + console.log('Purchase data for plan detection:', { + productId: purchase.productId, + hasOfferToken: Boolean(purchaseData.offerToken), + }); // Map offerToken to basePlanId using fetched subscription data (cross-platform) if (purchaseData.offerToken) { @@ -1695,10 +1694,7 @@ function SubscriptionFlowContainer() { ); } else { // Fallback if we can't find the matching offer - console.log( - 'Could not map offerToken to basePlanId:', - purchaseData.offerToken, - ); + console.log('Could not map offerToken to basePlanId'); } } } @@ -1809,10 +1805,10 @@ function SubscriptionFlowContainer() { iapkit: { apiKey: '***hidden***', ...(Platform.OS === 'ios' - ? {apple: {jws: `${jwsOrToken.substring(0, 50)}...`}} + ? {apple: {jws: ''}} : { google: { - purchaseToken: `${jwsOrToken.substring(0, 50)}...`, + purchaseToken: '', }, }), }, @@ -1847,7 +1843,10 @@ function SubscriptionFlowContainer() { } } } catch (error) { - console.warn('[SubscriptionFlow] Verification failed:', error); + console.warn( + '[SubscriptionFlow] Verification failed:', + getErrorMessage(error), + ); Alert.alert( 'Verification Failed', `Purchase verification failed: ${getErrorMessage(error)}`, @@ -1860,7 +1859,7 @@ function SubscriptionFlowContainer() { // ────────────────────────────────────────────────────────────────────── // STEP 4: GRANT ENTITLEMENT // ────────────────────────────────────────────────────────────────────── - // TODO: In production, update your backend here: + // Production integration point: // - Save subscription record to database // - Unlock premium features for user // - Update user's subscription status @@ -1914,7 +1913,7 @@ function SubscriptionFlowContainer() { try { await getActiveSubscriptions(SUBSCRIPTION_PRODUCT_IDS); } catch (e) { - console.warn('Failed to refresh subscriptions:', e); + console.warn('Failed to refresh subscriptions:', getErrorMessage(e)); } Alert.alert('Success', 'Purchase completed successfully!'); @@ -1924,7 +1923,10 @@ function SubscriptionFlowContainer() { // Purchase Error Handler // ──────────────────────────────────────────────────────────────────────── onPurchaseError: (error: PurchaseError) => { - console.error('Subscription failed:', error); + console.error('Subscription failed:', { + code: error.code, + message: error.message, + }); setIsProcessing(false); const dt = Date.now() - lastSuccessAtRef.current; if (error?.code === ErrorCode.ServiceError && dt >= 0 && dt < 1500) { @@ -2038,7 +2040,15 @@ function SubscriptionFlowContainer() { const activeSubs = await getActiveSubscriptions(); console.log('\n===== Active Subscriptions Check ====='); console.log('Total subscriptions:', activeSubs.length); - console.log('Full data:', JSON.stringify(activeSubs, null, 2)); + console.log( + 'Subscription summary:', + activeSubs.map((sub) => ({ + productId: sub.productId, + isActive: sub.isActive, + expirationDateIOS: sub.expirationDateIOS, + environmentIOS: sub.environmentIOS, + })), + ); // For iOS, check if there's a pending change in renewalInfo if (Platform.OS === 'ios') { @@ -2122,7 +2132,10 @@ function SubscriptionFlowContainer() { console.log('===================================\n'); } } catch (error) { - console.error('Error checking subscription status:', error); + console.error( + 'Error checking subscription status:', + getErrorMessage(error), + ); } finally { setIsCheckingStatus(false); } @@ -2181,7 +2194,10 @@ function SubscriptionFlowContainer() { }, type: 'subs', }).catch((err: PurchaseError) => { - console.warn('requestPurchase failed:', err); + console.warn('requestPurchase failed:', { + code: err.code, + message: err.message, + }); setIsProcessing(false); setPurchaseResult(`❌ Subscription failed: ${err.message}`); Alert.alert('Subscription Failed', err.message); @@ -2213,7 +2229,10 @@ function SubscriptionFlowContainer() { try { await deepLinkToSubscriptions(); } catch (error) { - console.warn('Failed to open subscription management:', error); + console.warn( + 'Failed to open subscription management:', + getErrorMessage(error), + ); Alert.alert( 'Cannot Open', 'Unable to open the subscription management screen on this device.', diff --git a/libraries/react-native-iap/example-expo/app/webhook-stream.tsx b/libraries/react-native-iap/example-expo/app/webhook-stream.tsx index 3b80e41c..f51bced9 100644 --- a/libraries/react-native-iap/example-expo/app/webhook-stream.tsx +++ b/libraries/react-native-iap/example-expo/app/webhook-stream.tsx @@ -122,7 +122,7 @@ export default function WebhookStreamScreen() { Webhook Stream SSE /v1/webhooks/stream/apiKey - api key: {apiKey ? `${apiKey.slice(0, 8)}...` : 'MISSING'} + api key: {apiKey ? 'CONFIGURED' : 'MISSING'} diff --git a/libraries/react-native-iap/example-expo/components/AndroidOneTimeOfferDetails.tsx b/libraries/react-native-iap/example-expo/components/AndroidOneTimeOfferDetails.tsx index d7161cdf..aff0460a 100644 --- a/libraries/react-native-iap/example-expo/components/AndroidOneTimeOfferDetails.tsx +++ b/libraries/react-native-iap/example-expo/components/AndroidOneTimeOfferDetails.tsx @@ -23,7 +23,10 @@ export default function AndroidOneTimeOfferDetails({ {offers.map( (offer: ProductAndroidOneTimePurchaseOfferDetail, index: number) => ( - + Offer {index + 1} {offer.offerId ? ` (${offer.offerId})` : ''} @@ -117,7 +120,7 @@ export default function AndroidOneTimeOfferDetails({ style={[styles.offerValue, styles.offerToken]} numberOfLines={2} > - {offer.offerToken} + {''} ), diff --git a/libraries/react-native-iap/example-expo/contexts/DataModalContext.tsx b/libraries/react-native-iap/example-expo/contexts/DataModalContext.tsx index 4b699f17..0cae11e7 100644 --- a/libraries/react-native-iap/example-expo/contexts/DataModalContext.tsx +++ b/libraries/react-native-iap/example-expo/contexts/DataModalContext.tsx @@ -63,9 +63,7 @@ export function DataModalProvider({children}: {children: React.ReactNode}) { const handleCopy = useCallback(() => { if (!data) return; - // Remove sensitive fields - const {purchaseToken, ...safeData} = data; - const jsonString = JSON.stringify(safeData, null, 2); + const jsonString = JSON.stringify(data, null, 2); Clipboard.setString(jsonString); Alert.alert('Copied', 'Data copied to clipboard'); @@ -96,8 +94,7 @@ export function DataModalProvider({children}: {children: React.ReactNode}) { {(() => { if (!data) return ''; - const {purchaseToken, ...safeData} = data; - return JSON.stringify(safeData, null, 2); + return JSON.stringify(data, null, 2); })()} diff --git a/libraries/react-native-iap/example-expo/utils/buildPurchaseRows.ts b/libraries/react-native-iap/example-expo/utils/buildPurchaseRows.ts index 0257afbe..2a8c68c9 100644 --- a/libraries/react-native-iap/example-expo/utils/buildPurchaseRows.ts +++ b/libraries/react-native-iap/example-expo/utils/buildPurchaseRows.ts @@ -49,7 +49,11 @@ export const buildPurchaseRows = (purchase: Purchase): PurchaseDetailRow[] => { if (platform === 'ios') { const iosPurchase = purchase as any; - pushRow(rows, 'App Account Token', iosPurchase.appAccountToken); + pushRow( + rows, + 'App Account Token', + iosPurchase.appAccountToken ? '' : null, + ); pushRow(rows, 'Expiration Date', formatDate(iosPurchase.expirationDateIOS)); pushRow(rows, 'Auto Renewing', purchase.isAutoRenewing ? 'Yes' : 'No'); } else if (platform === 'android') { diff --git a/libraries/react-native-iap/example/android/app/build.gradle b/libraries/react-native-iap/example/android/app/build.gradle index b938be93..a6b1b95e 100644 --- a/libraries/react-native-iap/example/android/app/build.gradle +++ b/libraries/react-native-iap/example/android/app/build.gradle @@ -73,17 +73,17 @@ def enableProguardInReleaseBuilds = false def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' android { - ndkVersion rootProject.ext.ndkVersion - buildToolsVersion rootProject.ext.buildToolsVersion - compileSdk rootProject.ext.compileSdkVersion + ndkVersion = rootProject.ext.ndkVersion + buildToolsVersion = rootProject.ext.buildToolsVersion + compileSdk = rootProject.ext.compileSdkVersion - namespace "dev.hyo.martie" + namespace = "dev.hyo.martie" defaultConfig { - applicationId "dev.hyo.martie" - minSdkVersion rootProject.ext.minSdkVersion - targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + applicationId = "dev.hyo.martie" + minSdkVersion = rootProject.ext.minSdkVersion + targetSdkVersion = rootProject.ext.targetSdkVersion + versionCode = 1 + versionName = "1.0" } compileOptions { @@ -99,21 +99,21 @@ android { } signingConfigs { debug { - storeFile file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' + storeFile = file('debug.keystore') + storePassword = 'android' + keyAlias = 'androiddebugkey' + keyPassword = 'android' } } buildTypes { debug { - signingConfig signingConfigs.debug + signingConfig = signingConfigs.debug } release { // Caution! In production, you need to generate your own keystore file. // see https://reactnative.dev/docs/signed-apk-android. - signingConfig signingConfigs.debug - minifyEnabled enableProguardInReleaseBuilds + signingConfig = signingConfigs.debug + minifyEnabled = enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } } diff --git a/libraries/react-native-iap/example/screens/AllProducts.tsx b/libraries/react-native-iap/example/screens/AllProducts.tsx index 716eec26..823c3316 100644 --- a/libraries/react-native-iap/example/screens/AllProducts.tsx +++ b/libraries/react-native-iap/example/screens/AllProducts.tsx @@ -77,8 +77,6 @@ function AllProducts() { useEffect(() => { console.log('[AllProducts] useEffect - connected:', connected); - console.log('[AllProducts] Current products:', products.length); - console.log('[AllProducts] Current subscriptions:', subscriptions.length); if (connected) { console.log( @@ -90,11 +88,6 @@ function AllProducts() { fetchProducts({skus: ALL_PRODUCT_IDS, type: 'all'}) .then(() => { console.log('[AllProducts] fetchProducts completed'); - console.log('[AllProducts] Products after fetch:', products.length); - console.log( - '[AllProducts] Subscriptions after fetch:', - subscriptions.length, - ); }) .catch((error) => { console.error('[AllProducts] fetchProducts error:', error); @@ -378,7 +371,7 @@ function AllProducts() { index: number, ) => ( @@ -436,7 +429,7 @@ function AllProducts() { style={[styles.offerValue, styles.offerToken]} numberOfLines={2} > - {offer.offerToken} + {''} ), @@ -532,7 +525,7 @@ function AllProducts() { ]} numberOfLines={2} > - {offer.offerTokenAndroid} + {''} )} diff --git a/libraries/react-native-iap/example/screens/AlternativeBilling.tsx b/libraries/react-native-iap/example/screens/AlternativeBilling.tsx index adde013b..c1d6572e 100644 --- a/libraries/react-native-iap/example/screens/AlternativeBilling.tsx +++ b/libraries/react-native-iap/example/screens/AlternativeBilling.tsx @@ -98,7 +98,11 @@ function AlternativeBillingScreen() { enableBillingProgramAndroid: Platform.OS === 'android' ? billingProgram : undefined, onPurchaseSuccess: async (purchase: Purchase) => { - console.log('Purchase successful:', purchase); + console.log('Purchase successful:', { + productId: purchase.productId, + transactionId: purchase.id, + platform: purchase.platform, + }); setLastPurchase(purchase); setIsProcessing(false); @@ -156,14 +160,14 @@ function AlternativeBillingScreen() { '[Android] User selected developer billing (External Payments)', ); console.log( - '[Android] External transaction token:', - details.externalTransactionToken, + '[Android] External transaction token available:', + Boolean(details.externalTransactionToken), ); setExternalPaymentsToken(details.externalTransactionToken); setIsProcessing(false); setPurchaseResult( - `✅ User selected Developer Billing (External Payments)\n\nToken: ${details.externalTransactionToken.substring(0, 30)}...\n\n⚠️ Important:\n1. Process payment through your external system\n2. Report token to Google Play within 24 hours`, + `✅ User selected Developer Billing (External Payments)\n\nToken: \n\n⚠️ Important:\n1. Process payment through your external system\n2. Report token to Google Play within 24 hours`, ); Alert.alert( @@ -299,13 +303,11 @@ function AlternativeBillingScreen() { // Step 3: Create reporting token (after user completes external purchase) const details = await createBillingProgramReportingDetailsAndroid(billingProgram); - console.log( - '[Android] Reporting token created:', - details.externalTransactionToken.substring(0, 20) + '...', - ); + const hasReportingToken = Boolean(details.externalTransactionToken); + console.log('[Android] Reporting token created:', hasReportingToken); setPurchaseResult( - `✅ Billing Programs API completed\n\nProgram: ${billingProgram}\nURL: ${externalUrl}\nToken: ${details.externalTransactionToken.substring(0, 30)}...\n\n⚠️ Important:\n1. User completes purchase externally\n2. Report token to Google Play within 24h`, + `✅ Billing Programs API completed\n\nProgram: ${billingProgram}\nURL: ${externalUrl}\nToken: ${hasReportingToken ? '' : 'missing'}\n\n⚠️ Important:\n1. User completes purchase externally\n2. Report token to Google Play within 24h`, ); Alert.alert( @@ -642,9 +644,7 @@ function AlternativeBillingScreen() { External Payments Token (Japan) - - Token: {externalPaymentsToken.substring(0, 40)}... - + Token: {''} ⚠️ Report this token to Google Play within 24 hours{'\n'} ℹ️ Process external payment through your system diff --git a/libraries/react-native-iap/example/screens/AvailablePurchases.tsx b/libraries/react-native-iap/example/screens/AvailablePurchases.tsx index 0520cf7d..6ea2eb3b 100644 --- a/libraries/react-native-iap/example/screens/AvailablePurchases.tsx +++ b/libraries/react-native-iap/example/screens/AvailablePurchases.tsx @@ -37,7 +37,11 @@ export default function AvailablePurchases() { finishTransaction, } = useIAP({ onPurchaseSuccess: async (purchase) => { - console.log('[AVAILABLE-PURCHASES] Purchase successful:', purchase); + console.log('[AVAILABLE-PURCHASES] Purchase successful:', { + productId: purchase.productId, + transactionId: purchase.id, + platform: purchase.platform, + }); // Finish transaction like in subscription-flow await finishTransaction({ @@ -69,7 +73,11 @@ export default function AvailablePurchases() { setIsCheckingStatus(true); try { const subs = await getActiveSubscriptions(); - console.log('[AVAILABLE-PURCHASES] Active subscriptions result:', subs); + console.log( + '[AVAILABLE-PURCHASES] Active subscriptions result:', + subs.length, + 'items', + ); } catch (error) { console.error( '[AVAILABLE-PURCHASES] Error checking subscription status:', @@ -149,7 +157,7 @@ export default function AvailablePurchases() { console.log( '[AVAILABLE-PURCHASES] activeSubscriptions:', activeSubscriptions.length, - activeSubscriptions, + 'items', ); }, [activeSubscriptions]); diff --git a/libraries/react-native-iap/example/screens/PurchaseFlow.tsx b/libraries/react-native-iap/example/screens/PurchaseFlow.tsx index 1d8fce2a..8092da08 100644 --- a/libraries/react-native-iap/example/screens/PurchaseFlow.tsx +++ b/libraries/react-native-iap/example/screens/PurchaseFlow.tsx @@ -724,10 +724,10 @@ function PurchaseFlowContainer() { iapkit: { apiKey: '***hidden***', ...(Platform.OS === 'ios' - ? {apple: {jws: `${jwsOrToken.substring(0, 50)}...`}} + ? {apple: {jws: ''}} : { google: { - purchaseToken: `${jwsOrToken.substring(0, 50)}...`, + purchaseToken: '', }, }), }, @@ -772,7 +772,7 @@ function PurchaseFlowContainer() { // ────────────────────────────────────────────────────────────────────── // Step 5: GRANT ENTITLEMENT // ────────────────────────────────────────────────────────────────────── - // TODO: In production, update your backend here: + // Production integration point: // - Save purchase record to database // - Unlock premium features for user // - Update user's subscription status diff --git a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx index ea475cdf..c390cb48 100644 --- a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx +++ b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx @@ -24,7 +24,6 @@ import { type SubscriptionOffer, ErrorCode, } from 'react-native-iap'; -import {IAPKIT_API_KEY} from '@env'; import Loading from '../src/components/Loading'; import {SUBSCRIPTION_PRODUCT_IDS} from '../src/utils/constants'; import {getErrorMessage} from '../src/utils/errorUtils'; @@ -33,6 +32,7 @@ import { type VerificationMethod, } from '../src/hooks/useVerificationMethod'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; +import {IAPKIT_API_KEY} from '@env'; type ExtendedPurchase = Purchase & { purchaseTokenAndroid?: string; @@ -418,13 +418,11 @@ function SubscriptionFlow({ targetBasePlanId, offerToken: targetOffer.offerTokenAndroid, replacementMode, - purchaseToken: tokenString - ? `<${tokenString.substring(0, 10)}...>` - : 'missing', + purchaseToken: tokenString ? '' : 'missing', allOffers: androidOffers?.map((o) => ({ basePlanId: o.basePlanIdAndroid, offerId: o.id, - offerToken: o.offerTokenAndroid?.substring(0, 20) + '...', + offerToken: o.offerTokenAndroid ? '' : undefined, })), }); @@ -445,8 +443,10 @@ function SubscriptionFlow({ }, type: 'subs', }).catch((err: PurchaseError) => { - console.error('Plan change failed:', err); - console.error('Full error:', JSON.stringify(err)); + console.error('Plan change failed:', { + code: err.code, + message: err.message, + }); // More helpful error messages let errorMessage = err.message; @@ -614,7 +614,7 @@ function SubscriptionFlow({ style={[styles.offerValue, styles.offerTokenText]} numberOfLines={2} > - {offer.offerTokenAndroid} + {''} )} @@ -1668,11 +1668,10 @@ function SubscriptionFlowContainer() { // Android: Check if we have offerToken or other data to identify the plan const purchaseData = purchase as ExtendedPurchase; - // Log full purchase data to understand what's available - console.log( - 'Full purchase data for plan detection:', - JSON.stringify(purchaseData, null, 2), - ); + console.log('Purchase data for plan detection:', { + productId: purchase.productId, + hasOfferToken: Boolean(purchaseData.offerToken), + }); // Map offerToken to basePlanId using fetched subscription data (cross-platform) if (purchaseData.offerToken) { @@ -1690,10 +1689,7 @@ function SubscriptionFlowContainer() { ); } else { // Fallback if we can't find the matching offer - console.log( - 'Could not map offerToken to basePlanId:', - purchaseData.offerToken, - ); + console.log('Could not map offerToken to basePlanId'); } } } @@ -1804,10 +1800,10 @@ function SubscriptionFlowContainer() { iapkit: { apiKey: '***hidden***', ...(Platform.OS === 'ios' - ? {apple: {jws: `${jwsOrToken.substring(0, 50)}...`}} + ? {apple: {jws: ''}} : { google: { - purchaseToken: `${jwsOrToken.substring(0, 50)}...`, + purchaseToken: '', }, }), }, @@ -1842,7 +1838,10 @@ function SubscriptionFlowContainer() { } } } catch (error) { - console.warn('[SubscriptionFlow] Verification failed:', error); + console.warn( + '[SubscriptionFlow] Verification failed:', + getErrorMessage(error), + ); Alert.alert( 'Verification Failed', `Purchase verification failed: ${getErrorMessage(error)}`, @@ -1855,7 +1854,7 @@ function SubscriptionFlowContainer() { // ────────────────────────────────────────────────────────────────────── // STEP 4: GRANT ENTITLEMENT // ────────────────────────────────────────────────────────────────────── - // TODO: In production, update your backend here: + // Production integration point: // - Save subscription record to database // - Unlock premium features for user // - Update user's subscription status @@ -1909,7 +1908,7 @@ function SubscriptionFlowContainer() { try { await getActiveSubscriptions(SUBSCRIPTION_PRODUCT_IDS); } catch (e) { - console.warn('Failed to refresh subscriptions:', e); + console.warn('Failed to refresh subscriptions:', getErrorMessage(e)); } Alert.alert('Success', 'Purchase completed successfully!'); @@ -1919,7 +1918,10 @@ function SubscriptionFlowContainer() { // Purchase Error Handler // ──────────────────────────────────────────────────────────────────────── onPurchaseError: (error: PurchaseError) => { - console.error('Subscription failed:', error); + console.error('Subscription failed:', { + code: error.code, + message: error.message, + }); setIsProcessing(false); const dt = Date.now() - lastSuccessAtRef.current; if (error?.code === ErrorCode.ServiceError && dt >= 0 && dt < 1500) { @@ -2033,7 +2035,15 @@ function SubscriptionFlowContainer() { const activeSubs = await getActiveSubscriptions(); console.log('\n===== Active Subscriptions Check ====='); console.log('Total subscriptions:', activeSubs.length); - console.log('Full data:', JSON.stringify(activeSubs, null, 2)); + console.log( + 'Subscription summary:', + activeSubs.map((sub) => ({ + productId: sub.productId, + isActive: sub.isActive, + expirationDateIOS: sub.expirationDateIOS, + environmentIOS: sub.environmentIOS, + })), + ); // For iOS, check if there's a pending change in renewalInfo if (Platform.OS === 'ios') { @@ -2117,7 +2127,10 @@ function SubscriptionFlowContainer() { console.log('===================================\n'); } } catch (error) { - console.error('Error checking subscription status:', error); + console.error( + 'Error checking subscription status:', + getErrorMessage(error), + ); } finally { setIsCheckingStatus(false); } @@ -2176,7 +2189,10 @@ function SubscriptionFlowContainer() { }, type: 'subs', }).catch((err: PurchaseError) => { - console.warn('requestPurchase failed:', err); + console.warn('requestPurchase failed:', { + code: err.code, + message: err.message, + }); setIsProcessing(false); setPurchaseResult(`❌ Subscription failed: ${err.message}`); Alert.alert('Subscription Failed', err.message); @@ -2208,7 +2224,10 @@ function SubscriptionFlowContainer() { try { await deepLinkToSubscriptions(); } catch (error) { - console.warn('Failed to open subscription management:', error); + console.warn( + 'Failed to open subscription management:', + getErrorMessage(error), + ); Alert.alert( 'Cannot Open', 'Unable to open the subscription management screen on this device.', diff --git a/libraries/react-native-iap/example/screens/WebhookStream.tsx b/libraries/react-native-iap/example/screens/WebhookStream.tsx index c848e9c2..350a5c60 100644 --- a/libraries/react-native-iap/example/screens/WebhookStream.tsx +++ b/libraries/react-native-iap/example/screens/WebhookStream.tsx @@ -119,7 +119,7 @@ export default function WebhookStream() { IAPKit SSE + test notification api key:{' '} - {IAPKIT_API_KEY ? `${IAPKIT_API_KEY.slice(0, 8)}...` : 'MISSING'} + {IAPKIT_API_KEY ? 'CONFIGURED' : 'MISSING'} diff --git a/libraries/react-native-iap/example/src/components/AndroidOneTimeOfferDetails.tsx b/libraries/react-native-iap/example/src/components/AndroidOneTimeOfferDetails.tsx index d7161cdf..aff0460a 100644 --- a/libraries/react-native-iap/example/src/components/AndroidOneTimeOfferDetails.tsx +++ b/libraries/react-native-iap/example/src/components/AndroidOneTimeOfferDetails.tsx @@ -23,7 +23,10 @@ export default function AndroidOneTimeOfferDetails({ {offers.map( (offer: ProductAndroidOneTimePurchaseOfferDetail, index: number) => ( - + Offer {index + 1} {offer.offerId ? ` (${offer.offerId})` : ''} @@ -117,7 +120,7 @@ export default function AndroidOneTimeOfferDetails({ style={[styles.offerValue, styles.offerToken]} numberOfLines={2} > - {offer.offerToken} + {''} ), diff --git a/libraries/react-native-iap/example/src/contexts/DataModalContext.tsx b/libraries/react-native-iap/example/src/contexts/DataModalContext.tsx index 4b699f17..0cae11e7 100644 --- a/libraries/react-native-iap/example/src/contexts/DataModalContext.tsx +++ b/libraries/react-native-iap/example/src/contexts/DataModalContext.tsx @@ -63,9 +63,7 @@ export function DataModalProvider({children}: {children: React.ReactNode}) { const handleCopy = useCallback(() => { if (!data) return; - // Remove sensitive fields - const {purchaseToken, ...safeData} = data; - const jsonString = JSON.stringify(safeData, null, 2); + const jsonString = JSON.stringify(data, null, 2); Clipboard.setString(jsonString); Alert.alert('Copied', 'Data copied to clipboard'); @@ -96,8 +94,7 @@ export function DataModalProvider({children}: {children: React.ReactNode}) { {(() => { if (!data) return ''; - const {purchaseToken, ...safeData} = data; - return JSON.stringify(safeData, null, 2); + return JSON.stringify(data, null, 2); })()} diff --git a/libraries/react-native-iap/example/src/utils/buildPurchaseRows.ts b/libraries/react-native-iap/example/src/utils/buildPurchaseRows.ts index b574e1b3..b4cac099 100644 --- a/libraries/react-native-iap/example/src/utils/buildPurchaseRows.ts +++ b/libraries/react-native-iap/example/src/utils/buildPurchaseRows.ts @@ -89,7 +89,11 @@ export const buildPurchaseRows = (purchase: Purchase): PurchaseDetailRow[] => { if (platform === 'ios') { const iosPurchase = purchase as PurchaseIOS; pushRow(rows, 'quantityIOS', iosPurchase.quantityIOS); - pushRow(rows, 'appAccountToken', iosPurchase.appAccountToken); + pushRow( + rows, + 'appAccountToken', + iosPurchase.appAccountToken ? '' : null, + ); pushRow(rows, 'appBundleIdIOS', iosPurchase.appBundleIdIOS); pushRow(rows, 'countryCodeIOS', iosPurchase.countryCodeIOS); pushRow(rows, 'currencyCodeIOS', iosPurchase.currencyCodeIOS); @@ -134,7 +138,11 @@ export const buildPurchaseRows = (purchase: Purchase): PurchaseDetailRow[] => { } } else if (platform === 'android') { const androidPurchase = purchase as PurchaseAndroid; - pushRow(rows, 'signatureAndroid', androidPurchase.signatureAndroid); + pushRow( + rows, + 'signatureAndroid', + androidPurchase.signatureAndroid ? '' : null, + ); pushRow(rows, 'packageNameAndroid', androidPurchase.packageNameAndroid); pushRow( rows, @@ -161,10 +169,14 @@ export const buildPurchaseRows = (purchase: Purchase): PurchaseDetailRow[] => { 'autoRenewingAndroid', formatBoolean(androidPurchase.autoRenewingAndroid), ); - pushRow(rows, 'dataAndroid', androidPurchase.dataAndroid); + pushRow( + rows, + 'dataAndroid', + androidPurchase.dataAndroid ? '' : null, + ); } - pushRow(rows, 'purchaseToken', purchase.purchaseToken); + pushRow(rows, 'purchaseToken', purchase.purchaseToken ? '' : null); return rows; }; diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index 6822c5fa..10f9b270 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -388,7 +388,10 @@ class HybridRnIap: HybridRnIapSpec { RnIapLog.payload("validateReceiptIOS", ["sku": sku]) let props = try OpenIapSerialization.verifyPurchaseProps(from: ["apple": ["sku": sku]]) - let result = try await OpenIapModule.shared.validateReceiptIOS(props) + let verifyResult = try await OpenIapModule.shared.verifyPurchase(props) + guard case let .verifyPurchaseResultIos(result) = verifyResult else { + throw OpenIapException.make(code: .featureNotSupported, message: "Expected iOS validation result") + } var encoded = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result)) if encoded["receiptData"] != nil { encoded["receiptData"] = "" @@ -488,7 +491,7 @@ class HybridRnIap: HybridRnIapSpec { return Promise.async { do { RnIapLog.payload("getStorefront", nil) - let storefront = try await OpenIapModule.shared.getStorefrontIOS() + let storefront = try await OpenIapModule.shared.getStorefront() RnIapLog.result("getStorefront", storefront) return storefront } catch let purchaseError as PurchaseError { @@ -578,9 +581,12 @@ class HybridRnIap: HybridRnIapSpec { RnIapLog.payload("buyPromotedProductIOS", nil) let ok = try await OpenIapModule.shared.requestPurchaseOnPromotedProductIOS() RnIapLog.result("buyPromotedProductIOS", ok) + } catch let purchaseError as PurchaseError { + RnIapLog.failure("buyPromotedProductIOS", error: purchaseError) + throw OpenIapException.from(purchaseError) } catch { - // Event-only: OpenIAP will emit purchaseError for this flow. Avoid Promise rejection. RnIapLog.failure("buyPromotedProductIOS", error: error) + throw OpenIapException.make(code: .featureNotSupported, message: error.localizedDescription) } } } diff --git a/libraries/react-native-iap/scripts/ci-check.sh b/libraries/react-native-iap/scripts/ci-check.sh index fa2e85d6..de787f19 100755 --- a/libraries/react-native-iap/scripts/ci-check.sh +++ b/libraries/react-native-iap/scripts/ci-check.sh @@ -15,66 +15,31 @@ NC='\033[0m' # No Color # Track if any checks fail FAILED=0 -# 1. Install dependencies -echo -e "\n${YELLOW}📦 Installing dependencies...${NC}" -yarn install --immutable -if [ $? -ne 0 ]; then - echo -e "${RED}❌ Dependency installation failed${NC}" - FAILED=1 -else - echo -e "${GREEN}✅ Dependencies installed${NC}" -fi - -# 2. Generate Nitro code -echo -e "\n${YELLOW}⚙️ Generating Nitro code...${NC}" -yarn nitrogen -if [ $? -ne 0 ]; then - echo -e "${RED}❌ Nitro code generation failed${NC}" - FAILED=1 -else - echo -e "${GREEN}✅ Nitro code generated${NC}" -fi - -# 3. TypeScript check -echo -e "\n${YELLOW}🔍 Running TypeScript check...${NC}" -yarn typecheck -if [ $? -ne 0 ]; then - echo -e "${RED}❌ TypeScript check failed${NC}" - FAILED=1 -else - echo -e "${GREEN}✅ TypeScript check passed${NC}" -fi - -# 4. ESLint -echo -e "\n${YELLOW}🔍 Running ESLint...${NC}" -yarn lint -if [ $? -ne 0 ]; then - echo -e "${RED}❌ ESLint check failed${NC}" - FAILED=1 -else - echo -e "${GREEN}✅ ESLint check passed${NC}" -fi - -# 5. Prettier format check -echo -e "\n${YELLOW}💅 Checking code formatting...${NC}" -yarn prettier --check "src/**/*.{ts,tsx,js,jsx}" -if [ $? -ne 0 ]; then - echo -e "${RED}❌ Code formatting issues found${NC}" - echo -e "${YELLOW}💡 Run 'yarn prettier --write \"src/**/*.{ts,tsx,js,jsx}\"' to fix${NC}" - FAILED=1 -else - echo -e "${GREEN}✅ Code formatting check passed${NC}" -fi - -# 6. Run tests -echo -e "\n${YELLOW}🧪 Running tests...${NC}" -yarn test --passWithNoTests -if [ $? -ne 0 ]; then - echo -e "${RED}❌ Tests failed${NC}" - FAILED=1 -else - echo -e "${GREEN}✅ Tests passed${NC}" -fi +run_check() { + local title=$1 + local success_message=$2 + local failure_message=$3 + shift 3 + + echo -e "\n${YELLOW}${title}${NC}" + if "$@"; then + echo -e "${GREEN}✅ ${success_message}${NC}" + else + echo -e "${RED}❌ ${failure_message}${NC}" + if [ -n "${CHECK_HINT:-}" ]; then + echo -e "${YELLOW}💡 ${CHECK_HINT}${NC}" + fi + FAILED=1 + fi +} + +run_check "📦 Installing dependencies..." "Dependencies installed" "Dependency installation failed" yarn install --immutable +run_check "⚙️ Generating Nitro code..." "Nitro code generated" "Nitro code generation failed" yarn nitrogen +run_check "🔍 Running TypeScript check..." "TypeScript check passed" "TypeScript check failed" yarn typecheck +run_check "🔍 Running ESLint..." "ESLint check passed" "ESLint check failed" yarn lint +CHECK_HINT='Run '\''yarn prettier --write "src/**/*.{ts,tsx,js,jsx}"'\'' to fix' \ + run_check "💅 Checking code formatting..." "Code formatting check passed" "Code formatting issues found" yarn prettier --check "src/**/*.{ts,tsx,js,jsx}" +run_check "🧪 Running tests..." "Tests passed" "Tests failed" yarn test --passWithNoTests # Summary echo -e "\n================================" @@ -84,4 +49,4 @@ if [ $FAILED -eq 0 ]; then else echo -e "${RED}❌ Some CI checks failed. Please fix the issues before committing.${NC}" exit 1 -fi \ No newline at end of file +fi diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index d2348d3c..580e79b6 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -610,6 +610,16 @@ describe('Public API (src/index.ts)', () => { ).rejects.toThrow(/skus/); }); + it('throws on unsupported platform', async () => { + (Platform as any).OS = 'web'; + await expect( + IAP.requestPurchase({ + request: {ios: {sku: 'p1'}} as any, + type: 'in-app', + }), + ).rejects.toThrow(/Unsupported platform: web/); + }); + it('passes unified request to native', async () => { (Platform as any).OS = 'android'; await IAP.requestPurchase({ @@ -843,6 +853,35 @@ describe('Public API (src/index.ts)', () => { const passed = mockIap.requestPurchase.mock.calls.pop()?.[0]; expect(passed.ios.advancedCommerceData).toBe(advancedData); }); + + it('iOS subs forwards advanced subscription offer fields', async () => { + (Platform as any).OS = 'ios'; + await IAP.requestPurchase({ + request: { + apple: { + sku: 'premium_sub', + introductoryOfferEligibility: false, + promotionalOfferJWS: { + offerId: 'promo-offer', + jws: 'compact-jws', + }, + winBackOffer: { + offerId: 'winback-offer', + }, + }, + }, + type: 'subs', + }); + const passed = mockIap.requestPurchase.mock.calls.pop()?.[0]; + expect(passed.ios.introductoryOfferEligibility).toBe(false); + expect(passed.ios.promotionalOfferJWS).toEqual({ + offerId: 'promo-offer', + jws: 'compact-jws', + }); + expect(passed.ios.winBackOffer).toEqual({ + offerId: 'winback-offer', + }); + }); }); describe('getAvailablePurchases', () => { @@ -892,7 +931,7 @@ describe('Public API (src/index.ts)', () => { it('throws on unsupported platform', async () => { (Platform as any).OS = 'web'; await expect(IAP.getAvailablePurchases()).rejects.toThrow( - /Unsupported platform/, + /Unsupported platform: web/, ); }); }); @@ -940,6 +979,13 @@ describe('Public API (src/index.ts)', () => { IAP.finishTransaction({purchase: {id: 'tid'} as any}), ).resolves.toBeUndefined(); }); + + it('throws on unsupported platform', async () => { + (Platform as any).OS = 'web'; + await expect( + IAP.finishTransaction({purchase: {id: 'tid'} as any}), + ).rejects.toThrow(/Unsupported platform: web/); + }); }); describe('storefront helpers', () => { @@ -1377,6 +1423,23 @@ describe('Public API (src/index.ts)', () => { await expect(IAP.deepLinkToSubscriptions()).resolves.toBeUndefined(); expect(mockIap.showManageSubscriptionsIOS).toHaveBeenCalled(); }); + + it('deepLinkToSubscriptions surfaces iOS native failures', async () => { + (Platform as any).OS = 'ios'; + mockIap.deepLinkToSubscriptionsIOS = jest.fn(async () => { + throw new Error('scene missing'); + }); + await expect(IAP.deepLinkToSubscriptions()).rejects.toThrow( + 'scene missing', + ); + }); + + it('deepLinkToSubscriptions throws on unsupported platform', async () => { + (Platform as any).OS = 'web'; + await expect(IAP.deepLinkToSubscriptions()).rejects.toThrow( + 'Unsupported platform: web', + ); + }); }); describe('subscription helpers', () => { diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index 227b7c18..e80234b0 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -87,7 +87,7 @@ type UseIap = { * @remarks **Critical:** Android purchases must be finalized within 3 days or Google * auto-refunds. iOS unfinished transactions replay on every app launch. * - * @see {@link https://www.openiap.dev/docs/apis/finish-transaction} + * @see {@link https://openiap.dev/docs/apis/finish-transaction} */ finishTransaction: (args: MutationFinishTransactionArgs) => Promise; /** @@ -118,7 +118,7 @@ type UseIap = { * }, [availablePurchases, finishTransaction]); * ``` * - * @see {@link https://www.openiap.dev/docs/apis/get-available-purchases} + * @see {@link https://openiap.dev/docs/apis/get-available-purchases} */ getAvailablePurchases: (options?: PurchaseOptions) => Promise; /** @@ -148,7 +148,7 @@ type UseIap = { * @remarks This is a regular promise-based call. Don't confuse with `request*` APIs * (`requestPurchase`), which are event-based. * - * @see {@link https://www.openiap.dev/docs/apis/fetch-products} + * @see {@link https://openiap.dev/docs/apis/fetch-products} */ fetchProducts: (params: { skus: string[]; @@ -179,13 +179,13 @@ type UseIap = { * @remarks Event-based. Listen for the result via {@link purchaseUpdatedListener} / * {@link purchaseErrorListener}, or use `useIAP({ onPurchaseSuccess, onPurchaseError })`. * - * @see {@link https://www.openiap.dev/docs/apis/request-purchase} + * @see {@link https://openiap.dev/docs/apis/request-purchase} */ requestPurchase: (params: RequestPurchaseProps) => Promise; /** * @deprecated Use `verifyPurchase` instead. This function will be removed in a future version. * - * @see {@link https://www.openiap.dev/docs/apis/validate-receipt} + * @see {@link https://openiap.dev/docs/apis/validate-receipt} */ validateReceipt: ( options: VerifyPurchaseProps, @@ -193,7 +193,7 @@ type UseIap = { /** * Verify a purchase against your own backend (returns isValid + raw store metadata). * - * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase} + * @see {@link https://openiap.dev/docs/features/validation#verify-purchase} */ verifyPurchase: ( options: VerifyPurchaseProps, @@ -201,7 +201,7 @@ type UseIap = { /** * Verify via a managed provider — currently only `iapkit` (IAPKit). The PurchaseVerificationProvider enum exposes no other provider literal today. * - * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider} + * @see {@link https://openiap.dev/docs/features/validation#verify-purchase-with-provider} */ verifyPurchaseWithProvider: ( options: VerifyPurchaseWithProviderProps, @@ -209,25 +209,25 @@ type UseIap = { /** * Restore non-consumable and active subscription purchases. * - * @see {@link https://www.openiap.dev/docs/apis/restore-purchases} + * @see {@link https://openiap.dev/docs/apis/restore-purchases} */ restorePurchases: (options?: PurchaseOptions) => Promise; /** * Read the App Store-promoted product, if any. * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-promoted-product-ios} */ getPromotedProductIOS: () => Promise; /** * Buy the currently promoted product. * - * @see {@link https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios} + * @see {@link https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios} */ requestPurchaseOnPromotedProductIOS: () => Promise; /** * Get details of all currently active subscriptions. * - * @see {@link https://www.openiap.dev/docs/apis/get-active-subscriptions} + * @see {@link https://openiap.dev/docs/apis/get-active-subscriptions} */ getActiveSubscriptions: ( subscriptionIds?: string[], @@ -235,7 +235,7 @@ type UseIap = { /** * Check whether the user has any active subscription. * - * @see {@link https://www.openiap.dev/docs/apis/has-active-subscriptions} + * @see {@link https://openiap.dev/docs/apis/has-active-subscriptions} */ hasActiveSubscriptions: (subscriptionIds?: string[]) => Promise; /** @@ -248,19 +248,19 @@ type UseIap = { /** * Check whether alternative billing is available for the user. * - * @see {@link https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android} + * @see {@link https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android} */ checkAlternativeBillingAvailabilityAndroid?: () => Promise; /** * Display Google's alternative billing information dialog. * - * @see {@link https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android} + * @see {@link https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android} */ showAlternativeBillingDialogAndroid?: () => Promise; /** * Create a reporting token for an alternative billing flow. * - * @see {@link https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android} + * @see {@link https://openiap.dev/docs/apis/android/create-alternative-billing-token-android} */ createAlternativeBillingTokenAndroid?: ( sku?: string, diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 0b2d5850..0f65e6fd 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -44,6 +44,8 @@ import type { RequestSubscriptionIosProps, RequestSubscriptionPropsByPlatforms, ActiveSubscription, + DeveloperProvidedBillingDetailsAndroid, + UserChoiceBillingDetails, } from './types'; import { convertNitroProductToProduct, @@ -72,8 +74,6 @@ import {parseAppTransactionPayload} from './utils'; // Import them here for use in this file's interfaces and functions. import type { BillingProgramAndroid, - ExternalLinkLaunchModeAndroid, - ExternalLinkTypeAndroid, } from './types'; // Export all types @@ -89,7 +89,7 @@ export * from './utils/error'; export type ProductTypeInput = 'inapp' | 'in-app' | 'subs'; const LEGACY_INAPP_WARNING = - "[react-native-iap] `type: 'inapp'` is deprecated and will be removed in v14.4.0. Use 'in-app' instead."; + "[react-native-iap] `type: 'inapp'` is deprecated and will be removed in a future major version. Use 'in-app' instead."; type NitroPurchaseRequest = Parameters[0]; type NitroAvailablePurchasesOptions = NonNullable< @@ -121,6 +121,9 @@ const toErrorMessage = (error: unknown): string => { return String(error ?? ''); }; +const unsupportedPlatformError = (): Error => + new Error(`Unsupported platform: ${Platform.OS}`); + export interface EventSubscription { remove(): void; } @@ -517,7 +520,7 @@ export const promotedProductListenerIOS = ( * const subscription = userChoiceBillingListenerAndroid((details) => { * console.log('User chose alternative billing'); * console.log('Products:', details.products); - * console.log('Token:', details.externalTransactionToken); + * console.log('External transaction token received; send it to your backend without logging it.'); * * // Send token to backend for Google Play reporting * await reportToGooglePlay(details.externalTransactionToken); @@ -531,7 +534,9 @@ type NitroUserChoiceBillingListener = Parameters< RnIap['addUserChoiceBillingListenerAndroid'] >[0]; -const userChoiceBillingJsListeners = new Set<(details: any) => void>(); +const userChoiceBillingJsListeners = new Set< + (details: UserChoiceBillingDetails) => void +>(); let userChoiceBillingNativeAttached = false; const userChoiceBillingNativeHandler: NitroUserChoiceBillingListener = ( details, @@ -549,7 +554,7 @@ const userChoiceBillingNativeHandler: NitroUserChoiceBillingListener = ( }; export const userChoiceBillingListenerAndroid = ( - listener: (details: any) => void, + listener: (details: UserChoiceBillingDetails) => void, ): EventSubscription => { if (Platform.OS !== 'android') { RnIapConsole.warn( @@ -602,7 +607,7 @@ export const userChoiceBillingListenerAndroid = ( * ```typescript * const subscription = developerProvidedBillingListenerAndroid((details) => { * console.log('User chose developer billing'); - * console.log('Token:', details.externalTransactionToken); + * console.log('External transaction token received; send it to your backend without logging it.'); * * // Process payment through your external payment system * await processExternalPayment(); @@ -637,15 +642,6 @@ const developerProvidedBillingNativeHandler: NitroDeveloperProvidedBillingListen } }; -export interface DeveloperProvidedBillingDetailsAndroid { - /** - * External transaction token used to report transactions made through developer billing. - * This token must be used when reporting the external transaction to Google Play. - * Must be reported within 24 hours of the transaction. - */ - externalTransactionToken: string; -} - export const developerProvidedBillingListenerAndroid = ( listener: (details: DeveloperProvidedBillingDetailsAndroid) => void, ): EventSubscription => { @@ -801,7 +797,7 @@ export const subscriptionBillingIssueListener = ( * @remarks This is a regular promise-based call. Don't confuse with `request*` APIs * (`requestPurchase`), which are event-based. * - * @see {@link https://www.openiap.dev/docs/apis/fetch-products} + * @see {@link https://openiap.dev/docs/apis/fetch-products} */ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { const {skus, type} = request; @@ -943,7 +939,7 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/get-available-purchases} + * @see {@link https://openiap.dev/docs/apis/get-available-purchases} */ export const getAvailablePurchases: QueryField< 'getAvailablePurchases' @@ -998,7 +994,7 @@ export const getAvailablePurchases: QueryField< return validPurchases.map(convertNitroPurchaseToPurchase); } else { - throw new Error('Unsupported platform'); + throw unsupportedPlatformError(); } } catch (error) { RnIapConsole.error('Failed to get available purchases:', error); @@ -1011,7 +1007,7 @@ export const getAvailablePurchases: QueryField< * @returns Promise - The promoted product or null if none available * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-promoted-product-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-promoted-product-ios} */ export const getPromotedProductIOS: QueryField< 'getPromotedProductIOS' @@ -1055,7 +1051,7 @@ export const requestPromotedProductIOS = getPromotedProductIOS; * console.log('User storefront:', storefront); // e.g., 'USA', 'GBR', 'KOR' * ``` * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-storefront-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-storefront-ios} */ export const getStorefrontIOS: QueryField<'getStorefrontIOS'> = async () => { if (Platform.OS !== 'ios') { @@ -1074,7 +1070,7 @@ export const getStorefrontIOS: QueryField<'getStorefrontIOS'> = async () => { /** * Return the user's storefront country code. * - * @see {@link https://www.openiap.dev/docs/apis/get-storefront} + * @see {@link https://openiap.dev/docs/apis/get-storefront} */ export const getStorefront: QueryField<'getStorefront'> = async () => { if (Platform.OS !== 'ios' && Platform.OS !== 'android') { @@ -1128,7 +1124,7 @@ export const getStorefront: QueryField<'getStorefront'> = async () => { * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-app-transaction-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-app-transaction-ios} */ export const getAppTransactionIOS: QueryField< 'getAppTransactionIOS' @@ -1169,7 +1165,7 @@ export const getAppTransactionIOS: QueryField< * @throws Error when called on non-iOS platforms or when IAP is not initialized * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/subscription-status-ios} + * @see {@link https://openiap.dev/docs/apis/ios/subscription-status-ios} */ export const subscriptionStatusIOS: QueryField< 'subscriptionStatusIOS' @@ -1202,7 +1198,7 @@ export const subscriptionStatusIOS: QueryField< * @returns Promise - Current entitlement or null * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/current-entitlement-ios} + * @see {@link https://openiap.dev/docs/apis/ios/current-entitlement-ios} */ export const currentEntitlementIOS: QueryField< 'currentEntitlementIOS' @@ -1236,7 +1232,7 @@ export const currentEntitlementIOS: QueryField< * @returns Promise - Latest transaction or null * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/latest-transaction-ios} + * @see {@link https://openiap.dev/docs/apis/ios/latest-transaction-ios} */ export const latestTransactionIOS: QueryField<'latestTransactionIOS'> = async ( sku, @@ -1269,7 +1265,7 @@ export const latestTransactionIOS: QueryField<'latestTransactionIOS'> = async ( * @returns Promise - Array of pending transactions * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-pending-transactions-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-pending-transactions-ios} */ export const getPendingTransactionsIOS: QueryField< 'getPendingTransactionsIOS' @@ -1300,7 +1296,7 @@ export const getPendingTransactionsIOS: QueryField< /** * List every StoreKit transaction (finished + unfinished) for the current user. * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-all-transactions-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-all-transactions-ios} */ export const getAllTransactionsIOS: QueryField< 'getAllTransactionsIOS' @@ -1333,7 +1329,7 @@ export const getAllTransactionsIOS: QueryField< * @returns Promise - Subscriptions where auto-renewal status changed * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/show-manage-subscriptions-ios} + * @see {@link https://openiap.dev/docs/apis/ios/show-manage-subscriptions-ios} */ export const showManageSubscriptionsIOS: MutationField< 'showManageSubscriptionsIOS' @@ -1367,7 +1363,7 @@ export const showManageSubscriptionsIOS: MutationField< * @returns Promise - Eligibility status * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios} + * @see {@link https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios} */ export const isEligibleForIntroOfferIOS: QueryField< 'isEligibleForIntroOfferIOS' @@ -1395,7 +1391,7 @@ export const isEligibleForIntroOfferIOS: QueryField< * @returns Promise - Base64 encoded receipt data * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-receipt-data-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-receipt-data-ios} */ export const getReceiptDataIOS: QueryField<'getReceiptDataIOS'> = async () => { if (Platform.OS !== 'ios') { @@ -1484,7 +1480,7 @@ export const requestReceiptRefreshIOS = async (): Promise => { * @returns Promise - Verification status * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/is-transaction-verified-ios} + * @see {@link https://openiap.dev/docs/apis/ios/is-transaction-verified-ios} */ export const isTransactionVerifiedIOS: QueryField< 'isTransactionVerifiedIOS' @@ -1513,7 +1509,7 @@ export const isTransactionVerifiedIOS: QueryField< * @returns Promise - JWS representation or null * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-transaction-jws-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-transaction-jws-ios} */ export const getTransactionJwsIOS: QueryField<'getTransactionJwsIOS'> = async ( sku, @@ -1557,7 +1553,7 @@ export const getTransactionJwsIOS: QueryField<'getTransactionJwsIOS'> = async ( * @remarks When using `useIAP()`, connection is auto-managed on mount/unmount — * pass options to the hook instead of calling this directly. * - * @see {@link https://www.openiap.dev/docs/apis/init-connection} + * @see {@link https://openiap.dev/docs/apis/init-connection} */ export const initConnection: MutationField<'initConnection'> = async ( config, @@ -1581,7 +1577,7 @@ export const initConnection: MutationField<'initConnection'> = async ( /** * Close the store connection and release resources. * - * @see {@link https://www.openiap.dev/docs/apis/end-connection} + * @see {@link https://openiap.dev/docs/apis/end-connection} */ export const endConnection: MutationField<'endConnection'> = async () => { try { @@ -1604,7 +1600,7 @@ export const endConnection: MutationField<'endConnection'> = async () => { /** * Restore non-consumable and active subscription purchases. * - * @see {@link https://www.openiap.dev/docs/apis/restore-purchases} + * @see {@link https://openiap.dev/docs/apis/restore-purchases} */ export const restorePurchases: MutationField<'restorePurchases'> = async () => { try { @@ -1652,7 +1648,7 @@ export const restorePurchases: MutationField<'restorePurchases'> = async () => { * @remarks Event-based. Listen for the result via {@link purchaseUpdatedListener} / * {@link purchaseErrorListener}, or use `useIAP({ onPurchaseSuccess, onPurchaseError })`. * - * @see {@link https://www.openiap.dev/docs/apis/request-purchase} + * @see {@link https://openiap.dev/docs/apis/request-purchase} */ export const requestPurchase: MutationField<'requestPurchase'> = async ( request, @@ -1688,7 +1684,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( ); } } else { - throw new Error('Unsupported platform'); + throw unsupportedPlatformError(); } const unifiedRequest: NitroPurchaseRequest = {}; @@ -1723,6 +1719,22 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( if (iosRequest.advancedCommerceData) { iosPayload.advancedCommerceData = iosRequest.advancedCommerceData; } + if (isSubs) { + const subscriptionRequest = iosRequest as RequestSubscriptionIosProps; + if ( + subscriptionRequest.introductoryOfferEligibility !== undefined + ) { + iosPayload.introductoryOfferEligibility = + subscriptionRequest.introductoryOfferEligibility; + } + if (subscriptionRequest.promotionalOfferJWS) { + iosPayload.promotionalOfferJWS = + subscriptionRequest.promotionalOfferJWS; + } + if (subscriptionRequest.winBackOffer) { + iosPayload.winBackOffer = subscriptionRequest.winBackOffer; + } + } unifiedRequest.ios = iosPayload; } @@ -1820,7 +1832,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( * @remarks **Critical:** Android purchases must be finalized within 3 days or Google * auto-refunds. iOS unfinished transactions replay on every app launch. * - * @see {@link https://www.openiap.dev/docs/apis/finish-transaction} + * @see {@link https://openiap.dev/docs/apis/finish-transaction} */ export const finishTransaction: MutationField<'finishTransaction'> = async ( args, @@ -1851,7 +1863,7 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ( }, }; } else { - throw new Error('Unsupported platform'); + throw unsupportedPlatformError(); } const result = await IAP.instance.finishTransaction(params); @@ -1889,7 +1901,7 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ( * await acknowledgePurchaseAndroid('purchase_token_here'); * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/acknowledge-purchase-android} + * @see {@link https://openiap.dev/docs/apis/android/acknowledge-purchase-android} */ export const acknowledgePurchaseAndroid: MutationField< 'acknowledgePurchaseAndroid' @@ -1930,7 +1942,7 @@ export const acknowledgePurchaseAndroid: MutationField< * await consumePurchaseAndroid('purchase_token_here'); * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/consume-purchase-android} + * @see {@link https://openiap.dev/docs/apis/android/consume-purchase-android} */ export const consumePurchaseAndroid: MutationField< 'consumePurchaseAndroid' @@ -1987,7 +1999,7 @@ export const consumePurchaseAndroid: MutationField< * }); * ``` * - * @see {@link https://www.openiap.dev/docs/apis/validate-receipt} + * @see {@link https://openiap.dev/docs/apis/validate-receipt} */ export const validateReceipt: MutationField<'validateReceipt'> = async ( options, @@ -2118,7 +2130,7 @@ export const validateReceipt: MutationField<'validateReceipt'> = async ( * @param options - Receipt validation options containing the SKU * @returns Promise resolving to receipt validation result * - * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase} + * @see {@link https://openiap.dev/docs/features/validation#verify-purchase} */ export const verifyPurchase: MutationField<'verifyPurchase'> = validateReceipt; @@ -2129,7 +2141,7 @@ export const verifyPurchase: MutationField<'verifyPurchase'> = validateReceipt; * consumers who imported `validateReceiptIOS` — which is still declared on the * OpenIAP Query interface — keep working. Throws on non-iOS platforms. * - * @see {@link https://www.openiap.dev/docs/apis/ios/validate-receipt-ios} + * @see {@link https://openiap.dev/docs/apis/ios/validate-receipt-ios} */ export const validateReceiptIOS: QueryField<'validateReceiptIOS'> = async ( options, @@ -2162,7 +2174,7 @@ export const validateReceiptIOS: QueryField<'validateReceiptIOS'> = async ( * }); * ``` * - * @see {@link https://www.openiap.dev/docs/features/validation#verify-purchase-with-provider} + * @see {@link https://openiap.dev/docs/features/validation#verify-purchase-with-provider} */ export const verifyPurchaseWithProvider: MutationField< 'verifyPurchaseWithProvider' @@ -2207,7 +2219,7 @@ export const verifyPurchaseWithProvider: MutationField< * @returns Promise * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/sync-ios} + * @see {@link https://openiap.dev/docs/apis/ios/sync-ios} */ export const syncIOS: MutationField<'syncIOS'> = async () => { if (Platform.OS !== 'ios') { @@ -2234,7 +2246,7 @@ export const syncIOS: MutationField<'syncIOS'> = async () => { * @returns Promise - Indicates whether the redemption sheet was presented * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios} + * @see {@link https://openiap.dev/docs/apis/ios/present-code-redemption-sheet-ios} */ export const presentCodeRedemptionSheetIOS: MutationField< 'presentCodeRedemptionSheetIOS' @@ -2278,7 +2290,7 @@ export const presentCodeRedemptionSheetIOS: MutationField< * @returns Promise - true when the request triggers successfully * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios} + * @see {@link https://openiap.dev/docs/apis/ios/request-purchase-on-promoted-product-ios} */ export const requestPurchaseOnPromotedProductIOS = async (): Promise => { @@ -2322,7 +2334,7 @@ export const requestPurchaseOnPromotedProductIOS = * @returns Promise * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/clear-transaction-ios} + * @see {@link https://openiap.dev/docs/apis/ios/clear-transaction-ios} */ export const clearTransactionIOS: MutationField< 'clearTransactionIOS' @@ -2352,7 +2364,7 @@ export const clearTransactionIOS: MutationField< * @returns Promise - The refund status or null if not available * @platform iOS * - * @see {@link https://www.openiap.dev/docs/apis/ios/begin-refund-request-ios} + * @see {@link https://openiap.dev/docs/apis/ios/begin-refund-request-ios} */ export const beginRefundRequestIOS: MutationField< 'beginRefundRequestIOS' @@ -2380,7 +2392,7 @@ export const beginRefundRequestIOS: MutationField< * Deeplinks to native interface that allows users to manage their subscriptions * Cross-platform alias aligning with expo-iap * - * @see {@link https://www.openiap.dev/docs/apis/deep-link-to-subscriptions} + * @see {@link https://openiap.dev/docs/apis/deep-link-to-subscriptions} */ export const deepLinkToSubscriptions: MutationField< 'deepLinkToSubscriptions' @@ -2395,16 +2407,15 @@ export const deepLinkToSubscriptions: MutationField< return; } if (Platform.OS === 'ios') { - try { - if (typeof IAP.instance.deepLinkToSubscriptionsIOS === 'function') { - await IAP.instance.deepLinkToSubscriptionsIOS(); - } else { - await IAP.instance.showManageSubscriptionsIOS(); - } - } catch (error) { - RnIapConsole.warn('[deepLinkToSubscriptions] Failed on iOS:', error); + if (typeof IAP.instance.deepLinkToSubscriptionsIOS === 'function') { + await IAP.instance.deepLinkToSubscriptionsIOS(); + } else { + await IAP.instance.showManageSubscriptionsIOS(); } + return; } + + throw unsupportedPlatformError(); }; export const deepLinkToSubscriptionsIOS = async (): Promise => { @@ -2443,7 +2454,7 @@ export const deepLinkToSubscriptionsIOS = async (): Promise => { * @param subscriptionIds - Optional array of subscription IDs to filter by * @returns Promise - Array of active subscriptions * - * @see {@link https://www.openiap.dev/docs/apis/get-active-subscriptions} + * @see {@link https://openiap.dev/docs/apis/get-active-subscriptions} */ export const getActiveSubscriptions: QueryField< 'getActiveSubscriptions' @@ -2469,7 +2480,7 @@ export const getActiveSubscriptions: QueryField< environmentIOS: sub.environmentIOS ?? null, willExpireSoon: sub.willExpireSoon ?? null, daysUntilExpirationIOS: sub.daysUntilExpirationIOS ?? null, - // 🆕 renewalInfoIOS - subscription lifecycle information (iOS only) + // renewalInfoIOS contains subscription lifecycle information on iOS. renewalInfoIOS: sub.renewalInfoIOS ? { willAutoRenew: sub.renewalInfoIOS.willAutoRenew ?? false, @@ -2512,90 +2523,6 @@ export const getActiveSubscriptions: QueryField< } }; -// OLD IMPLEMENTATION - REPLACED WITH NATIVE CALL -/* -export const getActiveSubscriptions_OLD: QueryField< - 'getActiveSubscriptions' -> = async (subscriptionIds) => { - try { - // Get all available purchases first - const allPurchases = await getAvailablePurchases(); - - // For the critical bug fix: this function was previously returning ALL purchases - // Now we properly filter for subscriptions only - - // In production with real data, Android subscription filtering is done via platform-specific calls - // But for backward compatibility and test support, we also check platform-specific fields - - // Since expirationDateIOS and subscriptionGroupIdIOS are not available in NitroPurchase, - // we need to rely on other indicators or assume all purchases are subscriptions - // when called from getActiveSubscriptions - const purchases = allPurchases; - - // Filter for subscriptions and map to ActiveSubscription format - const subscriptions = purchases - .filter((purchase) => { - // Filter by subscription IDs if provided - if (subscriptionIds && subscriptionIds.length > 0) { - return subscriptionIds.includes(purchase.productId); - } - return true; - }) - .map((purchase): ActiveSubscription => { - // Safe access to platform-specific fields with type guards - const expirationDateIOS = - 'expirationDateIOS' in purchase - ? ((purchase as PurchaseIOS).expirationDateIOS ?? null) - : null; - - const environmentIOS = - 'environmentIOS' in purchase - ? ((purchase as PurchaseIOS).environmentIOS ?? null) - : null; - - const autoRenewingAndroid = - 'autoRenewingAndroid' in purchase || 'isAutoRenewing' in purchase - ? ((purchase as PurchaseAndroid).autoRenewingAndroid ?? - (purchase as PurchaseAndroid).isAutoRenewing) // deprecated - use isAutoRenewing instead - : null; - - // 🆕 Extract renewalInfoIOS if available - const renewalInfoIOS = - 'renewalInfoIOS' in purchase - ? ((purchase as PurchaseIOS).renewalInfoIOS ?? null) - : null; - - return { - productId: purchase.productId, - isActive: true, // If it's in availablePurchases, it's active - // Backend validation fields - use transactionId ?? id for proper field mapping - transactionId: purchase.transactionId ?? purchase.id, - purchaseToken: purchase.purchaseToken, - transactionDate: purchase.transactionDate, - // Platform-specific fields - expirationDateIOS, - autoRenewingAndroid, - environmentIOS, - renewalInfoIOS, - // Convenience fields - willExpireSoon: false, // Would need to calculate based on expiration date - daysUntilExpirationIOS: - expirationDateIOS != null - ? Math.ceil( - (expirationDateIOS - Date.now()) / (1000 * 60 * 60 * 24), - ) - : null, - }; - }); - - return subscriptions; - } catch (error) { - RnIapConsole.error('Failed to get active subscriptions:', error); - const errorJson = parseErrorStringToJsonObj(error); - throw new Error(errorJson.message); - } -}; - /** * Check if the user has any active subscriptions (OpenIAP compliant) * Returns true if the user has at least one active subscription, false otherwise. @@ -2604,7 +2531,7 @@ export const getActiveSubscriptions_OLD: QueryField< * @param subscriptionIds - Optional array of subscription IDs to check * @returns Promise - True if there are active subscriptions * - * @see {@link https://www.openiap.dev/docs/apis/has-active-subscriptions} + * @see {@link https://openiap.dev/docs/apis/has-active-subscriptions} */ export const hasActiveSubscriptions: QueryField< 'hasActiveSubscriptions' @@ -2733,7 +2660,7 @@ const normalizeProductQueryType = ( * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/check-alternative-billing-availability-android} + * @see {@link https://openiap.dev/docs/apis/android/check-alternative-billing-availability-android} */ export const checkAlternativeBillingAvailabilityAndroid: MutationField< 'checkAlternativeBillingAvailabilityAndroid' @@ -2775,7 +2702,7 @@ export const checkAlternativeBillingAvailabilityAndroid: MutationField< * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/show-alternative-billing-dialog-android} + * @see {@link https://openiap.dev/docs/apis/android/show-alternative-billing-dialog-android} */ export const showAlternativeBillingDialogAndroid: MutationField< 'showAlternativeBillingDialogAndroid' @@ -2814,7 +2741,7 @@ export const showAlternativeBillingDialogAndroid: MutationField< * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/create-alternative-billing-token-android} + * @see {@link https://openiap.dev/docs/apis/android/create-alternative-billing-token-android} */ export const createAlternativeBillingTokenAndroid: MutationField< 'createAlternativeBillingTokenAndroid' @@ -2830,40 +2757,6 @@ export const createAlternativeBillingTokenAndroid: MutationField< } }; -/** - * Parameters for launching an external link (Android 8.2.0+). - */ -export interface LaunchExternalLinkParamsAndroid { - /** The billing program (external-content-link or external-offer) */ - billingProgram: BillingProgramAndroid; - /** The external link launch mode */ - launchMode: ExternalLinkLaunchModeAndroid; - /** The type of the external link */ - linkType: ExternalLinkTypeAndroid; - /** The URI where the content will be accessed from */ - linkUri: string; -} - -/** - * Result of checking billing program availability (Android 8.2.0+). - */ -export interface BillingProgramAvailabilityResultAndroid { - /** The billing program that was checked */ - billingProgram: BillingProgramAndroid; - /** Whether the billing program is available for the user */ - isAvailable: boolean; -} - -/** - * Reporting details for external transactions (Android 8.2.0+). - */ -export interface BillingProgramReportingDetailsAndroid { - /** The billing program that the reporting details are associated with */ - billingProgram: BillingProgramAndroid; - /** External transaction token used to report transactions to Google */ - externalTransactionToken: string; -} - /** * Enable a billing program before initConnection (Android only). * Must be called BEFORE initConnection() to configure the BillingClient. @@ -2889,7 +2782,7 @@ export const enableBillingProgramAndroid = ( return; } try { - IAP.instance.enableBillingProgramAndroid(program as any); + IAP.instance.enableBillingProgramAndroid(program); } catch (error) { RnIapConsole.error('Failed to enable billing program:', error); } @@ -2911,7 +2804,7 @@ export const enableBillingProgramAndroid = ( * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/is-billing-program-available-android} + * @see {@link https://openiap.dev/docs/apis/android/is-billing-program-available-android} */ export const isBillingProgramAvailableAndroid: MutationField< 'isBillingProgramAvailableAndroid' @@ -2920,9 +2813,7 @@ export const isBillingProgramAvailableAndroid: MutationField< throw new Error('Billing Programs API is only supported on Android'); } try { - const result = await IAP.instance.isBillingProgramAvailableAndroid( - program as any, - ); + const result = await IAP.instance.isBillingProgramAvailableAndroid(program); return { billingProgram: result.billingProgram as unknown as BillingProgramAndroid, isAvailable: result.isAvailable, @@ -2952,7 +2843,7 @@ export const isBillingProgramAvailableAndroid: MutationField< * }); * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/create-billing-program-reporting-details-android} + * @see {@link https://openiap.dev/docs/apis/android/create-billing-program-reporting-details-android} */ export const createBillingProgramReportingDetailsAndroid: MutationField< 'createBillingProgramReportingDetailsAndroid' @@ -2962,9 +2853,7 @@ export const createBillingProgramReportingDetailsAndroid: MutationField< } try { const result = - await IAP.instance.createBillingProgramReportingDetailsAndroid( - program as any, - ); + await IAP.instance.createBillingProgramReportingDetailsAndroid(program); return { billingProgram: result.billingProgram as unknown as BillingProgramAndroid, externalTransactionToken: result.externalTransactionToken, @@ -2999,7 +2888,7 @@ export const createBillingProgramReportingDetailsAndroid: MutationField< * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/android/launch-external-link-android} + * @see {@link https://openiap.dev/docs/apis/android/launch-external-link-android} */ export const launchExternalLinkAndroid: MutationField< 'launchExternalLinkAndroid' @@ -3009,9 +2898,9 @@ export const launchExternalLinkAndroid: MutationField< } try { return await IAP.instance.launchExternalLinkAndroid({ - billingProgram: params.billingProgram as any, - launchMode: params.launchMode as any, - linkType: params.linkType as any, + billingProgram: params.billingProgram, + launchMode: params.launchMode, + linkType: params.linkType, linkUri: params.linkUri, }); } catch (error) { @@ -3043,7 +2932,7 @@ export const launchExternalLinkAndroid: MutationField< * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios} + * @see {@link https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios} */ export const canPresentExternalPurchaseNoticeIOS: QueryField< 'canPresentExternalPurchaseNoticeIOS' @@ -3078,7 +2967,7 @@ export const canPresentExternalPurchaseNoticeIOS: QueryField< * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios} + * @see {@link https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios} */ export const presentExternalPurchaseNoticeSheetIOS = async (): Promise => { @@ -3111,7 +3000,7 @@ export const presentExternalPurchaseNoticeSheetIOS = * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/ios/present-external-purchase-link-ios} + * @see {@link https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios} */ export const presentExternalPurchaseLinkIOS: MutationField< 'presentExternalPurchaseLinkIOS' @@ -3120,7 +3009,7 @@ export const presentExternalPurchaseLinkIOS: MutationField< throw new Error('External purchase is only supported on iOS'); } try { - return (await IAP.instance.presentExternalPurchaseLinkIOS(url)) as any; + return await IAP.instance.presentExternalPurchaseLinkIOS(url); } catch (error) { RnIapConsole.error('Failed to present external purchase link:', error); throw error; @@ -3147,7 +3036,7 @@ export const presentExternalPurchaseLinkIOS: MutationField< * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios} + * @see {@link https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios} */ export const isEligibleForExternalPurchaseCustomLinkIOS = async (): Promise => { @@ -3184,7 +3073,7 @@ export const isEligibleForExternalPurchaseCustomLinkIOS = * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios} + * @see {@link https://openiap.dev/docs/apis/ios/get-external-purchase-custom-link-token-ios} */ export const getExternalPurchaseCustomLinkTokenIOS = async ( tokenType: ExternalPurchaseCustomLinkTokenTypeIOS, @@ -3224,7 +3113,7 @@ export const getExternalPurchaseCustomLinkTokenIOS = async ( * } * ``` * - * @see {@link https://www.openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios} + * @see {@link https://openiap.dev/docs/apis/ios/show-external-purchase-custom-link-notice-ios} */ export const showExternalPurchaseCustomLinkNoticeIOS = async ( noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS, diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index 65f86e25..cdd92767 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -35,6 +35,7 @@ import type { ExternalPurchaseCustomLinkTokenTypeIOS, ExternalPurchaseLinkResultIOS, ExternalPurchaseNoticeResultIOS, + DeveloperProvidedBillingDetailsAndroid, MutationFinishTransactionArgs, ProductCommon, PromotionalOfferJwsInputIOS, @@ -147,8 +148,7 @@ export interface NitroReceiptValidationHorizonOptions { userId: VerifyPurchaseHorizonOptions['userId']; } -export interface NitroPurchaseUpdatedListenerOptions - extends PurchaseUpdatedListenerOptions {} +export type NitroPurchaseUpdatedListenerOptions = PurchaseUpdatedListenerOptions; export interface NitroReceiptValidationParams { apple?: NitroReceiptValidationAppleOptions | null; @@ -426,19 +426,6 @@ export interface NitroBillingProgramReportingDetailsAndroid { externalTransactionToken: string; } -/** - * Details provided when user selects developer billing option (Android 8.3.0+) - * Received via DeveloperProvidedBillingListener callback in External Payments flow - */ -export interface DeveloperProvidedBillingDetailsAndroid { - /** - * External transaction token used to report transactions made through developer billing. - * This token must be used when reporting the external transaction to Google Play. - * Must be reported within 24 hours of the transaction. - */ - externalTransactionToken: string; -} - /** * Discount amount details for one-time purchase offers (Android) */ From b9f8a70a4a6a763012c8b0e69814a60528ab165f Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 16 May 2026 14:41:05 +0900 Subject: [PATCH 06/26] fix(kit): harden validation and sync flows Tighten Kit API validation, request handling, Convex sync behavior, and dashboard handling with focused test coverage. --- packages/kit/.env.example | 8 +- packages/kit/CONVENTION.md | 2 +- packages/kit/README.md | 59 +- packages/kit/convex/ResendOTP.ts | 19 +- packages/kit/convex/analytics/action.ts | 15 +- packages/kit/convex/apiKeys/helpers.ts | 4 +- packages/kit/convex/apiKeys/internal.ts | 56 +- packages/kit/convex/apiKeys/query.ts | 28 +- .../certificates/apple_root_certificates.ts | 5 +- packages/kit/convex/files/internal.ts | 9 +- packages/kit/convex/products/asc.test.ts | 31 +- packages/kit/convex/products/asc.ts | 86 ++- packages/kit/convex/products/jobs.ts | 95 ++- packages/kit/convex/products/mutation.ts | 71 ++- packages/kit/convex/products/play.test.ts | 60 +- packages/kit/convex/products/play.ts | 48 +- packages/kit/convex/products/query.ts | 18 +- packages/kit/convex/products/sync.test.ts | 17 + packages/kit/convex/products/sync.ts | 21 + packages/kit/convex/projects/helpers.ts | 62 +- packages/kit/convex/projects/internal.ts | 28 +- packages/kit/convex/projects/mutation.test.ts | 21 + packages/kit/convex/projects/mutation.ts | 8 +- packages/kit/convex/projects/query.ts | 140 ++++- packages/kit/convex/projects/setupStatus.ts | 20 +- packages/kit/convex/purchases/android.test.ts | 24 + packages/kit/convex/purchases/android.ts | 47 +- packages/kit/convex/purchases/horizon.ts | 8 +- packages/kit/convex/purchases/ios.ts | 8 +- packages/kit/convex/subscriptions/horizon.ts | 22 +- .../convex/subscriptions/horizonInternal.ts | 7 +- packages/kit/convex/subscriptions/mutation.ts | 8 +- packages/kit/convex/subscriptions/query.ts | 59 +- packages/kit/convex/webhooks/apple.ts | 5 +- packages/kit/convex/webhooks/google.ts | 11 +- packages/kit/convex/webhooks/query.ts | 21 +- packages/kit/package.json | 12 +- packages/kit/public/llms-full.txt | 11 +- packages/kit/public/llms.txt | 6 +- packages/kit/scripts/smoke-server.sh | 1 + packages/kit/server/api/v1/middleware.test.ts | 29 +- packages/kit/server/api/v1/middleware.ts | 30 + packages/kit/server/api/v1/products.test.ts | 583 ++++++++++++++++++ packages/kit/server/api/v1/products.ts | 521 +++++++++++----- packages/kit/server/api/v1/rate-limit.test.ts | 5 + .../kit/server/api/v1/replay-guard.test.ts | 21 + packages/kit/server/api/v1/replay-guard.ts | 37 +- .../kit/server/api/v1/request-body.test.ts | 20 + packages/kit/server/api/v1/request-body.ts | 64 ++ .../kit/server/api/v1/request-logger.test.ts | 25 + packages/kit/server/api/v1/request-logger.ts | 17 +- packages/kit/server/api/v1/routes.ts | 25 +- .../kit/server/api/v1/subscriptions.test.ts | 354 +++++++++++ packages/kit/server/api/v1/subscriptions.ts | 326 ++++++++-- packages/kit/server/api/v1/validator.test.ts | 76 ++- packages/kit/server/api/v1/validator.ts | 144 ++++- packages/kit/server/api/v1/webhooks.test.ts | 216 ++++++- packages/kit/server/api/v1/webhooks.ts | 318 +++++++--- packages/kit/server/convex.test.ts | 44 ++ packages/kit/server/convex.ts | 11 +- packages/kit/server/utils/env.test.ts | 9 +- packages/kit/server/utils/env.ts | 8 +- packages/kit/src/components/Footer.tsx | 2 +- .../src/components/FreeTransitionNotice.tsx | 2 +- .../kit/src/components/PublicNavigation.tsx | 4 +- .../src/pages/auth/organization/create.tsx | 2 +- .../src/pages/auth/organization/dashboard.tsx | 2 +- .../auth/organization/project/analytics.tsx | 7 +- .../auth/organization/project/apikeys.tsx | 16 +- .../organization/project/productPrice.test.ts | 20 + .../auth/organization/project/productPrice.ts | 15 + .../auth/organization/project/products.tsx | 42 +- .../organization/project/purchase-detail.tsx | 84 ++- .../auth/organization/project/settings.tsx | 1 - .../organization/project/subscriptions.tsx | 7 +- .../auth/organization/project/webhooks.tsx | 246 ++++---- .../organization/projects/ProjectCard.tsx | 1 - .../kit/src/pages/auth/organization/usage.tsx | 2 +- .../src/pages/blog/iapkit-joins-openiap.tsx | 6 +- packages/kit/src/pages/blog/index.tsx | 2 +- .../kit/src/pages/docs/sections/analytics.tsx | 25 +- packages/kit/src/pages/docs/sections/api.tsx | 39 +- .../src/pages/docs/sections/introduction.tsx | 7 +- .../src/pages/docs/sections/operations.tsx | 59 +- .../kit/src/pages/docs/sections/projects.tsx | 5 +- .../src/pages/docs/sections/quickstart.tsx | 12 +- .../src/pages/docs/sections/release-notes.tsx | 4 +- .../docs/sections/verification-apple.tsx | 2 +- .../docs/sections/verification-google.tsx | 2 +- .../docs/sections/verification-horizon.tsx | 2 +- packages/kit/src/pages/landing.tsx | 6 +- 91 files changed, 3798 insertions(+), 890 deletions(-) create mode 100644 packages/kit/convex/products/sync.test.ts create mode 100644 packages/kit/convex/projects/mutation.test.ts create mode 100644 packages/kit/server/api/v1/products.test.ts create mode 100644 packages/kit/server/api/v1/request-body.test.ts create mode 100644 packages/kit/server/api/v1/request-body.ts create mode 100644 packages/kit/server/api/v1/subscriptions.test.ts create mode 100644 packages/kit/server/convex.test.ts create mode 100644 packages/kit/src/pages/auth/organization/project/productPrice.test.ts create mode 100644 packages/kit/src/pages/auth/organization/project/productPrice.ts diff --git a/packages/kit/.env.example b/packages/kit/.env.example index 5978d5f8..2b0a9ae4 100644 --- a/packages/kit/.env.example +++ b/packages/kit/.env.example @@ -54,10 +54,10 @@ CONVEX_DEPLOY_KEY= # tune burst protection; the Convex-side monthly plan quota enforces # sustained usage separately. # ──────────────────────────────────────────────────────────────── -# Maximum burst size. Default: 60. -# RATE_LIMIT_CAPACITY=60 -# Steady-state refill, tokens per second. Default: 1 (= 60 req/min). -# RATE_LIMIT_REFILL_PER_SEC=1 +# Maximum burst size. Default: 600. +# RATE_LIMIT_CAPACITY=600 +# Steady-state refill, tokens per second. Default: 10 (= 600 req/min). +# RATE_LIMIT_REFILL_PER_SEC=10 # Upper bound on the in-memory bucket store (LRU-evicts oldest when # exceeded). Caps resident memory if a caller churns random API keys # — apiKeyMiddleware only parses the Bearer header; database validation diff --git a/packages/kit/CONVENTION.md b/packages/kit/CONVENTION.md index 784de0ad..fdcd5edb 100644 --- a/packages/kit/CONVENTION.md +++ b/packages/kit/CONVENTION.md @@ -147,7 +147,7 @@ checkouts. If you really need to bypass, fix the underlying issue rather than passing `--no-verify`. `smoke:server` (`scripts/smoke-server.sh`) compiles the Bun binary, -boots it on port 3100, and probes `/health`, `/`, `/api/v1` — catches +boots it on port 3100, and probes `/health`, `/`, `/v1`, `/api/v1` — catches startup regressions (missing env, bind conflicts, missing `dist/index.html`). diff --git a/packages/kit/README.md b/packages/kit/README.md index cb938bec..72315150 100644 --- a/packages/kit/README.md +++ b/packages/kit/README.md @@ -22,7 +22,7 @@ don't control. ```text openiap-kit/ ├── src/ # React 19 + Vite SPA (dashboard, auth, project UIs) -├── server/ # Hono + Bun server (/api/v1/* receipt validation) + static SPA serving +├── server/ # Hono + Bun server (/v1/* API + /api/v1/* compat) + static SPA serving ├── convex/ # Convex backend (auth, orgs, projects, API keys, receipts) ├── public/ # SPA static assets (favicons, og-image, etc.) ├── Dockerfile # Multi-stage build → single-binary Fly.io image @@ -38,7 +38,7 @@ One package, one binary, one Fly.io app. - **Meta Horizon entitlement verification** (Graph API) - **Scoped API keys per project** with usage telemetry - **Organization + project multi-tenancy** via Convex -- **Free for everyone** — no paywall, no usage caps. Sustained by sponsors at [openiap.dev/sponsors](https://www.openiap.dev/sponsors) +- **Free for everyone** — no paywall, no usage caps. Sustained by sponsors at [openiap.dev/sponsors](https://openiap.dev/sponsors) - **Email OTP (Resend) + GitHub OAuth** via `@convex-dev/auth` - **OpenAPI spec** auto-generated by `hono-openapi` @@ -83,7 +83,7 @@ bun run dev:server # Hono on http://localhost:3000 ```bash bun run build:all # Vite build + Bun compile → ./openiap-kit-server -./openiap-kit-server # serves /api/v1/* + static SPA on :3000 +./openiap-kit-server # serves /v1/*, /api/v1/* + static SPA on :3000 ``` ## Operations @@ -96,20 +96,27 @@ point Fly.io liveness/readiness probes at. ### Graceful shutdown The server installs `SIGTERM` and `SIGINT` handlers that call -`Bun.serve().stop()` and let in-flight `/api/v1/*` requests drain before -the process exits. Fly.io sends `SIGTERM` before stopping a machine, so -deploys don't cut off requests mid-verify. +`Bun.serve().stop()` and let in-flight `/v1/*` and `/api/v1/*` requests +drain before the process exits. Fly.io sends `SIGTERM` before stopping a +machine, so deploys don't cut off requests mid-verify. ### Rate limiting -`/api/v1/purchase/verify` is protected by an in-memory token bucket -keyed on the SHA-256 of the API key. Defaults: **60-request burst, 1 -req/sec steady state** (= 60 req/min sustained). Tunable via +`/v1/purchase/verify` is protected by an in-memory token bucket keyed +on the SHA-256 of the API key. The `/api/v1/purchase/verify` +compatibility alias uses the same middleware. Defaults: **600-request +burst, 10 req/sec steady state** (= 600 req/min sustained). Tunable via `RATE_LIMIT_CAPACITY` and `RATE_LIMIT_REFILL_PER_SEC` — see `.env.example`. Responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`; when the bucket is empty the server returns `429 RATE_LIMITED` with a `Retry-After` header. +The verify endpoint also has a per-(API key, payload) replay guard for +the exact same receipt: **30-request burst, ~1/min sustained**, plus a +5-minute cooldown after the upstream store rejects a payload as +invalid. Those paths return `429 DUPLICATE_PAYLOAD` or +`429 REPEATED_FAILURE` with `Retry-After`. + The bucket store itself is bounded (default **10,000 entries**, `RATE_LIMIT_MAX_STORE`) with LRU eviction — an attacker churning random API keys past the parse-only `apiKeyMiddleware` can't grow the @@ -122,20 +129,28 @@ out, see the note at the top of ### Request logging -Every request to `/api/v1/purchase/verify` emits one structured JSON log -line to stdout with `{ corrId, method, path, statusCode, durationMs, -apiKeyHash, store, isValid, state }`. The hashed API key is the same -16-hex SHA-256 prefix the rate limiter uses — the plaintext key is -never logged. +Every request that passes the auth-header shape check on +`/v1/purchase/verify` or its `/api/v1/purchase/verify` compatibility +alias emits one structured JSON log line to stdout with `{ corrId, +method, path, statusCode, durationMs, apiKeyHash, store, isValid, +state }`. The hashed API key is the same 16-hex SHA-256 prefix the rate +limiter uses — the plaintext key is never logged. -Each response also carries an `X-Correlation-Id` header so customer -support tickets can cross-reference logs. +Those logged responses also carry an `X-Correlation-Id` header so +customer support tickets can cross-reference logs. Missing or malformed +Authorization headers return before the logger runs. ### Input size limits -`jws` (Apple) and `purchaseToken` (Google) are bounded at 16 KB and 2 KB -respectively. Oversized requests return `400 INVALID_INPUT` before -doing any Convex work. +Receipt-verification request bodies are capped at 32 KB before JSON +parsing. Product-management writes are capped at 64 KB, subscription +user-binding writes at 8 KB, and webhook pushes at 256 KB. `jws` +(Apple) and `purchaseToken` (Google / subscription binding) are bounded +at 16 KB and 2 KB respectively; `userId`, `sku`, and `productId` are +bounded at 256 chars where accepted. Oversized fields return +`400 INVALID_INPUT`; oversized request bodies return +`413 PAYLOAD_TOO_LARGE`. Invalid inputs stop before upstream store calls +or Convex mutations. ### Outbound retries (Google Play) @@ -196,8 +211,8 @@ checkouts. Don't bypass with `--no-verify` — fix the underlying issue. `smoke:server` compiles the Bun binary via `build:all` and runs [`scripts/smoke-server.sh`](scripts/smoke-server.sh), which boots the server on port 3100, polls `/health` until ready, and probes `/`, -`/api/v1`, `/health` — catches startup regressions (missing env, bind -conflicts, missing `dist/index.html`). Same script runs in CI. +`/v1`, `/api/v1`, `/health` — catches startup regressions (missing env, +bind conflicts, missing `dist/index.html`). Same script runs in CI. To run ad-hoc: @@ -281,7 +296,7 @@ IAPKit is one of several ways to use the OpenIAP specification: | **Native modules** (free, MIT) | openiap-apple, openiap-google | same | | **Receipt validation backend** | IAPKit SaaS — free for everyone | Deploy this repo yourself (MIT) | -The specification ([openiap.dev](https://openiap.dev)) is 100% open source. IAPKit's hosted service is free for every developer; infrastructure is sustained by community sponsorship at [openiap.dev/sponsors](https://www.openiap.dev/sponsors). You can always run this repo on your own infrastructure and pay no recurring fees. +The specification ([openiap.dev](https://openiap.dev)) is 100% open source. IAPKit's hosted service is free for every developer; infrastructure is sustained by community sponsorship at [openiap.dev/sponsors](https://openiap.dev/sponsors). You can always run this repo on your own infrastructure and pay no recurring fees. ## License diff --git a/packages/kit/convex/ResendOTP.ts b/packages/kit/convex/ResendOTP.ts index e088d2c2..a1656541 100644 --- a/packages/kit/convex/ResendOTP.ts +++ b/packages/kit/convex/ResendOTP.ts @@ -3,6 +3,21 @@ import Resend from "@auth/core/providers/resend"; import { alphabet, generateRandomString } from "oslo/crypto"; import { Resend as ResendAPI } from "resend"; +function describeErrorForLog(error: unknown): string { + if (error instanceof Error) return error.name; + if (error && typeof error === "object" && "name" in error) { + const name = (error as { name?: unknown }).name; + if (typeof name === "string") return name; + } + return typeof error; +} + +function emailDomainForLog(email: string): string { + const atIndex = email.lastIndexOf("@"); + if (atIndex < 0 || atIndex === email.length - 1) return "(invalid email)"; + return `*@${email.slice(atIndex + 1)}`; +} + const createOTPEmailTemplate = (code: string, lang: "en" | "ko" | "ja") => { const messages = { en: { @@ -234,8 +249,8 @@ const createResendOTPProvider = (locale: OTPLocale) => }); if (error) { - console.error("Resend API error:", error); - console.error("Failed to send email to:", email); + console.error("Resend API error:", describeErrorForLog(error)); + console.error("Failed to send OTP email:", emailDomainForLog(email)); throw new Error(messages.sendFailed); } }, diff --git a/packages/kit/convex/analytics/action.ts b/packages/kit/convex/analytics/action.ts index b46400a3..09064390 100644 --- a/packages/kit/convex/analytics/action.ts +++ b/packages/kit/convex/analytics/action.ts @@ -26,6 +26,10 @@ import { internalAction } from "../_generated/server"; */ const MIXPANEL_TRACK_ENDPOINT = "https://api-eu.mixpanel.com/track"; +function describeErrorForLog(error: unknown): string { + return error instanceof Error ? error.name : typeof error; +} + export const trackFirstReceiptVerified = internalAction({ args: { projectId: v.id("projects"), @@ -77,15 +81,18 @@ export const trackFirstReceiptVerified = internalAction({ body: JSON.stringify(payload), }); if (!res.ok) { - console.error( - `Mixpanel /track returned ${res.status}: ${await res.text()}`, - ); + console.error("Mixpanel /track returned non-ok status", { + status: res.status, + }); } } catch (error) { // Never let analytics failure surface to the customer. Log and // move on — we can backfill missed events from `purchases` // history if we ever need to. - console.error("Mixpanel /track request failed:", error); + console.error( + "Mixpanel /track request failed:", + describeErrorForLog(error), + ); } }, }); diff --git a/packages/kit/convex/apiKeys/helpers.ts b/packages/kit/convex/apiKeys/helpers.ts index dba5c8ee..a28d552e 100644 --- a/packages/kit/convex/apiKeys/helpers.ts +++ b/packages/kit/convex/apiKeys/helpers.ts @@ -1,8 +1,8 @@ import { Doc } from "../_generated/dataModel"; -import { QueryCtx } from "../_generated/server"; +import { MutationCtx, QueryCtx } from "../_generated/server"; export async function getApiKeyByKey( - ctx: QueryCtx, + ctx: QueryCtx | MutationCtx, key: string, ): Promise | null> { return ctx.db diff --git a/packages/kit/convex/apiKeys/internal.ts b/packages/kit/convex/apiKeys/internal.ts index 643896f0..8f6e0936 100644 --- a/packages/kit/convex/apiKeys/internal.ts +++ b/packages/kit/convex/apiKeys/internal.ts @@ -2,54 +2,46 @@ import { internalQuery, internalMutation } from "../_generated/server"; import { v } from "convex/values"; import { ConvexError } from "convex/values"; +import { getApiKeyByKey } from "./helpers"; + // Internal query to validate an API key and get the associated project export const validateApiKey = internalQuery({ args: { apiKey: v.string(), }, handler: async (ctx, args) => { - // First check if it's a legacy API key in the projects table - const projectWithLegacyKey = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .first(); + // Check the key table first so inactive rotated keys cannot fall + // through to the legacy `projects.apiKey` column. + const apiKeyRecord = await getApiKeyByKey(ctx, args.apiKey); + if (apiKeyRecord) { + if (!apiKeyRecord.isActive) { + return { isValid: false, reason: "API key is inactive" }; + } + + const project = await ctx.db.get(apiKeyRecord.projectId); + if (!project) { + return { isValid: false, reason: "Associated project not found" }; + } - if (projectWithLegacyKey) { - // Legacy key found, return project return { isValid: true, - projectId: projectWithLegacyKey._id, - organizationId: projectWithLegacyKey.organizationId, - keyId: undefined, // No keyId for legacy keys + projectId: apiKeyRecord.projectId, + organizationId: apiKeyRecord.organizationId, + keyId: apiKeyRecord._id, }; } - // Check the new apiKeys table - const apiKeyRecord = await ctx.db - .query("apiKeys") - .withIndex("by_key", (q) => q.eq("key", args.apiKey)) + const legacyProject = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) .first(); - - if (!apiKeyRecord) { - return { isValid: false }; - } - - // Check if the key is active - if (!apiKeyRecord.isActive) { - return { isValid: false, reason: "API key is inactive" }; - } - - // Get the project - const project = await ctx.db.get(apiKeyRecord.projectId); - if (!project) { - return { isValid: false, reason: "Associated project not found" }; - } + if (!legacyProject) return { isValid: false }; return { isValid: true, - projectId: apiKeyRecord.projectId, - organizationId: apiKeyRecord.organizationId, - keyId: apiKeyRecord._id, + projectId: legacyProject._id, + organizationId: legacyProject.organizationId, + keyId: undefined, }; }, }); diff --git a/packages/kit/convex/apiKeys/query.ts b/packages/kit/convex/apiKeys/query.ts index 005a610c..763ad716 100644 --- a/packages/kit/convex/apiKeys/query.ts +++ b/packages/kit/convex/apiKeys/query.ts @@ -1,8 +1,28 @@ import { query } from "../_generated/server"; +import type { Doc } from "../_generated/dataModel"; import { v } from "convex/values"; import { getAuthUserId } from "@convex-dev/auth/server"; import { ConvexError } from "convex/values"; +type SafeApiKey = Omit, "key"> & { + keyPreview: string; +}; + +function getApiKeyPreview(key: string): string { + const suffix = key.slice(-4); + if (key.startsWith("openiap-kit_")) { + return `openiap-kit_...${suffix}`; + } + return `...${suffix}`; +} + +function toSafeApiKey({ key, ...apiKey }: Doc<"apiKeys">): SafeApiKey { + return { + ...apiKey, + keyPreview: getApiKeyPreview(key), + }; +} + // Get all API keys for a project export const listProjectApiKeys = query({ args: { @@ -39,8 +59,8 @@ export const listProjectApiKeys = query({ .order("desc") .collect(); - // Return all keys with full data for the frontend - return apiKeys; + // Full keys are only returned by create/regenerate mutations. + return apiKeys.map(toSafeApiKey); }, }); @@ -127,9 +147,7 @@ export const getById = query({ const creator = await ctx.db.get(apiKey.createdBy); return { - ...apiKey, - // Still mask the key for security - keyPreview: `${apiKey.key.slice(0, 8)}...${apiKey.key.slice(-4)}`, + ...toSafeApiKey(apiKey), creatorName: creator?.name || "Unknown", creatorEmail: creator?.email || "Unknown", }; diff --git a/packages/kit/convex/certificates/apple_root_certificates.ts b/packages/kit/convex/certificates/apple_root_certificates.ts index 49450a48..e2e3b059 100644 --- a/packages/kit/convex/certificates/apple_root_certificates.ts +++ b/packages/kit/convex/certificates/apple_root_certificates.ts @@ -59,7 +59,10 @@ export function loadAppleRootCertificates(): Buffer[] { const buffer = Buffer.from(cert.base64, "base64"); certificates.push(buffer); } catch (error) { - console.warn(`❌ Failed to load ${cert.name}:`, error); + console.warn( + `Failed to load ${cert.name}:`, + error instanceof Error ? error.name : typeof error, + ); } } diff --git a/packages/kit/convex/files/internal.ts b/packages/kit/convex/files/internal.ts index 7e28e5b3..49eec728 100644 --- a/packages/kit/convex/files/internal.ts +++ b/packages/kit/convex/files/internal.ts @@ -8,6 +8,10 @@ import { v } from "convex/values"; import { ConvexError } from "convex/values"; import { internal } from "../_generated/api"; +function describeErrorForLog(error: unknown): string { + return error instanceof Error ? error.name : typeof error; +} + // Internal query to get file record export const getFileRecord = internalQuery({ args: { @@ -404,7 +408,10 @@ export const cleanupOldFiles = internalMutation({ await ctx.db.delete(file._id); deletedCount++; } catch (error) { - console.error(`Failed to delete file ${file._id}:`, error); + console.error("Failed to delete file", { + fileId: file._id, + error: describeErrorForLog(error), + }); } } diff --git a/packages/kit/convex/products/asc.test.ts b/packages/kit/convex/products/asc.test.ts index a22fe69e..16d986ff 100644 --- a/packages/kit/convex/products/asc.test.ts +++ b/packages/kit/convex/products/asc.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + ascCustomerPriceToMicros, mapAscOfferDurationToIso, mapAscOfferKind, mapBillingPeriodToAsc, @@ -9,6 +10,20 @@ import { pickPricePointIdMatching, } from "./asc"; +describe("ascCustomerPriceToMicros", () => { + it("converts ASC customerPrice strings to micros", () => { + expect(ascCustomerPriceToMicros("0.99")).toBe(990_000); + expect(ascCustomerPriceToMicros("9")).toBe(9_000_000); + }); + + it("returns undefined for malformed or unsafe prices", () => { + expect(ascCustomerPriceToMicros(undefined)).toBeUndefined(); + expect(ascCustomerPriceToMicros("abc")).toBeUndefined(); + expect(ascCustomerPriceToMicros("-1")).toBeUndefined(); + expect(ascCustomerPriceToMicros("10000000000")).toBeUndefined(); + }); +}); + describe("pickPricePointIdMatching", () => { const list = { data: [ @@ -37,6 +52,11 @@ describe("pickPricePointIdMatching", () => { type: "inAppPurchasePricePoints" as const, attributes: { customerPrice: "abc" }, }, + { + id: "tier-unsafe", + type: "inAppPurchasePricePoints" as const, + attributes: { customerPrice: "9007199254.740993" }, + }, { id: "tier-empty", type: "inAppPurchasePricePoints" as const, @@ -53,6 +73,14 @@ describe("pickPricePointIdMatching", () => { expect(pickPricePointIdMatching(list, 1_500_000)).toBeNull(); }); + it("returns null for invalid requested amounts", () => { + expect(pickPricePointIdMatching(list, -1)).toBeNull(); + expect(pickPricePointIdMatching(list, 1.5)).toBeNull(); + expect( + pickPricePointIdMatching(list, Number.MAX_SAFE_INTEGER + 1), + ).toBeNull(); + }); + it("matches an exact tier on the cent boundary", () => { expect(pickPricePointIdMatching(list, 9_990_000)).toBe("tier-999"); expect(pickPricePointIdMatching(list, 290_000)).toBe("tier-29"); @@ -64,8 +92,9 @@ describe("pickPricePointIdMatching", () => { expect(pickPricePointIdMatching(list, 9_985_000)).toBe("tier-999"); }); - it("skips malformed and missing customerPrice rows", () => { + it("skips malformed, missing, and unsafe customerPrice rows", () => { expect(pickPricePointIdMatching(list, 0)).toBeNull(); + expect(pickPricePointIdMatching(list, Number.MAX_SAFE_INTEGER)).toBeNull(); }); }); diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts index 882b30e1..21ab38d8 100644 --- a/packages/kit/convex/products/asc.ts +++ b/packages/kit/convex/products/asc.ts @@ -1,8 +1,10 @@ "use node"; import { v } from "convex/values"; +import { getAuthUserId } from "@convex-dev/auth/server"; import { action, internalAction, type ActionCtx } from "../_generated/server"; import { internal } from "../_generated/api"; +import type { Doc, Id } from "../_generated/dataModel"; import { getProjectByApiKey } from "../purchases/shared"; import { mapWithConcurrency } from "../utils/concurrency"; import { mintAscJwt } from "./jwt"; @@ -34,7 +36,7 @@ type AscCredentials = { }; async function resolveAscCredentials( ctx: ActionCtx, - project: Awaited>, + project: Doc<"projects">, options: { detailedErrors?: boolean } = {}, ): Promise { // Apple uses ONE Issuer ID per team across both API gateways @@ -133,6 +135,42 @@ async function resolveAscCredentials( return { issuerId, keyId, keyContent }; } +async function getProjectForActionArgs( + ctx: ActionCtx, + args: { apiKey?: string; projectId?: Id<"projects"> }, +): Promise> { + if (args.projectId) { + const userId: Id<"users"> | null = await getAuthUserId(ctx); + if (!userId) { + throw new Error("Not authenticated"); + } + + const project: Doc<"projects"> | null = await ctx.runQuery( + internal.projects.internal.getProjectById, + { projectId: args.projectId }, + ); + if (!project) { + throw new Error("Project not found"); + } + + const membership = await ctx.runQuery( + internal.organizations.internal.getMembership, + { userId, organizationId: project.organizationId }, + ); + if (!membership) { + throw new Error("Not a member of this organization"); + } + + return project; + } + + if (args.apiKey !== undefined) { + return await getProjectByApiKey(ctx, args.apiKey); + } + + throw new Error("apiKey or projectId is required"); +} + // App Store Connect REST client + push-sync action. // // Auth: every request carries a freshly-minted ES256 JWT signed with @@ -781,13 +819,14 @@ export function pickPricePointIdMatching( targetMicros: number, ): string | null { if (!list) return null; + if (!Number.isSafeInteger(targetMicros) || targetMicros < 0) return null; const targetCents = Math.round(targetMicros / 10_000); for (const point of list.data) { - const raw = point.attributes?.customerPrice; - if (!raw) continue; - const n = Number(raw); - if (!Number.isFinite(n)) continue; - const pointCents = Math.round(n * 100); + const pointMicros = ascCustomerPriceToMicros( + point.attributes?.customerPrice, + ); + if (pointMicros === undefined) continue; + const pointCents = Math.round(pointMicros / 10_000); if (Math.abs(pointCents - targetCents) <= 1) return point.id; } return null; @@ -925,16 +964,26 @@ function parseAssignedPrice( const pointId = row.relationships?.[relationshipKey]?.data?.id; if (!pointId) return {}; const point = resp.included?.find((entry) => entry.id === pointId); - const raw = point?.attributes?.customerPrice; - if (!raw) return {}; - const n = Number(raw); - if (!Number.isFinite(n)) return {}; + const priceAmountMicros = ascCustomerPriceToMicros( + point?.attributes?.customerPrice, + ); + if (priceAmountMicros === undefined) return {}; return { - priceAmountMicros: Math.round(n * 1_000_000), + priceAmountMicros, currency: "USD", }; } +export function ascCustomerPriceToMicros( + raw: string | undefined, +): number | undefined { + if (!raw) return undefined; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) return undefined; + const micros = Math.round(n * 1_000_000); + return Number.isSafeInteger(micros) ? micros : undefined; +} + function extractAscError(parsed: unknown): string { if ( parsed && @@ -1736,13 +1785,16 @@ async function performIosSync( // thrown Error so the dashboard can show a toast and degrade // gracefully (the field stays a free-text input). export const listSubscriptionGroupsAppleIOS = action({ - args: { apiKey: v.string() }, + args: { + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), + }, returns: v.array(v.object({ id: v.string(), referenceName: v.string() })), handler: async ( ctx, args, ): Promise> => { - const project = await getProjectByApiKey(ctx, args.apiKey); + const project = await getProjectForActionArgs(ctx, args); if (!project.iosAppAppleId) { throw new Error("Project iosAppAppleId is not configured"); } @@ -1894,11 +1946,9 @@ export function parseIntroOffers( const point = pointId ? resp.included?.find((entry) => entry.id === pointId) : undefined; - const raw = point?.attributes?.customerPrice; - const n = raw ? Number(raw) : Number.NaN; - const priceAmountMicros = Number.isFinite(n) - ? Math.round(n * 1_000_000) - : undefined; + const priceAmountMicros = ascCustomerPriceToMicros( + point?.attributes?.customerPrice, + ); return { id: row.id, kind: mapAscOfferKind(row.attributes?.offerMode), diff --git a/packages/kit/convex/products/jobs.ts b/packages/kit/convex/products/jobs.ts index 37ed84ff..874cb3ae 100644 --- a/packages/kit/convex/products/jobs.ts +++ b/packages/kit/convex/products/jobs.ts @@ -7,10 +7,15 @@ import { internalQuery, mutation, query, + type MutationCtx, type QueryCtx, } from "../_generated/server"; import { internal } from "../_generated/api"; import type { Doc, Id } from "../_generated/dataModel"; +import { + resolveProjectByApiKeyFromDb, + resolveProjectByIdForCurrentUserFromDb, +} from "../projects/helpers"; import { ErrorCode, createError } from "../utils/errors"; // Per-job hard ceiling. Convex actions cap at ~10min; we allow 9min @@ -64,13 +69,11 @@ const directionValidator = v.union( // `getAuthUserId` and broke the documented HTTP API entirely // (Copilot review on PR #127). async function resolveProjectByApiKey( - ctx: QueryCtx, + ctx: QueryCtx | MutationCtx, apiKey: string, ): Promise<{ project: Doc<"projects">; userId: Id<"users"> | null }> { - const project = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", apiKey)) - .first(); + const resolved = await resolveProjectByApiKeyFromDb(ctx, apiKey); + const project = resolved?.project ?? null; if (!project) { throw createError(ErrorCode.PROJECT_NOT_FOUND); } @@ -98,13 +101,57 @@ async function resolveProjectByApiKey( return { project, userId }; } +async function resolveProjectForReadArgs( + ctx: QueryCtx, + args: { apiKey?: string; projectId?: Id<"projects"> }, +): Promise> { + if (args.projectId) { + const resolved = await resolveProjectByIdForCurrentUserFromDb( + ctx, + args.projectId, + ); + if (!resolved) { + throw createError(ErrorCode.PROJECT_NOT_FOUND); + } + return resolved.project; + } + + if (args.apiKey !== undefined) { + return (await resolveProjectByApiKey(ctx, args.apiKey)).project; + } + + throw createError(ErrorCode.INVALID_INPUT, "apiKey or projectId is required"); +} + +async function resolveProjectForMutationArgs( + ctx: MutationCtx, + args: { apiKey?: string; projectId?: Id<"projects"> }, +): Promise<{ project: Doc<"projects">; userId: Id<"users"> | null }> { + if (args.projectId) { + const resolved = await resolveProjectByIdForCurrentUserFromDb( + ctx, + args.projectId, + ); + if (!resolved) { + throw createError(ErrorCode.PROJECT_NOT_FOUND); + } + return resolved; + } + + if (args.apiKey !== undefined) { + return resolveProjectByApiKey(ctx, args.apiKey); + } + + throw createError(ErrorCode.INVALID_INPUT, "apiKey or projectId is required"); +} + // Authenticate `(apiKey, jobId)` together: resolve the project from // the apiKey, then verify the job belongs to that project. This // ensures the apiKey acts as a per-project capability — a stolen // jobId from one project can't be cancelled / read by another // project's apiKey. async function resolveJobByApiKey( - ctx: QueryCtx, + ctx: QueryCtx | MutationCtx, apiKey: string, jobId: Id<"productSyncJobs">, ): Promise<{ job: Doc<"productSyncJobs">; project: Doc<"projects"> }> { @@ -121,15 +168,32 @@ async function resolveJobByApiKey( return { job, project }; } +async function resolveJobForMutationArgs( + ctx: MutationCtx, + args: { + apiKey?: string; + projectId?: Id<"projects">; + jobId: Id<"productSyncJobs">; + }, +): Promise<{ job: Doc<"productSyncJobs">; project: Doc<"projects"> }> { + const { project } = await resolveProjectForMutationArgs(ctx, args); + const job = await ctx.db.get(args.jobId); + if (!job || job.projectId !== project._id) { + throw createError(ErrorCode.INVALID_INPUT, "Sync job not found"); + } + return { job, project }; +} + // Latest job (any status) for a project+platform — drives the // dashboard's button state, progress, and last-result toast. export const getActiveSyncJob = query({ args: { - apiKey: v.string(), + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), platform: platformValidator, }, handler: async (ctx, args) => { - const { project } = await resolveProjectByApiKey(ctx, args.apiKey); + const project = await resolveProjectForReadArgs(ctx, args); // Composite index `by_project_platform_created` narrows the // index range to just this (project, platform) — replaces the // earlier `by_project_and_created` + in-memory `.filter()` @@ -161,7 +225,8 @@ export const getSyncJobById = query({ // page reload doesn't fan out duplicate workers. export const enqueueProductSync = mutation({ args: { - apiKey: v.string(), + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), platform: platformValidator, direction: v.optional(directionValidator), dryRun: v.optional(v.boolean()), @@ -171,7 +236,7 @@ export const enqueueProductSync = mutation({ deduped: v.boolean(), }), handler: async (ctx, args) => { - const { project, userId } = await resolveProjectByApiKey(ctx, args.apiKey); + const { project, userId } = await resolveProjectForMutationArgs(ctx, args); // Atomic dedup via the project's `activeSyncJobIds` lock field. // Reading and writing the project doc lets Convex's OCC collapse // two concurrent enqueue mutations onto the same job: both read @@ -314,11 +379,12 @@ export const runProductSyncPurgeLocal = internalAction({ // that's enough to stop a runaway sync within seconds on most paths. export const cancelProductSync = mutation({ args: { - apiKey: v.string(), + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), jobId: v.id("productSyncJobs"), }, handler: async (ctx, args) => { - const { job } = await resolveJobByApiKey(ctx, args.apiKey, args.jobId); + const { job } = await resolveJobForMutationArgs(ctx, args); if (job.status !== "queued" && job.status !== "running") { return { ok: false, reason: "not active" as const }; } @@ -336,11 +402,12 @@ export const cancelProductSync = mutation({ // semantics stay distinct from operator dismiss. export const dismissCompletedJob = mutation({ args: { - apiKey: v.string(), + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), jobId: v.id("productSyncJobs"), }, handler: async (ctx, args) => { - const { job } = await resolveJobByApiKey(ctx, args.apiKey, args.jobId); + const { job } = await resolveJobForMutationArgs(ctx, args); if (job.status !== "succeeded" && job.status !== "failed") { return { ok: false as const }; } diff --git a/packages/kit/convex/products/mutation.ts b/packages/kit/convex/products/mutation.ts index 4f78835d..eaaccf7c 100644 --- a/packages/kit/convex/products/mutation.ts +++ b/packages/kit/convex/products/mutation.ts @@ -1,6 +1,11 @@ -import { mutation } from "../_generated/server"; +import { mutation, type MutationCtx } from "../_generated/server"; import { v } from "convex/values"; -import type { Doc } from "../_generated/dataModel"; +import type { Doc, Id } from "../_generated/dataModel"; + +import { + resolveProjectByApiKeyFromDb, + resolveProjectByIdForCurrentUserFromDb, +} from "../projects/helpers"; const platformValidator = v.union(v.literal("IOS"), v.literal("Android")); const typeValidator = v.union( @@ -15,6 +20,26 @@ const stateValidator = v.union( v.literal("Removed"), ); +async function resolveProjectForMutationArgs( + ctx: MutationCtx, + args: { apiKey?: string; projectId?: Id<"projects"> }, +): Promise | null> { + if (args.projectId) { + const resolved = await resolveProjectByIdForCurrentUserFromDb( + ctx, + args.projectId, + ); + return resolved?.project ?? null; + } + + if (args.apiKey !== undefined) { + const resolved = await resolveProjectByApiKeyFromDb(ctx, args.apiKey); + return resolved?.project ?? null; + } + + throw new Error("apiKey or projectId is required"); +} + // Public mutation: upsert a product in kit's catalog. Authoritative // state lives in App Store Connect / Play Console; this row is a // kit-side cache so the dashboard, MCP server, and SDKs share one @@ -22,7 +47,8 @@ const stateValidator = v.union( // — until then, treat this as a hand-managed catalog. export const upsertProduct = mutation({ args: { - apiKey: v.string(), + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), productId: v.string(), platform: platformValidator, type: typeValidator, @@ -50,20 +76,19 @@ export const upsertProduct = mutation({ created: v.boolean(), }), handler: async (ctx, args) => { - const project = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .unique(); + const project = await resolveProjectForMutationArgs(ctx, args); if (!project) throw new Error("Invalid API key"); - // Reject negative prices. The catalog row would otherwise round- - // trip into push-sync (asc.ts / play.ts) and either crash on - // Apple's price-tier lookup or land a negative `priceMicros` on - // Play, neither of which the operator can correct from the - // dashboard later (PR #124 - // (https://github.com/hyodotdev/openiap/pull/124) review). - if (args.priceAmountMicros !== undefined && args.priceAmountMicros < 0) { - throw new Error("priceAmountMicros must be non-negative"); + // Reject unsafe prices. The catalog row round-trips through JS + // numbers into push-sync (asc.ts / play.ts); values beyond the + // safe-integer range can already be rounded before they reach the + // store API. + if ( + args.priceAmountMicros !== undefined && + (!Number.isSafeInteger(args.priceAmountMicros) || + args.priceAmountMicros < 0) + ) { + throw new Error("priceAmountMicros must be a non-negative safe integer"); } // iOS subscriptions REQUIRE a subscriptionGroupName upstream — @@ -160,7 +185,8 @@ export const upsertProduct = mutation({ // drive-by clobber. export const setProductState = mutation({ args: { - apiKey: v.string(), + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), productId: v.string(), platform: platformValidator, state: stateValidator, @@ -170,10 +196,7 @@ export const setProductState = mutation({ state: stateValidator, }), handler: async (ctx, args) => { - const project = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .unique(); + const project = await resolveProjectForMutationArgs(ctx, args); if (!project) throw new Error("Invalid API key"); const existing = await ctx.db @@ -197,16 +220,14 @@ export const setProductState = mutation({ export const removeProduct = mutation({ args: { - apiKey: v.string(), + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), productId: v.string(), platform: platformValidator, }, returns: v.object({ ok: v.boolean() }), handler: async (ctx, args) => { - const project = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .unique(); + const project = await resolveProjectForMutationArgs(ctx, args); if (!project) return { ok: false }; const existing = await ctx.db diff --git a/packages/kit/convex/products/play.test.ts b/packages/kit/convex/products/play.test.ts index 1041319e..8bef4454 100644 --- a/packages/kit/convex/products/play.test.ts +++ b/packages/kit/convex/products/play.test.ts @@ -1,6 +1,32 @@ import { describe, expect, it } from "vitest"; -import { basePlanIdForPeriod, moneyToMicros } from "./play"; +import { + basePlanIdForPeriod, + moneyToMicros, + playPriceMicrosToNumber, +} from "./play"; + +describe("playPriceMicrosToNumber", () => { + it("accepts non-negative safe integer price strings", () => { + expect(playPriceMicrosToNumber("0")).toBe(0); + expect(playPriceMicrosToNumber("990000")).toBe(990_000); + expect(playPriceMicrosToNumber(String(Number.MAX_SAFE_INTEGER))).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); + + it("rejects malformed, negative, fractional, and unsafe price strings", () => { + expect(playPriceMicrosToNumber(undefined)).toBeUndefined(); + expect(playPriceMicrosToNumber("abc")).toBeUndefined(); + expect(playPriceMicrosToNumber(" 990000 ")).toBeUndefined(); + expect(playPriceMicrosToNumber("1e6")).toBeUndefined(); + expect(playPriceMicrosToNumber("-1")).toBeUndefined(); + expect(playPriceMicrosToNumber("1.5")).toBeUndefined(); + expect( + playPriceMicrosToNumber(String(Number.MAX_SAFE_INTEGER + 1)), + ).toBeUndefined(); + }); +}); describe("moneyToMicros", () => { it("returns undefined when input is missing or has no units", () => { @@ -54,10 +80,40 @@ describe("moneyToMicros", () => { ).toBeUndefined(); }); - it("returns undefined when units is not a parseable BigInt string", () => { + it("returns undefined when units is not a non-negative decimal string", () => { expect( moneyToMicros({ currencyCode: "USD", units: "abc", nanos: 0 }), ).toBeUndefined(); + expect( + moneyToMicros({ currencyCode: "USD", units: "+1", nanos: 0 }), + ).toBeUndefined(); + expect( + moneyToMicros({ currencyCode: "USD", units: " 1", nanos: 0 }), + ).toBeUndefined(); + expect( + moneyToMicros({ currencyCode: "USD", units: "1 ", nanos: 0 }), + ).toBeUndefined(); + }); + + it("returns undefined for negative prices", () => { + expect( + moneyToMicros({ currencyCode: "USD", units: "-1", nanos: 0 }), + ).toBeUndefined(); + expect( + moneyToMicros({ currencyCode: "USD", units: "0", nanos: -1_000 }), + ).toBeUndefined(); + }); + + it("returns undefined when nanos is outside Google Money bounds", () => { + expect( + moneyToMicros({ currencyCode: "USD", units: "0", nanos: 1_000_000_000 }), + ).toBeUndefined(); + expect( + moneyToMicros({ currencyCode: "USD", units: "0", nanos: -1_000_000_000 }), + ).toBeUndefined(); + expect( + moneyToMicros({ currencyCode: "USD", units: "0", nanos: 1.5 }), + ).toBeUndefined(); }); }); diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index 6f61f117..3ffb22c7 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -899,13 +899,19 @@ function pickPlayDescription( return product.listings?.[def]?.description ?? undefined; } -function parsePlayPriceMicros( - product: androidpublisher_v3.Schema$InAppProduct, +export function playPriceMicrosToNumber( + raw: string | undefined | null, ): number | undefined { - const raw = product.defaultPrice?.priceMicros; if (!raw) return undefined; + if (!/^\d+$/.test(raw)) return undefined; const n = Number(raw); - return Number.isFinite(n) ? n : undefined; + return Number.isSafeInteger(n) && n >= 0 ? n : undefined; +} + +function parsePlayPriceMicros( + product: androidpublisher_v3.Schema$InAppProduct, +): number | undefined { + return playPriceMicrosToNumber(product.defaultPrice?.priceMicros); } function pickPlayCurrency( @@ -1092,9 +1098,10 @@ function collectPlaySubscriptionOffers( * stores price points internally — Play uses micros as the canonical * unit, so any rounding here would re-introduce drift we just cleaned * up. Resolves to `undefined` when the input has no `units`, when the - * BigInt parse throws (malformed `units` string), or when the resulting - * micros exceed `Number.MAX_SAFE_INTEGER` (≈ USD 9 billion — kit treats - * those rows as price-unknown rather than silently corrupting them). + * `units` is not a non-negative decimal string, when `nanos` falls outside + * Google Money's int32 sub-unit range, or when the resulting micros exceed + * `Number.MAX_SAFE_INTEGER` (≈ USD 9 billion — kit treats those rows as + * price-unknown rather than silently corrupting them). * * PR #124 (https://github.com/hyodotdev/openiap/pull/124) review fix. */ @@ -1103,8 +1110,11 @@ export function moneyToMicros( ): number | undefined { if (!money?.units) return undefined; try { - const microsBigInt = - BigInt(money.units) * 1_000_000n + BigInt(money.nanos ?? 0) / 1_000n; + const unitsMicros = moneyUnitsToMicros(money.units); + if (unitsMicros === undefined) return undefined; + const nanosMicros = moneyNanosToMicros(money.nanos); + if (nanosMicros === undefined) return undefined; + const microsBigInt = unitsMicros + nanosMicros; // Drop values that exceed Number.MAX_SAFE_INTEGER. The schema // stores `priceAmountMicros` as a JS `number` (IEEE 754 double), // so anything above 2^53 - 1 would silently lose precision on @@ -1113,10 +1123,7 @@ export function moneyToMicros( // very high unit values like IDR / KRW it's worth the explicit // guard rather than a silent corruption — kit treats the row as // "price unknown" and the dashboard surfaces that affordance. - if ( - microsBigInt > BigInt(Number.MAX_SAFE_INTEGER) || - microsBigInt < BigInt(Number.MIN_SAFE_INTEGER) - ) { + if (microsBigInt > BigInt(Number.MAX_SAFE_INTEGER) || microsBigInt < 0n) { return undefined; } return Number(microsBigInt); @@ -1125,6 +1132,21 @@ export function moneyToMicros( } } +function moneyUnitsToMicros(units: string): bigint | undefined { + if (!/^\d+$/.test(units)) return undefined; + return BigInt(units) * 1_000_000n; +} + +function moneyNanosToMicros( + nanos: number | null | undefined, +): bigint | undefined { + if (nanos == null) return 0n; + if (!Number.isInteger(nanos) || nanos < -999_999_999 || nanos > 999_999_999) { + return undefined; + } + return BigInt(nanos) / 1_000n; +} + /** * Map an ISO 8601 billing-period string (`P1W` / `P1M` / `P1Y` / etc.) * to a stable, descriptive basePlanId for the Play console. Play's diff --git a/packages/kit/convex/products/query.ts b/packages/kit/convex/products/query.ts index 6acf213d..2fd4949e 100644 --- a/packages/kit/convex/products/query.ts +++ b/packages/kit/convex/products/query.ts @@ -2,6 +2,11 @@ import { query } from "../_generated/server"; import { v } from "convex/values"; import type { Doc } from "../_generated/dataModel"; +import { + resolveProjectByApiKeyFromDb, + resolveProjectByIdForCurrentUserFromDb, +} from "../projects/helpers"; + const offerShape = v.object({ id: v.string(), kind: v.union( @@ -84,15 +89,18 @@ function shape(product: Doc<"products">) { export const listProducts = query({ args: { - apiKey: v.string(), + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), platform: v.optional(v.union(v.literal("IOS"), v.literal("Android"))), }, returns: v.array(productShape), handler: async (ctx, args) => { - const project = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .unique(); + const resolved = args.projectId + ? await resolveProjectByIdForCurrentUserFromDb(ctx, args.projectId) + : args.apiKey + ? await resolveProjectByApiKeyFromDb(ctx, args.apiKey) + : null; + const project = resolved?.project ?? null; if (!project) return []; if (args.platform) { diff --git a/packages/kit/convex/products/sync.test.ts b/packages/kit/convex/products/sync.test.ts new file mode 100644 index 00000000..0719d5e5 --- /dev/null +++ b/packages/kit/convex/products/sync.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { isSafePriceAmountMicros } from "./sync"; + +describe("isSafePriceAmountMicros", () => { + it("accepts missing and non-negative safe integer prices", () => { + expect(isSafePriceAmountMicros(undefined)).toBe(true); + expect(isSafePriceAmountMicros(0)).toBe(true); + expect(isSafePriceAmountMicros(Number.MAX_SAFE_INTEGER)).toBe(true); + }); + + it("rejects negative, fractional, and unsafe prices", () => { + expect(isSafePriceAmountMicros(-1)).toBe(false); + expect(isSafePriceAmountMicros(1.5)).toBe(false); + expect(isSafePriceAmountMicros(Number.MAX_SAFE_INTEGER + 1)).toBe(false); + }); +}); diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts index 57ea991a..ef819028 100644 --- a/packages/kit/convex/products/sync.ts +++ b/packages/kit/convex/products/sync.ts @@ -62,6 +62,19 @@ export function coerceBillingPeriod( : undefined; } +export function isSafePriceAmountMicros(value: number | undefined): boolean { + return value === undefined || (Number.isSafeInteger(value) && value >= 0); +} + +function assertSafePriceAmountMicros( + value: number | undefined, + fieldName: string, +): void { + if (!isSafePriceAmountMicros(value)) { + throw new Error(`${fieldName} must be a non-negative safe integer`); + } +} + // Internal mutation called by the ASC / Play push-sync actions when a // row is mirrored from the upstream store. Distinct from the public // `upsertProduct` mutation in mutation.ts so server-driven sync can't @@ -102,6 +115,14 @@ export const upsertFromStore = internalMutation({ }, returns: v.id("products"), handler: async (ctx, args) => { + assertSafePriceAmountMicros(args.priceAmountMicros, "priceAmountMicros"); + args.offers?.forEach((offer, index) => { + assertSafePriceAmountMicros( + offer.priceAmountMicros, + `offers[${index}].priceAmountMicros`, + ); + }); + // Match by (projectId, platform, productId) — apps commonly use // the same productId on both stores, and the older // (projectId, productId)-only lookup would collide and silently diff --git a/packages/kit/convex/projects/helpers.ts b/packages/kit/convex/projects/helpers.ts index 59ee57b7..02d2a792 100644 --- a/packages/kit/convex/projects/helpers.ts +++ b/packages/kit/convex/projects/helpers.ts @@ -1,7 +1,67 @@ -import type { Id } from "../_generated/dataModel"; +import type { Doc, Id } from "../_generated/dataModel"; import type { MutationCtx, QueryCtx } from "../_generated/server"; +import { getAuthUserId } from "@convex-dev/auth/server"; +import { getApiKeyByKey } from "../apiKeys/helpers"; import { deletePurchaseStatsForProject } from "../purchases/stats"; +export type ApiKeyProjectResolution = { + project: Doc<"projects">; + keyId?: Id<"apiKeys">; + organizationId: Id<"organizations">; +}; + +export async function resolveProjectByApiKeyFromDb( + ctx: QueryCtx | MutationCtx, + apiKey: string, +): Promise { + const keyRow = await getApiKeyByKey(ctx, apiKey); + if (keyRow !== null) { + if (keyRow.isActive === false) return null; + const project = await ctx.db.get(keyRow.projectId); + if (!project) return null; + return { + project, + keyId: keyRow._id, + organizationId: keyRow.organizationId, + }; + } + + const legacyProject = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", apiKey)) + .first(); + + if (!legacyProject) return null; + return { + project: legacyProject, + organizationId: legacyProject.organizationId, + }; +} + +export async function resolveProjectByIdForCurrentUserFromDb( + ctx: QueryCtx | MutationCtx, + projectId: Id<"projects">, +): Promise<{ + project: Doc<"projects">; + userId: Id<"users">; + role: "owner" | "admin" | "member"; +} | null> { + const userId = await getAuthUserId(ctx); + if (!userId) return null; + + const project = await ctx.db.get(projectId); + if (!project) return null; + + const membership = await ctx.db + .query("organizationMembers") + .withIndex("by_org_and_user", (q) => + q.eq("organizationId", project.organizationId).eq("userId", userId), + ) + .first(); + + return membership ? { project, userId, role: membership.role } : null; +} + /** * Delete a project and all of its Convex data (API keys, receipts, files). * Keeps the cascade logic in one place so both direct and indirect callers stay in sync. diff --git a/packages/kit/convex/projects/internal.ts b/packages/kit/convex/projects/internal.ts index d8a02e22..f72ad969 100644 --- a/packages/kit/convex/projects/internal.ts +++ b/packages/kit/convex/projects/internal.ts @@ -2,37 +2,15 @@ import { v } from "convex/values"; import { internalQuery } from "../_generated/server"; import { Doc } from "../_generated/dataModel"; -import { getApiKeyByKey } from "../apiKeys/helpers"; -import { getProjectById as getProjectByIdFromDb } from "./helpers"; +import { resolveProjectByApiKeyFromDb } from "./helpers"; export const getProjectByApiKey = internalQuery({ args: { apiKey: v.string(), }, handler: async (ctx, args): Promise | null> => { - // Preferred path: look the key up in the `apiKeys` table. This is what - // `createProject` writes every new project's default key to, and is - // what carries per-key `isActive` / rotation semantics. - const apiKey = await getApiKeyByKey(ctx, args.apiKey); - - if (apiKey !== null) { - if (apiKey.isActive === false) { - return null; - } - return getProjectByIdFromDb(ctx, apiKey.projectId); - } - - // Legacy fallback: early projects — or anything created before the - // `apiKeys` table was introduced — only had the key on the `projects` - // row. Match those via the `by_api_key` index on `projects` so - // receipt verification doesn't fail on un-migrated rows. Paid for - // only on a miss in the `apiKeys` table, so new installs pay zero. - const legacyProject = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .first(); - - return legacyProject ?? null; + const resolved = await resolveProjectByApiKeyFromDb(ctx, args.apiKey); + return resolved?.project ?? null; }, }); diff --git a/packages/kit/convex/projects/mutation.test.ts b/packages/kit/convex/projects/mutation.test.ts new file mode 100644 index 00000000..824b5a8e --- /dev/null +++ b/packages/kit/convex/projects/mutation.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeAppAppleId } from "./mutation"; + +describe("normalizeAppAppleId", () => { + it("accepts positive safe integers", () => { + expect(normalizeAppAppleId(1234567890)).toBe(1_234_567_890); + }); + + it("rejects fractional, unsafe, and non-positive values", () => { + expect(() => normalizeAppAppleId(123.45)).toThrow( + "App Apple ID must be a positive safe integer.", + ); + expect(() => normalizeAppAppleId(Number.MAX_SAFE_INTEGER + 1)).toThrow( + "App Apple ID must be a positive safe integer.", + ); + expect(() => normalizeAppAppleId(0)).toThrow( + "App Apple ID must be a positive safe integer.", + ); + }); +}); diff --git a/packages/kit/convex/projects/mutation.ts b/packages/kit/convex/projects/mutation.ts index 70e7a0c5..41701454 100644 --- a/packages/kit/convex/projects/mutation.ts +++ b/packages/kit/convex/projects/mutation.ts @@ -63,15 +63,15 @@ function normalizeIosBundleId(input: string): string { return normalized; } -function normalizeAppAppleId(input: number): number { - if (!Number.isFinite(input) || input <= 0) { +export function normalizeAppAppleId(input: number): number { + if (!Number.isSafeInteger(input) || input <= 0) { throw createError( ErrorCode.INVALID_INPUT, - "App Apple ID must be a positive number.", + "App Apple ID must be a positive safe integer.", ); } - return Math.trunc(input); + return input; } function normalizeAppStoreIssuerId(input: string): string { diff --git a/packages/kit/convex/projects/query.ts b/packages/kit/convex/projects/query.ts index d50c1adc..8ece5f0b 100644 --- a/packages/kit/convex/projects/query.ts +++ b/packages/kit/convex/projects/query.ts @@ -1,8 +1,13 @@ -import { query } from "../_generated/server"; +import { query, type QueryCtx } from "../_generated/server"; import { v } from "convex/values"; import { getAuthUserId } from "@convex-dev/auth/server"; import { Doc } from "../_generated/dataModel"; +import { + resolveProjectByApiKeyFromDb, + resolveProjectByIdForCurrentUserFromDb, +} from "./helpers"; + /** * Strip long-lived server-side secrets from a project document before * returning it to the client. The Horizon App Secret is used by the @@ -31,6 +36,46 @@ function redactProjectSecrets(project: Doc<"projects">): Omit< }; } +function redactApiKeyLookupProject(project: Doc<"projects">): Omit< + Doc<"projects">, + "apiKey" | "horizonAppSecret" +> & { + hasHorizonAppSecret: boolean; +} { + const { apiKey, ...rest } = redactProjectSecrets(project); + void apiKey; + return rest; +} + +function redactDashboardProjectSecrets(project: Doc<"projects">): Omit< + Doc<"projects">, + "apiKey" | "horizonAppSecret" +> & { + hasHorizonAppSecret: boolean; +} { + return redactApiKeyLookupProject(project); +} + +function redactProjectListSecrets( + project: Doc<"projects">, + projectIdsWithAnyKey: Set, + projectIdsWithActiveKey: Set, +): Omit, "apiKey" | "horizonAppSecret"> & { + hasApiKey: boolean; + hasHorizonAppSecret: boolean; +} { + const { apiKey, ...rest } = redactProjectSecrets(project); + return { + ...rest, + // Legacy-only projects predate the apiKeys table, so fall back to + // projects.apiKey only when no apiKeys rows exist yet. Once a project has + // entered the key table, active/revoked state there is authoritative. + hasApiKey: + projectIdsWithActiveKey.has(project._id) || + (!projectIdsWithAnyKey.has(project._id) && apiKey.length > 0), + }; +} + export const listOrganizationProjects = query({ args: { organizationId: v.id("organizations") }, handler: async (ctx, args) => { @@ -59,7 +104,28 @@ export const listOrganizationProjects = query({ ) .collect(); - return projects.map(redactProjectSecrets); + const apiKeys = await ctx.db + .query("apiKeys") + .withIndex("by_organization", (q) => + q.eq("organizationId", args.organizationId), + ) + .collect(); + const projectIdsWithAnyKey = new Set( + apiKeys.map((apiKey) => apiKey.projectId), + ); + const projectIdsWithActiveKey = new Set( + apiKeys + .filter((apiKey) => apiKey.isActive) + .map((apiKey) => apiKey.projectId), + ); + + return projects.map((project) => + redactProjectListSecrets( + project, + projectIdsWithAnyKey, + projectIdsWithActiveKey, + ), + ); }, }); @@ -96,7 +162,7 @@ export const getProject = query({ ) .first(); - return project ? redactProjectSecrets(project) : null; + return project ? redactDashboardProjectSecrets(project) : null; }, }); @@ -128,7 +194,62 @@ export const getProjectById = query({ return null; } - return redactProjectSecrets(project); + return redactDashboardProjectSecrets(project); + }, +}); + +async function getWebhookApiKey(ctx: QueryCtx, project: Doc<"projects">) { + const apiKeys = await ctx.db + .query("apiKeys") + .withIndex("by_project", (q) => q.eq("projectId", project._id)) + .collect(); + const activeApiKey = apiKeys + .filter((apiKey) => apiKey.isActive) + .sort((a, b) => b.createdAt - a.createdAt)[0]; + + if (activeApiKey) { + return activeApiKey.key; + } + + // Legacy-only projects predate the apiKeys table. Once the table has rows + // for a project, do not fall back to projects.apiKey because revoked keys + // are authoritative there. + return apiKeys.length === 0 && project.apiKey.length > 0 + ? project.apiKey + : null; +} + +function webhookPathsForApiKey(apiKey: string) { + const encodedApiKey = encodeURIComponent(apiKey); + return { + unified: `/v1/webhooks/${encodedApiKey}`, + apple: `/v1/webhooks/apple/${encodedApiKey}`, + google: `/v1/webhooks/google/${encodedApiKey}`, + stream: `/v1/webhooks/stream/${encodedApiKey}`, + }; +} + +export const getWebhookEndpointPaths = query({ + args: { projectId: v.id("projects") }, + returns: v.union( + v.null(), + v.object({ + unified: v.string(), + apple: v.string(), + google: v.string(), + stream: v.string(), + }), + ), + handler: async (ctx, args) => { + const resolved = await resolveProjectByIdForCurrentUserFromDb( + ctx, + args.projectId, + ); + if (!resolved) return null; + if (resolved.role === "member") return null; + + const apiKey = await getWebhookApiKey(ctx, resolved.project); + return apiKey ? webhookPathsForApiKey(apiKey) : null; }, }); @@ -176,14 +297,13 @@ export const hasProjects = query({ }); // Public query to find project by API key (used by API verification endpoints). -// Uses the `by_api_key` index on `projects` so this is a single point lookup -// instead of a table scan. +// New keys resolve through `apiKeys` first so rotation / revocation semantics +// match the rest of the v1 surface; legacy project keys still fall back. Do +// not echo the apiKey back to callers; route code only needs a truthy project. export const getProjectByApiKey = query({ args: { apiKey: v.string() }, handler: async (ctx, args) => { - return await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .first(); + const resolved = await resolveProjectByApiKeyFromDb(ctx, args.apiKey); + return resolved ? redactApiKeyLookupProject(resolved.project) : null; }, }); diff --git a/packages/kit/convex/projects/setupStatus.ts b/packages/kit/convex/projects/setupStatus.ts index 08a663ab..6334f817 100644 --- a/packages/kit/convex/projects/setupStatus.ts +++ b/packages/kit/convex/projects/setupStatus.ts @@ -1,6 +1,11 @@ import { query } from "../_generated/server"; import { v } from "convex/values"; +import { + resolveProjectByApiKeyFromDb, + resolveProjectByIdForCurrentUserFromDb, +} from "./helpers"; + // Public query — surfaces which platforms a project has configured so // the dashboard, the SDK, and the MCP server can return a precise // "X not configured" error instead of a silent empty response. @@ -15,7 +20,10 @@ const platformShape = v.object({ }); export const getSetupStatus = query({ - args: { apiKey: v.string() }, + args: { + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), + }, returns: v.object({ found: v.boolean(), projectId: v.union(v.id("projects"), v.null()), @@ -26,10 +34,12 @@ export const getSetupStatus = query({ googleServiceAccountUploaded: v.boolean(), }), handler: async (ctx, args) => { - const project = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .unique(); + const resolved = args.projectId + ? await resolveProjectByIdForCurrentUserFromDb(ctx, args.projectId) + : args.apiKey + ? await resolveProjectByApiKeyFromDb(ctx, args.apiKey) + : null; + const project = resolved?.project ?? null; if (!project) { const empty = { configured: false, missing: ["project not found"] }; diff --git a/packages/kit/convex/purchases/android.test.ts b/packages/kit/convex/purchases/android.test.ts index 7b7c9076..e2936f06 100644 --- a/packages/kit/convex/purchases/android.test.ts +++ b/packages/kit/convex/purchases/android.test.ts @@ -3,12 +3,36 @@ import { isProductNotFoundError, mapProductResponseToReceiptData, mapSubscriptionResponseToReceiptData, + parseTimeToMillis, } from "./android"; import { HarmonizedPurchaseState } from "./purchaseState"; import { mapToGooglePlayReceiptResponse } from "./shared"; const packageName = "com.example.app"; +describe("parseTimeToMillis", () => { + it("accepts decimal epoch millis and RFC3339 timestamps", () => { + expect(parseTimeToMillis("1700000000000")).toBe(1_700_000_000_000); + expect(parseTimeToMillis(" 1700000000000 ")).toBe(1_700_000_000_000); + expect(parseTimeToMillis("2025-10-13T20:13:42.748Z")).toBe( + Date.parse("2025-10-13T20:13:42.748Z"), + ); + }); + + it("rejects malformed, numeric-like, and unsafe timestamps", () => { + expect(parseTimeToMillis(undefined)).toBeUndefined(); + expect(parseTimeToMillis("")).toBeUndefined(); + expect(parseTimeToMillis("0x10")).toBeUndefined(); + expect(parseTimeToMillis("+1000")).toBeUndefined(); + expect(parseTimeToMillis("1e3")).toBeUndefined(); + expect(parseTimeToMillis("123.45")).toBeUndefined(); + expect( + parseTimeToMillis(String(Number.MAX_SAFE_INTEGER + 1)), + ).toBeUndefined(); + expect(parseTimeToMillis("not-a-date")).toBeUndefined(); + }); +}); + describe("Google Play v2 mappings", () => { it("maps productsv2.getproductpurchasev2 PURCHASED + acknowledged + not consumed to ENTITLED", () => { const fixtures = [ diff --git a/packages/kit/convex/purchases/android.ts b/packages/kit/convex/purchases/android.ts index 5e9a2c8a..762dd9b3 100644 --- a/packages/kit/convex/purchases/android.ts +++ b/packages/kit/convex/purchases/android.ts @@ -33,6 +33,10 @@ import { ReceiptVerificationError } from "./errors"; import { HarmonizedPurchaseState } from "./purchaseState"; import { retryOnTransient } from "./retry"; +function describeError(error: unknown): string { + return error instanceof Error ? error.name : typeof error; +} + // Google Play receipt verification action export const verifyGooglePlayReceiptInternalV1 = action({ args: { @@ -186,7 +190,7 @@ export const verifyGooglePlayReceiptInternalV1 = action({ }); } - console.error("Error verifying Android purchase:", error); + console.error("Error verifying Android purchase:", describeError(error)); throw new PlayStoreVerificationError(error); } }, @@ -250,7 +254,10 @@ function parseAndValidateServiceAccountKey( return keyData; } catch (parseError) { - console.error("Failed to parse service account key:", parseError); + console.error( + "Failed to parse service account key:", + describeError(parseError), + ); throw new InvalidServiceAccountKeyFormatError(); } } @@ -260,18 +267,30 @@ type GooglePlayVerificationResult = { remoteResponse: string; }; -function parseTimeToMillis(time?: string | null): number | undefined { - if (!time) { +export function parseTimeToMillis(time?: string | null): number | undefined { + const value = time?.trim(); + if (!value) { return undefined; } - const asNumber = Number(time); - if (!Number.isNaN(asNumber)) { - return asNumber; + if (/^\d+$/.test(value)) { + const asNumber = Number(value); + return Number.isSafeInteger(asNumber) ? asNumber : undefined; + } + + if (isNumericLikeTimestamp(value)) { + return undefined; } - const parsed = Date.parse(time); - return Number.isNaN(parsed) ? undefined : parsed; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function isNumericLikeTimestamp(value: string): boolean { + return ( + /^[+-]?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(value) || + /^0x[0-9a-f]+$/i.test(value) + ); } export function mapSubscriptionResponseToReceiptData(args: { @@ -416,12 +435,10 @@ async function verifyPurchaseWithGooglePlay( remoteResponse = JSON.stringify(subResponse.data ?? null); } catch (subscriptionError: unknown) { - const errorMessage = - subscriptionError instanceof Error - ? subscriptionError.message - : String(subscriptionError); - console.error("Subscription verification also failed:", errorMessage); - console.error("Error details:", subscriptionError); + console.error( + "Subscription verification also failed:", + describeError(subscriptionError), + ); // Throw appropriate error based on the error type throw createPlayStoreError(subscriptionError); diff --git a/packages/kit/convex/purchases/horizon.ts b/packages/kit/convex/purchases/horizon.ts index e8ce9724..d49b1673 100644 --- a/packages/kit/convex/purchases/horizon.ts +++ b/packages/kit/convex/purchases/horizon.ts @@ -33,6 +33,12 @@ import { retryOnTransient } from "./retry"; // Docs: https://developers.meta.com/horizon/documentation/native/ps-iap const META_GRAPH_BASE = "https://graph.oculus.com"; +function describeError(error: unknown): string { + const status = (error as { code?: unknown })?.code; + const type = error instanceof Error ? error.name : typeof error; + return typeof status === "number" ? `${type} ${status}` : type; +} + export const verifyMetaHorizonReceiptInternalV1 = action({ args: { apiKey: v.string(), @@ -96,7 +102,7 @@ export const verifyMetaHorizonReceiptInternalV1 = action({ return (await res.json()) as unknown; }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); // Persist the failure so it shows up in the dashboard, mirroring // Apple / Google paths. await ctx.runMutation(internal.purchases.internal.saveReceiptInternal, { diff --git a/packages/kit/convex/purchases/ios.ts b/packages/kit/convex/purchases/ios.ts index 9b5098ff..ab089299 100644 --- a/packages/kit/convex/purchases/ios.ts +++ b/packages/kit/convex/purchases/ios.ts @@ -34,6 +34,10 @@ import { } from "./errors"; import { retryOnTransient } from "./retry"; +function describeError(error: unknown): string { + return error instanceof Error ? error.name : typeof error; +} + export const verifyAppStoreReceiptInternalV1 = action({ args: { apiKey: v.string(), @@ -214,7 +218,7 @@ async function verifyJWSTransaction( return transactionData; } catch (error) { - console.error("Error verifying JWS transaction:", error); + console.error("Error verifying JWS transaction:", describeError(error)); throw new AppStoreTransactionVerificationFailedError( getVerificationErrorMessage(error), ); @@ -252,7 +256,7 @@ async function getAppStoreServerCredentials( ); privateKey = keyResponse.keyContent; } catch (error) { - console.error("Failed to load Apple P8 key:", error); + console.error("Failed to load Apple P8 key:", describeError(error)); missingFields.push("privateKey"); } diff --git a/packages/kit/convex/subscriptions/horizon.ts b/packages/kit/convex/subscriptions/horizon.ts index ccffb315..3703d2e9 100644 --- a/packages/kit/convex/subscriptions/horizon.ts +++ b/packages/kit/convex/subscriptions/horizon.ts @@ -30,6 +30,10 @@ import { mapWithConcurrency } from "../utils/concurrency"; const META_GRAPH_BASE = "https://graph.oculus.com"; +function describeErrorForLog(error: unknown): string { + return error instanceof Error ? error.name : typeof error; +} + type HorizonProbe = { userId: string; sku: string; @@ -109,12 +113,10 @@ export const reconcileHorizonEntitlements = internalAction({ // aggregators long-term. The purchaseToken hash is enough // to correlate this entry to the row in `subscriptions` // when an operator needs to investigate. - console.warn( - "[horizon-reconciler] check failed", - project._id, - { tokenHash: hashForLog(probe.purchaseToken) }, - error instanceof Error ? error.message : error, - ); + console.warn("[horizon-reconciler] check failed", project._id, { + tokenHash: hashForLog(probe.purchaseToken), + error: describeErrorForLog(error), + }); continue; } // Meta's response is binary: `granted: true` means the user @@ -239,7 +241,10 @@ export const reconcileHorizonNow = action({ } } catch (error) { failures += 1; - console.warn("[horizon-reconciler] check failed", error); + console.warn("[horizon-reconciler] check failed", { + tokenHash: hashForLog(probe.purchaseToken), + error: describeErrorForLog(error), + }); } } return { checked, transitioned, failures }; @@ -282,8 +287,7 @@ async function checkHorizonEntitlement(args: { clearTimeout(timeout); } if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Meta Graph API ${res.status}: ${text.slice(0, 256)}`); + throw new Error(`Meta Graph API ${res.status}`); } const body = (await res.json()) as { success?: boolean }; return body.success === true; diff --git a/packages/kit/convex/subscriptions/horizonInternal.ts b/packages/kit/convex/subscriptions/horizonInternal.ts index 464b7a85..4071d711 100644 --- a/packages/kit/convex/subscriptions/horizonInternal.ts +++ b/packages/kit/convex/subscriptions/horizonInternal.ts @@ -2,6 +2,7 @@ import { internalMutation, internalQuery } from "../_generated/server"; import { v } from "convex/values"; import type { Doc } from "../_generated/dataModel"; +import { resolveProjectByApiKeyFromDb } from "../projects/helpers"; import { applySubscriptionTransition, type CurrentSubscription, @@ -51,10 +52,8 @@ export const getProjectByApiKey = internalQuery({ }), ), handler: async (ctx, args) => { - const project = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .unique(); + const resolved = await resolveProjectByApiKeyFromDb(ctx, args.apiKey); + const project = resolved?.project ?? null; if (!project) return null; return { _id: project._id, diff --git a/packages/kit/convex/subscriptions/mutation.ts b/packages/kit/convex/subscriptions/mutation.ts index 5efc45bf..988514ca 100644 --- a/packages/kit/convex/subscriptions/mutation.ts +++ b/packages/kit/convex/subscriptions/mutation.ts @@ -2,6 +2,8 @@ import { mutation } from "../_generated/server"; import { v } from "convex/values"; import type { Doc } from "../_generated/dataModel"; +import { resolveProjectByApiKeyFromDb } from "../projects/helpers"; + // Public mutation called by SDKs after a successful receipt verification: // they know who the host-app user is, so they tell kit which userId owns // the verified purchaseToken. Idempotent — re-binding the same userId is @@ -14,10 +16,8 @@ export const bindUser = mutation({ }, returns: v.object({ ok: v.boolean(), bound: v.boolean() }), handler: async (ctx, args) => { - const project = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .unique(); + const resolved = await resolveProjectByApiKeyFromDb(ctx, args.apiKey); + const project = resolved?.project ?? null; if (!project) return { ok: false, bound: false }; const sub: Doc<"subscriptions"> | null = await ctx.db diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts index 6f546056..839f3c2d 100644 --- a/packages/kit/convex/subscriptions/query.ts +++ b/packages/kit/convex/subscriptions/query.ts @@ -2,6 +2,10 @@ import { query, type QueryCtx } from "../_generated/server"; import { v } from "convex/values"; import type { Doc, Id } from "../_generated/dataModel"; +import { + resolveProjectByApiKeyFromDb, + resolveProjectByIdForCurrentUserFromDb, +} from "../projects/helpers"; import { monthlyMicrosForSub } from "./monthlyMicros"; import { selectMostRecentlyUpdatedSubscription } from "./selectLatest"; import { @@ -64,13 +68,39 @@ function shapeRow(sub: Doc<"subscriptions">) { } async function projectByApiKey( - ctx: { db: any }, - apiKey: string, + ctx: QueryCtx, + apiKey: string | undefined, +): Promise | null> { + if (!apiKey) return null; + const resolved = await resolveProjectByApiKeyFromDb(ctx, apiKey); + return resolved?.project ?? null; +} + +async function projectByIdForCurrentUser( + ctx: QueryCtx, + projectId: Id<"projects"> | undefined, ): Promise | null> { - return await ctx.db - .query("projects") - .withIndex("by_api_key", (q: any) => q.eq("apiKey", apiKey)) - .unique(); + if (!projectId) return null; + const resolved = await resolveProjectByIdForCurrentUserFromDb(ctx, projectId); + return resolved?.project ?? null; +} + +async function projectForReadArgs( + ctx: QueryCtx, + args: { + apiKey?: string; + projectId?: Id<"projects">; + }, +): Promise | null> { + if (args.projectId) { + return projectByIdForCurrentUser(ctx, args.projectId); + } + + if (args.apiKey !== undefined) { + return projectByApiKey(ctx, args.apiKey); + } + + throw new Error("apiKey or projectId is required."); } export interface MrrCurrencyEntry { @@ -186,7 +216,8 @@ export const entitlements = query({ // onesub's `SubscriptionStore.listFiltered` API. export const listSubscriptions = query({ args: { - apiKey: v.string(), + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), state: v.optional(subscriptionStateValidator), productId: v.optional(v.string()), userId: v.optional(v.string()), @@ -197,7 +228,7 @@ export const listSubscriptions = query({ total: v.number(), }), handler: async (ctx, args) => { - const project = await projectByApiKey(ctx, args.apiKey); + const project = await projectForReadArgs(ctx, args); if (!project) return { items: [], total: 0 }; const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); @@ -302,7 +333,10 @@ export const listSubscriptions = query({ // `recomputeSubscriptionStats` internal mutation populates rows for // future reads. export const metricsSummary = query({ - args: { apiKey: v.string() }, + args: { + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), + }, returns: v.object({ activeSubs: v.number(), inGracePeriod: v.number(), @@ -328,7 +362,7 @@ export const metricsSummary = query({ ), }), handler: async (ctx, args) => { - const project = await projectByApiKey(ctx, args.apiKey); + const project = await projectForReadArgs(ctx, args); if (!project) { return { activeSubs: 0, @@ -511,7 +545,8 @@ const platformValidator = v.union(v.literal("IOS"), v.literal("Android")); export const getRevenueMetrics = query({ args: { - apiKey: v.string(), + apiKey: v.optional(v.string()), + projectId: v.optional(v.id("projects")), fromDay: v.string(), toDay: v.string(), // Server-side `productId` / `currency` / `platform` filters were @@ -554,7 +589,7 @@ export const getRevenueMetrics = query({ truncated: v.boolean(), }), handler: async (ctx, args) => { - const project = await projectByApiKey(ctx, args.apiKey); + const project = await projectForReadArgs(ctx, args); if (!project) { return { days: [], diff --git a/packages/kit/convex/webhooks/apple.ts b/packages/kit/convex/webhooks/apple.ts index b42aa695..97cab9c3 100644 --- a/packages/kit/convex/webhooks/apple.ts +++ b/packages/kit/convex/webhooks/apple.ts @@ -112,7 +112,10 @@ export const ingestAppleAsnIOS = action({ try { payload = await verifier.verifyAndDecodeNotification(args.signedPayload); } catch (error) { - console.error("[webhooks/apple] notification verification failed", error); + console.error( + "[webhooks/apple] notification verification failed", + error instanceof Error ? error.name : typeof error, + ); // ConvexError so the Hono `mapWebhookError` translates to 400 — // signature failure is a permanent error and a 5xx would trigger // ASN's automatic retry loop forever. Apple's "do not retry on diff --git a/packages/kit/convex/webhooks/google.ts b/packages/kit/convex/webhooks/google.ts index 41d62cf0..ee487f88 100644 --- a/packages/kit/convex/webhooks/google.ts +++ b/packages/kit/convex/webhooks/google.ts @@ -467,14 +467,13 @@ async function maybeFetchSubscriptionInfo( if (error instanceof ConvexError) { throw error; } - // Sanitized: only the error name/code/message is logged. The full + // Sanitized: only the error name is logged. The full // googleapis error object can include the original request URL with // an OAuth bearer token and the response body — neither belongs in // logs that get shipped to error aggregation. const sanitized = - error instanceof Error - ? `${error.name}: ${error.message}` - : "(unknown error type)"; + error instanceof Error ? error.name : "(unknown error type)"; + const errorTextForDetection = error instanceof Error ? error.message : ""; // Auth-shaped failures (401/403, "invalid_grant", "Invalid JWT") // typically mean the operator rotated the service account. Drop // the cached client so the next webhook re-reads the file and @@ -497,8 +496,8 @@ async function maybeFetchSubscriptionInfo( errorCode === "403"; if ( numericAuthFailure || - sanitized.includes("invalid_grant") || - sanitized.includes("Invalid JWT") + errorTextForDetection.includes("invalid_grant") || + errorTextForDetection.includes("Invalid JWT") ) { playClientCache.delete(String(projectId)); } diff --git a/packages/kit/convex/webhooks/query.ts b/packages/kit/convex/webhooks/query.ts index 76842881..d6130bf0 100644 --- a/packages/kit/convex/webhooks/query.ts +++ b/packages/kit/convex/webhooks/query.ts @@ -2,6 +2,7 @@ import { query } from "../_generated/server"; import type { Doc } from "../_generated/dataModel"; import { v } from "convex/values"; +import { resolveProjectByApiKeyFromDb } from "../projects/helpers"; import { webhookEventTypeValidator, webhookEventSourceValidator, @@ -38,10 +39,8 @@ export const findEventCursor = query({ }), ), handler: async (ctx, args) => { - const project = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .unique(); + const resolved = await resolveProjectByApiKeyFromDb(ctx, args.apiKey); + const project = resolved?.project ?? null; if (!project) return null; const event = await ctx.db @@ -87,7 +86,6 @@ const webhookEventStreamShape = v.object({ cancellationReason: v.optional(webhookCancellationReasonValidator), currency: v.optional(v.string()), priceAmountMicros: v.optional(v.number()), - rawSignedPayload: v.optional(v.string()), }); function shapeWebhookEvent(event: Doc<"webhookEvents">) { @@ -113,7 +111,6 @@ function shapeWebhookEvent(event: Doc<"webhookEvents">) { cancellationReason: event.cancellationReason, currency: event.currency, priceAmountMicros: event.priceAmountMicros, - rawSignedPayload: event.rawSignedPayload, }; } @@ -141,10 +138,8 @@ export const webhookEventsSince = query({ }, returns: v.array(webhookEventStreamShape), handler: async (ctx, args) => { - const project = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .unique(); + const resolved = await resolveProjectByApiKeyFromDb(ctx, args.apiKey); + const project = resolved?.project ?? null; if (!project) { // Mirror the convention used by other v1 routes: return empty @@ -222,10 +217,8 @@ export const latestWebhookEventsSince = query({ }, returns: v.array(webhookEventStreamShape), handler: async (ctx, args) => { - const project = await ctx.db - .query("projects") - .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) - .unique(); + const resolved = await resolveProjectByApiKeyFromDb(ctx, args.apiKey); + const project = resolved?.project ?? null; if (!project) { return []; diff --git a/packages/kit/package.json b/packages/kit/package.json index 9318b713..657a37ef 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -40,10 +40,10 @@ "@sentry/react": "^10.49.0", "antd": "^6.1.0", "clsx": "^2.1.1", - "convex": "^1.29.2", + "convex": "^1.39.0", "google-auth-library": "^10.6.2", "googleapis": "^157.0.0", - "hono": "^4.9.9", + "hono": "^4.12.18", "hono-openapi": "^1.1.0", "lucide-react": "^0.577.0", "mixpanel-browser": "^2.72.0", @@ -62,7 +62,7 @@ "valibot": "^1.1.0" }, "devDependencies": { - "@eslint/js": "^9.21.0", + "@eslint/js": "^9.39.4", "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "^4.2.4", "@testing-library/jest-dom": "^6", @@ -73,11 +73,11 @@ "@types/node": "^22.13.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^5.2.0", "@vitest/ui": "^4", "autoprefixer": "~10", "dotenv": "^16.4.7", - "eslint": "^9.21.0", + "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^15.15.0", @@ -90,7 +90,7 @@ "tailwindcss": "~4", "typescript": "~5.9.3", "typescript-eslint": "^8.24.1", - "vite": "^6.2.0", + "vite": "^6.4.2", "vitest": "^4" }, "lint-staged": { diff --git a/packages/kit/public/llms-full.txt b/packages/kit/public/llms-full.txt index 3fc0ebe7..4273c7e1 100644 --- a/packages/kit/public/llms-full.txt +++ b/packages/kit/public/llms-full.txt @@ -137,22 +137,23 @@ carry these headers. ## Rate limits -In-memory token bucket keyed on SHA-256(api-key). Defaults: 60-request -burst + 1 req/sec steady state (≈ 60 req/min sustained). Tunable on +In-memory token bucket keyed on SHA-256(api-key). Defaults: 600-request +burst + 10 req/sec steady state (≈ 600 req/min sustained). Tunable on self-hosted deployments via `RATE_LIMIT_CAPACITY` and `RATE_LIMIT_REFILL_PER_SEC`. The Map is capped at 10,000 entries with LRU eviction (`RATE_LIMIT_MAX_STORE`). ## Input size caps +- request body ≤ 32 KB before JSON parsing - `jws` ≤ 16 KB (Apple) - `purchaseToken` ≤ 2 KB (Google) - `userId` ≤ 256 chars (Horizon) - `sku` ≤ 256 chars (Horizon) -Oversized requests return `400 INVALID_INPUT` before IAPKit calls the -upstream store, so a misbehaving client can't burn Apple / Google / Meta -quota. +Oversized fields return `400 INVALID_INPUT`; oversized request bodies return +`413 PAYLOAD_TOO_LARGE`. Neither path calls the upstream store, so a +misbehaving client can't burn Apple / Google / Meta quota. ## Structured logs diff --git a/packages/kit/public/llms.txt b/packages/kit/public/llms.txt index fa172f62..2f2c090f 100644 --- a/packages/kit/public/llms.txt +++ b/packages/kit/public/llms.txt @@ -15,8 +15,8 @@ Base URL: https://kit.openiap.dev Auth: `Authorization: Bearer openiap-kit_` - [POST /v1/purchase/verify](https://kit.openiap.dev/docs/api) — verify an in-app purchase; body is a tagged union on `store` -- [POST /v1/webhooks/{apiKey}](https://www.openiap.dev/docs/webhooks#setup) — lifecycle webhook receiver. Paste this URL into App Store Connect (Production + Sandbox) AND Google Cloud Pub/Sub push subscription. Auto-detects ASN v2 vs Pub/Sub by payload shape. **POST-only**; opening in a browser returns 404 — that's expected. -- [GET /v1/webhooks/stream/{apiKey}](https://www.openiap.dev/docs/webhooks#consume-stream) — long-lived SSE stream of normalized `WebhookEvent`s. Connect with `EventSource` (or the per-SDK helper); reconnects honor `Last-Event-ID`. Opening in a browser shows a blank page (text/event-stream that never closes) — use the SDK helpers or `curl -N`. +- [POST /v1/webhooks/{apiKey}](https://openiap.dev/docs/webhooks#setup) — lifecycle webhook receiver. Paste this URL into App Store Connect (Production + Sandbox) AND Google Cloud Pub/Sub push subscription. Auto-detects ASN v2 vs Pub/Sub by payload shape. **POST-only**; opening in a browser returns 404 — that's expected. +- [GET /v1/webhooks/stream/{apiKey}](https://openiap.dev/docs/webhooks#consume-stream) — long-lived SSE stream of normalized `WebhookEvent`s. Connect with `EventSource` (or the per-SDK helper); reconnects honor `Last-Event-ID`. Opening in a browser shows a blank page (text/event-stream that never closes) — use the SDK helpers or `curl -N`. - [GET /v1/openapi](https://kit.openiap.dev/v1/openapi) — machine-readable OpenAPI spec - [GET /v1](https://kit.openiap.dev/v1) — Redoc UI for the OpenAPI spec - [GET /health](https://kit.openiap.dev/health) — liveness probe (no Convex round-trip) @@ -67,6 +67,6 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - [/docs/verification/horizon](https://kit.openiap.dev/docs/verification/horizon) — App ID + App Secret (write-only) - [/docs/api](https://kit.openiap.dev/docs/api) — request shapes, responses, errors, headers - [/docs/operations](https://kit.openiap.dev/docs/operations) — rate limits, logs, `/health`, graceful shutdown -- [openiap.dev/docs/webhooks](https://www.openiap.dev/docs/webhooks) — operator setup steps for the lifecycle webhook URL (Apple ASN v2 + Google RTDN) and SDK code for consuming the SSE stream +- [openiap.dev/docs/webhooks](https://openiap.dev/docs/webhooks) — operator setup steps for the lifecycle webhook URL (Apple ASN v2 + Google RTDN) and SDK code for consuming the SSE stream - [/docs/ai-assistants](https://kit.openiap.dev/docs/ai-assistants) — how to point Claude / Cursor / etc. at this file - [/docs/release-notes](https://kit.openiap.dev/docs/release-notes) — changelog diff --git a/packages/kit/scripts/smoke-server.sh b/packages/kit/scripts/smoke-server.sh index 77e74178..dac173af 100755 --- a/packages/kit/scripts/smoke-server.sh +++ b/packages/kit/scripts/smoke-server.sh @@ -85,6 +85,7 @@ probe() { # must serve index.html for an unknown path. probe "/health" "200" probe "/" "200" +probe "/v1" "200" probe "/api/v1" "200" probe "/intu/project/intu/apikeys" "200" probe "/assets/missing-build-asset.js" "404" diff --git a/packages/kit/server/api/v1/middleware.test.ts b/packages/kit/server/api/v1/middleware.test.ts index fe71c367..2789cfd6 100644 --- a/packages/kit/server/api/v1/middleware.test.ts +++ b/packages/kit/server/api/v1/middleware.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { Hono } from "hono"; -import { apiKeyMiddleware } from "./middleware"; +import { apiKeyMiddleware, apiKeyValidationError } from "./middleware"; function buildApp() { const app = new Hono(); @@ -106,4 +106,31 @@ describe("apiKeyMiddleware", () => { }; expect(body.errors[0].code).toBe("INVALID_API_KEY"); }); + + test("returns 403 INVALID_API_KEY when the key is oversized", async () => { + const app = buildApp(); + const response = await app.request("/verify", { + method: "POST", + headers: { Authorization: `Bearer ${"a".repeat(129)}` }, + }); + expect(response.status).toBe(403); + const body = (await response.json()) as { + errors: Array<{ code: string; message: string }>; + }; + expect(body.errors[0]).toEqual({ + code: "INVALID_API_KEY", + message: "API key is too long", + }); + }); +}); + +describe("apiKeyValidationError", () => { + test("rejects blank, malformed, and oversized keys", () => { + expect(apiKeyValidationError(" ")).toBe("API key is required"); + expect(apiKeyValidationError("openiap-kit_abc 123")).toBe( + "API key is malformed", + ); + expect(apiKeyValidationError("a".repeat(129))).toBe("API key is too long"); + expect(apiKeyValidationError("openiap-kit_abc123")).toBeNull(); + }); }); diff --git a/packages/kit/server/api/v1/middleware.ts b/packages/kit/server/api/v1/middleware.ts index 4baf8637..c4b7d2b6 100644 --- a/packages/kit/server/api/v1/middleware.ts +++ b/packages/kit/server/api/v1/middleware.ts @@ -1,5 +1,20 @@ import { createMiddleware } from "hono/factory"; +const MAX_API_KEY_LENGTH = 128; + +function isValidApiKeyLength(apiKey: string): boolean { + return apiKey.length <= MAX_API_KEY_LENGTH; +} + +export function apiKeyValidationError( + apiKey: string | undefined, +): string | null { + if (!apiKey?.trim()) return "API key is required"; + if (/\s/.test(apiKey)) return "API key is malformed"; + if (!isValidApiKeyLength(apiKey)) return "API key is too long"; + return null; +} + export const apiKeyMiddleware = createMiddleware<{ Variables: { apiKey: string; @@ -45,6 +60,21 @@ export const apiKeyMiddleware = createMiddleware<{ ); } + const validationError = apiKeyValidationError(parts[1]); + if (validationError) { + return c.json( + { + errors: [ + { + code: "INVALID_API_KEY", + message: validationError, + }, + ], + }, + 403, + ); + } + c.set("apiKey", parts[1]); await next(); diff --git a/packages/kit/server/api/v1/products.test.ts b/packages/kit/server/api/v1/products.test.ts new file mode 100644 index 00000000..98c2dab8 --- /dev/null +++ b/packages/kit/server/api/v1/products.test.ts @@ -0,0 +1,583 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + mutation: vi.fn(), +})); + +vi.mock("@/convex", () => ({ + api: { + products: { + query: { + listProducts: "listProducts", + }, + mutation: { + upsertProduct: "upsertProduct", + setProductState: "setProductState", + removeProduct: "removeProduct", + }, + jobs: { + enqueueProductSync: "enqueueProductSync", + getSyncJobById: "getSyncJobById", + cancelProductSync: "cancelProductSync", + }, + }, + }, +})); + +vi.mock("../../convex", () => ({ + client: { + query: mocks.query, + mutation: mocks.mutation, + }, + handleConvexError: () => null, +})); + +const { productsRoutes } = await import("./products"); + +function buildApp() { + const app = new Hono(); + app.route("/products", productsRoutes); + return app; +} + +describe("productsRoutes", () => { + beforeEach(() => { + mocks.query.mockReset(); + mocks.mutation.mockReset(); + }); + + it("rejects oversized path apiKey before calling Convex", async () => { + const app = buildApp(); + const response = await app.request(`/products/${"a".repeat(129)}`); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + errors: [{ code: "INVALID_API_KEY", message: "API key is too long" }], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects blank path apiKey before calling Convex", async () => { + const app = buildApp(); + const response = await app.request("/products/%20%20"); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + errors: [{ code: "INVALID_API_KEY", message: "API key is required" }], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects oversized productId inputs before calling Convex", async () => { + const app = buildApp(); + const productId = "p".repeat(257); + + const cases = [ + app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + productId, + platform: "IOS", + type: "Subscription", + title: "Premium", + }), + }), + app.request("/products/key/state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + productId, + platform: "IOS", + state: "Draft", + }), + }), + app.request(`/products/key/${productId}?platform=IOS`, { + method: "DELETE", + }), + ]; + + for (const responsePromise of cases) { + const response = await responsePromise; + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [ + { code: "INVALID_INPUT", message: "productId must be ≤ 256 chars" }, + ], + }); + } + + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects blank productId path params before calling Convex", async () => { + const app = buildApp(); + + const response = await app.request("/products/key/%20%20?platform=IOS", { + method: "DELETE", + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [ + { code: "INVALID_INPUT", message: "productId must not be empty" }, + ], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects oversized product bodies before calling Convex", async () => { + const app = buildApp(); + + const response = await app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + productId: "premium_monthly", + platform: "IOS", + type: "Subscription", + title: "Premium", + description: "x".repeat(64 * 1024), + }), + }); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + errors: [ + { code: "PAYLOAD_TOO_LARGE", message: "Product payload is too large" }, + ], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects oversized product content-length before reading the body", async () => { + const app = buildApp(); + + const response = await app.request("/products/key", { + method: "POST", + headers: { "content-length": String(64 * 1024 + 1) }, + }); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + errors: [ + { code: "PAYLOAD_TOO_LARGE", message: "Product payload is too large" }, + ], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects non-object product bodies before calling Convex", async () => { + const app = buildApp(); + const cases = [ + [ + app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "null", + }), + { + code: "INVALID_INPUT", + message: "productId, platform, type, title are required", + }, + ], + [ + app.request("/products/key/state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "null", + }), + { + code: "INVALID_INPUT", + message: "productId, platform, state are required", + }, + ], + ] as const; + + for (const [responsePromise, error] of cases) { + const response = await responsePromise; + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ errors: [error] }); + } + + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects invalid product enum inputs before calling Convex", async () => { + const app = buildApp(); + + const cases = [ + [ + app.request("/products/key?platform=Web"), + { code: "INVALID_INPUT", message: "platform must be IOS|Android" }, + ], + [ + app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + productId: "premium_monthly", + platform: "IOS", + type: "Rental", + title: "Premium", + }), + }), + { + code: "INVALID_INPUT", + message: "type must be Subscription|NonConsumable|Consumable", + }, + ], + [ + app.request("/products/key/state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + productId: "premium_monthly", + platform: "IOS", + state: "Deleted", + }), + }), + { + code: "INVALID_INPUT", + message: "state must be Draft|Ready|Active|Removed", + }, + ], + ] as const; + + for (const [responsePromise, error] of cases) { + const response = await responsePromise; + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ errors: [error] }); + } + + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects invalid product string fields before calling Convex", async () => { + const app = buildApp(); + const cases = [ + [ + { + productId: " ", + platform: "IOS", + type: "Subscription", + title: "Premium", + subscriptionGroupName: "premium_tiers", + }, + { + code: "INVALID_INPUT", + message: "productId, platform, type, title are required", + }, + ], + [ + { + productId: "premium_monthly", + platform: "IOS", + type: "Subscription", + title: " ", + subscriptionGroupName: "premium_tiers", + }, + { + code: "INVALID_INPUT", + message: "productId, platform, type, title are required", + }, + ], + [ + { + productId: "premium_monthly", + platform: "IOS", + type: "Subscription", + title: 42, + subscriptionGroupName: "premium_tiers", + }, + { code: "INVALID_INPUT", message: "title must be a string" }, + ], + [ + { + productId: "premium_monthly", + platform: "IOS", + type: "Subscription", + title: "Premium", + subscriptionGroupName: {}, + }, + { + code: "INVALID_INPUT", + message: + "description, currency, subscriptionGroupName, reviewNote, storeRef must be strings", + }, + ], + ] as const; + + for (const [body, error] of cases) { + const response = await app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ errors: [error] }); + } + + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects invalid product prices before calling Convex", async () => { + const app = buildApp(); + + const cases = [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, "990000"] as const; + + for (const priceAmountMicros of cases) { + const response = await app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + productId: "premium_monthly", + platform: "IOS", + type: "Subscription", + title: "Premium", + subscriptionGroupName: "premium_tiers", + priceAmountMicros, + }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "INVALID_INPUT", + message: "priceAmountMicros must be a non-negative safe integer", + }, + ], + }); + } + + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects invalid sync query params before calling Convex", async () => { + const app = buildApp(); + const cases = [ + [ + app.request("/products/key/sync/ios?direction=sideways", { + method: "POST", + }), + { + code: "INVALID_INPUT", + message: "direction must be pull|push|both|purge-local", + }, + ], + [ + app.request("/products/key/sync/ios?dryRun=banana", { + method: "POST", + }), + { + code: "INVALID_INPUT", + message: "dryRun must be true|false", + }, + ], + ] as const; + + for (const [responsePromise, error] of cases) { + const response = await responsePromise; + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ errors: [error] }); + } + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects oversized sync job ids before calling Convex", async () => { + const app = buildApp(); + const jobId = "j".repeat(257); + + const cases = [ + app.request(`/products/key/sync/jobs/${jobId}`), + app.request(`/products/key/sync/jobs/${jobId}/cancel`, { + method: "POST", + }), + ]; + + for (const responsePromise of cases) { + const response = await responsePromise; + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [ + { code: "INVALID_INPUT", message: "jobId must be ≤ 256 chars" }, + ], + }); + } + + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects blank sync job ids before calling Convex", async () => { + const app = buildApp(); + + const cases = [ + app.request("/products/key/sync/jobs/%20%20"), + app.request("/products/key/sync/jobs/%20%20/cancel", { + method: "POST", + }), + ]; + + for (const responsePromise of cases) { + const response = await responsePromise; + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [{ code: "INVALID_INPUT", message: "jobId must not be empty" }], + }); + } + + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("requires iOS subscription group names before calling Convex", async () => { + const app = buildApp(); + + const response = await app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + productId: "premium_monthly", + platform: "IOS", + type: "Subscription", + title: "Premium", + }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "INVALID_INPUT", + message: + "subscriptionGroupName is required for iOS Subscription products", + }, + ], + }); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("forwards subscription metadata to Convex", async () => { + const app = buildApp(); + mocks.mutation.mockResolvedValueOnce({ id: "product-id", created: true }); + + const response = await app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + productId: "premium_monthly", + platform: "IOS", + type: "Subscription", + title: "Premium", + billingPeriod: "P1M", + subscriptionGroupName: "premium_tiers", + reviewNote: "Sandbox review note", + }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + id: "product-id", + created: true, + }); + expect(mocks.mutation).toHaveBeenCalledWith("upsertProduct", { + apiKey: "key", + productId: "premium_monthly", + platform: "IOS", + type: "Subscription", + title: "Premium", + description: undefined, + priceAmountMicros: undefined, + currency: undefined, + billingPeriod: "P1M", + subscriptionGroupName: "premium_tiers", + reviewNote: "Sandbox review note", + state: undefined, + storeRef: undefined, + }); + }); + + it("does not return raw internal product mutation errors", async () => { + const app = buildApp(); + mocks.mutation.mockRejectedValueOnce( + new Error("database password leaked in stack"), + ); + + const response = await app.request("/products/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + productId: "premium_monthly", + platform: "IOS", + type: "Subscription", + title: "Premium", + subscriptionGroupName: "premium_tiers", + }), + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "PRODUCT_UPSERT_FAILED", + message: "Product upsert failed", + }, + ], + }); + expect(mocks.mutation).toHaveBeenCalledOnce(); + }); + + it("does not return raw internal product list errors", async () => { + const app = buildApp(); + mocks.query.mockRejectedValueOnce(new Error("internal query detail")); + + const response = await app.request("/products/key"); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "PRODUCT_LIST_FAILED", + message: "Product list failed", + }, + ], + }); + expect(mocks.query).toHaveBeenCalledOnce(); + }); + + it("does not return raw internal product delete errors", async () => { + const app = buildApp(); + mocks.mutation.mockRejectedValueOnce(new Error("internal delete detail")); + + const response = await app.request( + "/products/key/premium_monthly?platform=IOS", + { method: "DELETE" }, + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "PRODUCT_REMOVE_FAILED", + message: "Product remove failed", + }, + ], + }); + expect(mocks.mutation).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/kit/server/api/v1/products.ts b/packages/kit/server/api/v1/products.ts index 1d567691..3f35af8a 100644 --- a/packages/kit/server/api/v1/products.ts +++ b/packages/kit/server/api/v1/products.ts @@ -1,8 +1,14 @@ -import { Hono } from "hono"; +import { Hono, type Context, type Next } from "hono"; import { api } from "@/convex"; import type { Id } from "@/convex"; -import { client } from "../../convex"; +import { client, handleConvexError } from "../../convex"; +import { apiKeyValidationError } from "./middleware"; +import { + isContentLengthOverLimit, + JsonBodyTooLargeError, + readJsonBodyWithLimit, +} from "./request-body"; // Catalog read/write surface mirroring onesub's @onesub/providers // admin path. The actual App Store Connect / Play Console push-sync @@ -10,82 +16,188 @@ import { client } from "../../convex"; // which the dashboard / MCP server / SDKs all share. const products = new Hono(); +const MAX_PRODUCT_ID_LENGTH = 256; +const MAX_SYNC_JOB_ID_LENGTH = 256; +const MAX_PRODUCT_BODY_BYTES = 64 * 1024; + +type ProductPlatform = "IOS" | "Android"; +type ProductType = "Subscription" | "NonConsumable" | "Consumable"; +type ProductState = "Draft" | "Ready" | "Active" | "Removed"; +type BillingPeriod = "P1W" | "P1M" | "P2M" | "P3M" | "P6M" | "P1Y"; +type SyncDirection = "pull" | "push" | "both" | "purge-local"; +const PRODUCT_PLATFORMS = new Set(["IOS", "Android"]); +const PRODUCT_TYPES = new Set([ + "Subscription", + "NonConsumable", + "Consumable", +]); +const PRODUCT_STATES = new Set(["Draft", "Ready", "Active", "Removed"]); +const BILLING_PERIODS = new Set([ + "P1W", + "P1M", + "P2M", + "P3M", + "P6M", + "P1Y", +]); +const SYNC_DIRECTIONS = new Set([ + "pull", + "push", + "both", + "purge-local", +]); + +products.use("/:apiKey", pathApiKeyGuard); +products.use("/:apiKey/*", pathApiKeyGuard); products.get("/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); const platformParam = c.req.query("platform"); - const platform = - platformParam === "IOS" || platformParam === "Android" - ? platformParam - : undefined; - const list = await client.query(api.products.query.listProducts, { - apiKey, - platform, - }); - return c.json({ products: list }); + let platform: ProductPlatform | undefined; + if (platformParam !== undefined) { + if (!isProductPlatform(platformParam)) { + return invalidInput(c, "platform must be IOS|Android"); + } + platform = platformParam; + } + try { + const list = await client.query(api.products.query.listProducts, { + apiKey, + platform, + }); + return c.json({ products: list }); + } catch (error) { + return productRouteError( + c, + error, + "PRODUCT_LIST_FAILED", + "Product list failed", + ); + } }); products.post("/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); - let body: { + let body: unknown; + try { + body = await readProductJsonBody(c.req.raw); + } catch (error) { + if (error instanceof JsonBodyTooLargeError) { + return payloadTooLarge(c); + } + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, + 400, + ); + } + if (!isJsonObject(body)) { + return invalidInput(c, "productId, platform, type, title are required"); + } + const payload = body as { productId?: string; platform?: "IOS" | "Android"; - type?: "Subscription" | "NonConsumable" | "Consumable"; + type?: ProductType; title?: string; description?: string; priceAmountMicros?: number; currency?: string; - billingPeriod?: "P1W" | "P1M" | "P2M" | "P3M" | "P6M" | "P1Y"; - state?: "Draft" | "Ready" | "Active" | "Removed"; + billingPeriod?: BillingPeriod; + subscriptionGroupName?: string; + reviewNote?: string; + state?: ProductState; storeRef?: string; }; - try { - body = await c.req.json(); - } catch { - return c.json( - { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, - 400, + if ( + !isNonBlankString(payload.productId) || + !payload.platform || + !payload.type || + payload.title == null + ) { + return invalidInput(c, "productId, platform, type, title are required"); + } + if (!isValidProductIdLength(payload.productId)) { + return invalidInput(c, "productId must be ≤ 256 chars"); + } + if (!isProductPlatform(payload.platform)) { + return invalidInput(c, "platform must be IOS|Android"); + } + if (!isProductType(payload.type)) { + return invalidInput( + c, + "type must be Subscription|NonConsumable|Consumable", ); } - if (!body.productId || !body.platform || !body.type || body.title == null) { - return c.json( - { - errors: [ - { - code: "INVALID_INPUT", - message: "productId, platform, type, title are required", - }, - ], - }, - 400, + if (typeof payload.title !== "string") { + return invalidInput(c, "title must be a string"); + } + if (!payload.title.trim()) { + return invalidInput(c, "productId, platform, type, title are required"); + } + if ( + !areOptionalStrings( + payload.description, + payload.currency, + payload.subscriptionGroupName, + payload.reviewNote, + payload.storeRef, + ) + ) { + return invalidInput( + c, + "description, currency, subscriptionGroupName, reviewNote, storeRef must be strings", + ); + } + if ( + payload.billingPeriod !== undefined && + !isBillingPeriod(payload.billingPeriod) + ) { + return invalidInput(c, "billingPeriod is invalid"); + } + if ( + payload.priceAmountMicros !== undefined && + !isValidPriceAmountMicros(payload.priceAmountMicros) + ) { + return invalidInput( + c, + "priceAmountMicros must be a non-negative safe integer", + ); + } + if (payload.state !== undefined && !isProductState(payload.state)) { + return invalidInput(c, "state must be Draft|Ready|Active|Removed"); + } + if ( + payload.platform === "IOS" && + payload.type === "Subscription" && + !payload.subscriptionGroupName?.trim() + ) { + return invalidInput( + c, + "subscriptionGroupName is required for iOS Subscription products", ); } try { const result = await client.mutation(api.products.mutation.upsertProduct, { apiKey, - productId: body.productId, - platform: body.platform, - type: body.type, - title: body.title, - description: body.description, - priceAmountMicros: body.priceAmountMicros, - currency: body.currency, - billingPeriod: body.billingPeriod, - state: body.state, - storeRef: body.storeRef, + productId: payload.productId, + platform: payload.platform, + type: payload.type, + title: payload.title, + description: payload.description, + priceAmountMicros: payload.priceAmountMicros, + currency: payload.currency, + billingPeriod: payload.billingPeriod, + subscriptionGroupName: payload.subscriptionGroupName, + reviewNote: payload.reviewNote, + state: payload.state, + storeRef: payload.storeRef, }); return c.json(result); } catch (error) { - return c.json( - { - errors: [ - { - code: "PRODUCT_UPSERT_FAILED", - message: error instanceof Error ? error.message : String(error), - }, - ], - }, - 400, + return productRouteError( + c, + error, + "PRODUCT_UPSERT_FAILED", + "Product upsert failed", ); } }); @@ -96,54 +208,59 @@ products.post("/:apiKey", async (c) => { // reuse pattern silently did). products.post("/:apiKey/state", async (c) => { const apiKey = c.req.param("apiKey"); - let body: { - productId?: string; - platform?: "IOS" | "Android"; - state?: "Draft" | "Ready" | "Active" | "Removed"; - }; + let body: unknown; try { - body = await c.req.json(); - } catch { + body = await readProductJsonBody(c.req.raw); + } catch (error) { + if (error instanceof JsonBodyTooLargeError) { + return payloadTooLarge(c); + } return c.json( { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, 400, ); } - if (!body.productId || !body.platform || !body.state) { - return c.json( - { - errors: [ - { - code: "INVALID_INPUT", - message: "productId, platform, state are required", - }, - ], - }, - 400, - ); + if (!isJsonObject(body)) { + return invalidInput(c, "productId, platform, state are required"); + } + const payload = body as { + productId?: string; + platform?: ProductPlatform; + state?: ProductState; + }; + if ( + !isNonBlankString(payload.productId) || + !payload.platform || + !payload.state + ) { + return invalidInput(c, "productId, platform, state are required"); + } + if (!isValidProductIdLength(payload.productId)) { + return invalidInput(c, "productId must be ≤ 256 chars"); + } + if (!isProductPlatform(payload.platform)) { + return invalidInput(c, "platform must be IOS|Android"); + } + if (!isProductState(payload.state)) { + return invalidInput(c, "state must be Draft|Ready|Active|Removed"); } try { const result = await client.mutation( api.products.mutation.setProductState, { apiKey, - productId: body.productId, - platform: body.platform, - state: body.state, + productId: payload.productId, + platform: payload.platform, + state: payload.state, }, ); return c.json(result); } catch (error) { - return c.json( - { - errors: [ - { - code: "PRODUCT_STATE_FAILED", - message: error instanceof Error ? error.message : String(error), - }, - ], - }, - 400, + return productRouteError( + c, + error, + "PRODUCT_STATE_FAILED", + "Product state update failed", ); } }); @@ -157,24 +274,22 @@ products.post("/:apiKey/state", async (c) => { products.post("/:apiKey/sync/:platform", async (c) => { const apiKey = c.req.param("apiKey"); const platformParam = c.req.param("platform"); - const direction = - (c.req.query("direction") as - | "pull" - | "push" - | "both" - | "purge-local" - | undefined) ?? "both"; - const dryRun = c.req.query("dryRun") === "true"; + const direction = c.req.query("direction") ?? "both"; + const dryRunParam = c.req.query("dryRun"); if (platformParam !== "ios" && platformParam !== "android") { - return c.json( - { - errors: [ - { code: "INVALID_INPUT", message: "platform must be ios|android" }, - ], - }, - 400, - ); + return invalidInput(c, "platform must be ios|android"); + } + if (!isSyncDirection(direction)) { + return invalidInput(c, "direction must be pull|push|both|purge-local"); } + if ( + dryRunParam !== undefined && + dryRunParam !== "true" && + dryRunParam !== "false" + ) { + return invalidInput(c, "dryRun must be true|false"); + } + const dryRun = dryRunParam === "true"; const platform: "IOS" | "Android" = platformParam === "ios" ? "IOS" : "Android"; try { @@ -186,16 +301,11 @@ products.post("/:apiKey/sync/:platform", async (c) => { }); return c.json(result, 202); } catch (error) { - return c.json( - { - errors: [ - { - code: "PRODUCT_SYNC_ENQUEUE_FAILED", - message: error instanceof Error ? error.message : String(error), - }, - ], - }, - 400, + return productRouteError( + c, + error, + "PRODUCT_SYNC_ENQUEUE_FAILED", + "Product sync enqueue failed", ); } }); @@ -207,6 +317,12 @@ products.post("/:apiKey/sync/:platform", async (c) => { products.get("/:apiKey/sync/jobs/:jobId", async (c) => { const apiKey = c.req.param("apiKey"); const jobId = c.req.param("jobId"); + if (!isNonBlankString(jobId)) { + return invalidInput(c, "jobId must not be empty"); + } + if (!isValidSyncJobIdLength(jobId)) { + return invalidInput(c, "jobId must be ≤ 256 chars"); + } try { const job = await client.query(api.products.jobs.getSyncJobById, { apiKey, @@ -220,16 +336,11 @@ products.get("/:apiKey/sync/jobs/:jobId", async (c) => { } return c.json(job); } catch (error) { - return c.json( - { - errors: [ - { - code: "PRODUCT_SYNC_LOOKUP_FAILED", - message: error instanceof Error ? error.message : String(error), - }, - ], - }, - 400, + return productRouteError( + c, + error, + "PRODUCT_SYNC_LOOKUP_FAILED", + "Product sync lookup failed", ); } }); @@ -239,6 +350,12 @@ products.get("/:apiKey/sync/jobs/:jobId", async (c) => { products.post("/:apiKey/sync/jobs/:jobId/cancel", async (c) => { const apiKey = c.req.param("apiKey"); const jobId = c.req.param("jobId"); + if (!isNonBlankString(jobId)) { + return invalidInput(c, "jobId must not be empty"); + } + if (!isValidSyncJobIdLength(jobId)) { + return invalidInput(c, "jobId must be ≤ 256 chars"); + } try { const result = await client.mutation(api.products.jobs.cancelProductSync, { apiKey, @@ -246,16 +363,11 @@ products.post("/:apiKey/sync/jobs/:jobId/cancel", async (c) => { }); return c.json(result); } catch (error) { - return c.json( - { - errors: [ - { - code: "PRODUCT_SYNC_CANCEL_FAILED", - message: error instanceof Error ? error.message : String(error), - }, - ], - }, - 400, + return productRouteError( + c, + error, + "PRODUCT_SYNC_CANCEL_FAILED", + "Product sync cancel failed", ); } }); @@ -263,26 +375,139 @@ products.post("/:apiKey/sync/jobs/:jobId/cancel", async (c) => { products.delete("/:apiKey/:productId", async (c) => { const apiKey = c.req.param("apiKey"); const productId = c.req.param("productId"); - const platform = c.req.query("platform") as "IOS" | "Android" | undefined; - if (platform !== "IOS" && platform !== "Android") { - return c.json( - { - errors: [ - { - code: "INVALID_INPUT", - message: "platform query param required (IOS | Android)", - }, - ], - }, - 400, + const platformParam = c.req.query("platform"); + if (!isProductPlatform(platformParam)) { + return invalidInput(c, "platform query param required (IOS | Android)"); + } + if (!isNonBlankString(productId)) { + return invalidInput(c, "productId must not be empty"); + } + if (!isValidProductIdLength(productId)) { + return invalidInput(c, "productId must be ≤ 256 chars"); + } + try { + const result = await client.mutation(api.products.mutation.removeProduct, { + apiKey, + productId, + platform: platformParam, + }); + return c.json(result); + } catch (error) { + return productRouteError( + c, + error, + "PRODUCT_REMOVE_FAILED", + "Product remove failed", ); } - const result = await client.mutation(api.products.mutation.removeProduct, { - apiKey, - productId, - platform, - }); - return c.json(result); }); +function isValidProductIdLength(productId: string): boolean { + return productId.length <= MAX_PRODUCT_ID_LENGTH; +} + +function isValidSyncJobIdLength(jobId: string): boolean { + return jobId.length <= MAX_SYNC_JOB_ID_LENGTH; +} + +function isValidPriceAmountMicros(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function isProductPlatform(value: unknown): value is ProductPlatform { + return typeof value === "string" && PRODUCT_PLATFORMS.has(value); +} + +function isProductType(value: unknown): value is ProductType { + return typeof value === "string" && PRODUCT_TYPES.has(value); +} + +function isProductState(value: unknown): value is ProductState { + return typeof value === "string" && PRODUCT_STATES.has(value); +} + +function isBillingPeriod(value: unknown): value is BillingPeriod { + return typeof value === "string" && BILLING_PERIODS.has(value); +} + +function isSyncDirection(direction: string): direction is SyncDirection { + return SYNC_DIRECTIONS.has(direction); +} + +function areOptionalStrings(...values: unknown[]): boolean { + return values.every( + (value) => value === undefined || typeof value === "string", + ); +} + +function isNonBlankString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isJsonObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function invalidInput(c: Context, message: string) { + return c.json({ errors: [{ code: "INVALID_INPUT", message }] }, 400); +} + +function payloadTooLarge(c: Context) { + return c.json( + { + errors: [ + { code: "PAYLOAD_TOO_LARGE", message: "Product payload is too large" }, + ], + }, + 413, + ); +} + +function readProductJsonBody(request: Request) { + return readJsonBodyWithLimit( + request, + MAX_PRODUCT_BODY_BYTES, + "Product payload is too large", + ); +} + +async function pathApiKeyGuard(c: Context, next: Next) { + const validationError = apiKeyValidationError(c.req.param("apiKey")); + if (validationError) { + return c.json( + { errors: [{ code: "INVALID_API_KEY", message: validationError }] }, + 403, + ); + } + if ( + c.req.method !== "GET" && + isContentLengthOverLimit( + c.req.header("content-length"), + MAX_PRODUCT_BODY_BYTES, + ) + ) { + return payloadTooLarge(c); + } + await next(); +} + +function productRouteError( + c: Context, + error: unknown, + code: string, + fallbackMessage: string, +) { + const convexError = handleConvexError(error); + if (convexError) { + return c.json({ errors: [convexError] }, 400); + } + + console.error(`[products] ${code}`, describeErrorForLog(error)); + return c.json({ errors: [{ code, message: fallbackMessage }] }, 500); +} + +function describeErrorForLog(error: unknown): string { + return error instanceof Error ? error.name : typeof error; +} + export { products as productsRoutes }; diff --git a/packages/kit/server/api/v1/rate-limit.test.ts b/packages/kit/server/api/v1/rate-limit.test.ts index 30536ad0..13b08c0c 100644 --- a/packages/kit/server/api/v1/rate-limit.test.ts +++ b/packages/kit/server/api/v1/rate-limit.test.ts @@ -114,6 +114,10 @@ describe("parsePositiveNumber", () => { test("returns fallback for NaN, Infinity, non-numeric strings", () => { expect(parsePositiveNumber("pineapple", 60, 1)).toBe(60); + expect(parsePositiveNumber("120rps", 60, 1)).toBe(60); + expect(parsePositiveNumber("0x10", 60, 1)).toBe(60); + expect(parsePositiveNumber("1e2", 60, 1)).toBe(60); + expect(parsePositiveNumber("+1", 60, 1)).toBe(60); expect(parsePositiveNumber("NaN", 60, 1)).toBe(60); expect(parsePositiveNumber("Infinity", 60, 1)).toBe(60); }); @@ -128,6 +132,7 @@ describe("parsePositiveNumber", () => { test("returns the parsed value when it is finite and above min", () => { expect(parsePositiveNumber("120", 60, 1)).toBe(120); + expect(parsePositiveNumber(" 120 ", 60, 1)).toBe(120); expect(parsePositiveNumber("0.25", 1, 0.001)).toBe(0.25); }); }); diff --git a/packages/kit/server/api/v1/replay-guard.test.ts b/packages/kit/server/api/v1/replay-guard.test.ts index a7d472b0..988889de 100644 --- a/packages/kit/server/api/v1/replay-guard.test.ts +++ b/packages/kit/server/api/v1/replay-guard.test.ts @@ -111,6 +111,27 @@ describe("markPayloadFailure + tryConsumeReplay cooldown", () => { expect(blocked.retryAfterSec).toBeGreaterThanOrEqual(58); }); + test("does not extend cooldown retry-after when the clock moves backward", () => { + const store = new Map(); + const now = 10_000; + const cooldownMs = 60_000; + + markPayloadFailure(store, "k:p", 30, now, 100); + const blocked = tryConsumeReplay( + store, + "k:p", + 30, + 1, + now - 5_000, + 100, + cooldownMs, + ); + + expect(blocked.allowed).toBe(false); + expect(blocked.reason).toBe("repeated_failure"); + expect(blocked.retryAfterSec).toBe(60); + }); + test("allows the same payload again after the cooldown elapses", () => { const store = new Map(); let now = 1_000; diff --git a/packages/kit/server/api/v1/replay-guard.ts b/packages/kit/server/api/v1/replay-guard.ts index 7edfb799..b6314d15 100644 --- a/packages/kit/server/api/v1/replay-guard.ts +++ b/packages/kit/server/api/v1/replay-guard.ts @@ -48,8 +48,8 @@ export interface ReplayGuardConfig { refillPerSecond: number; maxStoreSize: number; /** Cooldown after a failed verification of the same payload. Defaults - * are tuned for the common case where Apple / Google's verdict for a - * given receipt is stable for far longer than this window. */ + * are tuned for the common case where the store provider's verdict for + * a given receipt is stable for far longer than this window. */ failureCooldownMs: number; now?: () => number; store?: Map; @@ -130,20 +130,19 @@ export function tryConsumeReplay( // while the cooldown is active, even if the bucket happens to have // tokens. This is the layer that defeats "captured-then-revoked // receipt replay": the attacker has a real-shaped receipt that - // Apple / Google said no to, and trying again 200 ms later just + // the store provider said no to, and trying again 200 ms later just // burns our upstream quota for the same answer. - if ( - failureCooldownMs > 0 && - bucket.lastFailureMs !== undefined && - nowMs - bucket.lastFailureMs < failureCooldownMs - ) { - const remainingMs = failureCooldownMs - (nowMs - bucket.lastFailureMs); - return { - allowed: false, - remaining: 0, - retryAfterSec: Math.max(1, Math.ceil(remainingMs / 1000)), - reason: "repeated_failure", - }; + if (failureCooldownMs > 0 && bucket.lastFailureMs !== undefined) { + const elapsedSinceFailureMs = Math.max(0, nowMs - bucket.lastFailureMs); + if (elapsedSinceFailureMs < failureCooldownMs) { + const remainingMs = failureCooldownMs - elapsedSinceFailureMs; + return { + allowed: false, + remaining: 0, + retryAfterSec: Math.max(1, Math.ceil(remainingMs / 1000)), + reason: "repeated_failure", + }; + } } const elapsedSec = Math.max(0, (nowMs - bucket.lastRefillMs) / 1000); @@ -231,9 +230,9 @@ const DEFAULT_MAX_STORE_SIZE = parsePositiveNumber( // Default failure cooldown: 5 minutes. Long enough that "replay the // same revoked receipt" attacks see a hard wall well past any // reasonable client-side retry-on-transient cadence; short enough -// that if Apple / Google really did re-validate a previously-failed -// receipt (rare but possible during outages), the client recovers -// within one app session. +// that if the store provider really did re-validate a previously- +// failed receipt (rare but possible during outages), the client +// recovers within one app session. const DEFAULT_FAILURE_COOLDOWN_MS = parsePositiveNumber(process.env.REPLAY_GUARD_FAILURE_COOLDOWN_SEC, 300, 1) * 1000; @@ -305,7 +304,7 @@ export function replayGuardMiddleware( : "DUPLICATE_PAYLOAD"; const message = result.reason === "repeated_failure" - ? `This receipt was just rejected as invalid by the upstream store; the same payload won't be re-verified for ${result.retryAfterSec}s. If you believe this is wrong, wait the cooldown then retry — Apple / Google's verdict for a given receipt almost never changes within seconds.` + ? `This receipt was just rejected as invalid by the upstream store; the same payload won't be re-verified for ${result.retryAfterSec}s. If you believe this is wrong, wait the cooldown then retry — the store provider's verdict for a given receipt almost never changes within seconds.` : `Too many verifications for the same payload from this API key. Legitimate clients re-verify a receipt at most a handful of times per minute. Retry after ${result.retryAfterSec}s, or cache the previous result on your side.`; return c.json( { diff --git a/packages/kit/server/api/v1/request-body.test.ts b/packages/kit/server/api/v1/request-body.test.ts new file mode 100644 index 00000000..d41590b3 --- /dev/null +++ b/packages/kit/server/api/v1/request-body.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { isContentLengthOverLimit } from "./request-body"; + +describe("isContentLengthOverLimit", () => { + it("compares decimal content-length values against the byte limit", () => { + expect(isContentLengthOverLimit("1024", 1024)).toBe(false); + expect(isContentLengthOverLimit(" 1025 ", 1024)).toBe(true); + expect(isContentLengthOverLimit("9".repeat(100), 1024)).toBe(true); + }); + + it("ignores malformed non-decimal content-length values", () => { + expect(isContentLengthOverLimit(undefined, 1024)).toBe(false); + expect(isContentLengthOverLimit("", 1024)).toBe(false); + expect(isContentLengthOverLimit("+1025", 1024)).toBe(false); + expect(isContentLengthOverLimit("0x401", 1024)).toBe(false); + expect(isContentLengthOverLimit("1025abc", 1024)).toBe(false); + expect(isContentLengthOverLimit("-1", 1024)).toBe(false); + }); +}); diff --git a/packages/kit/server/api/v1/request-body.ts b/packages/kit/server/api/v1/request-body.ts new file mode 100644 index 00000000..4775f551 --- /dev/null +++ b/packages/kit/server/api/v1/request-body.ts @@ -0,0 +1,64 @@ +export class JsonBodyTooLargeError extends Error { + constructor(message = "Request body is too large") { + super(message); + } +} + +export function isContentLengthOverLimit( + contentLengthHeader: string | undefined, + limitBytes: number, +): boolean { + if (!contentLengthHeader) return false; + const value = contentLengthHeader.trim(); + if (!/^\d+$/.test(value)) return false; + try { + return BigInt(value) > BigInt(limitBytes); + } catch { + return false; + } +} + +export async function readJsonBodyWithLimit( + request: Request, + limitBytes: number, + errorMessage = "Request body is too large", +): Promise { + const text = await readRequestTextWithLimit( + request, + limitBytes, + errorMessage, + ); + return JSON.parse(text); +} + +async function readRequestTextWithLimit( + request: Request, + limitBytes: number, + errorMessage: string, +): Promise { + const reader = request.body?.getReader(); + if (!reader) return ""; + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + totalBytes += value.byteLength; + if (totalBytes > limitBytes) { + await reader.cancel().catch(() => undefined); + throw new JsonBodyTooLargeError(errorMessage); + } + chunks.push(value); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +} diff --git a/packages/kit/server/api/v1/request-logger.test.ts b/packages/kit/server/api/v1/request-logger.test.ts index fcc6067e..7fda3cdb 100644 --- a/packages/kit/server/api/v1/request-logger.test.ts +++ b/packages/kit/server/api/v1/request-logger.test.ts @@ -17,6 +17,8 @@ import { verifyPurchaseInputSchema } from "./route-input-schemas"; // the downstream handler behavior. const TEST_APPLE_JWS = `${"a".repeat(42)}.${"b".repeat(42)}.${"c".repeat(42)}`; const TEST_GOOGLE_TOKEN = "t".repeat(40); +const TEST_HORIZON_USER_ID = "user_123"; +const TEST_HORIZON_SKU = "premium.monthly"; type TestVars = { apiKey?: string; @@ -144,6 +146,28 @@ describe("requestLoggerMiddleware", () => { expect(logs[0].state).toBe("INAUTHENTIC"); }); + test("logs Horizon verification store values", async () => { + const logs: VerifyLogLine[] = []; + const app = buildApp({ logs }); + + const res = await app.request("/verify", { + method: "POST", + headers: { + Authorization: "Bearer key-horizon", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "horizon", + userId: TEST_HORIZON_USER_ID, + sku: TEST_HORIZON_SKU, + }), + }); + + expect(res.status).toBe(200); + expect(logs).toHaveLength(1); + expect(logs[0].store).toBe("horizon"); + }); + test("populates the X-Correlation-Id response header even on validator failure", async () => { const logs: VerifyLogLine[] = []; const app = buildApp({ logs }); @@ -220,6 +244,7 @@ describe("requestLoggerMiddleware", () => { expect(res.status).toBeGreaterThanOrEqual(500); expect(logs).toHaveLength(1); expect(logs[0].corrId).toBe("corr-fixed"); + expect(logs[0].statusCode).toBe(500); expect(logs[0].apiKeyHash).toBeDefined(); expect(logs[0].store).toBe("apple"); }); diff --git a/packages/kit/server/api/v1/request-logger.ts b/packages/kit/server/api/v1/request-logger.ts index 505376ea..cd4d13e1 100644 --- a/packages/kit/server/api/v1/request-logger.ts +++ b/packages/kit/server/api/v1/request-logger.ts @@ -9,7 +9,7 @@ import { hashApiKey } from "./rate-limit"; // never log the plaintext API key — only the SHA-256 prefix the rate // limiter already uses — so log leaks don't become credential leaks. -export type VerifyStore = "apple" | "google"; +export type VerifyStore = "apple" | "google" | "horizon"; export interface VerifyOutcome { isValid: boolean; @@ -37,6 +37,10 @@ export const defaultVerifyLogger: VerifyLogger = (line) => { console.log(JSON.stringify({ level: "info", ...line })); }; +function describeErrorForLog(error: unknown): string { + return error instanceof Error ? error.name : typeof error; +} + export interface RequestLoggerConfig { logger?: VerifyLogger; now?: () => number; @@ -70,8 +74,12 @@ export function requestLoggerMiddleware( // swallow the log line — the 5xx paths are exactly when we most // want structured context, and the error itself will re-throw after // the finally runs. + let nextError: unknown; try { await next(); + } catch (error) { + nextError = error; + throw error; } finally { const durationMs = clock() - start; @@ -104,7 +112,7 @@ export function requestLoggerMiddleware( corrId, method: c.req.method, path: c.req.path, - statusCode: c.res.status, + statusCode: nextError && c.res.status < 400 ? 500 : c.res.status, durationMs, apiKeyHash, store, @@ -112,7 +120,10 @@ export function requestLoggerMiddleware( state: outcome?.state, }); } catch (loggerError) { - console.error("request-logger failed:", loggerError); + console.error( + "request-logger failed:", + describeErrorForLog(loggerError), + ); } } }); diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index e8c4824f..77542f02 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -229,9 +229,10 @@ const verifyPurchaseRouteDescription = describeRoute({ "`X-Correlation-Id`. 401 / 403 responses from the auth layer run " + "before the rate-limit middleware and do not include those " + "headers.\n\n" + - "Input size caps: `jws` ≤ 16 KB, `purchaseToken` ≤ 2 KB, " + - "`userId` ≤ 256 chars, `sku` ≤ 256 chars. Oversized payloads return " + - "`400 INVALID_INPUT` without hitting the upstream store.", + "Input size caps: request body ≤ 32 KB, `jws` ≤ 16 KB, " + + "`purchaseToken` ≤ 2 KB, `userId` ≤ 256 chars, `sku` ≤ 256 chars. " + + "Oversized fields return `400 INVALID_INPUT`; oversized request " + + "bodies return `413 PAYLOAD_TOO_LARGE`. Neither hits the upstream store.", security: [{ apiKey: [] }], responses: { 200: { @@ -253,6 +254,16 @@ const verifyPurchaseRouteDescription = describeRoute({ }, }, }, + 413: { + description: + "Request body exceeds the 32 KB edge cap (`PAYLOAD_TOO_LARGE`).", + headers: commonResponseHeaders, + content: { + "application/json": { + schema: resolver(apiErrorResponseSchema), + }, + }, + }, 401: { description: "Missing bearer token", content: { @@ -282,7 +293,7 @@ const verifyPurchaseRouteDescription = describeRoute({ " • `REPEATED_FAILURE` — the exact same receipt was just " + "rejected as invalid by the upstream store; subsequent " + "requests for that payload are short-circuited for a 5-minute " + - "cooldown. Apple / Google's verdict for a given receipt rarely " + + "cooldown. The store provider's verdict for a given receipt rarely " + "changes within seconds, so the cached negative spares both " + "your quota and the upstream API. Retry after `Retry-After`.\n\n" + "Response body: `{ errors: [{ code, message, path? }] }`.", @@ -396,10 +407,11 @@ const verifyPurchaseHandler = async ( } const errorId = crypto.randomUUID(); + const errorType = error instanceof Error ? error.name : typeof error; console.error( "Unexpected error (%s) when verifying purchase: %s", errorId, - error, + errorType, ); return c.json( @@ -422,7 +434,8 @@ const verifyReplayGuard = replayGuardMiddleware(); // Middleware order matters: // 1. apiKeyMiddleware — 401/403 before anything expensive. -// 2. verifyRequestLogger — logs every attempt for audit/debug. +// 2. verifyRequestLogger — logs every attempt that passed auth-header +// shape validation for audit/debug. // 3. verifyRateLimit — per-key burst cap; also populates `apiKeyHash`. // 4. validator — rejects malformed payloads (400) before the guard // below hashes the body. diff --git a/packages/kit/server/api/v1/subscriptions.test.ts b/packages/kit/server/api/v1/subscriptions.test.ts new file mode 100644 index 00000000..1eaeb0ce --- /dev/null +++ b/packages/kit/server/api/v1/subscriptions.test.ts @@ -0,0 +1,354 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + mutation: vi.fn(), +})); + +vi.mock("@/convex", () => ({ + api: { + subscriptions: { + query: { + subscriptionStatus: "subscriptionStatus", + entitlements: "entitlements", + listSubscriptions: "listSubscriptions", + metricsSummary: "metricsSummary", + }, + mutation: { + bindUser: "bindUser", + }, + }, + }, +})); + +vi.mock("../../convex", () => ({ + client: { + query: mocks.query, + mutation: mocks.mutation, + }, + handleConvexError: () => null, +})); + +const { subscriptionsRoutes } = await import("./subscriptions"); + +function buildApp() { + const app = new Hono(); + app.route("/subscriptions", subscriptionsRoutes); + return app; +} + +describe("subscriptionsRoutes", () => { + beforeEach(() => { + mocks.query.mockReset(); + mocks.mutation.mockReset(); + }); + + it("rejects oversized path apiKey before calling Convex", async () => { + const app = buildApp(); + const response = await app.request( + `/subscriptions/status/${"a".repeat(129)}?userId=user-1`, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + errors: [{ code: "INVALID_API_KEY", message: "API key is too long" }], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects blank path apiKey before calling Convex", async () => { + const app = buildApp(); + const response = await app.request( + "/subscriptions/status/%20%20?userId=user-1", + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + errors: [{ code: "INVALID_API_KEY", message: "API key is required" }], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects oversized userId inputs before calling Convex", async () => { + const app = buildApp(); + const userId = "u".repeat(257); + + const cases = [ + app.request(`/subscriptions/status/key?userId=${userId}`), + app.request(`/subscriptions/entitlements/key?userId=${userId}`), + app.request(`/subscriptions/list/key?userId=${userId}`), + app.request("/subscriptions/bind-user/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ purchaseToken: "token", userId }), + }), + ]; + + for (const responsePromise of cases) { + const response = await responsePromise; + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [ + { code: "INVALID_INPUT", message: "userId must be ≤ 256 chars" }, + ], + }); + } + + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects blank query userId inputs before calling Convex", async () => { + const app = buildApp(); + + const cases = [ + app.request("/subscriptions/status/key?userId=%20%20"), + app.request("/subscriptions/entitlements/key?userId=%20%20"), + ]; + + for (const responsePromise of cases) { + const response = await responsePromise; + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [{ code: "INVALID_INPUT", message: "userId is required" }], + }); + } + + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects invalid list filters before calling Convex", async () => { + const app = buildApp(); + + const cases = [ + [ + "/subscriptions/list/key?state=Deleted", + { code: "INVALID_INPUT", message: "state is invalid" }, + ], + [ + "/subscriptions/list/key?userId=", + { code: "INVALID_INPUT", message: "userId must not be empty" }, + ], + [ + "/subscriptions/list/key?userId=%20%20", + { code: "INVALID_INPUT", message: "userId must not be empty" }, + ], + [ + "/subscriptions/list/key?productId=", + { code: "INVALID_INPUT", message: "productId must not be empty" }, + ], + [ + "/subscriptions/list/key?productId=%20%20", + { code: "INVALID_INPUT", message: "productId must not be empty" }, + ], + [ + `/subscriptions/list/key?productId=${"p".repeat(257)}`, + { code: "INVALID_INPUT", message: "productId must be ≤ 256 chars" }, + ], + [ + "/subscriptions/list/key?limit=abc", + { + code: "INVALID_INPUT", + message: "limit must be a positive integer", + }, + ], + [ + "/subscriptions/list/key?limit=", + { + code: "INVALID_INPUT", + message: "limit must be a positive integer", + }, + ], + [ + "/subscriptions/list/key?limit=0", + { + code: "INVALID_INPUT", + message: "limit must be a positive integer", + }, + ], + [ + "/subscriptions/list/key?limit=1.5", + { + code: "INVALID_INPUT", + message: "limit must be a positive integer", + }, + ], + [ + "/subscriptions/list/key?limit=1e2", + { + code: "INVALID_INPUT", + message: "limit must be a positive integer", + }, + ], + ] as const; + + for (const [url, error] of cases) { + const response = await app.request(url); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ errors: [error] }); + } + + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects oversized bind-user purchaseToken before calling Convex", async () => { + const app = buildApp(); + const response = await app.request("/subscriptions/bind-user/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + purchaseToken: "t".repeat(2_001), + userId: "user-1", + }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "INVALID_INPUT", + message: "purchaseToken must be ≤ 2000 chars", + }, + ], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects oversized bind-user bodies before calling Convex", async () => { + const app = buildApp(); + const response = await app.request("/subscriptions/bind-user/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + purchaseToken: "token", + userId: "user-1", + padding: "x".repeat(8 * 1024), + }), + }); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "PAYLOAD_TOO_LARGE", + message: "Subscription payload is too large", + }, + ], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects oversized bind-user content-length before reading the body", async () => { + const app = buildApp(); + const response = await app.request("/subscriptions/bind-user/key", { + method: "POST", + headers: { "content-length": String(8 * 1024 + 1) }, + }); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "PAYLOAD_TOO_LARGE", + message: "Subscription payload is too large", + }, + ], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects non-object bind-user bodies before calling Convex", async () => { + const app = buildApp(); + const response = await app.request("/subscriptions/bind-user/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "null", + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "INVALID_INPUT", + message: "purchaseToken and userId are required", + }, + ], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("rejects blank bind-user strings before calling Convex", async () => { + const app = buildApp(); + const response = await app.request("/subscriptions/bind-user/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ purchaseToken: " ", userId: "user-1" }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "INVALID_INPUT", + message: "purchaseToken and userId are required", + }, + ], + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("does not return raw internal subscription query errors", async () => { + const app = buildApp(); + mocks.query.mockRejectedValueOnce(new Error("internal query detail")); + + const response = await app.request( + "/subscriptions/status/key?userId=user-1", + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "SUBSCRIPTION_STATUS_FAILED", + message: "Subscription status lookup failed", + }, + ], + }); + expect(mocks.query).toHaveBeenCalledOnce(); + }); + + it("does not return raw internal bind-user mutation errors", async () => { + const app = buildApp(); + mocks.mutation.mockRejectedValueOnce(new Error("internal mutation detail")); + + const response = await app.request("/subscriptions/bind-user/key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + purchaseToken: "token", + userId: "user-1", + }), + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "SUBSCRIPTION_BIND_USER_FAILED", + message: "Subscription user binding failed", + }, + ], + }); + expect(mocks.mutation).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/kit/server/api/v1/subscriptions.ts b/packages/kit/server/api/v1/subscriptions.ts index 5872bfdd..ea0a9e66 100644 --- a/packages/kit/server/api/v1/subscriptions.ts +++ b/packages/kit/server/api/v1/subscriptions.ts @@ -1,7 +1,13 @@ -import { Hono } from "hono"; +import { Hono, type Context, type Next } from "hono"; import { api } from "@/convex"; -import { client } from "../../convex"; +import { client, handleConvexError } from "../../convex"; +import { apiKeyValidationError } from "./middleware"; +import { + isContentLengthOverLimit, + JsonBodyTooLargeError, + readJsonBodyWithLimit, +} from "./request-body"; // Subscription state, entitlements, metrics, and user-binding routes. // Mirrors the role of onesub's `/onesub/status`, `/onesub/admin/...` @@ -10,113 +16,299 @@ import { client } from "../../convex"; // fetch implementations that strip them. const subscriptions = new Hono(); +const MAX_USER_ID_LENGTH = 256; +const MAX_PRODUCT_ID_LENGTH = 256; +const MAX_PURCHASE_TOKEN_LENGTH = 2_000; +const MAX_BIND_USER_BODY_BYTES = 8 * 1024; +type SubscriptionState = + | "Active" + | "InGracePeriod" + | "InBillingRetry" + | "Expired" + | "Revoked" + | "Refunded" + | "Paused" + | "Unknown"; +const SUBSCRIPTION_STATES = new Set([ + "Active", + "InGracePeriod", + "InBillingRetry", + "Expired", + "Revoked", + "Refunded", + "Paused", + "Unknown", +]); +const API_KEY_ROUTES = [ + "/status/:apiKey", + "/entitlements/:apiKey", + "/list/:apiKey", + "/metrics/:apiKey", + "/bind-user/:apiKey", +]; + +for (const route of API_KEY_ROUTES) { + subscriptions.use(route, pathApiKeyGuard); +} subscriptions.get("/status/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); const userId = c.req.query("userId"); - if (!userId) { - return c.json( - { errors: [{ code: "INVALID_INPUT", message: "userId is required" }] }, - 400, - ); + if (!isNonBlankString(userId)) { + return invalidInput(c, "userId is required"); } - if (userId.length > 256) { - return c.json( + if (!isValidUserIdLength(userId)) { + return invalidInput(c, "userId must be ≤ 256 chars"); + } + try { + const result = await client.query( + api.subscriptions.query.subscriptionStatus, { - errors: [ - { code: "INVALID_INPUT", message: "userId must be ≤ 256 chars" }, - ], + apiKey, + userId, }, - 400, + ); + return c.json(result); + } catch (error) { + return subscriptionRouteError( + c, + error, + "SUBSCRIPTION_STATUS_FAILED", + "Subscription status lookup failed", ); } - const result = await client.query( - api.subscriptions.query.subscriptionStatus, - { - apiKey, - userId, - }, - ); - return c.json(result); }); subscriptions.get("/entitlements/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); const userId = c.req.query("userId"); - if (!userId) { - return c.json( - { errors: [{ code: "INVALID_INPUT", message: "userId is required" }] }, - 400, + if (!isNonBlankString(userId)) { + return invalidInput(c, "userId is required"); + } + if (!isValidUserIdLength(userId)) { + return invalidInput(c, "userId must be ≤ 256 chars"); + } + try { + const result = await client.query(api.subscriptions.query.entitlements, { + apiKey, + userId, + }); + return c.json(result); + } catch (error) { + return subscriptionRouteError( + c, + error, + "SUBSCRIPTION_ENTITLEMENTS_FAILED", + "Subscription entitlements lookup failed", ); } - const result = await client.query(api.subscriptions.query.entitlements, { - apiKey, - userId, - }); - return c.json(result); }); subscriptions.get("/list/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); - const state = c.req.query("state"); + const stateParam = c.req.query("state"); const productId = c.req.query("productId"); const userId = c.req.query("userId"); const limit = parseLimit(c.req.query("limit")); - const result = await client.query(api.subscriptions.query.listSubscriptions, { - apiKey, - state: state as never, - productId: productId ?? undefined, - userId: userId ?? undefined, - limit, - }); - return c.json(result); + if (limit === null) { + return invalidInput(c, "limit must be a positive integer"); + } + if (userId !== undefined && !isNonBlankString(userId)) { + return invalidInput(c, "userId must not be empty"); + } + if (productId !== undefined && !isNonBlankString(productId)) { + return invalidInput(c, "productId must not be empty"); + } + if (userId !== undefined && !isValidUserIdLength(userId)) { + return invalidInput(c, "userId must be ≤ 256 chars"); + } + if (productId !== undefined && !isValidProductIdLength(productId)) { + return invalidInput(c, "productId must be ≤ 256 chars"); + } + let state: SubscriptionState | undefined; + if (stateParam !== undefined) { + if (!isSubscriptionState(stateParam)) { + return invalidInput(c, "state is invalid"); + } + state = stateParam; + } + try { + const result = await client.query( + api.subscriptions.query.listSubscriptions, + { + apiKey, + state, + productId: productId ?? undefined, + userId: userId ?? undefined, + limit, + }, + ); + return c.json(result); + } catch (error) { + return subscriptionRouteError( + c, + error, + "SUBSCRIPTION_LIST_FAILED", + "Subscription list failed", + ); + } }); subscriptions.get("/metrics/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); - const result = await client.query(api.subscriptions.query.metricsSummary, { - apiKey, - }); - return c.json(result); + try { + const result = await client.query(api.subscriptions.query.metricsSummary, { + apiKey, + }); + return c.json(result); + } catch (error) { + return subscriptionRouteError( + c, + error, + "SUBSCRIPTION_METRICS_FAILED", + "Subscription metrics lookup failed", + ); + } }); subscriptions.post("/bind-user/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); - let body: { purchaseToken?: string; userId?: string }; + let body: unknown; try { - body = await c.req.json<{ purchaseToken?: string; userId?: string }>(); - } catch { + body = await readJsonBodyWithLimit( + c.req.raw, + MAX_BIND_USER_BODY_BYTES, + "Subscription payload is too large", + ); + } catch (error) { + if (error instanceof JsonBodyTooLargeError) { + return payloadTooLarge(c); + } return c.json( { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, 400, ); } - if (!body.purchaseToken || !body.userId) { - return c.json( - { - errors: [ - { - code: "INVALID_INPUT", - message: "purchaseToken and userId are required", - }, - ], - }, - 400, + if (!isJsonObject(body)) { + return invalidInput(c, "purchaseToken and userId are required"); + } + const payload = body as { purchaseToken?: string; userId?: string }; + if ( + !isNonBlankString(payload.purchaseToken) || + !isNonBlankString(payload.userId) + ) { + return invalidInput(c, "purchaseToken and userId are required"); + } + if (!isValidPurchaseTokenLength(payload.purchaseToken)) { + return invalidInput(c, "purchaseToken must be ≤ 2000 chars"); + } + if (!isValidUserIdLength(payload.userId)) { + return invalidInput(c, "userId must be ≤ 256 chars"); + } + try { + const result = await client.mutation(api.subscriptions.mutation.bindUser, { + apiKey, + purchaseToken: payload.purchaseToken, + userId: payload.userId, + }); + return c.json(result); + } catch (error) { + return subscriptionRouteError( + c, + error, + "SUBSCRIPTION_BIND_USER_FAILED", + "Subscription user binding failed", ); } - const result = await client.mutation(api.subscriptions.mutation.bindUser, { - apiKey, - purchaseToken: body.purchaseToken, - userId: body.userId, - }); - return c.json(result); }); -function parseLimit(raw: string | undefined): number | undefined { - if (!raw) return undefined; +function parseLimit(raw: string | undefined): number | undefined | null { + if (raw === undefined) return undefined; + if (!/^\d+$/.test(raw)) return null; const n = Number(raw); - if (!Number.isFinite(n) || n <= 0) return undefined; - return Math.min(Math.max(Math.trunc(n), 1), 200); + if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) return null; + return Math.min(n, 200); +} + +function isValidUserIdLength(userId: string): boolean { + return userId.length <= MAX_USER_ID_LENGTH; +} + +function isValidProductIdLength(productId: string): boolean { + return productId.length <= MAX_PRODUCT_ID_LENGTH; +} + +function isValidPurchaseTokenLength(purchaseToken: string): boolean { + return purchaseToken.length <= MAX_PURCHASE_TOKEN_LENGTH; +} + +function isSubscriptionState(state: string): state is SubscriptionState { + return SUBSCRIPTION_STATES.has(state); +} + +function isJsonObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isNonBlankString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function invalidInput(c: Context, message: string) { + return c.json({ errors: [{ code: "INVALID_INPUT", message }] }, 400); +} + +function payloadTooLarge(c: Context) { + return c.json( + { + errors: [ + { + code: "PAYLOAD_TOO_LARGE", + message: "Subscription payload is too large", + }, + ], + }, + 413, + ); +} + +async function pathApiKeyGuard(c: Context, next: Next) { + const validationError = apiKeyValidationError(c.req.param("apiKey")); + if (validationError) { + return c.json( + { errors: [{ code: "INVALID_API_KEY", message: validationError }] }, + 403, + ); + } + if ( + c.req.method !== "GET" && + isContentLengthOverLimit( + c.req.header("content-length"), + MAX_BIND_USER_BODY_BYTES, + ) + ) { + return payloadTooLarge(c); + } + await next(); +} + +function subscriptionRouteError( + c: Context, + error: unknown, + code: string, + fallbackMessage: string, +) { + const convexError = handleConvexError(error); + if (convexError) { + return c.json({ errors: [convexError] }, 400); + } + + console.error(`[subscriptions] ${code}`, describeErrorForLog(error)); + return c.json({ errors: [{ code, message: fallbackMessage }] }, 500); +} + +function describeErrorForLog(error: unknown): string { + return error instanceof Error ? error.name : typeof error; } export { subscriptions as subscriptionsRoutes }; diff --git a/packages/kit/server/api/v1/validator.test.ts b/packages/kit/server/api/v1/validator.test.ts index f82c5fab..f7d95ce7 100644 --- a/packages/kit/server/api/v1/validator.test.ts +++ b/packages/kit/server/api/v1/validator.test.ts @@ -14,7 +14,7 @@ const schema = v.object({ function buildApp() { const app = new Hono(); app.post("/echo", validator(schema), (c) => { - const json = c.req.valid("json"); + const json = c.req.valid("json" as never); return c.json({ ok: true, echo: json }); }); return app; @@ -38,6 +38,40 @@ describe("validator", () => { }); }); + test("parses JSON content types case-insensitively", async () => { + const app = buildApp(); + + const response = await app.request("/echo", { + method: "POST", + headers: { "content-type": "Application/JSON; Charset=UTF-8" }, + body: JSON.stringify({ name: "hello", nested: { count: 1 } }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + echo: { name: "hello", nested: { count: 1 } }, + }); + }); + + test("parses JSON suffix content types with parameter whitespace", async () => { + const app = buildApp(); + + const response = await app.request("/echo", { + method: "POST", + headers: { + "content-type": "Application/VND.OPENIAP+JSON ; Charset=UTF-8", + }, + body: JSON.stringify({ name: "hello", nested: { count: 1 } }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + echo: { name: "hello", nested: { count: 1 } }, + }); + }); + test("returns 400 with INVALID_INPUT errors for invalid payloads", async () => { const app = buildApp(); @@ -64,4 +98,44 @@ describe("validator", () => { expect(paths).toContain("name"); expect(paths).toContain("nested.count"); }); + + test("returns 413 before validation for oversized JSON bodies", async () => { + const app = buildApp(); + + const response = await app.request("/echo", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "hello", + nested: { count: 1 }, + padding: "x".repeat(32 * 1024), + }), + }); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + errors: [ + { code: "PAYLOAD_TOO_LARGE", message: "Request body is too large" }, + ], + }); + }); + + test("returns 413 before reading oversized content-length", async () => { + const app = buildApp(); + + const response = await app.request("/echo", { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(32 * 1024 + 1), + }, + }); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + errors: [ + { code: "PAYLOAD_TOO_LARGE", message: "Request body is too large" }, + ], + }); + }); }); diff --git a/packages/kit/server/api/v1/validator.ts b/packages/kit/server/api/v1/validator.ts index 30ca97b5..754f4a66 100644 --- a/packages/kit/server/api/v1/validator.ts +++ b/packages/kit/server/api/v1/validator.ts @@ -1,47 +1,125 @@ -import { validator as honoValidator } from "hono-openapi"; - -// Hono-openapi's Hook callback signature gets re-derived from a generic -// chain that resolves slightly differently depending on which hoisted -// copy of hono-openapi tsc picks up (Bun installs multiple peer-dep -// variants under node_modules/.bun). Cast to a stable narrow shape so -// the file typechecks the same on every install layout — without this, -// tsc reports `result` and `c` as implicit `any` on a fresh install. +import type { Context, MiddlewareHandler } from "hono"; +import { resolver, uniqueSymbol } from "hono-openapi"; + +import { + isContentLengthOverLimit, + JsonBodyTooLargeError, + readJsonBodyWithLimit, +} from "./request-body"; + +// Keep this JSON validator local instead of delegating to +// hono-openapi's validator: Hono's built-in JSON parser reads the +// whole body before schema validation, while verify requests need an +// edge cap before parsing. We still attach hono-openapi's metadata so +// generated OpenAPI request schemas keep working. type ValidatorIssue = { message: string; path?: ReadonlyArray }; -type ValidatorResult = +type ValidatorSchema = Parameters[0]; +type ValidatorResult = | { success: true; data: unknown } - | { success: false; error: ReadonlyArray; data: unknown }; + | { success: false; error: ReadonlyArray; data: unknown } + | { issues: ReadonlyArray } + | { value: Output }; -export function validator[1]>( - schema: Schema, -) { - return honoValidator( - "json", - schema, - ( - result: ValidatorResult, - // Hono context is typed as `any`-generic here intentionally — see - // comment above. We use only `c.json(...)`, which is stable. - c: { json: (body: unknown, status: number) => Response }, - ) => { - if (result.success) { - return; +const MAX_VALIDATOR_JSON_BODY_BYTES = 32 * 1024; + +export function validator(schema: Schema) { + const middleware: MiddlewareHandler = async (c, next) => { + let value: unknown = {}; + const contentType = c.req.header("content-type"); + if (isJsonContentType(contentType)) { + if ( + isContentLengthOverLimit( + c.req.header("content-length"), + MAX_VALIDATOR_JSON_BODY_BYTES, + ) + ) { + return payloadTooLarge(c, "Request body is too large"); + } + try { + value = await readJsonBodyWithLimit( + c.req.raw, + MAX_VALIDATOR_JSON_BODY_BYTES, + "Request body is too large", + ); + } catch (error) { + if (error instanceof JsonBodyTooLargeError) { + return payloadTooLarge(c, error.message); + } + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, + 400, + ); } + } - const errors = []; + const result = (await schema["~standard"].validate( + value, + )) as ValidatorResult; + if ("issues" in result && result.issues) { + return validationError(c, value, result.issues); + } + if ("success" in result && result.success === false) { + return validationError(c, result.data, result.error); + } - for (const issue of result.error) { - errors.push({ - code: "INVALID_INPUT", - message: issue.message, - path: issuePathToString(issue.path), - }); - } + const data = + "success" in result && result.success === true + ? result.data + : "value" in result + ? result.value + : value; + c.req.addValidatedData("json", data as Record); + return next(); + }; + + return Object.assign(middleware, { + [uniqueSymbol]: { + target: "json", + ...resolver(schema), + }, + }); +} - return c.json({ errors }, 400); +function payloadTooLarge(c: Context, message: string) { + return c.json( + { + errors: [{ code: "PAYLOAD_TOO_LARGE", message }], }, + 413, + ); +} + +function isJsonContentType(contentType: string | undefined): boolean { + if (!contentType) { + return false; + } + const mediaType = contentType.split(";")[0]?.trim().toLowerCase(); + return ( + mediaType === "application/json" || + Boolean( + mediaType?.startsWith("application/") && mediaType.endsWith("+json"), + ) ); } +function validationError( + c: Context, + _data: unknown, + issues: ReadonlyArray, +) { + const errors = []; + + for (const issue of issues) { + errors.push({ + code: "INVALID_INPUT", + message: issue.message, + path: issuePathToString(issue.path), + }); + } + + return c.json({ errors }, 400); +} + function issuePathToString( path: ReadonlyArray | undefined, ): string | undefined { diff --git a/packages/kit/server/api/v1/webhooks.test.ts b/packages/kit/server/api/v1/webhooks.test.ts index 3548eef3..87e624b2 100644 --- a/packages/kit/server/api/v1/webhooks.test.ts +++ b/packages/kit/server/api/v1/webhooks.test.ts @@ -1,4 +1,5 @@ import { beforeAll, describe, expect, it } from "vitest"; +import { Hono } from "hono"; let helpers: typeof import("./webhooks"); @@ -31,6 +32,102 @@ describe("pubSubOidcAudiences", () => { }); }); +describe("webhooksRoutes", () => { + it("rejects oversized path apiKey before reading the body", async () => { + const app = new Hono(); + app.route("/webhooks", helpers.webhooksRoutes); + + const response = await app.request(`/webhooks/${"a".repeat(129)}`, { + method: "POST", + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + errors: [{ code: "INVALID_API_KEY", message: "API key is too long" }], + }); + }); + + it("rejects blank path apiKey before reading the body", async () => { + const app = new Hono(); + app.route("/webhooks", helpers.webhooksRoutes); + + const response = await app.request("/webhooks/%20%20", { + method: "POST", + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + errors: [{ code: "INVALID_API_KEY", message: "API key is required" }], + }); + }); + + it("rejects oversized webhook bodies before JSON parsing", async () => { + const app = new Hono(); + app.route("/webhooks", helpers.webhooksRoutes); + + const response = await app.request("/webhooks/openiap-kit_secret", { + method: "POST", + headers: { "content-length": String(256 * 1024 + 1) }, + }); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + errors: [ + { code: "PAYLOAD_TOO_LARGE", message: "Webhook payload is too large" }, + ], + }); + }); +}); + +describe("legacyUnsupportedEventReason", () => { + it("keeps legacy unsupported-event responses free of raw error details", () => { + expect( + helpers.legacyUnsupportedEventReason( + new Error("UNSUPPORTED_EVENT: raw payload details"), + ), + ).toBe("Unsupported event"); + expect(helpers.legacyUnsupportedEventReason(new Error("OTHER"))).toBeNull(); + }); +}); + +describe("isWebhookBodyTooLarge", () => { + it("only rejects declared webhook bodies over the cap", () => { + expect(helpers.isWebhookBodyTooLarge(undefined)).toBe(false); + expect(helpers.isWebhookBodyTooLarge(String(256 * 1024))).toBe(false); + expect(helpers.isWebhookBodyTooLarge(String(256 * 1024 + 1))).toBe(true); + expect(helpers.isWebhookBodyTooLarge(String(Number.MAX_SAFE_INTEGER))).toBe( + true, + ); + expect(helpers.isWebhookBodyTooLarge("not-a-number")).toBe(false); + }); +}); + +describe("readWebhookJsonBody", () => { + it("rejects streamed webhook bodies over the cap", async () => { + const request = new Request("https://kit.openiap.dev/v1/webhooks/key", { + method: "POST", + body: JSON.stringify({ signedPayload: "a".repeat(256 * 1024) }), + }); + + await expect(helpers.readWebhookJsonBody(request)).rejects.toThrow( + "Webhook payload is too large", + ); + }); +}); + +describe("webhookStreamUnavailableError", () => { + it("does not expose raw stream lookup failures", () => { + expect(helpers.webhookStreamUnavailableError()).toEqual({ + errors: [ + { + code: "WEBHOOK_STREAM_UNAVAILABLE", + message: "Webhook stream is temporarily unavailable", + }, + ], + }); + }); +}); + describe("isAllowedPubSubServiceAccount", () => { it("accepts verified Google service account principals by default", () => { expect( @@ -62,12 +159,121 @@ describe("isAllowedPubSubServiceAccount", () => { }); }); -describe("sanitizePubSubAudienceForLog", () => { - it("redacts webhook api keys from audience logs", () => { +describe("extractBearerToken", () => { + it("accepts bearer scheme case-insensitively with flexible spacing", () => { + expect(helpers.extractBearerToken("Bearer jwt-token")).toBe("jwt-token"); + expect(helpers.extractBearerToken("bearer jwt-token")).toBe("jwt-token"); + expect(helpers.extractBearerToken(" BEARER jwt-token ")).toBe( + "jwt-token", + ); + }); + + it("rejects missing, non-bearer, or ambiguous authorization headers", () => { + expect(helpers.extractBearerToken(undefined)).toBeNull(); + expect(helpers.extractBearerToken("Basic abc")).toBeNull(); + expect(helpers.extractBearerToken("Bearer")).toBeNull(); + expect(helpers.extractBearerToken("Bearer token extra")).toBeNull(); + }); +}); + +describe("decodePubSubMessageData", () => { + it("decodes strict base64 JSON objects", () => { + const encoded = Buffer.from( + JSON.stringify({ packageName: "dev.hyo.app" }), + ).toString("base64"); + + expect(helpers.decodePubSubMessageData(encoded)).toEqual({ + decodedRaw: '{"packageName":"dev.hyo.app"}', + decoded: { packageName: "dev.hyo.app" }, + }); + }); + + it("rejects malformed base64 instead of letting Buffer ignore junk", () => { + const encoded = Buffer.from(JSON.stringify({ ok: true })).toString( + "base64", + ); + + expect(helpers.decodePubSubMessageData(`${encoded}!`)).toBeNull(); + expect(helpers.decodePubSubMessageData("not base64")).toBeNull(); + }); + + it("rejects decoded JSON primitives", () => { + const encoded = Buffer.from('"not-an-object"').toString("base64"); + + expect(helpers.decodePubSubMessageData(encoded)).toBeNull(); + }); +}); + +describe("resolveGoogleEventTimeMillis", () => { + it("accepts non-negative safe integer millis from Pub/Sub data", () => { expect( - helpers.sanitizePubSubAudienceForLog( - "https://kit.openiap.dev/v1/webhooks/openiap-kit_secret", + helpers.resolveGoogleEventTimeMillis("1700000000000", undefined), + ).toBe(1_700_000_000_000); + expect(helpers.resolveGoogleEventTimeMillis(1700000000000, undefined)).toBe( + 1_700_000_000_000, + ); + }); + + it("falls back to publishTime or now for malformed eventTimeMillis", () => { + const publishTime = "2024-01-02T03:04:05.000Z"; + const publishedAt = Date.parse(publishTime); + + expect(helpers.resolveGoogleEventTimeMillis("0x10", publishTime, 123)).toBe( + publishedAt, + ); + expect(helpers.resolveGoogleEventTimeMillis("1e3", undefined, 123)).toBe( + 123, + ); + expect( + helpers.resolveGoogleEventTimeMillis( + String(Number.MAX_SAFE_INTEGER + 1), + undefined, + 123, ), - ).toBe("https://kit.openiap.dev/v1/webhooks/"); + ).toBe(123); + }); +}); + +describe("sanitizePubSubAudienceForLog", () => { + it("redacts webhook api keys from all endpoint audience logs", () => { + const cases = [ + [ + "https://kit.openiap.dev/v1/webhooks/openiap-kit_secret", + "https://kit.openiap.dev/v1/webhooks/", + ], + [ + "https://kit.openiap.dev/v1/webhooks/apple/openiap-kit_secret", + "https://kit.openiap.dev/v1/webhooks/apple/", + ], + [ + "https://kit.openiap.dev/v1/webhooks/google/openiap-kit_secret", + "https://kit.openiap.dev/v1/webhooks/google/", + ], + [ + "https://kit.openiap.dev/v1/webhooks/stream/openiap-kit_secret?since=1", + "https://kit.openiap.dev/v1/webhooks/stream/?since=1", + ], + [ + "https://kit.openiap.dev/v1/webhooks/openiap-kit_secret?apiKey=openiap-kit_query&token=jwt-token&id_token=id-token&jwt=jwt-token&since=1", + "https://kit.openiap.dev/v1/webhooks/?apiKey=&token=&id_token=&jwt=&since=1", + ], + [ + "https://kit.openiap.dev/api/v1/webhooks/openiap-kit_secret", + "https://kit.openiap.dev/api/v1/webhooks/", + ], + ]; + + for (const [input, expected] of cases) { + expect(helpers.sanitizePubSubAudienceForLog(input)).toBe(expected); + } + }); +}); + +describe("normalizeLastEventId", () => { + it("drops oversized reconnect cursors before Convex lookup", () => { + expect(helpers.normalizeLastEventId(undefined)).toBeUndefined(); + expect(helpers.normalizeLastEventId("rtdn-msg-1")).toBe("rtdn-msg-1"); + expect(helpers.normalizeLastEventId("a".repeat(512))).toBe("a".repeat(512)); + expect(helpers.normalizeLastEventId("a".repeat(513))).toBeUndefined(); }); }); diff --git a/packages/kit/server/api/v1/webhooks.ts b/packages/kit/server/api/v1/webhooks.ts index 7589bf24..6cf49814 100644 --- a/packages/kit/server/api/v1/webhooks.ts +++ b/packages/kit/server/api/v1/webhooks.ts @@ -1,11 +1,17 @@ import { Hono } from "hono"; -import type { Context } from "hono"; +import type { Context, Next } from "hono"; import { streamSSE } from "hono/streaming"; import { OAuth2Client } from "google-auth-library"; import { ConvexClient } from "convex/browser"; import { api } from "@/convex"; import { client, convexUrlForRealtime, handleConvexError } from "../../convex"; +import { apiKeyValidationError } from "./middleware"; +import { + isContentLengthOverLimit, + JsonBodyTooLargeError, + readJsonBodyWithLimit, +} from "./request-body"; import { drainWebhookEventBatches } from "./webhookStreamDrain"; // Shared reactive client for the SSE webhook stream. We keep a @@ -28,18 +34,56 @@ function getSharedReactiveClient(): ConvexClient { return sharedReactiveClient; } +function describeError(error: unknown): string { + return error instanceof Error ? error.name : typeof error; +} + +const MAX_WEBHOOK_BODY_BYTES = 256 * 1024; + +export function legacyUnsupportedEventReason(error: unknown): string | null { + const errorMessage = error instanceof Error ? error.message : String(error); + return errorMessage.startsWith("UNSUPPORTED_EVENT") + ? "Unsupported event" + : null; +} + +export function isWebhookBodyTooLarge(contentLengthHeader: string | undefined) { + return isContentLengthOverLimit(contentLengthHeader, MAX_WEBHOOK_BODY_BYTES); +} + +export function webhookStreamUnavailableError() { + return { + errors: [ + { + code: "WEBHOOK_STREAM_UNAVAILABLE", + message: "Webhook stream is temporarily unavailable", + }, + ], + }; +} + +export async function readWebhookJsonBody(request: Request): Promise { + return readJsonBodyWithLimit( + request, + MAX_WEBHOOK_BODY_BYTES, + "Webhook payload is too large", + ); +} + // Inbound webhook receivers for Apple ASN v2 and Google Pub/Sub RTDN. // // Auth model: // - Apple ASN does not support custom Authorization headers, so the // project's API key is encoded in the path: kit gives each project a // webhook URL of the form -// https://kit.openiap.dev/v1/webhooks/apple/{apiKey} -// to register in App Store Connect. The path segment behaves like a -// capability token; rotating the project's API key invalidates the -// URL just like it invalidates verifyReceipt callers. The Convex -// action verifies the signedPayload signature against Apple's roots, -// so even if the URL leaks, only Apple-signed payloads are accepted. +// https://kit.openiap.dev/v1/webhooks/{apiKey} +// to register in App Store Connect. Platform-specific /apple and +// /google aliases remain supported for existing store-console wiring. +// The path segment behaves like a capability token; rotating the +// project's API key invalidates the URL just like it invalidates +// verifyReceipt callers. The Convex action verifies the signedPayload +// signature against Apple's roots, so even if the URL leaks, only +// Apple-signed payloads are accepted. // // - Google Pub/Sub push delivers a Bearer JWT from Google in the // Authorization header that we verify against @@ -49,6 +93,11 @@ function getSharedReactiveClient(): ConvexClient { const webhooks = new Hono(); +webhooks.use("/:apiKey", pathApiKeyGuard); +webhooks.use("/apple/:apiKey", pathApiKeyGuard); +webhooks.use("/google/:apiKey", pathApiKeyGuard); +webhooks.use("/stream/:apiKey", pathApiKeyGuard); + // Unified lifecycle endpoint. The exact same URL works for both Apple // App Store Connect and Google Pub/Sub push subscriptions: kit // inspects the body shape to detect which store sent the @@ -83,8 +132,11 @@ const unifiedHandler = async (c: Context) => { } let body: unknown; try { - body = await c.req.json(); - } catch { + body = await readWebhookJsonBody(c.req.raw); + } catch (error) { + if (error instanceof JsonBodyTooLargeError) { + return webhookPayloadTooLargeResponse(c); + } return c.json( { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, 400, @@ -161,6 +213,37 @@ function looksLikeGoogle(body: unknown): boolean { return typeof m.data === "string" && typeof m.messageId === "string"; } +async function pathApiKeyGuard(c: Context, next: Next) { + const validationError = apiKeyValidationError(c.req.param("apiKey")); + if (validationError) { + return c.json( + { errors: [{ code: "INVALID_API_KEY", message: validationError }] }, + 403, + ); + } + if ( + c.req.method !== "GET" && + isWebhookBodyTooLarge(c.req.header("content-length")) + ) { + return webhookPayloadTooLargeResponse(c); + } + await next(); +} + +function webhookPayloadTooLargeResponse(c: Context) { + return c.json( + { + errors: [ + { + code: "PAYLOAD_TOO_LARGE", + message: "Webhook payload is too large", + }, + ], + }, + 413, + ); +} + async function handleAppleNotification( c: Context, apiKey: string, @@ -255,12 +338,8 @@ async function handleGoogleNotification( // signature verifiers see exactly what Google sent — JSON.stringify // would normalize spacing + key order and break any byte-level // verification). - let decodedRaw: string; - let decoded: Record; - try { - decodedRaw = Buffer.from(body.message.data, "base64").toString("utf-8"); - decoded = JSON.parse(decodedRaw); - } catch { + const decodedMessage = decodePubSubMessageData(body.message.data); + if (!decodedMessage) { return c.json( { errors: [ @@ -273,17 +352,16 @@ async function handleGoogleNotification( 400, ); } + const { decodedRaw, decoded } = decodedMessage; const payload = { messageId: body.message.messageId, packageName: typeof decoded.packageName === "string" ? decoded.packageName : undefined, - eventTimeMillis: - typeof decoded.eventTimeMillis === "string" - ? Number(decoded.eventTimeMillis) - : typeof decoded.eventTimeMillis === "number" - ? decoded.eventTimeMillis - : Date.parse(body.message.publishTime ?? "") || Date.now(), + eventTimeMillis: resolveGoogleEventTimeMillis( + decoded.eventTimeMillis, + body.message.publishTime, + ), subscriptionNotification: decoded.subscriptionNotification as | undefined | { @@ -353,6 +431,7 @@ async function handleGoogleNotification( // intermediate proxies (Fly edge, Cloudflare, browser fetch) don't // close the idle connection. const HEARTBEAT_MS = 25_000; +const MAX_LAST_EVENT_ID_LENGTH = 512; // Drop fields the client doesn't need over the wire. `rawSignedPayload` // holds the original JWS / Pub/Sub envelope including the upstream @@ -374,7 +453,9 @@ function redactWebhookEventForStream( webhooks.get("/stream/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); - const lastEventId = c.req.header("last-event-id") ?? undefined; + const lastEventId = normalizeLastEventId( + c.req.header("last-event-id") ?? undefined, + ); // Validate the API key BEFORE entering streamSSE. If the key is // wrong / rotated, every downstream `webhookEventsSince(apiKey, …)` @@ -383,9 +464,18 @@ webhooks.get("/stream/:apiKey", async (c) => { // never receive lifecycle updates after a key rotation. Returning a // 401 surfaces the misconfiguration immediately instead of looking // like a healthy idle stream. - const project = await client.query(api.projects.query.getProjectByApiKey, { - apiKey, - }); + let project: unknown; + try { + project = await client.query(api.projects.query.getProjectByApiKey, { + apiKey, + }); + } catch (error) { + console.error( + "[webhooks/stream] project lookup failed", + describeError(error), + ); + return c.json(webhookStreamUnavailableError(), 503); + } if (!project) { return c.json( { @@ -582,7 +672,10 @@ webhooks.get("/stream/:apiKey", async (c) => { data: JSON.stringify(redactWebhookEventForStream(event)), }) .catch((err) => { - console.error("[webhooks/stream] drain write failed", err); + console.error( + "[webhooks/stream] drain write failed", + describeError(err), + ); }); if (typeof event.receivedAt === "number") { lastDeliveredReceivedAt = event.receivedAt; @@ -592,11 +685,11 @@ webhooks.get("/stream/:apiKey", async (c) => { if (batch.length < 500) break; } } catch (error) { - console.error("[webhooks/stream] drain failed", error); + console.error("[webhooks/stream] drain failed", describeError(error)); await stream.writeSSE({ event: "stream-error", data: JSON.stringify({ - message: error instanceof Error ? error.message : "Drain failed", + message: "Drain failed", }), }); // No reactive.close() — the client is shared across SSE @@ -659,7 +752,10 @@ webhooks.get("/stream/:apiKey", async (c) => { data: JSON.stringify(redactWebhookEventForStream(event)), }) .catch((err) => { - console.error("[webhooks/stream] live write failed", err); + console.error( + "[webhooks/stream] live write failed", + describeError(err), + ); }); }, onIterationLimit: ({ iterations, cursor }) => { @@ -690,12 +786,14 @@ webhooks.get("/stream/:apiKey", async (c) => { liveCreationCursor = result.cursor.afterCreationTime; } while (liveDrainRequested && !aborted); } catch (error) { - console.error("[webhooks/stream] live drain failed", error); + console.error( + "[webhooks/stream] live drain failed", + describeError(error), + ); await stream.writeSSE({ event: "stream-error", data: JSON.stringify({ - message: - error instanceof Error ? error.message : "Live drain failed", + message: "Live drain failed", }), }); } finally { @@ -722,11 +820,11 @@ webhooks.get("/stream/:apiKey", async (c) => { }, ); } catch (error) { - console.error("[webhooks/stream] subscribe failed", error); + console.error("[webhooks/stream] subscribe failed", describeError(error)); await stream.writeSSE({ event: "stream-error", data: JSON.stringify({ - message: error instanceof Error ? error.message : "Subscribe failed", + message: "Subscribe failed", }), }); // unsubscribe() not needed — onUpdate threw before returning a @@ -807,25 +905,28 @@ async function resolveStreamStartCursor( return { sinceMs: Date.now() }; } catch (error) { const sanitized = - error instanceof Error - ? `${error.name}: ${error.message}` - : "(unknown error type)"; + error instanceof Error ? error.name : "(unknown error type)"; console.warn("[webhooks/stream] cursor resolution failed", sanitized); return { sinceMs: Date.now() }; } } +export function normalizeLastEventId(value: string | undefined) { + if (!value) return undefined; + return value.length <= MAX_LAST_EVENT_ID_LENGTH ? value : undefined; +} + const oauth2Client = new OAuth2Client(); async function verifyPubSubOidcToken( authHeader: string | undefined, audience: string | string[], ): Promise { - if (!authHeader?.startsWith("Bearer ")) { + const token = extractBearerToken(authHeader); + if (!token) { console.warn("[webhooks/google] OIDC verification failed: missing bearer"); return false; } - const token = authHeader.slice(7); const expectedAudiences = Array.isArray(audience) ? audience : [audience]; try { const ticket = await oauth2Client.verifyIdToken({ @@ -841,7 +942,7 @@ async function verifyPubSubOidcToken( if (!email || payload.email_verified !== true) { console.warn("[webhooks/google] OIDC verification failed: email", { audience: sanitizePubSubAudienceForLog(payload.aud), - email, + email: sanitizeEmailForLog(email), emailVerified: payload.email_verified, issuer: payload.iss, }); @@ -857,8 +958,10 @@ async function verifyPubSubOidcToken( if (!isAllowedPubSubServiceAccount(email, configuredPrincipal)) { console.warn("[webhooks/google] OIDC principal rejected", { audience: sanitizePubSubAudienceForLog(payload.aud), - configuredPrincipal: configuredPrincipal ?? "(any service account)", - email, + configuredPrincipal: configuredPrincipal + ? sanitizeEmailForLog(configuredPrincipal) + : "(any service account)", + email: sanitizeEmailForLog(email), expectedAudiences: expectedAudiences.map(sanitizePubSubAudienceForLog), issuer: payload.iss, }); @@ -867,18 +970,27 @@ async function verifyPubSubOidcToken( return true; } catch (error) { const sanitized = - error instanceof Error - ? `${error.name}: ${error.message}` - : "(unknown error type)"; + error instanceof Error ? error.name : "(unknown error type)"; console.warn("[webhooks/google] OIDC verification error", { error: sanitized, expectedAudiences: expectedAudiences.map(sanitizePubSubAudienceForLog), - tokenClaims: decodeJwtPayloadForLog(token), }); return false; } } +export function extractBearerToken( + authHeader: string | undefined, +): string | null { + if (!authHeader) return null; + + const parts = authHeader.trim().split(/\s+/); + if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") { + return null; + } + return parts[1] || null; +} + export function isAllowedPubSubServiceAccount( email: string, configuredPrincipal?: string, @@ -887,6 +999,58 @@ export function isAllowedPubSubServiceAccount( return email.endsWith(".gserviceaccount.com"); } +export function decodePubSubMessageData( + data: string, +): { decodedRaw: string; decoded: Record } | null { + const trimmed = data.trim(); + if (!isStrictBase64(trimmed)) return null; + + try { + const decodedRaw = Buffer.from(trimmed, "base64").toString("utf-8"); + const decoded: unknown = JSON.parse(decodedRaw); + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) { + return null; + } + return { decodedRaw, decoded: decoded as Record }; + } catch { + return null; + } +} + +export function resolveGoogleEventTimeMillis( + eventTimeMillis: unknown, + publishTime: string | undefined, + now = Date.now(), +): number { + const parsedEventTime = parseGoogleMillis(eventTimeMillis); + if (parsedEventTime !== undefined) return parsedEventTime; + + const parsedPublishTime = Date.parse(publishTime ?? ""); + return Number.isFinite(parsedPublishTime) ? parsedPublishTime : now; +} + +function parseGoogleMillis(value: unknown): number | undefined { + if (typeof value === "number") { + return Number.isSafeInteger(value) && value >= 0 ? value : undefined; + } + if (typeof value !== "string") return undefined; + + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return undefined; + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function isStrictBase64(value: string): boolean { + if (value.length === 0 || value.length % 4 === 1) return false; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return false; + + const firstPadding = value.indexOf("="); + if (firstPadding === -1) return true; + + return value.length % 4 === 0 && /^=+$/.test(value.slice(firstPadding)); +} + export function pubSubOidcAudiences( requestUrl: string, configuredAudience: string, @@ -922,32 +1086,6 @@ function safeUrl(value: string): URL | null { } } -type JwtClaimsForLog = { - aud?: string | string[]; - email?: string; - emailVerified?: boolean; - issuer?: string; -}; - -function decodeJwtPayloadForLog(token: string): JwtClaimsForLog | null { - const [, payload] = token.split("."); - if (!payload) return null; - try { - const decoded = JSON.parse(Buffer.from(payload, "base64url").toString()); - return { - aud: sanitizePubSubAudienceForLog(decoded.aud), - email: typeof decoded.email === "string" ? decoded.email : undefined, - emailVerified: - typeof decoded.email_verified === "boolean" - ? decoded.email_verified - : undefined, - issuer: typeof decoded.iss === "string" ? decoded.iss : undefined, - }; - } catch { - return null; - } -} - export function sanitizePubSubAudienceForLog( audience: unknown, ): string | string[] | undefined { @@ -960,10 +1098,24 @@ export function sanitizePubSubAudienceForLog( const parsed = safeUrl(audience); if (!parsed) return audience; const path = parsed.pathname.replace( - /^(\/v1\/webhooks\/)[^/]+$/, + /^((?:\/api)?\/v1\/webhooks\/(?:apple\/|google\/|stream\/)?)[^/]+$/, "$1", ); - return `${parsed.origin}${path}${parsed.search}`; + return `${parsed.origin}${path}${sanitizeAudienceSearch(parsed.search)}`; +} + +function sanitizeAudienceSearch(search: string): string { + return search.replace( + /([?&](?:access[-_]?token|api[-_]?key|authorization|id[-_]?token|jwt|refresh[-_]?token|token)=)[^&]*/gi, + "$1", + ); +} + +function sanitizeEmailForLog(email: unknown): string | undefined { + if (typeof email !== "string" || email.length === 0) return undefined; + const atIndex = email.lastIndexOf("@"); + if (atIndex < 1 || atIndex === email.length - 1) return ""; + return `@${email.slice(atIndex + 1)}`; } function mapWebhookError( @@ -1002,24 +1154,24 @@ function mapWebhookError( return c.json({ errors: [convexError] }, 400); } - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.startsWith("UNSUPPORTED_EVENT")) { + const legacyUnsupportedReason = legacyUnsupportedEventReason(error); + if (legacyUnsupportedReason !== null) { // Legacy fallback — kept until all action paths migrate to the // ConvexError shape above. - return c.json({ ok: true, dropped: true, reason: errorMessage }); + return c.json({ + ok: true, + dropped: true, + reason: legacyUnsupportedReason, + }); } - console.error( - `[webhooks/${source}] unexpected error`, - errorMessage, - error instanceof Error ? error.stack : "", - ); + console.error(`[webhooks/${source}] unexpected error`, describeError(error)); return c.json( { errors: [ { code: "WEBHOOK_INTERNAL_ERROR", - message: errorMessage, + message: "Webhook processing failed", }, ], }, diff --git a/packages/kit/server/convex.test.ts b/packages/kit/server/convex.test.ts new file mode 100644 index 00000000..60b2b8d6 --- /dev/null +++ b/packages/kit/server/convex.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { ConvexError } from "convex/values"; + +process.env.VITE_KIT_CONVEX_URL ??= "https://placeholder.convex.cloud"; + +const { handleConvexError } = await import("./convex"); + +describe("handleConvexError", () => { + it("returns structured ConvexError payloads", () => { + expect( + handleConvexError( + new ConvexError({ + code: "INVALID_API_KEY", + message: "Invalid API key", + }), + ), + ).toEqual({ + code: "INVALID_API_KEY", + message: "Invalid API key", + }); + }); + + it("returns legacy JSON ConvexError payloads", () => { + expect( + handleConvexError( + new ConvexError( + JSON.stringify({ + error: "INVALID_API_KEY", + message: "Invalid API key", + }), + ), + ), + ).toEqual({ + code: "INVALID_API_KEY", + message: "Invalid API key", + }); + }); + + it("does not expose unstructured ConvexError strings", () => { + expect(handleConvexError(new ConvexError("internal backend detail"))).toBe( + null, + ); + }); +}); diff --git a/packages/kit/server/convex.ts b/packages/kit/server/convex.ts index 0482bc16..e62d4c7e 100644 --- a/packages/kit/server/convex.ts +++ b/packages/kit/server/convex.ts @@ -95,11 +95,8 @@ function getConvexError(error: ConvexError): ApiError | null { } // Unstructured error — the mutation/action threw - // `new ConvexError("some message")`. Return a generic mapping so the - // API surface responds with the original message + a stable code - // rather than a 500 / "UNKNOWN_ERROR". - return { - code: "CONVEX_ERROR", - message: error.data, - }; + // `new ConvexError("some message")`. Treat it as internal so public + // route layers use their generic 500 fallback instead of exposing + // arbitrary backend details as client-safe text. + return null; } diff --git a/packages/kit/server/utils/env.test.ts b/packages/kit/server/utils/env.test.ts index 5754017a..82af1240 100644 --- a/packages/kit/server/utils/env.test.ts +++ b/packages/kit/server/utils/env.test.ts @@ -10,6 +10,10 @@ describe("parsePositiveNumber", () => { test("falls back for NaN / Infinity / non-numeric", () => { expect(parsePositiveNumber("pineapple", 60, 1)).toBe(60); + expect(parsePositiveNumber("120ms", 60, 1)).toBe(60); + expect(parsePositiveNumber("0x10", 60, 1)).toBe(60); + expect(parsePositiveNumber("1e2", 60, 1)).toBe(60); + expect(parsePositiveNumber("+1", 60, 1)).toBe(60); expect(parsePositiveNumber("NaN", 60, 1)).toBe(60); expect(parsePositiveNumber("Infinity", 60, 1)).toBe(60); }); @@ -23,6 +27,7 @@ describe("parsePositiveNumber", () => { test("returns the parsed value when finite and at-or-above min", () => { expect(parsePositiveNumber("120", 60, 1)).toBe(120); + expect(parsePositiveNumber(" 120 ", 60, 1)).toBe(120); expect(parsePositiveNumber("1", 60, 1)).toBe(1); }); }); @@ -35,7 +40,8 @@ describe("parsePort", () => { test("falls back for non-numeric or fractional values", () => { expect(parsePort("banana", 3000)).toBe(3000); - expect(parsePort("8080.5", 3000)).toBe(8080); // parseInt truncates — still valid + expect(parsePort("3000abc", 3000)).toBe(3000); + expect(parsePort("8080.5", 3000)).toBe(3000); expect(parsePort("NaN", 3000)).toBe(3000); }); @@ -48,6 +54,7 @@ describe("parsePort", () => { test("returns the parsed value for 1..65535", () => { expect(parsePort("1", 3000)).toBe(1); + expect(parsePort(" 3000 ", 8080)).toBe(3000); expect(parsePort("3000", 3000)).toBe(3000); expect(parsePort("8080", 3000)).toBe(8080); expect(parsePort("65535", 3000)).toBe(65_535); diff --git a/packages/kit/server/utils/env.ts b/packages/kit/server/utils/env.ts index ae85bd8f..5c399e42 100644 --- a/packages/kit/server/utils/env.ts +++ b/packages/kit/server/utils/env.ts @@ -21,7 +21,9 @@ export function parsePositiveNumber( min: number, ): number { if (raw === undefined || raw === "") return fallback; - const n = Number(raw); + const value = raw.trim(); + if (!/^\d+(?:\.\d+)?$/.test(value)) return fallback; + const n = Number(value); if (!Number.isFinite(n) || n < min) return fallback; return n; } @@ -33,7 +35,9 @@ export function parsePositiveNumber( */ export function parsePort(raw: string | undefined, fallback: number): number { if (raw === undefined || raw === "") return fallback; - const n = Number.parseInt(raw, 10); + const value = raw.trim(); + if (!/^\d+$/.test(value)) return fallback; + const n = Number(value); if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65_535) { return fallback; } diff --git a/packages/kit/src/components/Footer.tsx b/packages/kit/src/components/Footer.tsx index 338e6b58..4d38161b 100644 --- a/packages/kit/src/components/Footer.tsx +++ b/packages/kit/src/components/Footer.tsx @@ -88,7 +88,7 @@ export default function Footer() {
  • setIsMobileMenuOpen(false)} diff --git a/packages/kit/src/pages/auth/organization/create.tsx b/packages/kit/src/pages/auth/organization/create.tsx index 0ef36799..26ca3759 100644 --- a/packages/kit/src/pages/auth/organization/create.tsx +++ b/packages/kit/src/pages/auth/organization/create.tsx @@ -131,7 +131,7 @@ export default function CreateOrganization() {
    - openiap-kit.com/ + kit.openiap.dev/ acc + (p.apiKey ? 1 : 0), 0) || 0, + value: projects?.reduce((acc, p) => acc + (p.hasApiKey ? 1 : 0), 0) || 0, icon: Key, color: "text-purple-500", bgColor: "bg-purple-500/10", diff --git a/packages/kit/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx index 9020fa5a..b359a088 100644 --- a/packages/kit/src/pages/auth/organization/project/analytics.tsx +++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx @@ -30,7 +30,8 @@ import { api } from "@/convex"; import { PageLoading } from "@/components/LoadingSpinner"; import { cn, formatMicros, normalizeCurrencyCode } from "@/lib/utils"; -type ProjectContext = { project: Doc<"projects"> }; +type DashboardProject = Omit, "apiKey" | "horizonAppSecret">; +type ProjectContext = { project: DashboardProject }; type Platform = "IOS" | "Android"; type PlatformFilter = "all" | Platform; @@ -174,8 +175,8 @@ export default function ProjectAnalytics() { // off the dep list because `maxFromDay` / `toDay` are derived // from it via `utcDayKey(...)` and only change at UTC midnight. const queryArgs = useMemo( - () => ({ apiKey: project.apiKey, fromDay: maxFromDay, toDay }), - [project.apiKey, maxFromDay, toDay], + () => ({ projectId: project._id, fromDay: maxFromDay, toDay }), + [project._id, maxFromDay, toDay], ); const metrics = useQuery( api.subscriptions.query.getRevenueMetrics, diff --git a/packages/kit/src/pages/auth/organization/project/apikeys.tsx b/packages/kit/src/pages/auth/organization/project/apikeys.tsx index 75aaee23..81be7b61 100644 --- a/packages/kit/src/pages/auth/organization/project/apikeys.tsx +++ b/packages/kit/src/pages/auth/organization/project/apikeys.tsx @@ -6,7 +6,6 @@ import { toast } from "sonner"; import { Key, Plus, - Copy, Trash2, RefreshCw, Calendar, @@ -115,11 +114,6 @@ export default function ApiKeys() { } }; - const copyToClipboard = (key: string) => { - void navigator.clipboard.writeText(key); - toast.success("Copied to clipboard"); - }; - if (!project) { return ; } @@ -276,16 +270,8 @@ export default function ApiKeys() {
    - {apiKey.key} + {apiKey.keyPreview} -
    diff --git a/packages/kit/src/pages/auth/organization/project/productPrice.test.ts b/packages/kit/src/pages/auth/organization/project/productPrice.test.ts new file mode 100644 index 00000000..418aa769 --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/productPrice.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { usdPriceToMicros } from "./productPrice"; + +describe("usdPriceToMicros", () => { + it("converts decimal USD strings to micros", () => { + expect(usdPriceToMicros("9")).toBe(9_000_000); + expect(usdPriceToMicros("9.99")).toBe(9_990_000); + expect(usdPriceToMicros(" 0.000001 ")).toBe(1); + }); + + it("returns undefined for empty, zero, malformed, and unsafe prices", () => { + expect(usdPriceToMicros("")).toBeUndefined(); + expect(usdPriceToMicros("0")).toBeUndefined(); + expect(usdPriceToMicros("12abc")).toBeUndefined(); + expect(usdPriceToMicros("1e2")).toBeUndefined(); + expect(usdPriceToMicros("1.1234567")).toBeUndefined(); + expect(usdPriceToMicros(String(Number.MAX_SAFE_INTEGER))).toBeUndefined(); + }); +}); diff --git a/packages/kit/src/pages/auth/organization/project/productPrice.ts b/packages/kit/src/pages/auth/organization/project/productPrice.ts new file mode 100644 index 00000000..1ec4240f --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/productPrice.ts @@ -0,0 +1,15 @@ +export function usdPriceToMicros(raw: string): number | undefined { + const value = raw.trim(); + if (value === "") return undefined; + + const match = /^(\d+)(?:\.(\d{1,6}))?$/.exec(value); + if (!match) return undefined; + + const units = BigInt(match[1]); + const fraction = BigInt((match[2] ?? "").padEnd(6, "0")); + const micros = units * 1_000_000n + fraction; + if (micros <= 0n || micros > BigInt(Number.MAX_SAFE_INTEGER)) { + return undefined; + } + return Number(micros); +} diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index 7dba6a3a..d2e2f9ce 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -21,25 +21,27 @@ import { PageLoading } from "@/components/LoadingSpinner"; import { Modal } from "@/components/Modal"; import { Tooltip } from "@/components/Tooltip"; import { Badge, PlatformBadge } from "../../../../components/Badge"; +import { usdPriceToMicros } from "./productPrice"; -type ProjectContext = { project: Doc<"projects"> }; +type DashboardProject = Omit, "apiKey" | "horizonAppSecret">; +type ProjectContext = { project: DashboardProject }; type SyncJob = Doc<"productSyncJobs">; export default function ProjectProducts() { const { project } = useOutletContext(); const products = useQuery(api.products.query.listProducts, { - apiKey: project.apiKey, + projectId: project._id, }); const upsert = useMutation(api.products.mutation.upsertProduct); const enqueueSync = useMutation(api.products.jobs.enqueueProductSync); const cancelSync = useMutation(api.products.jobs.cancelProductSync); const dismissJob = useMutation(api.products.jobs.dismissCompletedJob); const iosJob = useQuery(api.products.jobs.getActiveSyncJob, { - apiKey: project.apiKey, + projectId: project._id, platform: "IOS", }); const androidJob = useQuery(api.products.jobs.getActiveSyncJob, { - apiKey: project.apiKey, + projectId: project._id, platform: "Android", }); const listAscGroups = useAction( @@ -197,20 +199,19 @@ export default function ProjectProducts() { // description / reviewNote. const description = draft.description.trim() || undefined; const reviewNote = draft.reviewNote.trim() || undefined; - const priceUsd = parseFloat(draft.priceUsd); - const priceAmountMicros = - Number.isFinite(priceUsd) && priceUsd > 0 - ? Math.round(priceUsd * 1_000_000) - : undefined; + const priceAmountMicros = usdPriceToMicros(draft.priceUsd); const isSubIos = draft.type === "Subscription" && draft.platform === "IOS"; - const subscriptionGroupName = - isSubIos && draft.subscriptionGroupName.trim() - ? draft.subscriptionGroupName.trim() - : undefined; + if (isSubIos && !draft.subscriptionGroupName.trim()) { + toast.error("Subscription group is required for iOS subscriptions"); + return; + } + const subscriptionGroupName = isSubIos + ? draft.subscriptionGroupName.trim() + : undefined; const billingPeriod = draft.type === "Subscription" ? draft.billingPeriod : undefined; await upsert({ - apiKey: project.apiKey, + projectId: project._id, productId: draft.productId, platform: draft.platform, type: draft.type, @@ -247,7 +248,7 @@ export default function ProjectProducts() { const dryRun = options?.dryRun === true; try { const { jobId, deduped } = await enqueueSync({ - apiKey: project.apiKey, + projectId: project._id, platform, direction: "both", ...(dryRun ? { dryRun: true } : {}), @@ -275,7 +276,7 @@ export default function ProjectProducts() { const label = platform === "IOS" ? "App Store Connect" : "Play Console"; try { const { jobId, deduped } = await enqueueSync({ - apiKey: project.apiKey, + projectId: project._id, platform, direction: "purge-local", }); @@ -295,7 +296,7 @@ export default function ProjectProducts() { const onCancel = async (jobId: SyncJob["_id"], label: string) => { try { - const result = await cancelSync({ apiKey: project.apiKey, jobId }); + const result = await cancelSync({ projectId: project._id, jobId }); // The mutation returns `{ ok: false, reason: "not active" }` // when the job already finished between render and click. // Showing "cancellation requested" in that case is misleading @@ -435,10 +436,11 @@ export default function ProjectProducts() { { if (ascGroupNames !== null || ascGroupLoadFailed) return; - void listAscGroups({ apiKey: project.apiKey }) + void listAscGroups({ projectId: project._id }) .then((groups) => setAscGroupNames(groups.map((g) => g.referenceName)), ) @@ -524,7 +526,7 @@ export default function ProjectProducts() { void onCancel(jobId, "App Store Connect"); }} onDismiss={(jobId) => { - void dismissJob({ apiKey: project.apiKey, jobId }); + void dismissJob({ projectId: project._id, jobId }); }} /> { - void dismissJob({ apiKey: project.apiKey, jobId }); + void dismissJob({ projectId: project._id, jobId }); }} />
    diff --git a/packages/kit/src/pages/auth/organization/project/purchase-detail.tsx b/packages/kit/src/pages/auth/organization/project/purchase-detail.tsx index 1b1bcc84..03848e37 100644 --- a/packages/kit/src/pages/auth/organization/project/purchase-detail.tsx +++ b/packages/kit/src/pages/auth/organization/project/purchase-detail.tsx @@ -37,6 +37,76 @@ type DetailItem = { monospace?: boolean; }; +const sensitiveJsonFieldNames = new Set([ + "accesstoken", + "apikey", + "appsecret", + "authorization", + "cookie", + "dataandroid", + "externaltoken", + "externaltransactiontoken", + "horizonappsecret", + "idtoken", + "jws", + "jwsrepresentation", + "jwt", + "keycontent", + "offertoken", + "offertokenandroid", + "password", + "privatekey", + "purchasetoken", + "purchasetokenandroid", + "rawmessage", + "rawsignedpayload", + "receiptdata", + "refreshtoken", + "secret", + "signatureandroid", + "signedpayload", + "token", +]); + +function isSensitiveJsonFieldName(key: string): boolean { + return sensitiveJsonFieldNames.has(key.replace(/[_-]/g, "").toLowerCase()); +} + +function redactSensitiveJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(redactSensitiveJson); + } + + if (!value || typeof value !== "object") { + return value; + } + + return Object.fromEntries( + Object.entries(value as Record).map(([key, child]) => [ + key, + isSensitiveJsonFieldName(key) && child + ? "" + : redactSensitiveJson(child), + ]), + ); +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +} + +function formatRedactedJson(value: unknown): string | null { + try { + return JSON.stringify(redactSensitiveJson(value), null, 2); + } catch { + return null; + } +} + function DetailSection({ title, items, @@ -187,26 +257,18 @@ export default function PurchaseDetail() { (purchase as { productId?: string | null }).productId ?? null; const remoteResponse = purchase.remoteResponse - ? (JSON.parse(purchase.remoteResponse) as Record) + ? parseJson(purchase.remoteResponse) : undefined; const formattedRemoteResponse: string | null = (() => { if (!purchase.remoteResponse) { return null; } - try { - return JSON.stringify(remoteResponse, null, 2); - } catch { - return purchase.remoteResponse; - } + return formatRedactedJson(remoteResponse); })(); const requestPayload = (() => { - try { - return JSON.stringify(purchase.requestData, null, 2); - } catch { - return null; - } + return formatRedactedJson(purchase.requestData); })(); const requestItems: DetailItem[] = [ diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index 35828f74..26775da3 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -33,7 +33,6 @@ interface ProjectData { organizationId: Id<"organizations">; name: string; slug: string; - apiKey: string; platform?: string; androidPackageName?: string; iosBundleId?: string; diff --git a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx index 7d9091e3..a3ce095b 100644 --- a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx +++ b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx @@ -21,7 +21,8 @@ import { normalizeCurrencyCode, } from "@/lib/utils"; -type ProjectContext = { project: Doc<"projects"> }; +type DashboardProject = Omit, "apiKey" | "horizonAppSecret">; +type ProjectContext = { project: DashboardProject }; const STATE_FILTERS = [ { id: "all", label: "All" }, @@ -41,10 +42,10 @@ export default function ProjectSubscriptions() { const [filter, setFilter] = useState("all"); const metrics = useQuery(api.subscriptions.query.metricsSummary, { - apiKey: project.apiKey, + projectId: project._id, }); const subscriptions = useQuery(api.subscriptions.query.listSubscriptions, { - apiKey: project.apiKey, + projectId: project._id, state: filter === "all" ? undefined : filter, limit: 200, }); diff --git a/packages/kit/src/pages/auth/organization/project/webhooks.tsx b/packages/kit/src/pages/auth/organization/project/webhooks.tsx index b974aed2..ab097df4 100644 --- a/packages/kit/src/pages/auth/organization/project/webhooks.tsx +++ b/packages/kit/src/pages/auth/organization/project/webhooks.tsx @@ -12,8 +12,11 @@ import { import type { Doc } from "@/convex"; import { api } from "@/convex"; +import { PageLoading } from "@/components/LoadingSpinner"; -type ProjectContext = { project: Doc<"projects"> }; +type ProjectContext = { + project: Omit, "apiKey" | "horizonAppSecret">; +}; export default function ProjectWebhooks() { const { project } = useOutletContext(); @@ -27,15 +30,24 @@ export default function ProjectWebhooks() { : null; const baseUrl = window.location.origin; const setup = useQuery(api.projects.setupStatus.getSetupStatus, { - apiKey: project.apiKey, + projectId: project._id, }); + const endpointPaths = useQuery(api.projects.query.getWebhookEndpointPaths, { + projectId: project._id, + }); + + if (endpointPaths === undefined) { + return ; + } - const urls = { - unified: `${baseUrl}/v1/webhooks/${encodeURIComponent(project.apiKey)}`, - apple: `${baseUrl}/v1/webhooks/apple/${encodeURIComponent(project.apiKey)}`, - google: `${baseUrl}/v1/webhooks/google/${encodeURIComponent(project.apiKey)}`, - stream: `${baseUrl}/v1/webhooks/stream/${encodeURIComponent(project.apiKey)}`, - }; + const urls = endpointPaths + ? { + unified: `${baseUrl}${endpointPaths.unified}`, + apple: `${baseUrl}${endpointPaths.apple}`, + google: `${baseUrl}${endpointPaths.google}`, + stream: `${baseUrl}${endpointPaths.stream}`, + } + : null; return (
    @@ -80,109 +92,126 @@ export default function ProjectWebhooks() {
    ) : null} - - Paste this URL into both: -
      -
    • - App Store Connect → Apps → Your App → App Information → App - Store Server Notifications (Production + Sandbox). -
    • -
    • - Google Cloud Pub/Sub → Subscription → Push endpoint (then point - Play Console → Monetization setup → RTDN at the topic). -
    • -
    - - kit auto-detects the payload shape and dispatches to the right - verifier — Apple notifications signed with your{" "} - .p8 + Google Pub/Sub messages - with OIDC bearer. - - - POST-only — opening this URL in a browser returns 404 (that's - expected). Verify wiring with the curl recipe below or with App - Store Connect's "Send Test Notification" button.{" "} -
    - Full setup guide - - . - - - } - url={urls.unified} - external="https://developer.apple.com/documentation/appstoreservernotifications" - /> + {urls ? ( + + Paste this URL into both: +
      +
    • + App Store Connect → Apps → Your App → App Information → App + Store Server Notifications (Production + Sandbox). +
    • +
    • + Google Cloud Pub/Sub → Subscription → Push endpoint (then + point Play Console → Monetization setup → RTDN at the topic). +
    • +
    + + kit auto-detects the payload shape and dispatches to the right + verifier — Apple notifications signed with your{" "} + .p8 + Google Pub/Sub messages + with OIDC bearer. + + + POST-only — opening this URL in a browser returns 404 (that's + expected). Verify production wiring with App Store Connect's + "Send Test Notification" or Google Pub/Sub's authenticated push + delivery.{" "} + + Full setup guide + + . + + + } + url={urls.unified} + external="https://developer.apple.com/documentation/appstoreservernotifications" + /> + ) : ( +
    +
    Webhook endpoints unavailable
    +

    + Create or activate an API key, or ask an admin to view webhook + endpoints. +

    +
    + )} - - Open this URL with EventSource (or kit's per-SDK helper) to receive - normalized webhook events. Reconnects are handled automatically - using Last-Event-ID so events fired - during a closed connection are delivered in order on the next - connect. - - Long-lived text/event-stream{" "} - response — opening it in a browser shows a blank tab (expected). - Test it with{" "} - curl -N {urls.stream} or wire one - of the per-SDK hooks at{" "} - - openiap.dev/docs/webhooks - - . - - - } - url={urls.stream} - /> + {urls ? ( + + Open this URL with EventSource (or kit's per-SDK helper) to + receive normalized webhook events. Reconnects are handled + automatically using Last-Event-ID{" "} + so events fired during a closed connection are delivered in order + on the next connect. + + Long-lived text/event-stream{" "} + response — opening it in a browser shows a blank tab (expected). + Test it with{" "} + curl -N {urls.stream} or wire + one of the per-SDK hooks at{" "} + + openiap.dev/docs/webhooks + + . + + + } + url={urls.stream} + /> + ) : null} + + {urls ? ( +
    + + Advanced — platform-specific URLs (legacy) + +
    +

    + These URLs accept only the matching platform's payload. Use the + unified URL above unless an upstream tool insists on a + store-prefixed path. +

    + + +
    +
    + ) : null} -
    - - Advanced — platform-specific URLs (legacy) - -
    + {urls ? ( +
    +
    Local/dev receiver smoke test

    - These URLs accept only the matching platform's payload. Use the - unified URL above unless an upstream tool insists on a - store-prefixed path. + POST a synthetic Pub/Sub test message to the unified URL only on + local/dev deployments with{" "} + KIT_ALLOW_UNAUTHENTICATED_PUBSUB=1. + Hosted production Google RTDN requires Pub/Sub OIDC; use the + store-console test notification buttons there.

    - - -
    -
    - -
    -
    Live test
    -

    - POST a synthetic Pub/Sub test message to the unified URL to verify - wiring without going through the App Store / Play Console. The MCP - server's openiap_simulate_webhook{" "} - tool runs this same request. -

    -
    {`curl -X POST \\
    +          
    {`curl -X POST \\
       ${urls.unified} \\
       -H 'content-type: application/json' \\
       -d '{
    @@ -198,7 +227,8 @@ export default function ProjectWebhooks() {
           "publishTime": "${new Date().toISOString()}"
         }
       }'`}
    -
    +
  • + ) : null}
    ); } diff --git a/packages/kit/src/pages/auth/organization/projects/ProjectCard.tsx b/packages/kit/src/pages/auth/organization/projects/ProjectCard.tsx index 87708643..e59bb9e0 100644 --- a/packages/kit/src/pages/auth/organization/projects/ProjectCard.tsx +++ b/packages/kit/src/pages/auth/organization/projects/ProjectCard.tsx @@ -9,7 +9,6 @@ interface ProjectCardProps { _id: Id<"projects">; name: string; slug: string; - apiKey: string; platform?: string; createdAt: number; }; diff --git a/packages/kit/src/pages/auth/organization/usage.tsx b/packages/kit/src/pages/auth/organization/usage.tsx index 0fe37788..a4adf079 100644 --- a/packages/kit/src/pages/auth/organization/usage.tsx +++ b/packages/kit/src/pages/auth/organization/usage.tsx @@ -90,7 +90,7 @@ export default function OrganizationUsagePage() { }

    OpenIAP Sponsorship. Support OpenIAP at any tier ($25 / $100 / $300 / $500 / $1,000) via PayPal or GitHub Sponsors at{" "} @@ -293,7 +293,7 @@ export default function IapkitJoinsOpenIap() { Running IAPKit in production and depending on it? Please consider sponsoring OpenIAP at{" "} diff --git a/packages/kit/src/pages/blog/index.tsx b/packages/kit/src/pages/blog/index.tsx index 451360cb..0da6c9b2 100644 --- a/packages/kit/src/pages/blog/index.tsx +++ b/packages/kit/src/pages/blog/index.tsx @@ -18,7 +18,7 @@ export default function BlogIndex() { publisher: { "@type": "Organization", name: "OpenIAP", - url: "https://www.openiap.dev", + url: "https://openiap.dev", }, blogPost: POSTS.map((post) => ({ "@type": "BlogPosting", diff --git a/packages/kit/src/pages/docs/sections/analytics.tsx b/packages/kit/src/pages/docs/sections/analytics.tsx index 8d777f0a..b356cdc1 100644 --- a/packages/kit/src/pages/docs/sections/analytics.tsx +++ b/packages/kit/src/pages/docs/sections/analytics.tsx @@ -8,7 +8,7 @@ export default function AnalyticsPage() {

    The Analytics tab visualizes revenue and subscription @@ -21,7 +21,7 @@ export default function AnalyticsPage() {

    - The dashboard reads from a daily-rolled-up table populated from + The dashboard reads from a daily-bucketed table populated from ingested Apple App Store Server Notifications v2 and{" "} Google Play Real-time Developer Notifications (RTDN). Without webhooks, the Analytics tab will stay empty regardless of how @@ -30,9 +30,9 @@ export default function AnalyticsPage() {

    Open the project's Webhooks tab to copy your - IAPKit-hosted webhook URLs and register them with the App Store / Play - Console. Once notifications start arriving, the next cron tick (within - 24h) will populate this view. + IAPKit-hosted lifecycle webhook URL and register it with the App Store + / Play Console. Once notifications start arriving, the next cron tick + (within about 10 minutes) will populate this view.

    @@ -52,18 +52,19 @@ export default function AnalyticsPage() {
  • Open the project's Webhooks tab in the dashboard. - Copy the per-store URLs (one for Apple ASN v2, one for Google RTDN). + Copy the lifecycle webhook URL. The same endpoint accepts Apple ASN v2 + and Google RTDN payloads.
  • Apple: in App Store Connect →{" "} - App Store Server Notifications, paste the Apple webhook URL - and select Version 2 for both Production and Sandbox + App Store Server Notifications, paste the lifecycle webhook + URL and select Version 2 for both Production and Sandbox environments.
  • Google: in Play Console → Monetization setup , point Real-time Developer Notifications to a Pub/Sub topic that fans - out to the Google webhook URL (Pub/Sub push subscription with the + out to the lifecycle webhook URL (Pub/Sub push subscription with the IAPKit URL as endpoint).
  • @@ -72,8 +73,8 @@ export default function AnalyticsPage() { Webhooks tab's event log.
  • - Wait up to 24h for the next analytics rollup tick — or trigger it - manually if you have access to the Convex dashboard ( + Wait up to about 10 minutes for the next analytics rollup tick — or + trigger it manually if you have access to the Convex dashboard ( recomputeRevenueMetricsForProject).
  • @@ -82,7 +83,7 @@ export default function AnalyticsPage() {

    Analytics data lives in a separate revenueMetricsDaily{" "} table that the Analytics tab reads from directly — the dashboard never - scans the raw webhook event log on render. A daily cron walks each + scans the raw webhook event log on render. A 10-minute cron walks each project's recent webhookEvents and writes one row per{" "} (day, productId, currency, platform) bucket.

    diff --git a/packages/kit/src/pages/docs/sections/api.tsx b/packages/kit/src/pages/docs/sections/api.tsx index b0774212..315c9ac2 100644 --- a/packages/kit/src/pages/docs/sections/api.tsx +++ b/packages/kit/src/pages/docs/sections/api.tsx @@ -12,9 +12,10 @@ export default function ApiReferencePage() { description="POST /v1/purchase/verify — request shapes, responses, errors, headers." >

    - IAPKit exposes exactly one endpoint. Every interaction with the service - from your backend goes through it. The full OpenAPI spec is also served - at{" "} + IAPKit exposes one core purchase-verification endpoint for your backend: + POST /v1/purchase/verify. Webhooks, subscription state, + and product-catalog operations live on separate project-scoped surfaces. + The full OpenAPI spec is also served at{" "} Authentication

    Every request must include a Bearer API key:

    - {`Authorization: Bearer openiap-kit_`} + {`Authorization: Bearer openiap-kit_`}

    Missing header → 401 MISSING_API_KEY. Wrong scheme or @@ -82,12 +83,14 @@ export default function ApiReferencePage() { }`} - +

    - Every string field is validated server-side for non-empty + per-field - length bounds. Oversized payloads return{" "} - 400 INVALID_INPUT before IAPKit calls Apple / - Google / Meta, so malformed clients don't burn your upstream quota. + The JSON body is capped at 32 KB before parsing. Every string field is + then validated server-side for non-empty + per-field length bounds. + Oversized fields return 400 INVALID_INPUT; oversized + request bodies return 413 PAYLOAD_TOO_LARGE. Neither path + calls Apple / Google / Meta, so malformed clients don't burn your + upstream quota.

    @@ -224,6 +227,13 @@ export default function ApiReferencePage() { Malformed body / unknown store / input exceeds size cap. + + 413 + PAYLOAD_TOO_LARGE + + Request body exceeds the 32 KB edge cap. + + 401 MISSING_API_KEY @@ -238,9 +248,16 @@ export default function ApiReferencePage() { 429 - RATE_LIMITED + + RATE_LIMITED +
    + DUPLICATE_PAYLOAD +
    + REPEATED_FAILURE + - Per-key burst bucket empty; check Retry-After. + Per-key or per-payload guard rejected the request; check + Retry-After. diff --git a/packages/kit/src/pages/docs/sections/introduction.tsx b/packages/kit/src/pages/docs/sections/introduction.tsx index 5c369d67..a2773aef 100644 --- a/packages/kit/src/pages/docs/sections/introduction.tsx +++ b/packages/kit/src/pages/docs/sections/introduction.tsx @@ -67,7 +67,8 @@ export default function IntroductionPage() { One Fly.io machine serves the dashboard, REST API, and the SPA under the same origin. Convex holds organizations, projects, API keys, store credentials, and persisted purchase rows; the Bun server front-ends{" "} - /api/v1/* and the static dashboard build. + /v1/* plus the /api/v1/* compatibility alias + and the static dashboard build.

             {`  your backend / device          IAPKit                         upstream store
    @@ -103,8 +104,8 @@ export default function IntroductionPage() {
               
                 API reference
               {" "}
    -          — the one endpoint you call from your server, with every request shape
    -          and error code.
    +          — the purchase-verification endpoint you call from your server, with
    +          every request shape and error code.
             
             
  • diff --git a/packages/kit/src/pages/docs/sections/operations.tsx b/packages/kit/src/pages/docs/sections/operations.tsx index 587899a9..20a19d92 100644 --- a/packages/kit/src/pages/docs/sections/operations.tsx +++ b/packages/kit/src/pages/docs/sections/operations.tsx @@ -13,17 +13,25 @@ export default function OperationsPage() {

    /v1/purchase/verify is protected by an in-memory token-bucket keyed on a SHA-256 hash of the API key. Defaults: - 60-request burst, 1 req/sec steady state — - equivalently 60 req/min sustained. Self-hosted deployments can tune via{" "} + 600-request burst, 10 req/sec steady state — + equivalently 600 req/min sustained. Self-hosted deployments can tune via{" "} RATE_LIMIT_CAPACITY and{" "} RATE_LIMIT_REFILL_PER_SEC.

    When the bucket empties, IAPKit returns 429 RATE_LIMITED{" "} - with a Retry-After header (seconds). Every response — - successful or otherwise — also carries X-RateLimit-Limit{" "} - and X-RateLimit-Remaining so your client can back off - before getting 429'd. + with a Retry-After header (seconds). The verify endpoint + also has a per-(API key, payload) replay guard; it returns{" "} + 429 DUPLICATE_PAYLOAD when the same receipt is retried too + aggressively, or 429 REPEATED_FAILURE during the short + cooldown after the upstream store rejects that exact payload. +

    +

    + Authenticated responses after the auth layer — successful responses, + validation errors, and 429s — carry X-RateLimit-Limit and{" "} + X-RateLimit-Remaining so your client can back off before + getting 429'd. 401 / 403 auth failures return before those headers are + attached.

    @@ -36,22 +44,26 @@ export default function OperationsPage() {

    Correlation IDs

    - Every response from the verify endpoint carries an{" "} + Every verify response after the auth-header shape check carries an{" "} X-Correlation-Id header — a UUIDv4 IAPKit generates at the - middleware level. The same id appears in the structured log line for - that request, so support can pivot from a customer report straight to - the exact log entry. + logger middleware level. The same id appears in the structured log line + for that request, so support can pivot from a customer report straight + to the exact log entry. Missing or malformed Authorization headers + return before the logger runs.

    {`HTTP/1.1 200 OK Content-Type: application/json X-Correlation-Id: 6ebb9c9e-2e6e-4f9a-9bf2-4a6a9d5f9d20 -X-RateLimit-Limit: 60 -X-RateLimit-Remaining: 57`} +X-RateLimit-Limit: 600 +X-RateLimit-Remaining: 599`}

    Structured logs

    -

    Each verify request emits one JSON line to stdout:

    +

    + Each verify request that reaches the logger emits one JSON line to + stdout: +

    {`{ "level": "info", @@ -90,9 +102,9 @@ X-RateLimit-Remaining: 57`}

    The server installs SIGTERM and SIGINT{" "} handlers that call Bun.serve().stop() and drain in-flight{" "} - /api/v1/* requests before the process exits. Fly.io sends{" "} - SIGTERM before stopping a machine, so rolling deploys don't - cut off requests mid-verify. + /v1/* and /api/v1/* requests before the + process exits. Fly.io sends SIGTERM before stopping a + machine, so rolling deploys don't cut off requests mid-verify.

    Outbound retries

    @@ -117,11 +129,15 @@ X-RateLimit-Remaining: 57`}

    Input size limits

      +
    • receipt verification body ≤ 32 KB before JSON parsing
    • +
    • product management body ≤ 64 KB before JSON parsing
    • +
    • subscription user-binding body ≤ 8 KB before JSON parsing
    • +
    • webhook push body ≤ 256 KB before JSON parsing
    • jws ≤ 16 KB (Apple)
    • - purchaseToken ≤ 2 KB (Google) + purchaseToken ≤ 2 KB (Google / subscription binding)
    • userId ≤ 256 chars (Horizon) @@ -129,11 +145,14 @@ X-RateLimit-Remaining: 57`}
    • sku ≤ 256 chars (Horizon)
    • +
    • + productId ≤ 256 chars (catalog / subscriptions) +

    - Oversized requests return 400 INVALID_INPUT before IAPKit - calls the upstream store, so a misbehaving client can't burn your Apple - / Google / Meta quota. + Oversized fields return 400 INVALID_INPUT; oversized + request bodies return 413 PAYLOAD_TOO_LARGE. Invalid inputs + stop before upstream store calls or Convex mutations.

    ); diff --git a/packages/kit/src/pages/docs/sections/projects.tsx b/packages/kit/src/pages/docs/sections/projects.tsx index 48903068..d5768127 100644 --- a/packages/kit/src/pages/docs/sections/projects.tsx +++ b/packages/kit/src/pages/docs/sections/projects.tsx @@ -92,8 +92,9 @@ export default function ProjectsPage() {

    All keys are:

  • Local

    @@ -626,7 +626,7 @@ adb install Example/build/outputs/apk/debug/Example-debug.apk`} cp local.properties.example local.properties # Add your API key -iapkit.api.key=iapkit_your_api_key_here`} +iapkit.api.key=openiap-kit_`}

    Local

    @@ -706,7 +706,8 @@ android { packages/google/ directory
  • - Verify it contains iapkit.api.key=your_key + Verify it contains{' '} + iapkit.api.key=openiap-kit_<your-key>
  • Clean and rebuild:{' '} diff --git a/packages/docs/src/pages/docs/features/debugging.tsx b/packages/docs/src/pages/docs/features/debugging.tsx index 42f35777..19ebc14a 100644 --- a/packages/docs/src/pages/docs/features/debugging.tsx +++ b/packages/docs/src/pages/docs/features/debugging.tsx @@ -198,7 +198,7 @@ onPurchaseSuccess: async (purchase) => { const result = await verifyPurchaseWithProvider({ provider: 'iapkit', iapkit: { - apiKey: 'your-iapkit-api-key', + apiKey: 'openiap-kit_', google: { purchaseToken: purchase.purchaseToken }, }, }); diff --git a/packages/docs/src/pages/docs/features/external-purchase.tsx b/packages/docs/src/pages/docs/features/external-purchase.tsx index c739b6d8..02c4b0bb 100644 --- a/packages/docs/src/pages/docs/features/external-purchase.tsx +++ b/packages/docs/src/pages/docs/features/external-purchase.tsx @@ -727,7 +727,7 @@ async function handleAlternativeBillingPurchase(productId: string) { // Step 4: Create reporting token (after successful payment) const token = await createAlternativeBillingTokenAndroid(); - console.log(\`Token created: \${token?.slice(0, 20)}...\`); + console.log('Token created; send it to your backend without logging it.'); // Step 5: Send token to your backend server // Backend will report token to Google Play within 24 hours @@ -792,7 +792,7 @@ suspend fun handleAlternativeBillingPurchase(productId: String) { // Step 4: Create reporting token (after successful payment) val token = iapStore.createAlternativeBillingReportingToken() - Log.d("IAP", "Token created: \${token?.take(20)}...") + Log.d("IAP", "Token created; send it to your backend without logging it.") // Step 5: Send token to your backend server // Backend will report token to Google Play within 24 hours @@ -854,7 +854,7 @@ suspend fun handleAlternativeBillingPurchase(productId: String) { // Step 4: Create reporting token (after successful payment) val token = kmpIAP.createAlternativeBillingReportingToken() - Log.d("IAP", "Token created: \${token?.take(20)}...") + Log.d("IAP", "Token created; send it to your backend without logging it.") // Step 5: Send token to your backend server // Backend will report token to Google Play within 24 hours @@ -912,7 +912,7 @@ Future handleAlternativeBillingPurchase(String productId) async { // Step 4: Create reporting token (after successful payment) final token = await FlutterInappPurchase.instance .createAlternativeBillingTokenAndroid(); - print('Token created: \${token?.substring(0, 20)}...'); + print('Token created; send it to your backend without logging it.'); // Step 5: Send token to your backend server // Backend will report token to Google Play within 24 hours @@ -972,7 +972,7 @@ Task HandleAlternativeBillingPurchaseAsync(String ProductId) { // Step 4: Create reporting token (after successful payment) var token = iapStore.createAlternativeBillingReportingToken() - Log.d("IAP", "Token created: \${token?.take(20)}...") + Log.d("IAP", "Token created; send it to your backend without logging it.") // Step 5: Send token to your backend server // Backend will report token to Google Play within 24 hours @@ -1023,7 +1023,7 @@ func handle_alternative_billing_purchase(product_id: String) -> void: # Step 4: Create reporting token (after successful payment) var token = await iap.create_alternative_billing_token_android() if token: - print("Token created: %s..." % token.substr(0, 20)) + print("Token created; send it to your backend without logging it.") # Step 5: Send token to your backend server # Backend will report token to Google Play within 24 hours @@ -1074,7 +1074,7 @@ await initConnection({ const userChoiceSubscription = userChoiceBillingListenerAndroid( async (details: UserChoiceBillingDetails) => { console.log('User selected alternative billing'); - console.log('Token:', details.externalTransactionToken); + console.log('External transaction token received; send it to your backend without logging it.'); console.log('Products:', details.products); try { @@ -1142,7 +1142,7 @@ val iapStore = OpenIapStore( // Set user choice billing listener (for alternative billing selection) iapStore.setUserChoiceBillingListener { details -> Log.d("IAP", "User selected alternative billing") - Log.d("IAP", "Token: \${details.externalTransactionToken}") + Log.d("IAP", "External transaction token received; send it to your backend without logging it.") Log.d("IAP", "Products: \${details.products}") // Process payment with your backend API @@ -1218,7 +1218,7 @@ val kmpIAP = KmpIAP( // Set user choice billing listener (for alternative billing selection) kmpIAP.setUserChoiceBillingListener { details -> Log.d("IAP", "User selected alternative billing") - Log.d("IAP", "Token: \${details.externalTransactionToken}") + Log.d("IAP", "External transaction token received; send it to your backend without logging it.") Log.d("IAP", "Products: \${details.products}") // Process payment with your backend API @@ -1293,7 +1293,7 @@ await FlutterInappPurchase.instance.initConnection( // Set user choice billing listener (for alternative billing selection) FlutterInappPurchase.userChoiceBillingStream.listen((details) async { print('User selected alternative billing'); - print('Token: \${details.externalTransactionToken}'); + print('External transaction token received; send it to your backend without logging it.'); print('Products: \${details.products}'); try { @@ -1347,7 +1347,7 @@ var iapStore = OpenIapStore( // Set user choice billing listener (for alternative billing selection) iapStore.setUserChoiceBillingListener { details -> Log.d("IAP", "User selected alternative billing") - Log.d("IAP", "Token: \${details.externalTransactionToken}") + Log.d("IAP", "External transaction token received; send it to your backend without logging it.") Log.d("IAP", "Products: \${details.products}") // Process payment with your backend API @@ -1424,7 +1424,7 @@ func _ready() -> void: func _on_user_choice_billing(details: UserChoiceBillingDetails) -> void: print("User selected alternative billing") - print("Token: %s" % details.external_transaction_token) + print("External transaction token received; send it to your backend without logging it.") print("Products: %s" % details.products) var payment_result = await your_backend.create_payment( @@ -1548,7 +1548,7 @@ async function handleExternalPurchaseWithBillingPrograms(productId: string) { // Step 4: Create reporting details (replaces createAlternativeBillingToken) const reportingDetails = await createBillingProgramReportingDetailsAndroid('EXTERNAL_OFFER'); - console.log(\`Token created: \${reportingDetails.externalTransactionToken.slice(0, 20)}...\`); + console.log('Token created; send it to your backend without logging it.'); // Step 5: Send token to your backend server await yourBackend.reportToken({ @@ -1621,7 +1621,7 @@ suspend fun handleExternalPurchaseWithBillingPrograms(productId: String) { val reportingDetails = iapStore.createBillingProgramReportingDetails( BillingProgramAndroid.ExternalOffer ) - Log.d("IAP", "Token created: \${reportingDetails.externalTransactionToken.take(20)}...") + Log.d("IAP", "Token created; send it to your backend without logging it.") // Step 5: Send token to your backend server yourBackend.reportToken( @@ -1690,7 +1690,7 @@ suspend fun handleExternalPurchaseWithBillingPrograms(productId: String) { val reportingDetails = kmpIAP.createBillingProgramReportingDetails( BillingProgramAndroid.ExternalOffer ) - Log.d("IAP", "Token created: \${reportingDetails.externalTransactionToken.take(20)}...") + Log.d("IAP", "Token created; send it to your backend without logging it.") // Step 5: Send token to your backend server yourBackend.reportToken( @@ -1757,7 +1757,7 @@ Future handleExternalPurchaseWithBillingPrograms(String productId) async { // Step 4: Create reporting details (replaces createAlternativeBillingToken) final reportingDetails = await FlutterInappPurchase.instance .createBillingProgramReportingDetailsAndroid(BillingProgramAndroid.externalOffer); - print('Token created: \${reportingDetails.externalTransactionToken.substring(0, 20)}...'); + print('Token created; send it to your backend without logging it.'); // Step 5: Send token to your backend server await yourBackend.reportToken( @@ -1824,7 +1824,7 @@ Task HandleExternalPurchaseWithBillingProgramsAsync(String ProductId) { var reportingDetails = iapStore.createBillingProgramReportingDetails( BillingProgramAndroid.ExternalOffer ) - Log.d("IAP", "Token created: \${reportingDetails.externalTransactionToken.take(20)}...") + Log.d("IAP", "Token created; send it to your backend without logging it.") // Step 5: Send token to your backend server yourBackend.reportToken( @@ -1885,7 +1885,7 @@ func handle_external_purchase_with_billing_programs(product_id: String) -> void: BillingProgramAndroid.EXTERNAL_OFFER ) if reporting_details and reporting_details.external_transaction_token: - print("Token created: %s..." % reporting_details.external_transaction_token.substr(0, 20)) + print("Token created; send it to your backend without logging it.") # Step 5: Send token to your backend server await your_backend.report_token( @@ -2011,7 +2011,7 @@ await initConnection(); const developerBillingSubscription = developerProvidedBillingListenerAndroid( async (details: DeveloperProvidedBillingDetails) => { console.log('User selected developer billing'); - console.log('External transaction token:', details.externalTransactionToken); + console.log('External transaction token received; send it to your backend without logging it.'); try { // Step 2: Process payment with your backend @@ -2092,7 +2092,7 @@ iapStore.initConnection(null) // Step 1: Set up listener for when user selects developer billing iapStore.addDeveloperProvidedBillingListener { details -> Log.d("IAP", "User selected developer billing") - Log.d("IAP", "External transaction token: \${details.externalTransactionToken}") + Log.d("IAP", "External transaction token received; send it to your backend without logging it.") // Step 2: Process payment with your backend lifecycleScope.launch { @@ -2176,7 +2176,7 @@ kmpIAP.initConnection(null) // Step 1: Set up listener for when user selects developer billing kmpIAP.addDeveloperProvidedBillingListener { details -> Log.d("IAP", "User selected developer billing") - Log.d("IAP", "External transaction token: \${details.externalTransactionToken}") + Log.d("IAP", "External transaction token received; send it to your backend without logging it.") // Step 2: Process payment with your backend lifecycleScope.launch { @@ -2259,7 +2259,7 @@ await FlutterInappPurchase.instance.initConnection(); // Step 1: Set up listener for when user selects developer billing FlutterInappPurchase.developerProvidedBillingStream.listen((details) async { print('User selected developer billing'); - print('External transaction token: \${details.externalTransactionToken}'); + print('External transaction token received; send it to your backend without logging it.'); try { // Step 2: Process payment with your backend @@ -2327,7 +2327,7 @@ iapStore.initConnection(null) // Step 1: Set up listener for when user selects developer billing iapStore.addDeveloperProvidedBillingListener { details -> Log.d("IAP", "User selected developer billing") - Log.d("IAP", "External transaction token: \${details.externalTransactionToken}") + Log.d("IAP", "External transaction token received; send it to your backend without logging it.") // Step 2: Process payment with your backend lifecycleScope.launch { @@ -2409,7 +2409,7 @@ func _ready() -> void: func _on_developer_provided_billing(details: DeveloperProvidedBillingDetails) -> void: print("User selected developer billing") - print("External transaction token: %s" % details.external_transaction_token) + print("External transaction token received; send it to your backend without logging it.") # Step 2: Process payment with your backend var payment_result = await your_backend.create_payment( diff --git a/packages/docs/src/pages/docs/features/offer-code-redemption.tsx b/packages/docs/src/pages/docs/features/offer-code-redemption.tsx index 79abb911..31025e88 100644 --- a/packages/docs/src/pages/docs/features/offer-code-redemption.tsx +++ b/packages/docs/src/pages/docs/features/offer-code-redemption.tsx @@ -225,7 +225,7 @@ function RedeemCodeButton() { useEffect(() => { // Listen for purchases from code redemption const subscription = purchaseUpdatedListener(async (purchase) => { - console.log('Purchase from code redemption:', purchase); + console.log('Purchase from code redemption:', purchase.productId); // Verify and finish the transaction const isValid = await verifyPurchaseOnServer(purchase); diff --git a/packages/docs/src/pages/docs/features/purchase.tsx b/packages/docs/src/pages/docs/features/purchase.tsx index d73cb5a0..b3899932 100644 --- a/packages/docs/src/pages/docs/features/purchase.tsx +++ b/packages/docs/src/pages/docs/features/purchase.tsx @@ -111,7 +111,7 @@ function App() { // 2. Setup purchase success listener purchaseUpdateSubscription = purchaseUpdatedListener((purchase) => { - console.log('Purchase received:', purchase); + console.log('Purchase received:', purchase.productId); // Handle the purchase (verify + finish) void handlePurchase(purchase); }); diff --git a/packages/docs/src/pages/docs/features/subscription/index.tsx b/packages/docs/src/pages/docs/features/subscription/index.tsx index e8c23481..6975470b 100644 --- a/packages/docs/src/pages/docs/features/subscription/index.tsx +++ b/packages/docs/src/pages/docs/features/subscription/index.tsx @@ -1033,7 +1033,7 @@ if (subscription?.subscriptionOfferDetailsAndroid) { subscription.subscriptionOfferDetailsAndroid.forEach((offer) => { console.log('Base Plan:', offer.basePlanId); console.log('Offer ID:', offer.offerId ?? 'Base plan'); - console.log('Offer Token:', offer.offerToken); + console.log('Offer Token:', ''); // Check pricing phases offer.pricingPhases.pricingPhaseList.forEach((phase) => { @@ -1073,7 +1073,7 @@ val subscription = subscriptions.find { it.id == "premium_monthly" } subscription?.subscriptionOfferDetailsAndroid?.forEach { offer -> println("Base Plan: \${offer.basePlanId}") println("Offer ID: \${offer.offerId ?: "Base plan"}") - println("Offer Token: \${offer.offerToken}") + println("Offer Token: ") // Check pricing phases offer.pricingPhases.pricingPhaseList.forEach { phase -> @@ -1110,7 +1110,7 @@ val subscription = subscriptions.find { it.id == "premium_monthly" } subscription?.subscriptionOfferDetailsAndroid?.forEach { offer -> println("Base Plan: \${offer.basePlanId}") println("Offer ID: \${offer.offerId ?: "Base plan"}") - println("Offer Token: \${offer.offerToken}") + println("Offer Token: ") // Check pricing phases offer.pricingPhases.pricingPhaseList.forEach { phase -> @@ -1141,7 +1141,7 @@ if (subscription.subscriptionOfferDetailsAndroid != null) { for (final offer in subscription.subscriptionOfferDetailsAndroid!) { print('Base Plan: \${offer.basePlanId}'); print('Offer ID: \${offer.offerId ?? "Base plan"}'); - print('Offer Token: \${offer.offerToken}'); + print('Offer Token: '); // Check pricing phases for (final phase in offer.pricingPhases?.pricingPhaseList ?? []) { @@ -1179,7 +1179,7 @@ foreach (var offer in subscription?.SubscriptionOfferDetailsAndroid { Console.WriteLine($"Base Plan: {offer.BasePlanId}"); Console.WriteLine($"Offer ID: {offer.OfferId ?? "Base plan"}"); - Console.WriteLine($"Offer Token: {offer.OfferToken}"); + Console.WriteLine("Offer Token: "); // Check pricing phases foreach (var phase in offer.PricingPhases.PricingPhaseList) @@ -1217,7 +1217,7 @@ if subscription and subscription.subscription_offer_details_android: for offer in subscription.subscription_offer_details_android: print("Base Plan: %s" % offer.base_plan_id) print("Offer ID: %s" % (offer.offer_id if offer.offer_id else "Base plan")) - print("Offer Token: %s" % offer.offer_token) + print("Offer Token: ") # Check pricing phases for phase in offer.pricing_phases.pricing_phase_list: @@ -3366,9 +3366,8 @@ const checkAndroidSubscription = async () => { const purchase = subscriptionPurchases[0]; console.log('Purchase found:', purchase.productId); - console.log('Purchase token:', purchase.purchaseToken); - // Send to server for verification + // Send purchaseToken to your server for verification; do not log it. const serverResult = await fetch('https://your-server.com/api/verify-android', { method: 'POST', body: JSON.stringify({ @@ -3421,9 +3420,8 @@ suspend fun checkAndroidSubscription(): Map { val purchase = subscriptionPurchases.first() println("Purchase found: \${purchase.productId}") - println("Purchase token: \${purchase.purchaseToken}") - // Send to server for verification + // Send purchaseToken to your server for verification; do not log it. val serverResult = withContext(Dispatchers.IO) { verifyOnServer( purchaseToken = purchase.purchaseToken ?: "", @@ -3472,9 +3470,8 @@ suspend fun checkAndroidSubscription(): Map { val purchase = subscriptionPurchases.first() println("Purchase found: \${purchase.productId}") - println("Purchase token: \${purchase.purchaseToken}") - // Send to server for verification + // Send purchaseToken to your server for verification; do not log it. val serverResult = withContext(Dispatchers.IO) { verifyOnServer( purchaseToken = purchase.purchaseToken ?: "", @@ -3524,9 +3521,8 @@ Future> checkAndroidSubscription() async { final purchase = subscriptionPurchases.first; print('Purchase found: \${purchase.productId}'); - print('Purchase token: \${purchase.purchaseToken}'); - // Send to server for verification + // Send purchaseToken to your server for verification; do not log it. final response = await http.post( Uri.parse('https://your-server.com/api/verify-android'), body: jsonEncode({ @@ -3577,9 +3573,8 @@ async Task CheckAndroidSubscriptionAsync(string subscr // Purchase exists, but the client cannot determine expiry/refund/cancel state. Console.WriteLine($"Purchase found: {purchase.ProductId}"); - Console.WriteLine($"Purchase token: {purchase.PurchaseToken}"); - // Send to server for verification. + // Send purchaseToken to your server for verification; do not log it. var serverResult = await VerifyOnServerAsync( purchaseToken: purchase.PurchaseToken ?? "", productId: purchase.ProductId, @@ -3615,9 +3610,8 @@ func check_android_subscription() -> Dictionary: var purchase = subscription_purchases[0] print("Purchase found: %s" % purchase.product_id) - print("Purchase token: %s" % purchase.purchase_token) - # Send to server for verification + # Send purchaseToken to your server for verification; do not log it. var http_request = HTTPRequest.new() add_child(http_request) http_request.request( diff --git a/packages/docs/src/pages/docs/features/validation.tsx b/packages/docs/src/pages/docs/features/validation.tsx index 5594d5ab..22ea5ebb 100644 --- a/packages/docs/src/pages/docs/features/validation.tsx +++ b/packages/docs/src/pages/docs/features/validation.tsx @@ -287,7 +287,7 @@ if result.is_valid: const result = await verifyPurchaseWithProvider({ provider: 'iapkit', iapkit: { - apiKey: 'your-iapkit-api-key', + apiKey: 'openiap-kit_', apple: { jws: purchase.purchaseToken }, google: { purchaseToken: purchase.purchaseToken }, }, @@ -302,7 +302,7 @@ if (result.iapkit?.isValid && result.iapkit?.state === 'entitled') { {`let result = try await store.verifyPurchaseWithProvider( VerifyPurchaseWithProviderProps( iapkit: RequestVerifyPurchaseWithIapkitProps( - apiKey: "your-iapkit-api-key", + apiKey: "openiap-kit_", apple: RequestVerifyPurchaseWithIapkitAppleProps( jws: purchase.purchaseToken ?? "" ), @@ -316,7 +316,7 @@ if (result.iapkit?.isValid && result.iapkit?.state === 'entitled') { {`val result = module.verifyPurchaseWithProvider( VerifyPurchaseWithProviderProps( iapkit = RequestVerifyPurchaseWithIapkitProps( - apiKey = "your-api-key", + apiKey = "openiap-kit_", google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = purchase.purchaseToken ) @@ -329,7 +329,7 @@ if (result.iapkit?.isValid && result.iapkit?.state === 'entitled') { {`val result = kmpIAP.verifyPurchaseWithProvider( VerifyPurchaseWithProviderProps( iapkit = RequestVerifyPurchaseWithIapkitProps( - apiKey = "your-api-key", + apiKey = "openiap-kit_", google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = purchase.purchaseToken ) @@ -343,7 +343,7 @@ if (result.iapkit?.isValid && result.iapkit?.state === 'entitled') { VerifyPurchaseWithProviderProps( provider: PurchaseVerificationProvider.iapkit, iapkit: RequestVerifyPurchaseWithIapkitProps( - apiKey: 'your-iapkit-api-key', + apiKey: 'openiap-kit_', apple: RequestVerifyPurchaseWithIapkitAppleProps(jws: purchase.purchaseToken ?? ''), ), ), @@ -356,7 +356,7 @@ using OpenIap.Maui; var result = module.verifyPurchaseWithProvider( VerifyPurchaseWithProviderProps( iapkit = RequestVerifyPurchaseWithIapkitProps( - apiKey = "your-api-key", + apiKey = "openiap-kit_", google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = purchase.purchaseToken ) @@ -367,7 +367,7 @@ var result = module.verifyPurchaseWithProvider( ), gdscript: ( {`var iapkit_props = RequestVerifyPurchaseWithIapkitProps.new() -iapkit_props.api_key = "your-iapkit-api-key" +iapkit_props.api_key = "openiap-kit_" iapkit_props.google = RequestVerifyPurchaseWithIapkitGoogleProps.new() iapkit_props.google.purchase_token = purchase.purchase_token diff --git a/packages/docs/src/pages/docs/horizon-setup.tsx b/packages/docs/src/pages/docs/horizon-setup.tsx index 8d264125..9b1610b8 100644 --- a/packages/docs/src/pages/docs/horizon-setup.tsx +++ b/packages/docs/src/pages/docs/horizon-setup.tsx @@ -1,5 +1,6 @@ import CodeBlock from '../../components/CodeBlock'; import SEO from '../../components/SEO'; +import { OPENIAP_VERSIONS } from '../../lib/versioning'; function HorizonSetup() { return ( @@ -50,8 +51,8 @@ function HorizonSetup() {
  • - openiap-google@1.3.2 or later installed (Horizon flavor - support added in 1.3.2) + openiap-google@{OPENIAP_VERSIONS.google} installed. + Current OpenIAP Google releases include Horizon flavor support.
  • A Meta Quest device for testing (Quest 2, Quest 3, Quest Pro)
  • diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx index b55c1875..9d9c49fc 100644 --- a/packages/docs/src/pages/docs/kit-backend.tsx +++ b/packages/docs/src/pages/docs/kit-backend.tsx @@ -22,7 +22,7 @@ function KitBackend() { after a user taps "buy" — receipt validation, lifecycle webhooks, subscription state, revenue metrics, and App Store Connect / Play Console product sync — and exposes everything through one URL surface - that all five SDKs and an MCP server speak. + that the framework SDKs and MCP server speak.

    @@ -30,22 +30,22 @@ function KitBackend() { Surface map

    - Every endpoint takes the project's API key as a path segment so the - same URL works in App Store Connect, Pub/Sub push subscribers, mobile - WebViews, and stdio MCP tools without juggling bearer tokens. + Receipt verification uses an Authorization: Bearer API + key header. Webhook, subscription, product, and MCP-friendly endpoints + carry the project API key as a path segment so store consoles, mobile + WebViews, and stdio MCP tools can call them without custom bearer + header plumbing.

    • POST /v1/purchase/verify — receipt validation (Apple - JWS, Google purchaseToken, Meta Horizon). + JWS, Google purchaseToken, Meta Horizon) with a Bearer API key.
    • - POST /v1/webhooks/apple/{apiKey} — App Store - Server Notifications v2 receiver. -
    • -
    • - POST /v1/webhooks/google/{apiKey} — Google - Pub/Sub RTDN receiver (OIDC verified). + POST /v1/webhooks/{apiKey} — unified App + Store Server Notifications v2 / Google Pub/Sub RTDN receiver (Google + OIDC verified). Platform-specific /apple /{' '} + /google aliases remain supported for existing setups.
    • GET /v1/webhooks/stream/{apiKey} — SSE stream @@ -108,10 +108,9 @@ function KitBackend() { or Play Console (via the service-account JSON).
    • - Webhooks — copyable Apple ASN v2 / Google RTDN - endpoints, the SSE stream URL, and a curl recipe for emitting a - synthetic test notification without going through the App Store / - Play Console. + Webhooks — copyable lifecycle webhook URL, the SSE + stream URL, and a curl recipe for emitting a synthetic test + notification without going through the App Store / Play Console.
    @@ -260,7 +259,7 @@ if status.active: "command": "bunx", "args": ["@hyodotdev/openiap-mcp-server"], "env": { - "OPENIAP_API_KEY": "sk_live_...", + "OPENIAP_API_KEY": "openiap-kit_", "OPENIAP_BASE_URL": "https://kit.openiap.dev" } } diff --git a/packages/docs/src/pages/docs/lifecycle/subscription.tsx b/packages/docs/src/pages/docs/lifecycle/subscription.tsx index 010af7fb..9d7760ae 100644 --- a/packages/docs/src/pages/docs/lifecycle/subscription.tsx +++ b/packages/docs/src/pages/docs/lifecycle/subscription.tsx @@ -274,7 +274,7 @@ function Subscription() {

    Learn more about IAPKit integration in our{' '} diff --git a/packages/docs/src/pages/docs/setup/expo.tsx b/packages/docs/src/pages/docs/setup/expo.tsx index f5e3d5f8..c28f647e 100644 --- a/packages/docs/src/pages/docs/setup/expo.tsx +++ b/packages/docs/src/pages/docs/setup/expo.tsx @@ -1,6 +1,11 @@ import { Link } from 'react-router-dom'; import CodeBlock from '../../../components/CodeBlock'; import SEO from '../../../components/SEO'; +import { + ANDROID_SDK, + EXPO_PACKAGE, + GOOGLE_PLAY_BILLING, +} from '../../../lib/versioning'; function ExpoSetup() { return ( @@ -128,7 +133,8 @@ function ExpoSetup() {

    - expo-iap uses Google Play Billing Library v8.2, which requires{' '} + expo-iap uses Google Play Billing Library v + {GOOGLE_PLAY_BILLING.version}, which requires{' '} Kotlin 2.0+.