diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b3f42c26..66948a37b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,6 +212,13 @@ jobs: - name: Run non-Godot SDK parity audit run: node scripts/audit-non-godot-parity.mjs + # Unconditional: kit and the spec deploy on separate workflows. + - name: Test IAPKit spec contract audit + run: node --test scripts/audit-kit-spec-contract.test.mjs + + - name: Run IAPKit spec contract audit + run: node scripts/audit-kit-spec-contract.mjs + test-gql: name: Test GQL Types runs-on: ubuntu-latest diff --git a/.github/workflows/deploy-kit.yml b/.github/workflows/deploy-kit.yml index c45b78344..e468a566d 100644 --- a/.github/workflows/deploy-kit.yml +++ b/.github/workflows/deploy-kit.yml @@ -72,6 +72,11 @@ jobs: exit 1 fi + # ci.yml runs independently, so the guard has to gate the deploy here too. + - name: Run IAPKit spec contract audit + working-directory: ${{ github.workspace }} + run: node scripts/audit-kit-spec-contract.mjs + - name: Lint (app + Convex typecheck + eslint) run: bun run lint diff --git a/.husky/pre-commit b/.husky/pre-commit index 2b5e28341..b3840f324 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -52,6 +52,11 @@ fi echo "πŸ”Ž SDK parity audit β€” running CI mirror…" node scripts/audit-non-godot-parity.mjs +# Unconditional: either side of the kit/spec contract can move. +echo "πŸ”Ž IAPKit spec contract audit β€” running CI mirror…" +node --test scripts/audit-kit-spec-contract.test.mjs +node scripts/audit-kit-spec-contract.mjs + # Paths-aware kit pre-commit gate. Only runs when staged changes touch # packages/kit/**, so unrelated edits to apple/google/gql/docs/libraries # aren't blocked. diff --git a/AGENTS.md b/AGENTS.md index 4df3f355d..15c766369 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ openiap/ - [`packages/google/CONVENTION.md`](packages/google/CONVENTION.md) - [`packages/apple/CONVENTION.md`](packages/apple/CONVENTION.md) - [`packages/docs/CONVENTION.md`](packages/docs/CONVENTION.md) - - [`packages/kit/CONVENTION.md`](packages/kit/CONVENTION.md) β€” kit is a deployable SaaS (not a library); has its own Convex schema and isn't part of the GQL type-sync chain + - [`packages/kit/CONVENTION.md`](packages/kit/CONVENTION.md) β€” kit is a deployable SaaS (not a library); has its own Convex schema and isn't part of the GQL type-sync chain. Its `/v1` responses are still a published contract that shipped SDKs decode: read the `/v1` response contract section and run `bun audit:kit-contract` before changing a response enum, `isValidState`, or a purchase-state mapping 3. **For framework libraries, read the library-specific CLAUDE.md**: - [`libraries/react-native-iap/CLAUDE.md`](libraries/react-native-iap/CLAUDE.md) β€” Yarn 3, Nitro Modules, useIAP hook semantics, error handling - [`libraries/expo-iap/CLAUDE.md`](libraries/expo-iap/CLAUDE.md) β€” Bun, Expo Modules, iOS podspec 13.4 workaround, tvOS 16.0 requirement @@ -121,6 +121,7 @@ including its stricter release-note limits. ### Auto-Generated Files (DO NOT EDIT) - `packages/gql/src/generated/*` - All generated type files (SSOT) +- `packages/apple/Sources/OpenIapGeneratedVersion.swift` - Synced from `openiap-versions.json` - `packages/apple/Sources/Models/Types.swift` - Synced from GQL - `packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt` - Synced from GQL - `libraries/react-native-iap/src/types.ts` - Synced from GQL diff --git a/libraries/expo-iap/src/__tests__/kit-api.test.ts b/libraries/expo-iap/src/__tests__/kit-api.test.ts index 5b20cc83d..f290227e7 100644 --- a/libraries/expo-iap/src/__tests__/kit-api.test.ts +++ b/libraries/expo-iap/src/__tests__/kit-api.test.ts @@ -1,4 +1,4 @@ -import {kitApi, KitApiError} from '../kit-api'; +import {kitApi, KitApiError, type KitProductClientPayload} from '../kit-api'; const payload = { clientPayload: { @@ -344,6 +344,37 @@ describe('kitApi cache resilience', () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); + // Evicting on an unknown format would kill ETag revalidation and offline + // reads, for a value the live path forwards unchanged. + it('serves a cached payload whose format this build predates', async () => { + const clientPayload: KitProductClientPayload = { + format: 'yaml', + body: 'tier: gold', + version: 2, + updatedAt: 9, + }; + const stored = { + clientPayload, + etag: 'W/"cached"', + }; + const cache = { + getItem: jest.fn().mockResolvedValue(JSON.stringify(stored)), + setItem: jest.fn(), + removeItem: jest.fn(), + }; + const fetchImpl = jest.fn(); + + await expect( + kitApi({ + apiKey: 'key', + fetchImpl, + clientPayloadCache: cache, + }).clientPayload('premium', 'IOS'), + ).resolves.toEqual({clientPayload: stored.clientPayload}); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(cache.removeItem).not.toHaveBeenCalled(); + }); + it('keeps successful reads when cache operations fail', async () => { const cache = { getItem: jest.fn().mockRejectedValue(new Error('read failed')), diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index fb2e16017..6d6978663 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -65,7 +65,7 @@ const createService = (): jest.Mocked => notifyFulfillment: jest.fn(async () => ({ responseCode: 1, })), - }) as unknown as jest.Mocked; + } as unknown as jest.Mocked); describe('Amazon Vega Expo adapter', () => { it('initializes without fetching Amazon user data', async () => { @@ -269,6 +269,61 @@ describe('Amazon Vega Expo adapter', () => { }); }); + it('supports subscription checks and consumption', async () => { + const service = createService(); + const module = createExpoIapVegaModule(service); + + await expect(module.hasActiveSubscriptions()).resolves.toBe(true); + await expect( + module.consumePurchaseAndroid('sub-receipt'), + ).resolves.toBeUndefined(); + + expect(service.notifyFulfillment).toHaveBeenCalledWith({ + fulfillmentResult: 1, + receiptId: 'sub-receipt', + }); + }); + + it('clears cached state and removed listeners on disconnect', async () => { + const service = createService(); + const module = createExpoIapVegaModule(service); + const subscriptionListener = jest.fn(); + const directListener = jest.fn(); + const subscription = module.addListener( + 'purchase-updated', + subscriptionListener, + ); + module.addListener('purchase-updated', directListener); + + await module.getStorefront(); + subscription.remove(); + module.removeListener('purchase-updated', directListener); + await module.requestPurchase({skus: ['coins_100'], type: 'in-app'}); + await expect(module.endConnection()).resolves.toBe(true); + await module.getStorefront(); + + expect(subscriptionListener).not.toHaveBeenCalled(); + expect(directListener).not.toHaveBeenCalled(); + expect(service.getUserData).toHaveBeenCalledTimes(2); + }); + + it('normalizes non-error purchase failures for listeners', async () => { + const service = createService(); + service.purchase.mockRejectedValueOnce('purchase failed'); + const module = createExpoIapVegaModule(service); + const listener = jest.fn(); + module.addListener('purchase-error', listener); + + await expect( + module.requestPurchase({skus: ['coins_100'], type: 'in-app'}), + ).rejects.toBe('purchase failed'); + expect(listener).toHaveBeenCalledWith({ + code: ErrorCode.PurchaseError, + message: 'Failed to complete Amazon Vega purchase', + productId: 'coins_100', + }); + }); + it('retries transient Amazon Vega fulfillment failures', async () => { jest.useFakeTimers(); const service = createService(); @@ -1264,6 +1319,10 @@ describe('Amazon Vega Expo adapter', () => { ['http://localhost:3100/', 'http://localhost:3100/v1/purchase/verify'], ['http://192.168.0.4:3100', 'http://192.168.0.4:3100/v1/purchase/verify'], ['http://[::1]:3100', 'http://[::1]:3100/v1/purchase/verify'], + [ + 'http://[::ffff:192.168.0.1]:3100', + 'http://[::ffff:192.168.0.1]:3100/v1/purchase/verify', + ], [ 'https://[2001:db8::1]:65535///', 'https://[2001:db8::1]:65535/v1/purchase/verify', @@ -1635,9 +1694,16 @@ describe('Amazon Vega Expo adapter', () => { } }); - it.each([42, 'Staging'])( - 'rejects an invalid IAPKit environment: %s', - async (environment) => { + // Forwarded opaquely; only a non-string is dropped. Neither fails. + it.each([ + {environment: 'Xcode', expected: 'Xcode'}, + {environment: 'LocalTesting', expected: 'LocalTesting'}, + {environment: 'Staging', expected: 'Staging'}, + {environment: 42, expected: undefined}, + {environment: '', expected: undefined}, + ])( + 'never fails a receipt over the IAPKit environment: $environment', + async ({environment, expected}) => { const service = createService(); const originalFetch = globalThis.fetch; const fetchMock = jest.fn(async () => @@ -1653,20 +1719,18 @@ describe('Amazon Vega Expo adapter', () => { try { const module = createExpoIapVegaModule(service); - await expect( - module.verifyPurchaseWithProvider({ - provider: 'iapkit', - iapkit: { - amazon: { - userId: 'amazon-user', - receiptId: 'receipt-vega-1', - }, + const result = await module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', }, - }), - ).rejects.toMatchObject({ - code: ErrorCode.PurchaseVerificationFailed, - message: 'IAPKit returned malformed response (HTTP 200).', + }, }); + + expect(result.iapkit?.isValid).toBe(true); + expect(result.iapkit?.environment).toBe(expected); } finally { globalThis.fetch = originalFetch; } diff --git a/libraries/expo-iap/src/kit-api.ts b/libraries/expo-iap/src/kit-api.ts index 5d60a87ad..f5a78d7af 100644 --- a/libraries/expo-iap/src/kit-api.ts +++ b/libraries/expo-iap/src/kit-api.ts @@ -58,7 +58,8 @@ export type StatusResponse = { export type KitProductPlatform = "IOS" | "Android"; export type KitProductClientPayload = { - format: "toml" | "json" | "text"; + /** Current values are toml, json, and text; preserve unknown values. */ + format: string; body: string; version: number; updatedAt: number; @@ -283,9 +284,11 @@ export function kitApi(options: KitApiOptions) { if (!raw) return null; const candidate = JSON.parse(raw) as Partial; const payload = candidate.clientPayload; + // Only the invariants the cache depends on. `format` is opaque: evicting + // on an unknown one would kill ETag revalidation and offline reads. if ( !payload || - !["toml", "json", "text"].includes(payload.format) || + typeof payload.format !== "string" || typeof payload.body !== "string" || !Number.isSafeInteger(payload.version) || payload.version < 1 || diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 29d5ca10f..132105b27 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1857,6 +1857,12 @@ export interface RequestVerifyPurchaseWithIapkitResult { * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or * `Production` on handled Amazon verification results. + * + * Deliberately String, not an enum: the value space belongs to IAPKit and the + * stores behind it, and Apple's App Store Server alone also names `Xcode` and + * `LocalTesting`. SDKs must forward this value opaquely. Never reject a + * verification because the environment is unrecognised β€” that fails a purchase + * the store already confirmed. */ environment?: (string | null); /** diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts index a63311558..0836781e5 100644 --- a/libraries/expo-iap/src/vega-adapter.ts +++ b/libraries/expo-iap/src/vega-adapter.ts @@ -1177,17 +1177,12 @@ export function createExpoIapVegaModule( `IAPKit returned malformed response (HTTP ${status}).`, ); } - const environment = json.environment; - if ( - environment != null && - (typeof environment !== 'string' || - (environment !== 'Sandbox' && environment !== 'Production')) - ) { - throw createVegaError( - ErrorCode.PurchaseVerificationFailed, - `IAPKit returned malformed response (HTTP ${status}).`, - ); - } + // Forwarded opaquely: `environment` is String in the spec. + const rawEnvironment = json.environment; + const environment = + typeof rawEnvironment === 'string' && rawEnvironment.length > 0 + ? rawEnvironment + : undefined; return { ...(environment == null ? {} : {environment}), diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 7237efbdf..bf0c2e61f 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -1921,17 +1921,12 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } + // Forwarded opaquely: `environment` is String in the spec. final environmentValue = itemMap['environment']; - if (environmentValue != null && - (environmentValue is! String || - (environmentValue != 'Sandbox' && - environmentValue != 'Production'))) { - throw PurchaseError( - code: gentype.ErrorCode.PurchaseVerificationFailed, - message: - 'Malformed IAPKit verification result: environment must be Sandbox or Production', - ); - } + final environment = + environmentValue is String && environmentValue.isNotEmpty + ? environmentValue + : null; gentype.IapkitProductClientPayload? clientPayload; final clientPayloadValue = itemMap['clientPayload']; @@ -1956,47 +1951,45 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { final updatedAt = updatedAtValue is num ? updatedAtValue.toDouble() : double.nan; - if (format is! String || - (format != 'toml' && - format != 'json' && - format != 'text') || - body is! String || - !version.isFinite || - version <= 0 || - version.truncateToDouble() != version || - !updatedAt.isFinite || - updatedAt < 0) { - throw PurchaseError( - code: gentype.ErrorCode.PurchaseVerificationFailed, - message: - 'Malformed IAPKit verification result: invalid clientPayload', - ); + // Optional enrichment: dropped, never thrown. + if (format is String && + body is String && + version.isFinite && + version > 0 && + version.truncateToDouble() == version && + updatedAt.isFinite && + updatedAt >= 0) { + try { + clientPayload = gentype.IapkitProductClientPayload( + body: body, + format: + gentype.IapkitClientPayloadFormat.fromJson(format), + updatedAt: updatedAt, + version: version, + ); + } on ArgumentError { + clientPayload = null; + } } + } + + gentype.IapkitPurchaseState parseState() { try { - clientPayload = gentype.IapkitProductClientPayload( - body: body, - format: - gentype.IapkitClientPayloadFormat.fromJson(format), - updatedAt: updatedAt, - version: version, + return gentype.IapkitPurchaseState.fromJson( + state.toString(), ); } on ArgumentError { - throw PurchaseError( - code: gentype.ErrorCode.PurchaseVerificationFailed, - message: - 'Malformed IAPKit verification result: invalid clientPayload format', - ); + // A state added after this build shipped; `isValid` stands. + return gentype.IapkitPurchaseState.Unknown; } } return gentype.RequestVerifyPurchaseWithIapkitResult( clientPayload: clientPayload, - environment: environmentValue as String?, + environment: environment, isValid: isValid, productId: productIdValue as String?, - state: gentype.IapkitPurchaseState.fromJson( - state.toString(), - ), + state: parseState(), store: gentype.IapStore.fromJson(store.toString()), ); } diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 94fcbe735..13e69dde8 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -1783,10 +1783,10 @@ class DiscountDisplayInfoAndroid { /// Standardized one-time product discount offer. /// Provides a platform-neutral OpenIAP shape for Google Play one-time product /// purchase options and offers. -/// +/// /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. -/// +/// /// @see https://openiap.dev/docs/types/discount-offer class DiscountOffer { const DiscountOffer({ @@ -3353,6 +3353,12 @@ class RequestVerifyPurchaseWithIapkitResult { /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or /// `Production` on handled Amazon verification results. + /// + /// Deliberately String, not an enum: the value space belongs to IAPKit and the + /// stores behind it, and Apple's App Store Server alone also names `Xcode` and + /// `LocalTesting`. SDKs must forward this value opaquely. Never reject a + /// verification because the environment is unrecognised β€” that fails a purchase + /// the store already confirmed. final String? environment; /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. @@ -3421,11 +3427,11 @@ class SubscriptionCommitmentInfoIOS { /// Standardized subscription discount/promotional offer. /// Provides a unified interface for subscription offers across iOS and Android. -/// +/// /// Both platforms support subscription offers with different implementations: /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases -/// +/// /// @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer { const SubscriptionOffer({ @@ -4588,7 +4594,7 @@ class _SubsPurchase extends RequestPurchaseProps { } /// Platform-specific purchase request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -4766,7 +4772,7 @@ class RequestSubscriptionIosProps { } /// Platform-specific subscription request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -4878,7 +4884,7 @@ class RequestVerifyPurchaseWithIapkitGoogleProps { } /// Platform-specific verification parameters for IAPKit. -/// +/// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) /// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) @@ -4988,7 +4994,7 @@ class VerifyPurchaseAppleOptions { /// Google Play Store verification parameters. /// Used for server-side receipt validation via Google Play Developer API. -/// +/// /// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. class VerifyPurchaseGoogleOptions { const VerifyPurchaseGoogleOptions({ @@ -5036,7 +5042,7 @@ class VerifyPurchaseGoogleOptions { /// Meta Horizon (Quest) verification parameters. /// Used for server-side entitlement verification via Meta's S2S API. /// POST https://graph.oculus.com/$APP_ID/verify_entitlement -/// +/// /// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. class VerifyPurchaseHorizonOptions { const VerifyPurchaseHorizonOptions({ @@ -5071,7 +5077,7 @@ class VerifyPurchaseHorizonOptions { } /// Platform-specific purchase verification parameters. -/// +/// /// - apple: Verifies via App Store Server API /// - google: Verifies via Google Play Developer API /// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) @@ -5622,7 +5628,7 @@ abstract class SubscriptionResolver { }); /// Fires when a subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: - /// + /// /// - iOS 16.4+ / Mac Catalyst 16.4+ / visionOS 1.0+: delivered via StoreKit 2 /// `Message.Reason.billingIssue`. /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected @@ -5631,7 +5637,7 @@ abstract class SubscriptionResolver { /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. /// - Android (Amazon flavor): NOT emitted. Amazon Appstore IAP does not expose an /// equivalent subscription billing-issue signal. - /// + /// /// Listeners should not assume the event will fire on every store. Direct users to the /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. Future subscriptionBillingIssue(); diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index 0de455f0d..0a8b6f966 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -3100,7 +3100,8 @@ void main() { ); }); - test('rejects malformed IAPKit client payload', () async { + test('drops a malformed IAPKit client payload without failing the receipt', + () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { switch (call.method) { @@ -3130,21 +3131,22 @@ void main() { ); await iap.initConnection(); - await expectLater( - iap.verifyPurchaseWithProvider( - provider: types.PurchaseVerificationProvider.Iapkit, - iapkit: const types.RequestVerifyPurchaseWithIapkitProps( - includeClientPayload: true, - apple: types.RequestVerifyPurchaseWithIapkitAppleProps( - jws: 'test-jws-token', - ), + final result = await iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + includeClientPayload: true, + apple: types.RequestVerifyPurchaseWithIapkitAppleProps( + jws: 'test-jws-token', ), ), - throwsA(isA()), ); + + // The payload is optional enrichment; the verified receipt survives it. + expect(result.iapkit!.isValid, isTrue); + expect(result.iapkit!.clientPayload, isNull); }); - test('rejects malformed IAPKit environment', () async { + test('drops a non-string IAPKit environment', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { switch (call.method) { @@ -3169,17 +3171,69 @@ void main() { ); await iap.initConnection(); - await expectLater( - iap.verifyPurchaseWithProvider( - provider: types.PurchaseVerificationProvider.Iapkit, - iapkit: const types.RequestVerifyPurchaseWithIapkitProps( - amazon: types.RequestVerifyPurchaseWithIapkitAmazonProps( - receiptId: 'amzn1.receipt.test', - ), + final result = await iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + amazon: types.RequestVerifyPurchaseWithIapkitAmazonProps( + receiptId: 'amzn1.receipt.test', ), ), - throwsA(isA()), ); + + expect(result.iapkit!.isValid, isTrue); + expect(result.iapkit!.environment, isNull); + }); + + // A value IAPKit adds later must degrade, not fail the purchase. + test('never fails a receipt over metadata this build predates', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'verifyPurchaseWithProvider': + return { + 'provider': 'iapkit', + 'iapkit': { + 'isValid': true, + 'productId': 'premium.monthly', + 'state': 'grace-period', + 'store': 'apple', + 'environment': 'Xcode', + 'clientPayload': { + 'format': 'yaml', + 'body': 'tier: gold', + 'version': 2, + 'updatedAt': 1720000000000, + }, + }, + }; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + await iap.initConnection(); + + final result = await iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + apiKey: 'test-api-key', + includeClientPayload: true, + apple: types.RequestVerifyPurchaseWithIapkitAppleProps( + jws: 'test-jws-token', + ), + ), + ); + + expect(result.iapkit!.isValid, isTrue); + expect(result.iapkit!.productId, 'premium.monthly'); + // Unknown state degrades, unknown format drops, environment forwards. + expect(result.iapkit!.state, types.IapkitPurchaseState.Unknown); + expect(result.iapkit!.clientPayload, isNull); + expect(result.iapkit!.environment, 'Xcode'); }); }); } diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 0b60a5973..097d3c06d 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -993,7 +993,7 @@ class DiscountDisplayInfoAndroid: dict["discountAmount"] = discount_amount return dict -## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer +## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer class DiscountOffer: ## Unique identifier for the offer. - iOS: Not applicable (one-time discounts not supported) - Android: offerId from the Google Play one-time purchase option var id: Variant = null @@ -2755,7 +2755,7 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: IapStore - ## Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. Present as `Sandbox` or `Production` on handled Amazon verification results. + ## Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. Present as `Sandbox` or `Production` on handled Amazon verification results. Deliberately String, not an enum: the value space belongs to IAPKit and the stores behind it, and Apple's App Store Server alone also names `Xcode` and `LocalTesting`. SDKs must forward this value opaquely. Never reject a verification because the environment is unrecognised β€” that fails a purchase the store already confirmed. var environment: Variant = null ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. var is_valid: bool = false @@ -2842,7 +2842,7 @@ class SubscriptionCommitmentInfoIOS: dict["price"] = price return dict -## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer +## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer: ## Unique identifier for the offer. - iOS: Discount identifier from App Store Connect - Android: offerId from the Google Play subscription offer var id: String = "" @@ -3718,7 +3718,7 @@ class InAppMessageParamsAndroid: ## Connection initialization configuration class InitConnectionConfig: - ## Enable a specific billing program for Android (7.0+) When set, enables the specified billing program for external transactions. - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) - EXTERNAL_CONTENT_LINK: Link to external content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_OFFER: External offers for digital content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) - BILLING_CHOICE: Google-rendered or developer-rendered billing choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) + ## Enable a specific billing program for Android (7.0+) When set, enables the specified billing program for external transactions. - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) - EXTERNAL_CONTENT_LINK: Link to external content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_OFFER: External offers for digital content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) - BILLING_CHOICE: Google-rendered or developer-rendered billing choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) var enable_billing_program_android: Variant = null ## Billing Choice renderer configured in Play Console. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP can emit the selection event. DEVELOPER_RENDERED omits that listener so the app can render its own choice screen and use the reporting/dialog/link APIs. Must match choiceScreenType returned by isBillingProgramAvailableAndroid. Defaults to GOOGLE_RENDERED. var billing_choice_screen_type_android: BillingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED @@ -4160,7 +4160,7 @@ class RequestPurchaseProps: dict["type"] = PRODUCT_QUERY_TYPE_VALUES.get(type, type) return dict -## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestPurchasePropsByPlatforms: ## Apple-specific purchase parameters var apple: RequestPurchaseIosProps @@ -4380,7 +4380,7 @@ class RequestSubscriptionIosProps: dict["advancedCommerceData"] = advanced_commerce_data return dict -## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestSubscriptionPropsByPlatforms: ## Apple-specific subscription parameters var apple: RequestSubscriptionIosProps @@ -4481,7 +4481,7 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: dict["purchaseToken"] = purchase_token return dict -## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) +## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null @@ -4593,7 +4593,7 @@ class VerifyPurchaseAppleOptions: dict["sku"] = sku return dict -## Google Play Store verification parameters. Used for server-side receipt validation via Google Play Developer API. ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. +## Google Play Store verification parameters. Used for server-side receipt validation via Google Play Developer API. ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. class VerifyPurchaseGoogleOptions: ## Product SKU to validate var sku: String = "" @@ -4634,7 +4634,7 @@ class VerifyPurchaseGoogleOptions: dict["isSub"] = is_sub return dict -## Meta Horizon (Quest) verification parameters. Used for server-side entitlement verification via Meta's S2S API. POST https://graph.oculus.com/$APP_ID/verify_entitlement ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. +## Meta Horizon (Quest) verification parameters. Used for server-side entitlement verification via Meta's S2S API. POST https://graph.oculus.com/$APP_ID/verify_entitlement ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. class VerifyPurchaseHorizonOptions: ## The SKU for the add-on item, defined in Meta Developer Dashboard var sku: String = "" @@ -4663,7 +4663,7 @@ class VerifyPurchaseHorizonOptions: dict["accessToken"] = access_token return dict -## Platform-specific purchase verification parameters. - apple: Verifies via App Store Server API - google: Verifies via Google Play Developer API - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) +## Platform-specific purchase verification parameters. - apple: Verifies via App Store Server API - google: Verifies via Google Play Developer API - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) class VerifyPurchaseProps: ## Apple App Store verification parameters. var apple: VerifyPurchaseAppleOptions diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt index 3be2554ec..185c8e6c4 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt @@ -55,7 +55,6 @@ import io.github.hyochan.kmpiap.openiap.RequestPurchaseProps import io.github.hyochan.kmpiap.openiap.RequestPurchaseResult import io.github.hyochan.kmpiap.openiap.RequestPurchaseResultPurchase import io.github.hyochan.kmpiap.openiap.RequestPurchaseResultPurchases -import io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitResult import io.github.hyochan.kmpiap.openiap.SubscriptionStatusIOS import io.github.hyochan.kmpiap.openiap.UserChoiceBillingDetails import io.github.hyochan.kmpiap.openiap.VerifyPurchaseProps @@ -249,11 +248,26 @@ internal class AmazonInAppPurchaseAndroid( ) ) } - val androidResult = verifyPurchaseWithIapkitAndroid(androidOptions, "kmp-iap-android-$storeName") - return VerifyPurchaseWithProviderResult( - iapkit = RequestVerifyPurchaseWithIapkitResult.fromJson(androidResult.toJson()), - provider = options.provider - ) + // The validator also throws IllegalArgumentException for a malformed + // options shape, so catch broadly like the Play path rather than only + // the typed OpenIapError. + return try { + val androidResult = + verifyPurchaseWithIapkitAndroid(androidOptions, "kmp-iap-android-$storeName") + VerifyPurchaseWithProviderResult( + iapkit = androidResult.toKmpIapkitResult(), + provider = options.provider + ) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + failWith( + PurchaseError( + code = ErrorCode.PurchaseVerificationFailed, + message = error.message ?: "Purchase verification failed" + ) + ) + } } override suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult = diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt index 4c24357c0..8c0f85eb6 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt @@ -16,6 +16,11 @@ import io.github.hyochan.kmpiap.openiap.ExternalLinkLaunchModeAndroid import io.github.hyochan.kmpiap.openiap.ExternalLinkTypeAndroid import io.github.hyochan.kmpiap.openiap.IapPlatform import io.github.hyochan.kmpiap.openiap.IapStore +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitResult as AndroidRequestVerifyPurchaseWithIapkitResult +import io.github.hyochan.kmpiap.openiap.IapkitClientPayloadFormat +import io.github.hyochan.kmpiap.openiap.IapkitProductClientPayload +import io.github.hyochan.kmpiap.openiap.IapkitPurchaseState +import io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitResult import io.github.hyochan.kmpiap.openiap.InstallmentPlanDetailsAndroid import io.github.hyochan.kmpiap.openiap.LaunchExternalLinkParamsAndroid import io.github.hyochan.kmpiap.openiap.LimitedQuantityInfoAndroid @@ -774,3 +779,33 @@ internal fun LaunchExternalLinkParamsAndroid.toOpenIapParams(): OpenIapLaunchExt linkType = linkType.toOpenIapLinkType(), linkUri = linkUri ) + +/** + * Re-shapes an openiap-google IAPKit result into this module's generated types. + * openiap-google has already decoded it safely, so unknown values degrade here + * rather than re-imposing a fail-closed gate when the two versions drift. + */ +internal fun AndroidRequestVerifyPurchaseWithIapkitResult.toKmpIapkitResult(): RequestVerifyPurchaseWithIapkitResult = + RequestVerifyPurchaseWithIapkitResult( + clientPayload = clientPayload?.let { payload -> + IapkitClientPayloadFormat.entries + .firstOrNull { it.rawValue == payload.format.toJson() } + ?.let { format -> + IapkitProductClientPayload( + body = payload.body, + format = format, + updatedAt = payload.updatedAt, + version = payload.version + ) + } + }, + environment = environment, + isValid = isValid, + productId = productId, + state = runCatching { + IapkitPurchaseState.fromJson(state.toJson()) + }.getOrDefault(IapkitPurchaseState.Unknown), + store = runCatching { + IapStore.fromJson(store.toJson()) + }.getOrDefault(IapStore.Unknown) + ) diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index 7d1f1caaf..919e0436b 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -87,11 +87,6 @@ import io.github.hyochan.kmpiap.openiap.VerifyPurchaseResultAndroid import io.github.hyochan.kmpiap.openiap.VerifyPurchaseResultIOS import io.github.hyochan.kmpiap.openiap.PurchaseIOS import io.github.hyochan.kmpiap.openiap.PurchaseVerificationProvider -import io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitResult -import io.github.hyochan.kmpiap.openiap.IapStore -import io.github.hyochan.kmpiap.openiap.IapkitClientPayloadFormat -import io.github.hyochan.kmpiap.openiap.IapkitPurchaseState -import io.github.hyochan.kmpiap.openiap.IapkitProductClientPayload import io.github.hyochan.kmpiap.openiap.BillingChoiceImageLayoutAndroid import io.github.hyochan.kmpiap.openiap.BillingChoiceInfoAndroid import io.github.hyochan.kmpiap.openiap.BillingChoiceScreenTypeAndroid @@ -2211,26 +2206,14 @@ internal class InAppPurchaseAndroid( val androidResult = verifyPurchaseWithIapkitAndroid(openIapProps, "kmp-iap-android") - val iapkitResult = RequestVerifyPurchaseWithIapkitResult( - clientPayload = androidResult.clientPayload?.let { payload -> - IapkitProductClientPayload( - body = payload.body, - format = IapkitClientPayloadFormat.fromJson(payload.format.toJson()), - updatedAt = payload.updatedAt, - version = payload.version - ) - }, - environment = androidResult.environment, - isValid = androidResult.isValid, - productId = androidResult.productId, - state = IapkitPurchaseState.fromJson(androidResult.state.toJson()), - store = IapStore.fromJson(androidResult.store.toJson()) - ) + val iapkitResult = androidResult.toKmpIapkitResult() VerifyPurchaseWithProviderResult( iapkit = iapkitResult, provider = options.provider ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { failWith( PurchaseError( diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt index a99298350..c51bafe67 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt @@ -23,9 +23,17 @@ class IapkitBaseUrlBridgeTest { "src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt" ).readText() + val helperSource = File( + "src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt" + ).readText() + assertTrue(androidSource.contains("expectedProductId = amazon.expectedProductId")) - assertTrue(androidSource.contains("environment = androidResult.environment")) + assertTrue(androidSource.contains("androidResult.toKmpIapkitResult()")) + assertTrue(helperSource.contains("environment = environment")) + // Unknown values degrade; openiap-google already decoded them safely. + assertTrue(helperSource.contains("getOrDefault(IapkitPurchaseState.Unknown)")) assertTrue(iosSource.contains("environment = environment")) - assertTrue(iosSource.contains("\"Sandbox\", \"Production\"")) + // Forwarded opaquely: `environment` is String in the spec. + assertTrue(iosSource.contains("map[\"environment\"] as? String")) } } 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 1b869d7c3..295f1e5c5 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 @@ -3448,6 +3448,12 @@ public data class RequestVerifyPurchaseWithIapkitResult( * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or * `Production` on handled Amazon verification results. + * + * Deliberately String, not an enum: the value space belongs to IAPKit and the + * stores behind it, and Apple's App Store Server alone also names `Xcode` and + * `LocalTesting`. SDKs must forward this value opaquely. Never reject a + * verification because the environment is unrecognised β€” that fails a purchase + * the store already confirmed. */ var environment: String? = null private set diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 2f4a1ff4f..079d24157 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -1071,41 +1071,29 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { is String -> rawProductId else -> throw IllegalArgumentException("IAPKit result productId must be a string") } - val environment = when (val rawEnvironment = map["environment"]) { - null, is NSNull -> null - "Sandbox", "Production" -> rawEnvironment as String - else -> throw IllegalArgumentException( - "IAPKit result environment must be Sandbox or Production" - ) - } - val clientPayload = when (val rawClientPayload = map["clientPayload"]) { - null, is NSNull -> null - is Map<*, *> -> { - val payload = rawClientPayload.mapKeys { it.key.toString() } - val format = payload["format"] as? String - ?: throw IllegalArgumentException("IAPKit clientPayload missing format") - if (format !in setOf("toml", "json", "text")) { - throw IllegalArgumentException("IAPKit clientPayload contains invalid format") - } - val body = payload["body"] as? String - ?: throw IllegalArgumentException("IAPKit clientPayload missing body") - val version = (payload["version"] as? Number)?.toDouble() - ?: throw IllegalArgumentException("IAPKit clientPayload missing version") - val updatedAt = (payload["updatedAt"] as? Number)?.toDouble() - ?: throw IllegalArgumentException("IAPKit clientPayload missing updatedAt") - if (!version.isFinite() || version <= 0.0 || version % 1.0 != 0.0 || - !updatedAt.isFinite() || updatedAt < 0.0 - ) { - throw IllegalArgumentException("IAPKit clientPayload contains invalid numeric fields") - } + // Forwarded opaquely: `environment` is String in the spec. + val environment = (map["environment"] as? String)?.takeIf { it.isNotEmpty() } + // Optional enrichment: dropped, never thrown. + val clientPayload = (map["clientPayload"] as? Map<*, *>)?.let { rawClientPayload -> + val payload = rawClientPayload.mapKeys { it.key.toString() } + val format = (payload["format"] as? String) + ?.let { raw -> IapkitClientPayloadFormat.entries.firstOrNull { it.rawValue == raw } } + val body = payload["body"] as? String + val version = (payload["version"] as? Number)?.toDouble() + val updatedAt = (payload["updatedAt"] as? Number)?.toDouble() + if (format == null || body == null || version == null || updatedAt == null || + !version.isFinite() || version <= 0.0 || version % 1.0 != 0.0 || + !updatedAt.isFinite() || updatedAt < 0.0 + ) { + null + } else { IapkitProductClientPayload( body = body, - format = IapkitClientPayloadFormat.fromJson(format), + format = format, updatedAt = updatedAt, version = version ) } - else -> throw IllegalArgumentException("IAPKit clientPayload must be an object") } val iapkitResult = RequestVerifyPurchaseWithIapkitResult( clientPayload = clientPayload, diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index 89dc753c5..0c6d8a4c1 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -3520,6 +3520,12 @@ public sealed record RequestVerifyPurchaseWithIapkitResult /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or /// `Production` on handled Amazon verification results. + /// + /// Deliberately String, not an enum: the value space belongs to IAPKit and the + /// stores behind it, and Apple's App Store Server alone also names `Xcode` and + /// `LocalTesting`. SDKs must forward this value opaquely. Never reject a + /// verification because the environment is unrecognised β€” that fails a purchase + /// the store already confirmed. /// [JsonPropertyName("environment")] public string? Environment { get; init; } diff --git a/libraries/react-native-iap/src/__tests__/kit-api.test.ts b/libraries/react-native-iap/src/__tests__/kit-api.test.ts index 9472f2237..6a7571e65 100644 --- a/libraries/react-native-iap/src/__tests__/kit-api.test.ts +++ b/libraries/react-native-iap/src/__tests__/kit-api.test.ts @@ -1,4 +1,4 @@ -import {kitApi, KitApiError} from '../kit-api'; +import {kitApi, KitApiError, type KitProductClientPayload} from '../kit-api'; const payload = { clientPayload: { @@ -348,6 +348,37 @@ describe('kitApi cache resilience', () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); + // Evicting on an unknown format would kill ETag revalidation and offline + // reads, for a value the live path forwards unchanged. + it('serves a cached payload whose format this build predates', async () => { + const clientPayload: KitProductClientPayload = { + format: 'yaml', + body: 'tier: gold', + version: 2, + updatedAt: 9, + }; + const stored = { + clientPayload, + etag: 'W/"cached"', + }; + const cache = { + getItem: jest.fn().mockResolvedValue(JSON.stringify(stored)), + setItem: jest.fn(), + removeItem: jest.fn(), + }; + const fetchImpl = jest.fn(); + + await expect( + kitApi({ + apiKey: 'key', + fetchImpl, + clientPayloadCache: cache, + }).clientPayload('premium', 'IOS'), + ).resolves.toEqual({clientPayload: stored.clientPayload}); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(cache.removeItem).not.toHaveBeenCalled(); + }); + it('keeps successful reads when cache operations fail', async () => { const cache = { getItem: jest.fn().mockRejectedValue(new Error('read failed')), diff --git a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts index 350f4cb54..fdb1131fa 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -1686,9 +1686,16 @@ describe('Amazon Vega adapter', () => { } }); - it.each([42, 'Staging'])( - 'rejects an invalid IAPKit environment: %s', - async (environment) => { + // Forwarded opaquely; only a non-string is dropped. Neither fails. + it.each([ + {environment: 'Xcode', expected: 'Xcode'}, + {environment: 'LocalTesting', expected: 'LocalTesting'}, + {environment: 'Staging', expected: 'Staging'}, + {environment: 42, expected: undefined}, + {environment: '', expected: undefined}, + ])( + 'never fails a receipt over the IAPKit environment: $environment', + async ({environment, expected}) => { const service = createService(); const originalFetch = globalThis.fetch; const fetchMock = jest.fn(async () => @@ -1704,17 +1711,18 @@ describe('Amazon Vega adapter', () => { try { const module = createVegaIapModule(service); - await expect( - module.verifyPurchaseWithProvider({ - provider: 'iapkit', - iapkit: { - amazon: { - userId: 'amazon-user', - receiptId: 'receipt-vega-1', - }, + const result = await module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', }, - }), - ).rejects.toThrow('IAPKit returned malformed response (HTTP 200).'); + }, + }); + + expect(result.iapkit?.isValid).toBe(true); + expect(result.iapkit?.environment).toBe(expected); } finally { globalThis.fetch = originalFetch; } diff --git a/libraries/react-native-iap/src/kit-api.ts b/libraries/react-native-iap/src/kit-api.ts index 5d60a87ad..f5a78d7af 100644 --- a/libraries/react-native-iap/src/kit-api.ts +++ b/libraries/react-native-iap/src/kit-api.ts @@ -58,7 +58,8 @@ export type StatusResponse = { export type KitProductPlatform = "IOS" | "Android"; export type KitProductClientPayload = { - format: "toml" | "json" | "text"; + /** Current values are toml, json, and text; preserve unknown values. */ + format: string; body: string; version: number; updatedAt: number; @@ -283,9 +284,11 @@ export function kitApi(options: KitApiOptions) { if (!raw) return null; const candidate = JSON.parse(raw) as Partial; const payload = candidate.clientPayload; + // Only the invariants the cache depends on. `format` is opaque: evicting + // on an unknown one would kill ETag revalidation and offline reads. if ( !payload || - !["toml", "json", "text"].includes(payload.format) || + typeof payload.format !== "string" || typeof payload.body !== "string" || !Number.isSafeInteger(payload.version) || payload.version < 1 || diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 29d5ca10f..132105b27 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1857,6 +1857,12 @@ export interface RequestVerifyPurchaseWithIapkitResult { * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or * `Production` on handled Amazon verification results. + * + * Deliberately String, not an enum: the value space belongs to IAPKit and the + * stores behind it, and Apple's App Store Server alone also names `Xcode` and + * `LocalTesting`. SDKs must forward this value opaquely. Never reject a + * verification because the environment is unrecognised β€” that fails a purchase + * the store already confirmed. */ environment?: (string | null); /** diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index 2bbb406b7..ffd3d6743 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -1271,17 +1271,12 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { `IAPKit returned malformed response (HTTP ${status}).`, ); } - const environment = json.environment; - if ( - environment != null && - (typeof environment !== 'string' || - (environment !== 'Sandbox' && environment !== 'Production')) - ) { - throw createVegaError( - ErrorCode.PurchaseVerificationFailed, - `IAPKit returned malformed response (HTTP ${status}).`, - ); - } + // Forwarded opaquely: `environment` is String in the spec. + const rawEnvironment = json.environment; + const environment = + typeof rawEnvironment === 'string' && rawEnvironment.length > 0 + ? rawEnvironment + : undefined; return { ...(environment == null ? {} : {environment}), diff --git a/package.json b/package.json index 299a3abff..c1bb34e37 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "e2e:web": "node scripts/e2e-web-sites.mjs", "audit:deprecations": "node --test scripts/audit-deprecation-schedule.test.mjs && node scripts/audit-deprecation-schedule.mjs", "audit:parity": "node scripts/audit-non-godot-parity.mjs", + "audit:kit-contract": "node --test scripts/audit-kit-spec-contract.test.mjs && node scripts/audit-kit-spec-contract.mjs", "audit:docs": "bun run scripts/audit-docs.ts", "audit:release-state": "node scripts/release-branch-policy.mjs audit", "sbom": "node scripts/generate-sbom.mjs", diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index fa039e64c..e8e641c04 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -738,10 +738,10 @@ public struct DiscountDisplayInfoAndroid: Codable { /// Standardized one-time product discount offer. /// Provides a platform-neutral OpenIAP shape for Google Play one-time product /// purchase options and offers. -/// +/// /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. -/// +/// /// @see https://openiap.dev/docs/types/discount-offer public struct DiscountOffer: Codable { /// Currency code (ISO 4217, e.g., "USD") @@ -1239,6 +1239,12 @@ public struct RequestVerifyPurchaseWithIapkitResult: Codable { /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or /// `Production` on handled Amazon verification results. + /// + /// Deliberately String, not an enum: the value space belongs to IAPKit and the + /// stores behind it, and Apple's App Store Server alone also names `Xcode` and + /// `LocalTesting`. SDKs must forward this value opaquely. Never reject a + /// verification because the environment is unrecognised β€” that fails a purchase + /// the store already confirmed. public var environment: String? = nil /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. @@ -1261,11 +1267,11 @@ public struct SubscriptionCommitmentInfoIOS: Codable { /// Standardized subscription discount/promotional offer. /// Provides a unified interface for subscription offers across iOS and Android. -/// +/// /// Both platforms support subscription offers with different implementations: /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases -/// +/// /// @see https://openiap.dev/docs/types/subscription-offer public struct SubscriptionOffer: Codable { /// [Android] Base plan identifier. @@ -1893,7 +1899,7 @@ public struct RequestPurchaseProps: Codable { } /// Platform-specific purchase request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -2023,7 +2029,7 @@ public struct RequestSubscriptionIosProps: Codable { } /// Platform-specific subscription request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -2091,7 +2097,7 @@ public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable { } /// Platform-specific verification parameters for IAPKit. -/// +/// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) /// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) @@ -2165,7 +2171,7 @@ public struct VerifyPurchaseAppleOptions: Codable { /// Google Play Store verification parameters. /// Used for server-side receipt validation via Google Play Developer API. -/// +/// /// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. public struct VerifyPurchaseGoogleOptions: Codable { /// Google OAuth2 access token for API authentication. @@ -2199,7 +2205,7 @@ public struct VerifyPurchaseGoogleOptions: Codable { /// Meta Horizon (Quest) verification parameters. /// Used for server-side entitlement verification via Meta's S2S API. /// POST https://graph.oculus.com/$APP_ID/verify_entitlement -/// +/// /// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. public struct VerifyPurchaseHorizonOptions: Codable { /// Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). @@ -2222,7 +2228,7 @@ public struct VerifyPurchaseHorizonOptions: Codable { } /// Platform-specific purchase verification parameters. -/// +/// /// - apple: Verifies via App Store Server API /// - google: Verifies via Google Play Developer API /// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) @@ -2831,7 +2837,7 @@ public protocol SubscriptionResolver { func purchaseUpdated(_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase /// Fires when a subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: - /// + /// /// - iOS 16.4+ / Mac Catalyst 16.4+ / visionOS 1.0+: delivered via StoreKit 2 /// `Message.Reason.billingIssue`. /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected @@ -2840,7 +2846,7 @@ public protocol SubscriptionResolver { /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. /// - Android (Amazon flavor): NOT emitted. Amazon Appstore IAP does not expose an /// equivalent subscription billing-issue signal. - /// + /// /// Listeners should not assume the event will fire on every store. Direct users to the /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. func subscriptionBillingIssue() async throws -> Purchase diff --git a/packages/apple/Sources/OpenIapGeneratedVersion.swift b/packages/apple/Sources/OpenIapGeneratedVersion.swift new file mode 100644 index 000000000..94f257596 --- /dev/null +++ b/packages/apple/Sources/OpenIapGeneratedVersion.swift @@ -0,0 +1,8 @@ +// Generated by scripts/sync-versions.sh from openiap-versions.json. +// Do not edit. + +enum OpenIapGeneratedVersion { + static let spec = "3.2.0" + static let apple = "3.2.0" + static let google = "3.3.0" +} diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index cf8697aab..81ff94c19 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -77,7 +77,9 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return url } - static func iapkitClientPayload(from rawValue: Any?) throws -> IapkitProductClientPayload? { + /// Optional enrichment: returns nil for anything this build cannot read, + /// including a `format` IAPKit added later. Never fails the receipt. + static func iapkitClientPayload(from rawValue: Any?) -> IapkitProductClientPayload? { guard let rawValue, !(rawValue is NSNull) else { return nil } guard let payload = rawValue as? [String: Any], let formatString = payload["format"] as? String, @@ -92,10 +94,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { version.doubleValue.rounded(.towardZero) == version.doubleValue, updatedAt.doubleValue.isFinite, updatedAt.doubleValue >= 0 else { - throw PurchaseError.make( - code: .purchaseVerificationFailed, - message: "IAPKit returned malformed client payload" - ) + OpenIapLog.warn("Ignoring an IAPKit client payload this build cannot read") + return nil } return IapkitProductClientPayload( @@ -106,6 +106,25 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { ) } + /// Reports the compile-time response contract; never used for negotiation. + static func makeIapkitRequest( + url: URL, + apiKey: String?, + body: Data, + specVersion: String = OpenIapVersion.specVersion + ) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(specVersion, forHTTPHeaderField: "X-OpenIAP-Spec") + let trimmedApiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedApiKey, trimmedApiKey.isEmpty == false { + request.setValue("Bearer \(trimmedApiKey)", forHTTPHeaderField: "Authorization") + } + request.httpBody = body + return request + } + static func iapkitBoolean(from rawValue: Any?) throws -> Bool { guard let value = rawValue as? NSNumber, CFGetTypeID(value) == CFBooleanGetTypeID() else { @@ -118,14 +137,13 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return value.boolValue } - static func iapkitEnvironment(from rawValue: Any?) throws -> String? { + /// Forwarded opaquely: `environment` is String in the spec, and App Store + /// Server also names `Xcode` and `LocalTesting`. + static func iapkitEnvironment(from rawValue: Any?) -> String? { guard let rawValue, !(rawValue is NSNull) else { return nil } - guard let environment = rawValue as? String, - environment == "Sandbox" || environment == "Production" else { - throw PurchaseError.make( - code: .purchaseVerificationFailed, - message: "IAPKit returned malformed response" - ) + guard let environment = rawValue as? String, environment.isEmpty == false else { + OpenIapLog.warn("Ignoring an IAPKit environment this build cannot read") + return nil } return environment @@ -958,14 +976,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { let store = payload.store let body = payload.body - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - let apiKey = props.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) - if let apiKey, apiKey.isEmpty == false { - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } - request.httpBody = body + let request = Self.makeIapkitRequest(url: url, apiKey: props.apiKey, body: body) OpenIapLog.debug("IAPKit request URL: \(url.absoluteString)") OpenIapLog.debug("IAPKit request body bytes=\(body.count)") @@ -1031,14 +1042,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } else { productId = nil } - let environment = try Self.iapkitEnvironment(from: json["environment"]) - let clientPayload: IapkitProductClientPayload? - do { - clientPayload = try Self.iapkitClientPayload(from: json["clientPayload"]) - } catch { - OpenIapLog.warn("IAPKit verification response contains a malformed clientPayload") - throw error - } + let environment = Self.iapkitEnvironment(from: json["environment"]) + let clientPayload = Self.iapkitClientPayload(from: json["clientPayload"]) OpenIapLog.info("IAPKit verification result: store=\(parsedStore.rawValue), isValid=\(isValid), state=\(parsedState.rawValue)") return RequestVerifyPurchaseWithIapkitResult( clientPayload: clientPayload, diff --git a/packages/apple/Sources/OpenIapVersion.swift b/packages/apple/Sources/OpenIapVersion.swift index 410a33698..b3b36c0d6 100644 --- a/packages/apple/Sources/OpenIapVersion.swift +++ b/packages/apple/Sources/OpenIapVersion.swift @@ -1,58 +1,13 @@ -import Foundation - -private final class OpenIapVersionBundleToken {} - /// OpenIAP version management public struct OpenIapVersion { /// Current OpenIAP Apple SDK version public static var current: String { - version(for: "apple") + OpenIapGeneratedVersion.apple } /// Current OpenIAP specification version public static var specVersion: String { - version(for: "spec") - } - - 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 + OpenIapGeneratedVersion.spec } } diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift index b6b895af9..f08ebc42b 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -64,7 +64,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { XCTAssertTrue(OpenIapModule.shared.responds(to: clientPayloadSelector)) } - func testIapkitClientPayloadParsesAndRejectsMalformedValues() throws { + func testIapkitClientPayloadParsesAndDropsUnreadableValues() throws { let payload = try XCTUnwrap( OpenIapModule.iapkitClientPayload(from: [ "format": "toml", @@ -77,26 +77,35 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { XCTAssertEqual(.toml, payload.format) XCTAssertEqual("tier = \"gold\"", payload.body) XCTAssertEqual(2, payload.version) - XCTAssertNil(try OpenIapModule.iapkitClientPayload(from: nil)) - XCTAssertNil(try OpenIapModule.iapkitClientPayload(from: NSNull())) - XCTAssertThrowsError( - try OpenIapModule.iapkitClientPayload(from: [ + XCTAssertNil(OpenIapModule.iapkitClientPayload(from: nil)) + XCTAssertNil(OpenIapModule.iapkitClientPayload(from: NSNull())) + // A later format, and every structural defect, drop the payload. + XCTAssertNil( + OpenIapModule.iapkitClientPayload(from: [ + "format": "yaml", + "body": "tier: gold", + "version": 1, + "updatedAt": 1, + ]) + ) + XCTAssertNil( + OpenIapModule.iapkitClientPayload(from: [ "format": "TOML", "body": "invalid format", "version": 1, "updatedAt": 1, ]) ) - XCTAssertThrowsError( - try OpenIapModule.iapkitClientPayload(from: [ + XCTAssertNil( + OpenIapModule.iapkitClientPayload(from: [ "format": "toml", "body": "missing version", "updatedAt": 1, ]) ) for invalidVersion: Any in [true, 0, 1.5] { - XCTAssertThrowsError( - try OpenIapModule.iapkitClientPayload(from: [ + XCTAssertNil( + OpenIapModule.iapkitClientPayload(from: [ "format": "toml", "body": "invalid version", "version": invalidVersion, @@ -104,8 +113,8 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { ]) ) } - XCTAssertThrowsError( - try OpenIapModule.iapkitClientPayload(from: [ + XCTAssertNil( + OpenIapModule.iapkitClientPayload(from: [ "format": "toml", "body": "invalid timestamp", "version": 1, @@ -125,16 +134,64 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { } } - func testIapkitEnvironmentAcceptsOnlyCanonicalValues() throws { - XCTAssertNil(try OpenIapModule.iapkitEnvironment(from: nil)) - XCTAssertNil(try OpenIapModule.iapkitEnvironment(from: NSNull())) - XCTAssertEqual("Sandbox", try OpenIapModule.iapkitEnvironment(from: "Sandbox")) - XCTAssertEqual("Production", try OpenIapModule.iapkitEnvironment(from: "Production")) + func testIapkitEnvironmentForwardsAnyStringAndNeverFails() throws { + XCTAssertNil(OpenIapModule.iapkitEnvironment(from: nil)) + XCTAssertNil(OpenIapModule.iapkitEnvironment(from: NSNull())) + XCTAssertEqual("Sandbox", OpenIapModule.iapkitEnvironment(from: "Sandbox")) + XCTAssertEqual("Production", OpenIapModule.iapkitEnvironment(from: "Production")) - for invalidValue: Any in ["sandbox", "Xcode", "", 1, true, [:], []] { - XCTAssertThrowsError( - try OpenIapModule.iapkitEnvironment(from: invalidValue) + // "Xcode" and "LocalTesting" are real App Store Server environments. + for forwarded in ["sandbox", "Xcode", "LocalTesting", "Staging"] { + XCTAssertEqual(forwarded, OpenIapModule.iapkitEnvironment(from: forwarded)) + } + + // Only a non-string, or an empty string, has nothing to forward. + for unreadableValue: Any in ["", 1, true, [:], []] { + XCTAssertNil(OpenIapModule.iapkitEnvironment(from: unreadableValue)) + } + } + + func testIapkitRequestReportsTheSpecItWasBuiltAgainst() throws { + let url = try XCTUnwrap(URL(string: "https://kit.openiap.dev/v1/purchase/verify")) + let request = OpenIapModule.makeIapkitRequest( + url: url, + apiKey: " iapkit_pk_test ", + body: Data("{}".utf8), + specVersion: "3.2.0" + ) + + XCTAssertEqual("POST", request.httpMethod) + XCTAssertEqual("application/json", request.value(forHTTPHeaderField: "Content-Type")) + XCTAssertEqual("Bearer iapkit_pk_test", request.value(forHTTPHeaderField: "Authorization")) + // Injected so the assertion has an expected value independent of the + // accessor the builder reads. + XCTAssertEqual("3.2.0", request.value(forHTTPHeaderField: "X-OpenIAP-Spec")) + XCTAssertEqual(Data("{}".utf8), request.httpBody) + } + + func testSpecVersionIsACompileTimeConstant() throws { + // Generated from openiap-versions.json, so it cannot fail to resolve in + // any distribution channel. + XCTAssertEqual(OpenIapVersion.specVersion, OpenIapGeneratedVersion.spec) + XCTAssertNotNil( + OpenIapVersion.specVersion.range( + of: #"^\d+\.\d+\.\d+"#, + options: .regularExpression + ), + "expected a semver, got \(OpenIapVersion.specVersion)" + ) + } + + func testIapkitRequestOmitsAuthorizationForABlankApiKey() throws { + let url = try XCTUnwrap(URL(string: "https://kit.openiap.dev/v1/purchase/verify")) + + for blank in [nil, "", " "] as [String?] { + let request = OpenIapModule.makeIapkitRequest( + url: url, + apiKey: blank, + body: Data() ) + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) } } diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index bf0a76956..7bfc1dfae 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -90,6 +90,7 @@ import APIsOpenRedeemOfferCodeAndroid from './apis/android/open-redeem-offer-cod import Events from './events'; import Webhooks from './webhooks'; import KitBackend from './kit-backend'; +import KitCompatibility from './kit-compatibility'; import EventsPurchaseUpdatedListener from './events/purchase-updated-listener'; import EventsPurchaseErrorListener from './events/purchase-error-listener'; import EventsSubscriptionBillingIssueListener from './events/subscription-billing-issue-listener'; @@ -845,6 +846,15 @@ function Docs() { Purchase Verification +
  • + (isActive ? 'active' : '')} + onClick={closeSidebar} + > + Version Compatibility + +
  • } /> } /> } /> + } /> } diff --git a/packages/docs/src/pages/docs/kit-compatibility.tsx b/packages/docs/src/pages/docs/kit-compatibility.tsx new file mode 100644 index 000000000..2bbaaee98 --- /dev/null +++ b/packages/docs/src/pages/docs/kit-compatibility.tsx @@ -0,0 +1,203 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../components/AnchorLink'; +import Callout from '../../components/Callout'; +import CodeBlock from '../../components/CodeBlock'; +import SEO from '../../components/SEO'; +import { useScrollToHash } from '../../hooks/useScrollToHash'; + +function KitCompatibility() { + useScrollToHash(); + + return ( +
    + +

    Version Compatibility

    +

    + You do not have to force your users onto a new app build when IAPKit + changes. This page states what IAPKit guarantees to an app that was + compiled against an older OpenIAP SDK, and what it deliberately does + not. +

    + +
    + + Why this needs a policy + +

    + Three things move on separate clocks, and only the first is under + OpenIAP's control: +

    +
      +
    • + IAPKit β€” deploys from main, so every + caller sees a change at the same moment. +
    • +
    • + The SDK version you compiled against β€” changes when + you upgrade and ship. +
    • +
    • + The app build on a user's device β€” changes when + that user updates. Some never do. +
    • +
    +

    + The third has no upper bound, so IAPKit cannot assume the oldest + caller is recent. Everything below follows from that. +

    +
    + +
    + + What IAPKit guarantees + +
      +
    • + Responses are additive. A field is never removed, + renamed, or given a new meaning. New fields are optional, and a new + response shape is gated behind an explicit request flag β€” the way{' '} + includeClientPayload is, so a build that never sends it + never receives it. +
    • +
    • + A truly breaking change gets a new path. It would + ship as /v2, and /v1 would keep serving. + No app is ever forced to move. +
    • +
    • + The verdict stays strict. isValid and + the echoed store are the security boundary and are + always validated exactly. +
    • +
    +
    + +
    + + What the SDKs do with a value they do not know + +

    + Optional metadata degrades; it never fails the purchase. Receipt + verification is the security boundary, and losing a label is not worth + rejecting a receipt the store already confirmed. +

    +
      +
    • + state β€” an unrecognised value becomes{' '} + unknown. isValid remains authoritative, so + gate entitlement on it rather than on the state label. +
    • +
    • + clientPayload β€” a payload this build cannot read, + including one using a format added later, is dropped and the result + still returns. +
    • +
    • + environment β€” forwarded as an opaque string. It is{' '} + String in the spec, not an enum, so do not reject a + value you do not recognise. App Store Server alone names{' '} + Sandbox, Production, Xcode{' '} + and LocalTesting. +
    • +
    + +

    + Do not re-derive these constraints in your app.{' '} + Comparing environment against the value you asked for + is fine. Rejecting the verification because the value is not one you + enumerated is the pattern that breaks when IAPKit adds one. +

    +
    +
    + +
    + + Reporting your spec version + +

    + Native verification requests carry the OpenIAP spec version the build + was compiled against: +

    + {`POST /v1/purchase/verify +Authorization: Bearer +X-OpenIAP-Spec: 3.2.0`} +

    + It is reported, never negotiated. IAPKit records it so a contract + change can be measured against the versions actually calling, and + never branches verification on it β€” a client cannot change how its + receipt is verified by claiming a version. SDK builds that support the + header always send their compile-time spec version; older builds omit + it. +

    +
    + +
    + + How this is enforced + +

    The rules above are checked by CI rather than left to review:

    +
      +
    • + The purchase-state, client-payload-format and verify-store enums are + compared against the GraphQL spec every SDK generates from. Drift + fails both CI and the IAPKit deploy. +
    • +
    • + Every /v1/purchase/verify response is validated against + the published schema before it is sent, so the documented shape and + the emitted one cannot diverge. +
    • +
    • + Each SDK has tests that feed its parser values from a hypothetical + future IAPKit β€” an unknown state, an unknown client-payload format, + an unrecognised environment β€” and assert the receipt survives. +
    • +
    • + The entitlement decision is pinned by an exhaustive table, so a new + purchase state has to be classified deliberately. +
    • +
    +
    + +
    + + What to do in your app + +
      +
    • + Gate entitlement on isValid, then match{' '} + productId. Treat state as a label for + logging and UI, not as the decision. +
    • +
    • + Handle every optional field being absent. Any of them can be missing + for a legitimate reason. +
    • +
    • + Never fail a verification because a value is unrecognised. Log it + and continue. +
    • +
    • + Keep the SDK reasonably current so new fields become available, but + know that an old build keeps verifying correctly in the meantime. +
    • +
    +

    + See Purchase Verification for the + endpoint surface and{' '} + + VerifyPurchaseWithProviderResult + {' '} + for the field-by-field contract. +

    +
    +
    + ); +} + +export default KitCompatibility; diff --git a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx index 0ac64c182..18b5101a3 100644 --- a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx +++ b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx @@ -165,9 +165,13 @@ function VerifyPurchaseWithProviderResult() { Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / - openiap-google 3.3.0. Amazon RVS environment. Handled Amazon - responses use exactly 'Sandbox' or{' '} - 'Production'; other stores omit it. + openiap-google 3.3.0. Opaque, provider-defined store + environment. Handled Amazon responses currently report{' '} + 'Sandbox' or 'Production', and other + stores omit it today, but the value space belongs to the + provider β€” App Store Server also names 'Xcode' and{' '} + 'LocalTesting'. Forward it and never fail a + verification because the value is unrecognised. diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index e803dccbf..ff2e5c68d 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -76,6 +76,11 @@ val openIapVersion: String = project.findProperty("openIapVersion")?.toString()?.takeIf { it.isNotBlank() } ?: versionsJson["google"]?.toString()?.takeIf { it.isNotBlank() } ?: throw GradleException("packages/google: 'google' version missing in openiap-versions.json") +// Spec version this artifact was compiled against, reported to IAPKit on +// verify. Never a gradle property: it describes the contract, not the artifact. +val openIapSpecVersion: String = + versionsJson["spec"]?.toString()?.takeIf { it.isNotBlank() } + ?: throw GradleException("packages/google: 'spec' version missing in openiap-versions.json") val isCentralPublishTaskRequested = gradle.startParameter.taskNames.any { taskName -> taskName.contains("mavenCentral", ignoreCase = true) @@ -90,6 +95,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") + buildConfigField("String", "OPENIAP_SPEC_VERSION", "\"$openIapSpecVersion\"") } buildTypes { 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 0d7cb5ef1..2dbdca7fe 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 @@ -3500,6 +3500,12 @@ public data class RequestVerifyPurchaseWithIapkitResult( * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or * `Production` on handled Amazon verification results. + * + * Deliberately String, not an enum: the value space belongs to IAPKit and the + * stores behind it, and Apple's App Store Server alone also names `Xcode` and + * `LocalTesting`. SDKs must forward this value opaquely. Never reject a + * verification because the environment is unrecognised β€” that fails a purchase + * the store already confirmed. */ var environment: String? = null private set diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt index da72698cf..8c3249033 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt @@ -14,6 +14,7 @@ import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitResult import dev.hyo.openiap.VerifyPurchaseProps import dev.hyo.openiap.VerifyPurchaseResultAndroid import dev.hyo.openiap.VerifyPurchaseResultHorizon +import io.github.hyochan.openiap.BuildConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.IOException @@ -186,6 +187,37 @@ suspend fun verifyPurchaseWithIapkit( fun malformedIapkitResponse(): OpenIapError.PurchaseVerificationFailed = OpenIapError.PurchaseVerificationFailed("IAPKit returned malformed response") + fun unreadableIapkitClientPayload(): IapkitProductClientPayload? { + OpenIapLog.warn("Ignoring an IAPKit client payload this build cannot read", tag) + return null + } + + fun readIapkitClientPayload(raw: Any?): IapkitProductClientPayload? { + if (raw == null) return null + val payload = raw as? Map<*, *> ?: return unreadableIapkitClientPayload() + // Derived from the generated enum so a new format is readable on regen. + val format = (payload["format"] as? String) + ?.let { rawFormat -> IapkitClientPayloadFormat.entries.firstOrNull { it.rawValue == rawFormat } } + ?: return unreadableIapkitClientPayload() + val body = payload["body"] as? String + ?: return unreadableIapkitClientPayload() + val version = (payload["version"] as? Number)?.toDouble() + ?: return unreadableIapkitClientPayload() + val updatedAt = (payload["updatedAt"] as? Number)?.toDouble() + ?: return unreadableIapkitClientPayload() + if (!version.isFinite() || version <= 0.0 || version % 1.0 != 0.0 || + !updatedAt.isFinite() || updatedAt < 0.0 + ) { + return unreadableIapkitClientPayload() + } + return IapkitProductClientPayload( + body = body, + format = format, + updatedAt = updatedAt, + version = version + ) + } + fun resolveIapkitEndpoint(): String { val requestedBaseUrl = props.baseUrl?.trim() if (requestedBaseUrl.isNullOrEmpty()) { @@ -319,6 +351,8 @@ suspend fun verifyPurchaseWithIapkit( requestMethod = "POST" doOutput = true setRequestProperty("Content-Type", "application/json") + // Reported, never negotiated. + setRequestProperty("X-OpenIAP-Spec", BuildConfig.OPENIAP_SPEC_VERSION) props.apiKey?.takeIf { it.isNotBlank() }?.let { apiKey -> setRequestProperty("Authorization", "Bearer $apiKey") } @@ -361,46 +395,17 @@ suspend fun verifyPurchaseWithIapkit( IapkitPurchaseState.fromJson(normalizedState) }.getOrDefault(IapkitPurchaseState.Unknown) - val clientPayload = when (val rawClientPayload = parsed["clientPayload"]) { - null -> null - is Map<*, *> -> { - val formatValue = rawClientPayload["format"] as? String - ?: throw malformedIapkitResponse() - if (formatValue !in setOf("toml", "json", "text")) { - throw malformedIapkitResponse() - } - val format = IapkitClientPayloadFormat.fromJson(formatValue) - val payloadBody = rawClientPayload["body"] as? String - ?: throw malformedIapkitResponse() - val version = (rawClientPayload["version"] as? Number)?.toDouble() - ?: throw malformedIapkitResponse() - val updatedAt = (rawClientPayload["updatedAt"] as? Number)?.toDouble() - ?: throw malformedIapkitResponse() - if (!version.isFinite() || version <= 0.0 || version % 1.0 != 0.0 || - !updatedAt.isFinite() || updatedAt < 0.0 - ) { - throw malformedIapkitResponse() - } - IapkitProductClientPayload( - body = payloadBody, - format = format, - updatedAt = updatedAt, - version = version - ) - } - else -> throw malformedIapkitResponse() - } + // Optional enrichment: dropped, never thrown. + val clientPayload = readIapkitClientPayload(parsed["clientPayload"]) val productId = when (val rawProductId = parsed["productId"]) { null -> null is String -> rawProductId else -> throw malformedIapkitResponse() } - val environment = when (val rawEnvironment = parsed["environment"]) { - null -> null - is String -> rawEnvironment.takeIf { - it == "Sandbox" || it == "Production" - } ?: throw malformedIapkitResponse() - else -> throw malformedIapkitResponse() + // Forwarded opaquely: `environment` is String in the spec. + val environment = (parsed["environment"] as? String)?.takeIf { it.isNotEmpty() } + if (environment == null && parsed["environment"] != null) { + OpenIapLog.warn("Ignoring an IAPKit environment this build cannot read", tag) } return RequestVerifyPurchaseWithIapkitResult( diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt index b308721df..08e811410 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt @@ -22,6 +22,7 @@ import java.net.HttpURLConnection import java.net.URL import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -420,50 +421,55 @@ class PurchaseVerificationValidatorTest { } @Test - fun `verifyPurchaseWithIapkit rejects malformed client payload`() = runTest { + fun `verifyPurchaseWithIapkit drops client payloads it cannot read`() = runTest { val props = RequestVerifyPurchaseWithIapkitProps( google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = "token-123" ), includeClientPayload = true ) + // `yaml` stands in for a later format; the rest are structural defects. + val unreadablePayloads = listOf( + """{"format":"toml","body":"missing timestamps"}""", + """{"format":"yaml","body":"tier: gold","version":1,"updatedAt":1}""", + """{"format":"TOML","body":"x=1","version":1,"updatedAt":1}""", + """{"format":"toml","body":"x=1","version":0,"updatedAt":1}""", + """{"format":"toml","body":"x=1","version":1.5,"updatedAt":1}""", + """{"format":"toml","body":"x=1","version":1,"updatedAt":-1}""" + ) - try { - verifyPurchaseWithIapkit(props, "TEST") { _ -> + for (payload in unreadablePayloads) { + val result = verifyPurchaseWithIapkit(props, "TEST") { _ -> FakeHttpURLConnection( 200, - """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":{"format":"toml","body":"missing timestamps"}}""" + """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":$payload}""" ) } - throw AssertionError("Expected PurchaseVerificationFailed for malformed client payload") - } catch (error: OpenIapError.PurchaseVerificationFailed) { - assertTrue(error.message.contains("malformed")) + + assertTrue(result.isValid) + assertEquals(IapkitPurchaseState.Entitled, result.state) + assertNull(result.clientPayload) } } @Test - fun `verifyPurchaseWithIapkit rejects invalid payload numbers and productId`() = runTest { + fun `verifyPurchaseWithIapkit rejects a non-string productId`() = runTest { val props = RequestVerifyPurchaseWithIapkitProps( google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = "token-123" - ), - includeClientPayload = true - ) - val invalidResponses = listOf( - """{"store":"google","isValid":true,"state":"ENTITLED","productId":42}""", - """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":{"format":"TOML","body":"x=1","version":1,"updatedAt":1}}""", - """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":{"format":"toml","body":"x=1","version":0,"updatedAt":1}}""", - """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":{"format":"toml","body":"x=1","version":1.5,"updatedAt":1}}""", - """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":{"format":"toml","body":"x=1","version":1,"updatedAt":-1}}""" + ) ) - for (response in invalidResponses) { - try { - verifyPurchaseWithIapkit(props, "TEST") { _ -> FakeHttpURLConnection(200, response) } - throw AssertionError("Expected malformed IAPKit response to fail: $response") - } catch (error: OpenIapError.PurchaseVerificationFailed) { - assertTrue(error.message.contains("malformed")) + try { + verifyPurchaseWithIapkit(props, "TEST") { _ -> + FakeHttpURLConnection( + 200, + """{"store":"google","isValid":true,"state":"ENTITLED","productId":42}""" + ) } + throw AssertionError("Expected a non-string productId to fail") + } catch (error: OpenIapError.PurchaseVerificationFailed) { + assertTrue(error.message.contains("malformed")) } } @@ -546,34 +552,58 @@ class PurchaseVerificationValidatorTest { } @Test - fun `verifyPurchaseWithIapkit rejects invalid environments`() = runTest { + fun `verifyPurchaseWithIapkit reports the spec it was built against`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = "iapkit_pk_test", + google = RequestVerifyPurchaseWithIapkitGoogleProps(purchaseToken = "token-123") + ) + lateinit var connection: FakeHttpURLConnection + + verifyPurchaseWithIapkit(props, "TEST") { _ -> + FakeHttpURLConnection( + 200, + """{"store":"google","isValid":true,"state":"ENTITLED"}""" + ).also { connection = it } + } + + assertEquals( + io.github.hyochan.openiap.BuildConfig.OPENIAP_SPEC_VERSION, + connection.headers["X-OpenIAP-Spec"] + ) + assertEquals("Bearer iapkit_pk_test", connection.headers["Authorization"]) + } + + @Test + fun `verifyPurchaseWithIapkit never fails a receipt over the environment`() = runTest { val props = RequestVerifyPurchaseWithIapkitProps( amazon = RequestVerifyPurchaseWithIapkitAmazonProps( userId = "amzn1.account.ABC123", receiptId = "amzn1.receipt.ABC123456789" ) ) - val invalidEnvironments = listOf( - "\"sandbox\"", - "\"Xcode\"", - "42", - "true", - "{}", - "[]" + // "Xcode" and "LocalTesting" are real App Store Server environments. + val cases = listOf( + "\"Sandbox\"" to "Sandbox", + "\"Xcode\"" to "Xcode", + "\"LocalTesting\"" to "LocalTesting", + "\"sandbox\"" to "sandbox", + "42" to null, + "true" to null, + "{}" to null, + "[]" to null, + "\"\"" to null ) - for (environment in invalidEnvironments) { - try { - verifyPurchaseWithIapkit(props, "TEST") { _ -> - FakeHttpURLConnection( - 200, - """{"store":"amazon","isValid":true,"state":"ENTITLED","environment":$environment}""" - ) - } - throw AssertionError("Expected malformed environment to fail: $environment") - } catch (error: OpenIapError.PurchaseVerificationFailed) { - assertTrue(error.message.contains("malformed")) + for ((environment, expected) in cases) { + val result = verifyPurchaseWithIapkit(props, "TEST") { _ -> + FakeHttpURLConnection( + 200, + """{"store":"amazon","isValid":true,"state":"ENTITLED","environment":$environment}""" + ) } + + assertTrue(result.isValid) + assertEquals(expected, result.environment) } } diff --git a/packages/gql/codegen/plugins/dart.ts b/packages/gql/codegen/plugins/dart.ts index 1d7e1bb7c..3f8227a4c 100644 --- a/packages/gql/codegen/plugins/dart.ts +++ b/packages/gql/codegen/plugins/dart.ts @@ -149,7 +149,7 @@ export class DartPlugin extends CodegenPlugin { protected generateDocComment(description: string | undefined, indent: string = ''): void { if (!description) return; for (const line of description.split(/\r?\n/)) { - this.emit(`${indent}/// ${line}`); + this.emit(line ? `${indent}/// ${line}` : `${indent}///`); } } diff --git a/packages/gql/codegen/plugins/gdscript.ts b/packages/gql/codegen/plugins/gdscript.ts index f47e4d8b9..4222640ab 100644 --- a/packages/gql/codegen/plugins/gdscript.ts +++ b/packages/gql/codegen/plugins/gdscript.ts @@ -273,7 +273,7 @@ export class GDScriptPlugin extends CodegenPlugin { protected generateDocComment(description: string | undefined, indent: string = ''): void { if (!description) return; - const singleLine = description.replace(/\r?\n/g, ' ').trim(); + const singleLine = description.replace(/\s+/g, ' ').trim(); this.emit(`${indent}## ${singleLine}`); } @@ -286,10 +286,7 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit(`enum ${irEnum.name} {`); irEnum.values.forEach((value, index) => { - if (value.description) { - const singleLine = value.description.replace(/\r?\n/g, ' ').trim(); - this.emit(`\t## ${singleLine}`); - } + this.generateDocComment(value.description, '\t'); this.emit(`\t${this.enumValueCase(value.name)} = ${index},`); }); diff --git a/packages/gql/codegen/plugins/swift.ts b/packages/gql/codegen/plugins/swift.ts index 8acd8da02..a0d807c1f 100644 --- a/packages/gql/codegen/plugins/swift.ts +++ b/packages/gql/codegen/plugins/swift.ts @@ -739,7 +739,7 @@ export class SwiftPlugin extends CodegenPlugin { protected generateDocComment(description: string | undefined, indent: string = ''): void { if (!description) return; for (const line of description.split(/\r?\n/)) { - this.emit(`${indent}/// ${line}`); + this.emit(line ? `${indent}/// ${line}` : `${indent}///`); } } diff --git a/packages/gql/src/codegen-defaults.test.ts b/packages/gql/src/codegen-defaults.test.ts index b05022769..5eeb1cc52 100644 --- a/packages/gql/src/codegen-defaults.test.ts +++ b/packages/gql/src/codegen-defaults.test.ts @@ -133,6 +133,21 @@ describe('codegen defaults', () => { expect(output).not.toContain('\n /// '); }); + it('emits blank Swift and Dart doc lines without trailing whitespace', () => { + const documentedField = field('value', stringType); + documentedField.description = 'First line.\n\nSecond line.'; + + for (const output of [ + new SwiftPlugin({ outputPath: 'Types.swift' }).generate(schema([documentedField])), + new DartPlugin({ outputPath: 'types.dart' }).generate(schema([documentedField])), + ]) { + expect(output).toContain('/// First line.'); + expect(output).toContain('///\n'); + expect(output).toContain('/// Second line.'); + expect(output).not.toMatch(/[ \t]+$/m); + } + }); + it('keeps unsupported non-null C# defaults required and escapes string literals', () => { const output = new CSharpPlugin({ outputPath: 'Types.cs' }).generate( schema([field('unsupportedDefault', stringType, { raw: 'unsupported' }), field('escapedString', stringType, 'quote " and slash \\')]), diff --git a/packages/gql/src/generated-gdscript.test.ts b/packages/gql/src/generated-gdscript.test.ts index 68835fca7..d5c0c88b7 100644 --- a/packages/gql/src/generated-gdscript.test.ts +++ b/packages/gql/src/generated-gdscript.test.ts @@ -85,7 +85,7 @@ describe('generated GDScript list decoding', () => { fields: [ { name: 'statuses', - description: 'Status values from the schema.\nPreserves every documentation line.\n@see https://openiap.dev/docs/types', + description: 'Status values from the schema.\n\nPreserves every documentation line.\n@see https://openiap.dev/docs/types', type: { kind: 'list', nullable: false, diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index 89dc753c5..0c6d8a4c1 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -3520,6 +3520,12 @@ public sealed record RequestVerifyPurchaseWithIapkitResult /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or /// `Production` on handled Amazon verification results. + /// + /// Deliberately String, not an enum: the value space belongs to IAPKit and the + /// stores behind it, and Apple's App Store Server alone also names `Xcode` and + /// `LocalTesting`. SDKs must forward this value opaquely. Never reject a + /// verification because the environment is unrecognised β€” that fails a purchase + /// the store already confirmed. /// [JsonPropertyName("environment")] public string? Environment { get; init; } diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index c31510286..5ed78d22d 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -3446,6 +3446,12 @@ public data class RequestVerifyPurchaseWithIapkitResult( * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or * `Production` on handled Amazon verification results. + * + * Deliberately String, not an enum: the value space belongs to IAPKit and the + * stores behind it, and Apple's App Store Server alone also names `Xcode` and + * `LocalTesting`. SDKs must forward this value opaquely. Never reject a + * verification because the environment is unrecognised β€” that fails a purchase + * the store already confirmed. */ var environment: String? = null private set diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index fa039e64c..e8e641c04 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -738,10 +738,10 @@ public struct DiscountDisplayInfoAndroid: Codable { /// Standardized one-time product discount offer. /// Provides a platform-neutral OpenIAP shape for Google Play one-time product /// purchase options and offers. -/// +/// /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. -/// +/// /// @see https://openiap.dev/docs/types/discount-offer public struct DiscountOffer: Codable { /// Currency code (ISO 4217, e.g., "USD") @@ -1239,6 +1239,12 @@ public struct RequestVerifyPurchaseWithIapkitResult: Codable { /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or /// `Production` on handled Amazon verification results. + /// + /// Deliberately String, not an enum: the value space belongs to IAPKit and the + /// stores behind it, and Apple's App Store Server alone also names `Xcode` and + /// `LocalTesting`. SDKs must forward this value opaquely. Never reject a + /// verification because the environment is unrecognised β€” that fails a purchase + /// the store already confirmed. public var environment: String? = nil /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. @@ -1261,11 +1267,11 @@ public struct SubscriptionCommitmentInfoIOS: Codable { /// Standardized subscription discount/promotional offer. /// Provides a unified interface for subscription offers across iOS and Android. -/// +/// /// Both platforms support subscription offers with different implementations: /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases -/// +/// /// @see https://openiap.dev/docs/types/subscription-offer public struct SubscriptionOffer: Codable { /// [Android] Base plan identifier. @@ -1893,7 +1899,7 @@ public struct RequestPurchaseProps: Codable { } /// Platform-specific purchase request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -2023,7 +2029,7 @@ public struct RequestSubscriptionIosProps: Codable { } /// Platform-specific subscription request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -2091,7 +2097,7 @@ public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable { } /// Platform-specific verification parameters for IAPKit. -/// +/// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) /// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) @@ -2165,7 +2171,7 @@ public struct VerifyPurchaseAppleOptions: Codable { /// Google Play Store verification parameters. /// Used for server-side receipt validation via Google Play Developer API. -/// +/// /// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. public struct VerifyPurchaseGoogleOptions: Codable { /// Google OAuth2 access token for API authentication. @@ -2199,7 +2205,7 @@ public struct VerifyPurchaseGoogleOptions: Codable { /// Meta Horizon (Quest) verification parameters. /// Used for server-side entitlement verification via Meta's S2S API. /// POST https://graph.oculus.com/$APP_ID/verify_entitlement -/// +/// /// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. public struct VerifyPurchaseHorizonOptions: Codable { /// Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). @@ -2222,7 +2228,7 @@ public struct VerifyPurchaseHorizonOptions: Codable { } /// Platform-specific purchase verification parameters. -/// +/// /// - apple: Verifies via App Store Server API /// - google: Verifies via Google Play Developer API /// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) @@ -2831,7 +2837,7 @@ public protocol SubscriptionResolver { func purchaseUpdated(_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase /// Fires when a subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: - /// + /// /// - iOS 16.4+ / Mac Catalyst 16.4+ / visionOS 1.0+: delivered via StoreKit 2 /// `Message.Reason.billingIssue`. /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected @@ -2840,7 +2846,7 @@ public protocol SubscriptionResolver { /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. /// - Android (Amazon flavor): NOT emitted. Amazon Appstore IAP does not expose an /// equivalent subscription billing-issue signal. - /// + /// /// Listeners should not assume the event will fire on every store. Direct users to the /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. func subscriptionBillingIssue() async throws -> Purchase diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 94fcbe735..13e69dde8 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -1783,10 +1783,10 @@ class DiscountDisplayInfoAndroid { /// Standardized one-time product discount offer. /// Provides a platform-neutral OpenIAP shape for Google Play one-time product /// purchase options and offers. -/// +/// /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. -/// +/// /// @see https://openiap.dev/docs/types/discount-offer class DiscountOffer { const DiscountOffer({ @@ -3353,6 +3353,12 @@ class RequestVerifyPurchaseWithIapkitResult { /// Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. /// Amazon RVS environment selected by IAPKit. Present as `Sandbox` or /// `Production` on handled Amazon verification results. + /// + /// Deliberately String, not an enum: the value space belongs to IAPKit and the + /// stores behind it, and Apple's App Store Server alone also names `Xcode` and + /// `LocalTesting`. SDKs must forward this value opaquely. Never reject a + /// verification because the environment is unrecognised β€” that fails a purchase + /// the store already confirmed. final String? environment; /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. @@ -3421,11 +3427,11 @@ class SubscriptionCommitmentInfoIOS { /// Standardized subscription discount/promotional offer. /// Provides a unified interface for subscription offers across iOS and Android. -/// +/// /// Both platforms support subscription offers with different implementations: /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases -/// +/// /// @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer { const SubscriptionOffer({ @@ -4588,7 +4594,7 @@ class _SubsPurchase extends RequestPurchaseProps { } /// Platform-specific purchase request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -4766,7 +4772,7 @@ class RequestSubscriptionIosProps { } /// Platform-specific subscription request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -4878,7 +4884,7 @@ class RequestVerifyPurchaseWithIapkitGoogleProps { } /// Platform-specific verification parameters for IAPKit. -/// +/// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) /// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) @@ -4988,7 +4994,7 @@ class VerifyPurchaseAppleOptions { /// Google Play Store verification parameters. /// Used for server-side receipt validation via Google Play Developer API. -/// +/// /// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. class VerifyPurchaseGoogleOptions { const VerifyPurchaseGoogleOptions({ @@ -5036,7 +5042,7 @@ class VerifyPurchaseGoogleOptions { /// Meta Horizon (Quest) verification parameters. /// Used for server-side entitlement verification via Meta's S2S API. /// POST https://graph.oculus.com/$APP_ID/verify_entitlement -/// +/// /// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. class VerifyPurchaseHorizonOptions { const VerifyPurchaseHorizonOptions({ @@ -5071,7 +5077,7 @@ class VerifyPurchaseHorizonOptions { } /// Platform-specific purchase verification parameters. -/// +/// /// - apple: Verifies via App Store Server API /// - google: Verifies via Google Play Developer API /// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) @@ -5622,7 +5628,7 @@ abstract class SubscriptionResolver { }); /// Fires when a subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: - /// + /// /// - iOS 16.4+ / Mac Catalyst 16.4+ / visionOS 1.0+: delivered via StoreKit 2 /// `Message.Reason.billingIssue`. /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected @@ -5631,7 +5637,7 @@ abstract class SubscriptionResolver { /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. /// - Android (Amazon flavor): NOT emitted. Amazon Appstore IAP does not expose an /// equivalent subscription billing-issue signal. - /// + /// /// Listeners should not assume the event will fire on every store. Direct users to the /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. Future subscriptionBillingIssue(); diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 0b60a5973..097d3c06d 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -993,7 +993,7 @@ class DiscountDisplayInfoAndroid: dict["discountAmount"] = discount_amount return dict -## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer +## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer class DiscountOffer: ## Unique identifier for the offer. - iOS: Not applicable (one-time discounts not supported) - Android: offerId from the Google Play one-time purchase option var id: Variant = null @@ -2755,7 +2755,7 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: IapStore - ## Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. Present as `Sandbox` or `Production` on handled Amazon verification results. + ## Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. Present as `Sandbox` or `Production` on handled Amazon verification results. Deliberately String, not an enum: the value space belongs to IAPKit and the stores behind it, and Apple's App Store Server alone also names `Xcode` and `LocalTesting`. SDKs must forward this value opaquely. Never reject a verification because the environment is unrecognised β€” that fails a purchase the store already confirmed. var environment: Variant = null ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. var is_valid: bool = false @@ -2842,7 +2842,7 @@ class SubscriptionCommitmentInfoIOS: dict["price"] = price return dict -## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer +## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer: ## Unique identifier for the offer. - iOS: Discount identifier from App Store Connect - Android: offerId from the Google Play subscription offer var id: String = "" @@ -3718,7 +3718,7 @@ class InAppMessageParamsAndroid: ## Connection initialization configuration class InitConnectionConfig: - ## Enable a specific billing program for Android (7.0+) When set, enables the specified billing program for external transactions. - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) - EXTERNAL_CONTENT_LINK: Link to external content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_OFFER: External offers for digital content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) - BILLING_CHOICE: Google-rendered or developer-rendered billing choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) + ## Enable a specific billing program for Android (7.0+) When set, enables the specified billing program for external transactions. - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) - EXTERNAL_CONTENT_LINK: Link to external content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_OFFER: External offers for digital content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) - BILLING_CHOICE: Google-rendered or developer-rendered billing choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) var enable_billing_program_android: Variant = null ## Billing Choice renderer configured in Play Console. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP can emit the selection event. DEVELOPER_RENDERED omits that listener so the app can render its own choice screen and use the reporting/dialog/link APIs. Must match choiceScreenType returned by isBillingProgramAvailableAndroid. Defaults to GOOGLE_RENDERED. var billing_choice_screen_type_android: BillingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED @@ -4160,7 +4160,7 @@ class RequestPurchaseProps: dict["type"] = PRODUCT_QUERY_TYPE_VALUES.get(type, type) return dict -## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestPurchasePropsByPlatforms: ## Apple-specific purchase parameters var apple: RequestPurchaseIosProps @@ -4380,7 +4380,7 @@ class RequestSubscriptionIosProps: dict["advancedCommerceData"] = advanced_commerce_data return dict -## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestSubscriptionPropsByPlatforms: ## Apple-specific subscription parameters var apple: RequestSubscriptionIosProps @@ -4481,7 +4481,7 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: dict["purchaseToken"] = purchase_token return dict -## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) +## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null @@ -4593,7 +4593,7 @@ class VerifyPurchaseAppleOptions: dict["sku"] = sku return dict -## Google Play Store verification parameters. Used for server-side receipt validation via Google Play Developer API. ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. +## Google Play Store verification parameters. Used for server-side receipt validation via Google Play Developer API. ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. class VerifyPurchaseGoogleOptions: ## Product SKU to validate var sku: String = "" @@ -4634,7 +4634,7 @@ class VerifyPurchaseGoogleOptions: dict["isSub"] = is_sub return dict -## Meta Horizon (Quest) verification parameters. Used for server-side entitlement verification via Meta's S2S API. POST https://graph.oculus.com/$APP_ID/verify_entitlement ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. +## Meta Horizon (Quest) verification parameters. Used for server-side entitlement verification via Meta's S2S API. POST https://graph.oculus.com/$APP_ID/verify_entitlement ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. class VerifyPurchaseHorizonOptions: ## The SKU for the add-on item, defined in Meta Developer Dashboard var sku: String = "" @@ -4663,7 +4663,7 @@ class VerifyPurchaseHorizonOptions: dict["accessToken"] = access_token return dict -## Platform-specific purchase verification parameters. - apple: Verifies via App Store Server API - google: Verifies via Google Play Developer API - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) +## Platform-specific purchase verification parameters. - apple: Verifies via App Store Server API - google: Verifies via Google Play Developer API - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) class VerifyPurchaseProps: ## Apple App Store verification parameters. var apple: VerifyPurchaseAppleOptions diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 29d5ca10f..132105b27 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1857,6 +1857,12 @@ export interface RequestVerifyPurchaseWithIapkitResult { * Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. * Amazon RVS environment selected by IAPKit. Present as `Sandbox` or * `Production` on handled Amazon verification results. + * + * Deliberately String, not an enum: the value space belongs to IAPKit and the + * stores behind it, and Apple's App Store Server alone also names `Xcode` and + * `LocalTesting`. SDKs must forward this value opaquely. Never reject a + * verification because the environment is unrecognised β€” that fails a purchase + * the store already confirmed. */ environment?: (string | null); /** diff --git a/packages/gql/src/kit-api.ts b/packages/gql/src/kit-api.ts index 5d60a87ad..f5a78d7af 100644 --- a/packages/gql/src/kit-api.ts +++ b/packages/gql/src/kit-api.ts @@ -58,7 +58,8 @@ export type StatusResponse = { export type KitProductPlatform = "IOS" | "Android"; export type KitProductClientPayload = { - format: "toml" | "json" | "text"; + /** Current values are toml, json, and text; preserve unknown values. */ + format: string; body: string; version: number; updatedAt: number; @@ -283,9 +284,11 @@ export function kitApi(options: KitApiOptions) { if (!raw) return null; const candidate = JSON.parse(raw) as Partial; const payload = candidate.clientPayload; + // Only the invariants the cache depends on. `format` is opaque: evicting + // on an unknown one would kill ETag revalidation and offline reads. if ( !payload || - !["toml", "json", "text"].includes(payload.format) || + typeof payload.format !== "string" || typeof payload.body !== "string" || !Number.isSafeInteger(payload.version) || payload.version < 1 || diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index 43ea47dfc..c223754a4 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -434,6 +434,12 @@ type RequestVerifyPurchaseWithIapkitResult { Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. Present as `Sandbox` or `Production` on handled Amazon verification results. + + Deliberately String, not an enum: the value space belongs to IAPKit and the + stores behind it, and Apple's App Store Server alone also names `Xcode` and + `LocalTesting`. SDKs must forward this value opaquely. Never reject a + verification because the environment is unrecognised β€” that fails a purchase + the store already confirmed. """ environment: String """ diff --git a/packages/kit/CONVENTION.md b/packages/kit/CONVENTION.md index 35273ebd3..80814867d 100644 --- a/packages/kit/CONVENTION.md +++ b/packages/kit/CONVENTION.md @@ -151,6 +151,32 @@ client-payload endpoints. A developer backend may send APNs/FCM notifications for resources it protects, but IAPKit must not publish project-wide lifecycle events to shipped apps. +## `/v1` response contract + +IAPKit deploys from `main` on its own workflow. The SDKs that decode its +responses are frozen inside apps already on the stores, so a kit-only change +reaches every user at once with no SDK release and no way to roll forward. +Treat the `/v1` response shape as a published API: + +- **Additive only.** Never remove a field, rename one, or change what an + existing value means. New fields must be optional, and anything that changes + a response shape should be gated on an explicit request flag, the way + `includeClientPayload` is. +- **Enum values are spec changes.** `IapkitPurchaseState`, + `IapkitClientPayloadFormat`, and `IapStore` live in + `packages/gql/src/type.graphql`. Change the schema first, regenerate, and + confirm each SDK degrades an unknown value instead of failing the receipt β€” + `bun audit:kit-contract` compares the three declarations and fails on drift. +- **`isValid` is the entitlement gate.** `isValidState` in + `convex/purchases/shared.ts` decides what published apps unlock. Widening or + narrowing it, or changing `mapAppStorePurchaseState` / + `mapGooglePlayPurchaseState`, changes live behavior for existing users. + Golden tests in `shared.test.ts` pin both; update them deliberately. +- **The emitted body is validated.** `enforceVerifyResponseContract` holds + responses to `verifyPurchaseSuccessResponseSchema` before they are sent, so + the OpenAPI document cannot drift from what clients receive. Extend the + schema when you add a field; do not bypass the check. + ## Icons Always use icon components, never inline ``: diff --git a/packages/kit/convex/purchases/ios.ts b/packages/kit/convex/purchases/ios.ts index 39ff8d190..4b8150b32 100644 --- a/packages/kit/convex/purchases/ios.ts +++ b/packages/kit/convex/purchases/ios.ts @@ -23,6 +23,7 @@ import { AppStoreProductType, receiptResponseValidator, isValidState, + narrowAppleEnvironment, } from "./shared"; import { HarmonizedPurchaseState } from "./purchaseState"; import { @@ -262,7 +263,7 @@ export async function verifyJWSTransaction( currency: verifiedTransaction.currency, storefront: verifiedTransaction.storefront, storefrontId: verifiedTransaction.storefrontId, - environment: verifiedTransaction.environment as "Sandbox" | "Production", + environment: narrowAppleEnvironment(verifiedTransaction.environment), webOrderLineItemId: verifiedTransaction.webOrderLineItemId, subscriptionGroupIdentifier: verifiedTransaction.subscriptionGroupIdentifier, @@ -549,7 +550,7 @@ function buildFailedAppStoreReceiptData( currency: payload.currency, storefront: payload.storefront, storefrontId: payload.storefrontId, - environment: payload.environment as "Sandbox" | "Production", + environment: narrowAppleEnvironment(payload.environment), webOrderLineItemId: payload.webOrderLineItemId, subscriptionGroupIdentifier: payload.subscriptionGroupIdentifier, expiresDate: payload.expiresDate, diff --git a/packages/kit/convex/purchases/shared.test.ts b/packages/kit/convex/purchases/shared.test.ts index 88cd98e82..bd759bc84 100644 --- a/packages/kit/convex/purchases/shared.test.ts +++ b/packages/kit/convex/purchases/shared.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; import { + AppStoreProductType, + narrowAppleEnvironment, + AppStoreTransactionReason, mapAppStorePurchaseState, mapGooglePlayPurchaseState, mapToPurchaseType, @@ -213,6 +216,100 @@ describe("mapAppStorePurchaseState", () => { expect(state).toBe(HarmonizedPurchaseState.EXPIRED); }); + + // Golden table: a mapping change ships to published apps without an SDK + // release, so it must show up here as an explicit diff. + const APP_STORE_GOLDEN: Array<{ + label: string; + reason?: AppStoreTransactionReason; + expiresDate?: number; + type?: AppStoreProductType; + revocationDate?: number; + expected: HarmonizedPurchaseState; + }> = [ + { + label: "revoked transaction outranks everything else", + reason: AppStoreTransactionReason.PURCHASE, + revocationDate: 1_700_000_000_000, + type: AppStoreProductType.NON_CONSUMABLE, + expected: HarmonizedPurchaseState.CANCELED, + }, + { + label: "lapsed subscription", + reason: AppStoreTransactionReason.RENEWAL, + expiresDate: 1_700_000_000_000, + type: AppStoreProductType.AUTO_RENEWABLE_SUBSCRIPTION, + expected: HarmonizedPurchaseState.EXPIRED, + }, + { + label: "first purchase of a consumable", + reason: AppStoreTransactionReason.PURCHASE, + type: AppStoreProductType.CONSUMABLE, + expected: HarmonizedPurchaseState.READY_TO_CONSUME, + }, + { + label: "first purchase of a non-consumable", + reason: AppStoreTransactionReason.PURCHASE, + type: AppStoreProductType.NON_CONSUMABLE, + expected: HarmonizedPurchaseState.ENTITLED, + }, + { + label: "subscription renewal", + reason: AppStoreTransactionReason.RENEWAL, + type: AppStoreProductType.AUTO_RENEWABLE_SUBSCRIPTION, + expected: HarmonizedPurchaseState.ENTITLED, + }, + { + label: "renewal of a consumable is still a renewal", + reason: AppStoreTransactionReason.RENEWAL, + type: AppStoreProductType.CONSUMABLE, + expected: HarmonizedPurchaseState.ENTITLED, + }, + { + label: "consumable without a transaction reason", + type: AppStoreProductType.CONSUMABLE, + expected: HarmonizedPurchaseState.READY_TO_CONSUME, + }, + { + label: "non-renewing subscription without a transaction reason", + type: AppStoreProductType.NON_RENEWING_SUBSCRIPTION, + expected: HarmonizedPurchaseState.ENTITLED, + }, + { + label: "unexpired transaction with nothing else known", + expiresDate: Date.now() + 86_400_000, + expected: HarmonizedPurchaseState.ENTITLED, + }, + ]; + + it.each(APP_STORE_GOLDEN)("maps $label", (row) => { + expect( + mapAppStorePurchaseState( + row.reason, + row.expiresDate, + row.type, + row.revocationDate, + ), + ).toBe(row.expected); + }); +}); + +describe("narrowAppleEnvironment", () => { + it("keeps Production and narrows every non-production value to Sandbox", () => { + expect(narrowAppleEnvironment("Production")).toBe("Production"); + // Apple's Environment enum also defines Xcode and LocalTesting; a receipt + // row stores the pair, so neither may be reported as a real purchase. + for (const value of [ + "Sandbox", + "Xcode", + "LocalTesting", + "", + undefined, + null, + ]) { + expect(narrowAppleEnvironment(value)).toBe("Sandbox"); + } + }); }); describe("isValidState", () => { @@ -253,4 +350,30 @@ describe("isValidState", () => { it("returns false for INAUTHENTIC state", () => { expect(isValidState(HarmonizedPurchaseState.INAUTHENTIC)).toBe(false); }); + + // `isValid` gates entitlement in every SDK, and kit ships without an SDK + // release. Keyed by the full enum so TypeScript rejects a new state until + // someone classifies it, in either direction, and order does not matter. + const ENTITLEMENT_GOLDEN: Record = { + [HarmonizedPurchaseState.ENTITLED]: true, + [HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT]: true, + [HarmonizedPurchaseState.READY_TO_CONSUME]: true, + [HarmonizedPurchaseState.PENDING]: false, + [HarmonizedPurchaseState.CANCELED]: false, + [HarmonizedPurchaseState.EXPIRED]: false, + [HarmonizedPurchaseState.CONSUMED]: false, + [HarmonizedPurchaseState.UNKNOWN]: false, + [HarmonizedPurchaseState.INAUTHENTIC]: false, + }; + + it("classifies every declared state exactly as pinned", () => { + const actual = Object.fromEntries( + Object.values(HarmonizedPurchaseState).map((state) => [ + state, + isValidState(state), + ]), + ); + + expect(actual).toEqual(ENTITLEMENT_GOLDEN); + }); }); diff --git a/packages/kit/convex/purchases/shared.ts b/packages/kit/convex/purchases/shared.ts index 1a0a1e52c..aeff1d520 100644 --- a/packages/kit/convex/purchases/shared.ts +++ b/packages/kit/convex/purchases/shared.ts @@ -90,6 +90,15 @@ export type AppStoreReceiptData = Infer; export type ReceiptResponse = Infer; +// Apple reports four environments; a receipt row stores the pair. Anything that +// is not Production is a non-production purchase, so it narrows to Sandbox +// rather than being cast β€” the cast would fail this validator at the boundary. +export function narrowAppleEnvironment( + value: string | null | undefined, +): "Sandbox" | "Production" { + return value === "Production" ? "Production" : "Sandbox"; +} + export const purchaseTypeValidator = v.union( v.literal("NON_CONSUMABLE"), v.literal("SUBSCRIPTION"), diff --git a/packages/kit/server/api/v1/request-logger.test.ts b/packages/kit/server/api/v1/request-logger.test.ts index 6d9e22159..77b3d913a 100644 --- a/packages/kit/server/api/v1/request-logger.test.ts +++ b/packages/kit/server/api/v1/request-logger.test.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { apiKeyMiddleware } from "./middleware"; import { + readSpecVersion, requestLoggerMiddleware, type VerifyDebugLogLine, type VerifyLogLine, @@ -101,6 +102,56 @@ describe("requestLoggerMiddleware", () => { expect(line.durationMs).toBeGreaterThanOrEqual(0); }); + test("carries the client spec version from the wire onto the log line", async () => { + // The header name has to agree across kit, openiap-apple and openiap-google. + const logs: VerifyLogLine[] = []; + const app = buildApp({ logs }); + + await app.request("/verify", { + method: "POST", + headers: { + Authorization: "Bearer spec-header-key", + "content-type": "application/json", + "X-OpenIAP-Spec": "3.2.0", + }, + body: JSON.stringify({ store: "apple", jws: TEST_APPLE_JWS }), + }); + + expect(logs[0]?.specVersion).toBe("3.2.0"); + }); + + test("omits a spec version the shape check rejects", async () => { + const logs: VerifyLogLine[] = []; + const app = buildApp({ logs }); + + await app.request("/verify", { + method: "POST", + headers: { + Authorization: "Bearer spec-header-junk", + "content-type": "application/json", + // Injection shapes are covered by the readSpecVersion unit test below; + // the runtime rejects a newline header before kit sees it. + "X-OpenIAP-Spec": "latest", + }, + body: JSON.stringify({ store: "apple", jws: TEST_APPLE_JWS }), + }); + + expect(logs[0]).toBeDefined(); + expect(logs[0]?.specVersion).toBeUndefined(); + }); + + test("records a plausible client spec version and ignores anything else", async () => { + // Caller-controlled; nothing branches on it. + expect(readSpecVersion("3.2.0")).toBe("3.2.0"); + expect(readSpecVersion("3.2.0-rc.1")).toBe("3.2.0-rc.1"); + expect(readSpecVersion(undefined)).toBeUndefined(); + expect(readSpecVersion("")).toBeUndefined(); + expect(readSpecVersion("latest")).toBeUndefined(); + expect(readSpecVersion("3.2")).toBeUndefined(); + expect(readSpecVersion(`3.2.0-${"a".repeat(64)}`)).toBeUndefined(); + expect(readSpecVersion('3.2.0"}\n{"level":"info"')).toBeUndefined(); + }); + test("still logs when the validator rejects the payload (400)", async () => { const logs: VerifyLogLine[] = []; const app = buildApp({ logs }); diff --git a/packages/kit/server/api/v1/request-logger.ts b/packages/kit/server/api/v1/request-logger.ts index 6b21a39ca..fd0a0a112 100644 --- a/packages/kit/server/api/v1/request-logger.ts +++ b/packages/kit/server/api/v1/request-logger.ts @@ -27,6 +27,21 @@ export interface VerifyLogLine { store?: VerifyStore; isValid?: boolean; state?: string; + /** `X-OpenIAP-Spec`, when the client sent a plausible version. */ + specVersion?: string; +} + +// Caller-controlled, so it is shape-checked and bounded before reaching a log +// line. Nothing branches on it: a client must not be able to change how its +// receipt is verified by claiming a version. +const SPEC_VERSION_PATTERN = + /^\d{1,4}\.\d{1,4}\.\d{1,4}(-[0-9A-Za-z.-]{1,32})?$/; + +export function readSpecVersion( + header: string | undefined, +): string | undefined { + if (!header) return undefined; + return SPEC_VERSION_PATTERN.test(header) ? header : undefined; } export interface RedactedDebugValue { @@ -54,6 +69,7 @@ export interface VerifyDebugLogLine { store?: VerifyStore; isValid?: boolean; state?: string; + specVersion?: string; sandbox?: boolean; identifiers?: VerifyDebugIdentifiers; } @@ -246,6 +262,7 @@ export function requestLoggerMiddleware( const apiKeyHash = c.var.apiKeyHash ?? (apiKey ? hashApiKey(apiKey) : undefined); const statusCode = nextError && c.res.status < 400 ? 500 : c.res.status; + const specVersion = readSpecVersion(c.req.header("X-OpenIAP-Spec")); // Swallow logger-side throws β€” a broken sink should never take // down a request whose real work already succeeded (or already @@ -263,6 +280,7 @@ export function requestLoggerMiddleware( store, isValid: outcome?.isValid, state: outcome?.state, + specVersion, }); } catch (loggerError) { console.error( @@ -284,6 +302,7 @@ export function requestLoggerMiddleware( store, isValid: outcome?.isValid, state: outcome?.state, + specVersion, sandbox: body?.sandbox, identifiers: collectDebugIdentifiers(body), }); diff --git a/packages/kit/server/api/v1/response-contract.test.ts b/packages/kit/server/api/v1/response-contract.test.ts new file mode 100644 index 000000000..2442f5b6a --- /dev/null +++ b/packages/kit/server/api/v1/response-contract.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "vitest"; +import * as v from "valibot"; + +import { enforceVerifyResponseContract } from "./response-contract"; +import { verifyPurchaseSuccessResponseSchema } from "./route-response-schemas"; + +const ENTITLED = { + store: "apple", + isValid: true, + state: "ENTITLED", + productId: "premium.monthly", +} as const; + +function assertMatchesPublishedSchema(response: unknown) { + expect( + v.safeParse(verifyPurchaseSuccessResponseSchema, response).success, + ).toBe(true); +} + +describe("enforceVerifyResponseContract", () => { + test("passes a contract-valid response through untouched", () => { + const result = enforceVerifyResponseContract({ ...ENTITLED }); + + expect(result).toEqual({ + ok: true, + response: { ...ENTITLED }, + violations: [], + }); + }); + + test("keeps every documented optional field", () => { + const full = { + ...ENTITLED, + environment: "Sandbox", + clientPayload: { + format: "toml", + body: 'tier = "gold"', + version: 3, + updatedAt: 1_700_000_000_000, + }, + }; + + const result = enforceVerifyResponseContract(full); + + expect(result.ok).toBe(true); + expect(result.ok && result.response).toEqual(full); + expect(result.violations).toEqual([]); + }); + + test("degrades an unpublished state to UNKNOWN without touching the verdict", () => { + const result = enforceVerifyResponseContract({ + ...ENTITLED, + state: "REFUNDED", + }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual(["state"]); + expect(result.ok && result.response.state).toBe("UNKNOWN"); + // The entitlement survives the metadata drift. + expect(result.ok && result.response.isValid).toBe(true); + assertMatchesPublishedSchema(result.ok && result.response); + }); + + test("preserves environment strings opaquely", () => { + for (const environment of ["Xcode", "LocalTesting", "AppTester"]) { + const result = enforceVerifyResponseContract({ + ...ENTITLED, + environment, + }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual([]); + expect(result.ok && result.response.environment).toBe(environment); + assertMatchesPublishedSchema(result.ok && result.response); + } + }); + + test("drops a client payload format the SDKs cannot decode", () => { + const result = enforceVerifyResponseContract({ + ...ENTITLED, + clientPayload: { + format: "yaml", + body: "tier: gold", + version: 1, + updatedAt: 1_700_000_000_000, + }, + }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual(["clientPayload"]); + expect(result.ok && result.response).not.toHaveProperty("clientPayload"); + assertMatchesPublishedSchema(result.ok && result.response); + }); + + test("drops a non-string productId", () => { + const result = enforceVerifyResponseContract({ + ...ENTITLED, + productId: 42, + }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual(["productId"]); + expect(result.ok && result.response).not.toHaveProperty("productId"); + }); + + test("reports every drifted field at once", () => { + const result = enforceVerifyResponseContract({ + ...ENTITLED, + state: "REFUNDED", + environment: 42, + clientPayload: { format: "yaml", body: "", version: 1, updatedAt: 0 }, + }); + + expect(result.violations).toEqual([ + "state", + "environment", + "clientPayload", + ]); + assertMatchesPublishedSchema(result.ok && result.response); + }); + + test("refuses to publish a malformed verdict", () => { + // Server-defect path: neither field can drift from metadata changes. + for (const broken of [ + { ...ENTITLED, isValid: "true" }, + { ...ENTITLED, store: "steam" }, + ]) { + const result = enforceVerifyResponseContract(broken); + expect(result.ok).toBe(false); + expect(result.violations.length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/kit/server/api/v1/response-contract.ts b/packages/kit/server/api/v1/response-contract.ts new file mode 100644 index 000000000..d8c56b9f0 --- /dev/null +++ b/packages/kit/server/api/v1/response-contract.ts @@ -0,0 +1,75 @@ +import * as v from "valibot"; + +import { + clientPayloadSchema, + environmentSchema, + FALLBACK_PURCHASE_STATE, + productIdSchema, + unifiedPurchaseStateSchema, + verifyPurchaseSuccessResponseSchema, +} from "./route-response-schemas"; + +export type VerifyPurchaseResponse = v.InferOutput< + typeof verifyPurchaseSuccessResponseSchema +>; + +export type VerifyResponseContractResult = + | { ok: true; response: VerifyPurchaseResponse; violations: string[] } + | { ok: false; violations: string[] }; + +const fits = (schema: v.GenericSchema, value: unknown): boolean => + v.safeParse(schema, value).success; + +/** + * Holds `/v1/purchase/verify` responses to the schema the OpenAPI document + * publishes and every SDK decodes. + * + * `verifyPurchaseSuccessResponseSchema` previously only fed `describeRoute`, + * so the documented shape and the emitted body could drift apart silently β€” + * and shipped apps decode this body with fixed parsers they cannot update. + * + * Metadata that falls outside the contract is degraded, not passed through. + * `isValid` is never rewritten β€” the verdict is authoritative, and drifting + * metadata must not revoke a real entitlement. + */ +export const enforceVerifyResponseContract = ( + candidate: Record, +): VerifyResponseContractResult => { + const parsed = v.safeParse(verifyPurchaseSuccessResponseSchema, candidate); + if (parsed.success) { + return { ok: true, response: parsed.output, violations: [] }; + } + + const violations: string[] = []; + const degraded: Record = { ...candidate }; + + if (!fits(unifiedPurchaseStateSchema, degraded.state)) { + violations.push("state"); + degraded.state = FALLBACK_PURCHASE_STATE; + } + for (const [field, schema] of [ + ["productId", productIdSchema], + ["environment", environmentSchema], + ["clientPayload", clientPayloadSchema], + ] as const) { + if (degraded[field] !== undefined && !fits(schema, degraded[field])) { + violations.push(field); + delete degraded[field]; + } + } + + const reparsed = v.safeParse(verifyPurchaseSuccessResponseSchema, degraded); + if (!reparsed.success) { + // Only `store` and `isValid` remain, both handler-supplied β€” a malformed + // verdict is a server defect, not contract drift. + return { + ok: false, + violations: [ + ...violations, + ...reparsed.issues.map((issue) => v.getDotPath(issue) ?? ""), + ], + }; + } + + return { ok: true, response: reparsed.output, violations }; +}; diff --git a/packages/kit/server/api/v1/route-response-schemas.test.ts b/packages/kit/server/api/v1/route-response-schemas.test.ts index 7da93df3e..30e3a01fc 100644 --- a/packages/kit/server/api/v1/route-response-schemas.test.ts +++ b/packages/kit/server/api/v1/route-response-schemas.test.ts @@ -37,8 +37,14 @@ describe("verifyPurchaseSuccessResponseSchema", () => { expect(result.success).toBe(false); }); - test("accepts Amazon environments and rejects unknown values", () => { - for (const environment of ["Sandbox", "Production"]) { + test("accepts store environment strings opaquely", () => { + for (const environment of [ + "Sandbox", + "Production", + "Xcode", + "LocalTesting", + "AppTester", + ]) { expect( parse({ store: "amazon", @@ -53,7 +59,7 @@ describe("verifyPurchaseSuccessResponseSchema", () => { store: "amazon", isValid: true, state: "ENTITLED", - environment: "AppTester", + environment: 42, }).success, ).toBe(false); }); diff --git a/packages/kit/server/api/v1/route-response-schemas.ts b/packages/kit/server/api/v1/route-response-schemas.ts index b1f1de9c7..63cecd4f6 100644 --- a/packages/kit/server/api/v1/route-response-schemas.ts +++ b/packages/kit/server/api/v1/route-response-schemas.ts @@ -51,12 +51,16 @@ const unifiedPurchaseStates = [ }, ] as const; -const unifiedPurchaseStateSchema = v.union( +export const unifiedPurchaseStateSchema = v.union( unifiedPurchaseStates.map(({ name, description }) => v.pipe(v.literal(name), v.description(description)), ), ); +// Degraded state for a value outside the published enum. `isValid` is never +// rewritten β€” metadata drift must not revoke a real entitlement. +export const FALLBACK_PURCHASE_STATE = "UNKNOWN"; + const verifyStoreSchema = v.union([ v.literal("apple"), v.literal("google"), @@ -64,33 +68,33 @@ const verifyStoreSchema = v.union([ v.literal("amazon"), ]); -const clientPayloadSchema = v.object({ +export const clientPayloadSchema = v.object({ format: v.union([v.literal("toml"), v.literal("json"), v.literal("text")]), body: v.string(), version: v.number(), updatedAt: v.number(), }); +export const productIdSchema = v.pipe( + v.string(), + v.description( + "Product id verified by the upstream store. For Meta Horizon this is the SKU IAPKit checked.", + ), +); + +export const environmentSchema = v.pipe( + v.string(), + v.description( + "Store environment selected by IAPKit. Forward this value opaquely; stores may add new values.", + ), +); + const baseReceiptResponseSchema = v.object({ store: verifyStoreSchema, isValid: v.boolean(), state: unifiedPurchaseStateSchema, - productId: v.optional( - v.pipe( - v.string(), - v.description( - "Product id verified by the upstream store. For Meta Horizon this is the SKU IAPKit checked.", - ), - ), - ), - environment: v.optional( - v.pipe( - v.union([v.literal("Sandbox"), v.literal("Production")]), - v.description( - "Amazon RVS environment selected by IAPKit. Present on handled Amazon verification results.", - ), - ), - ), + productId: v.optional(productIdSchema), + environment: v.optional(environmentSchema), clientPayload: v.optional( v.pipe( clientPayloadSchema, diff --git a/packages/kit/server/api/v1/routes.test.ts b/packages/kit/server/api/v1/routes.test.ts index 7801b7976..028f26178 100644 --- a/packages/kit/server/api/v1/routes.test.ts +++ b/packages/kit/server/api/v1/routes.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getFunctionName } from "convex/server"; vi.mock("hono/bun", () => ({ @@ -19,10 +19,28 @@ vi.mock("../../convex", () => ({ const { apiRoutes } = await import("./routes"); describe("apiRoutes", () => { + // The logger writes one JSON line per request to stdout. + let logLines: Array>; + let logSpy: ReturnType; + beforeEach(() => { convexClientMock.action.mockReset(); convexClientMock.mutation.mockReset(); convexClientMock.query.mockReset(); + logLines = []; + logSpy = vi.spyOn(console, "log").mockImplementation((...args) => { + const [first] = args; + if (typeof first !== "string") return; + try { + logLines.push(JSON.parse(first) as Record); + } catch { + // Not a structured line; ignore. + } + }); + }); + + afterEach(() => { + logSpy.mockRestore(); }); it("serves the generated OpenAPI specification", async () => { @@ -472,4 +490,125 @@ describe("apiRoutes", () => { } expect(convexClientMock.query).not.toHaveBeenCalled(); }); + + it("degrades an out-of-contract state instead of publishing it", async () => { + // A state Convex knows but the published schema does not must not ship. + convexClientMock.action.mockResolvedValueOnce({ + isValid: true, + state: "REFUNDED", + productId: "premium.monthly", + }); + + const response = await apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: "Bearer route-test-state-drift", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "google", + purchaseToken: "token".repeat(8), + }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + store: "google", + isValid: true, + state: "UNKNOWN", + productId: "premium.monthly", + }); + }); + + it("refuses to publish a verdict that cannot be made contract-valid", async () => { + // Server-defect path: Convex's validator pins isValid to a boolean. + convexClientMock.action.mockResolvedValueOnce({ + isValid: "true", + state: "ENTITLED", + }); + + const response = await apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: "Bearer route-test-malformed-verdict", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "google", + purchaseToken: "token".repeat(8), + }), + }); + + expect(response.status).toBe(500); + expect(await response.json()).toMatchObject({ + errors: [{ code: "UNKNOWN_ERROR" }], + }); + // The client saw a failure; the log must not report a success verdict. + const verifyLine = logLines.find((line) => line.kind === "verify_request"); + expect(verifyLine).toMatchObject({ + statusCode: 500, + isValid: false, + state: "UNKNOWN", + }); + }); + + it("keeps a stable rejection armed through a malformed verdict", async () => { + // The reported state is overwritten on this path, so the cooldown has to be + // derived before the reset β€” otherwise a revoked receipt becomes replayable. + convexClientMock.action.mockResolvedValue({ + isValid: "false", + state: "INAUTHENTIC", + }); + const send = () => + apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: "Bearer route-test-stable-rejection-500", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "google", + purchaseToken: "stablereject".repeat(4), + }), + }); + + expect((await send()).status).toBe(500); + + const replayed = await send(); + expect(replayed.status).toBe(429); + expect(await replayed.json()).toMatchObject({ + errors: [{ code: "REPEATED_FAILURE" }], + }); + }); + + it("forwards an opaque environment value", async () => { + convexClientMock.action.mockResolvedValueOnce({ + isValid: true, + state: "ENTITLED", + productId: "amazon.premium.monthly", + environment: "Xcode", + }); + + const response = await apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: "Bearer route-test-environment-drift", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "amazon", + userId: "amzn1.account.ABC123", + receiptId: "amzn1.receipt.ABC123456789=:1", + }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + store: "amazon", + isValid: true, + state: "ENTITLED", + productId: "amazon.premium.monthly", + environment: "Xcode", + }); + }); }); diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index b95c5812c..2583afcb8 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -11,11 +11,13 @@ import { verifyPurchaseInputSchema } from "./route-input-schemas"; import { client, handleConvexError } from "../../convex"; import { apiErrorResponseSchema, + FALLBACK_PURCHASE_STATE, verifyPurchaseSuccessResponseSchema, } from "./route-response-schemas"; +import { enforceVerifyResponseContract } from "./response-contract"; import { apiKeyMiddleware } from "./middleware"; import { getRequestIp, multiAxisRateLimitMiddleware } from "./rate-limit"; -import { replayGuardMiddleware } from "./replay-guard"; +import { isStableRejection, replayGuardMiddleware } from "./replay-guard"; import { inFlightLimitMiddleware } from "./in-flight-limit"; import { requestLoggerMiddleware } from "./request-logger"; import { validator } from "./validator"; @@ -223,8 +225,23 @@ const verifyPurchaseRouteDescription = describeRoute({ "Meta `userId` ≀ 256 chars, `sku` ≀ 256 chars. " + "Amazon `userId` ≀ 512 chars and `receiptId` ≀ 4 KB. " + "Oversized fields return `400 INVALID_INPUT`; oversized request " + - "bodies return `413 PAYLOAD_TOO_LARGE`. Neither hits the upstream store.", + "bodies return `413 PAYLOAD_TOO_LARGE`. Neither hits the upstream store.\n\n" + + "Optional `X-OpenIAP-Spec` request header: the OpenIAP spec version the " + + "calling SDK was built against (for example `3.2.0`). IAPKit records it so " + + "a response-contract change can be rolled out against real client-version " + + "data. It never changes how a receipt is verified, and an unrecognised " + + "value is ignored rather than rejected.", security: [{ apiKey: [] }], + parameters: [ + { + in: "header" as const, + name: "X-OpenIAP-Spec", + required: false, + description: + "OpenIAP spec version the calling SDK was built against, for example `3.2.0`. Recorded for rollout measurement only: it never changes how a receipt is verified, and an unrecognised value is ignored rather than rejected.", + schema: { type: "string" as const }, + }, + ], responses: { 200: { description: "Successful verification", @@ -447,11 +464,49 @@ const verifyPurchaseHandler = async ( } } - return c.json({ + const contract = enforceVerifyResponseContract({ store, ...publicReceipt, ...(clientPayload ? { clientPayload } : {}), }); + if (contract.violations.length > 0) { + // Field names only β€” never values. + console.error( + "[purchase/verify] RESPONSE_CONTRACT_VIOLATION: store=%s fields=%s", + store, + contract.violations.join(","), + ); + } + if (!contract.ok) { + // Reset only the reported verdict. The rejection's stability is resolved + // first, because `state` is about to be overwritten and the replay guard + // derives the cooldown from it. Defect-only path: Convex pins isValid. + setOutcome({ + isValid: false, + state: FALLBACK_PURCHASE_STATE, + ...(isStableRejection(outcome.state, outcome.stableRejection === true) + ? { stableRejection: true } + : {}), + }); + const errorId = crypto.randomUUID(); + console.error( + "Unexpected error (%s) when verifying purchase: malformed verdict", + errorId, + ); + return c.json( + { + errors: [ + { + code: "UNKNOWN_ERROR", + message: util.format("Unknown error: %s", errorId), + }, + ], + }, + 500, + ); + } + + return c.json(contract.response); }; try { diff --git a/packages/kit/src/pages/docs/sections/release-notes.tsx b/packages/kit/src/pages/docs/sections/release-notes.tsx index c79a6e756..899b04a2c 100644 --- a/packages/kit/src/pages/docs/sections/release-notes.tsx +++ b/packages/kit/src/pages/docs/sections/release-notes.tsx @@ -26,6 +26,111 @@ const KIND_STYLES: Record = { }; const RELEASES: ReleaseEntry[] = [ + { + id: "hosted-2026-08-13", + date: "2026-08-13", + tagline: + "Verify responses stay decodable by app builds compiled against an older SDK.", + items: [ + { + kind: "fix", + text: "Every /v1/purchase/verify response is now validated against the published schema before it is sent. A value outside that schema is degraded rather than emitted: an unpublished state becomes UNKNOWN and an unreadable productId, environment, or clientPayload is dropped. isValid is never rewritten, so a metadata change cannot revoke an entitlement, and a verdict that cannot be made contract-valid returns 500 instead of a body no SDK can trust.", + }, + { + kind: "fix", + text: "SDKs no longer fail a confirmed purchase over optional metadata. environment is forwarded as the opaque String the spec declares instead of being re-checked against Sandbox/Production, and a client payload whose format this build predates is dropped rather than thrown. The store echo and isValid typing stay strict.", + }, + { + kind: "ops", + text: "The purchase-state, client-payload-format, and verify-store enums are declared in kit's Convex layer, its OpenAPI response docs, and the GraphQL schema every SDK generates from. bun audit:kit-contract compares all three and gates both CI and this deploy, so a kit-only enum change can no longer reach published apps unnoticed.", + }, + { + kind: "feature", + text: "Native verification requests send X-OpenIAP-Spec with the OpenIAP spec version the build was compiled against, and kit records it on the structured verify log line. The value is shape-checked and bounded, and nothing branches on it: a client cannot change how its receipt is verified by claiming a version.", + }, + { + kind: "docs", + text: "Version Compatibility documents what IAPKit guarantees to an app compiled against an older SDK: responses are additive, a breaking change would ship as /v2 while /v1 keeps serving, and unrecognised optional values degrade. Gate entitlement on isValid, treat state as a label, and never reject a verification because a value is unrecognised.", + }, + ], + }, + { + id: "hosted-2026-08-13-entitlements", + date: "2026-08-13", + tagline: + "Entitlement defects found by the new behavioral conformance suite.", + items: [ + { + kind: "fix", + text: "A versioned behavioral conformance suite now binds spec behaviors to real implementations, and the entitlement defects that binding surfaced are fixed. The type and API-surface contract was already drift-gated, but nothing verified what a declared symbol actually did.", + }, + ], + }, + { + id: "hosted-2026-08-12", + date: "2026-08-12", + tagline: + "Store verification integrity across Amazon, Horizon, and raw REST.", + items: [ + { + kind: "security", + text: "Amazon RVS now requires an explicit project-level sandbox opt-in and carries first-class Sandbox / Production provenance, expected-product binding, and strict receipt identity and response validation. A bounded purchase-row reconciler preserves authoritative state across transient failures.", + }, + { + kind: "fix", + text: "Raw REST verification persists only strict boolean verdicts and keeps the last confirmed snapshot across a transient or malformed store response. The unfinished Meta Horizon subscription reconciler is retired and its legacy synthetic metrics source quarantined.", + }, + { + kind: "feature", + text: "Amazon expectedProductId and environment are wired through the GraphQL SSOT into Apple, Google, and every framework SDK, preserving published Kotlin constructor compatibility. Amazon and Horizon purchase counters were added with bounded migration support for existing self-hosted rows.", + }, + { + kind: "ops", + text: "The Convex verifier tree is included in coverage behind separate server (90%) and Convex (48%) gates.", + }, + ], + }, + { + id: "hosted-2026-08-11", + date: "2026-08-11", + tagline: "Production deploys verify their Convex target first.", + items: [ + { + kind: "ops", + text: "The production deploy script verifies it is pointed at the production Convex deployment before publishing, and refuses a development target.", + }, + ], + }, + { + id: "hosted-2026-08-07", + date: "2026-08-07", + tagline: "Product sync, verification, and MCP session correctness.", + items: [ + { + kind: "fix", + text: "Google Play product sync converts the authored price into every Play region and writes each local currency, and reads before masked updates so existing regions, purchase options, and console-authored locales survive. App Store Connect sync, localized listings, and sales regions received the matching corrections.", + }, + { + kind: "fix", + text: "MCP session routing and webhook lifecycle processing were corrected. This is phase 2 of the webhook idempotency work; later phases wait on legacy rows aging past the retention window.", + }, + ], + }, + { + id: "hosted-2026-08-05", + date: "2026-08-05", + tagline: "Read-only order lookup for customer inquiries.", + items: [ + { + kind: "feature", + text: "Paste an Apple or Google order id from a customer receipt and the dashboard returns the full order, plus current subscription status for subscription orders. Lookups are proxied live to the store APIs using the credentials the project already configured for verification; nothing is persisted or logged.", + }, + { + kind: "security", + text: "Order lookup is gated on a dashboard session and organization membership and never accepts an API key. It is operator tooling, not part of the public /v1 surface.", + }, + ], + }, { id: "hosted-2026-07-28", date: "2026-07-28", diff --git a/packages/mcp-server/src/kit-client.ts b/packages/mcp-server/src/kit-client.ts index 6f85dfd8f..4733c0051 100644 --- a/packages/mcp-server/src/kit-client.ts +++ b/packages/mcp-server/src/kit-client.ts @@ -205,7 +205,8 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { adminCall<{ expectedVersion: number; clientPayload?: { - format: "toml" | "json" | "text"; + // Opaque: IAPKit owns the format value space (IapkitClientPayloadFormat). + format: string; body: string; version: number; updatedAt: number; @@ -216,7 +217,7 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { setClientPayload: (params: { productId: string; platform: "IOS" | "Android"; - format: "toml" | "json" | "text"; + format: string; body: string; expectedVersion?: number; }) => diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts index bf3ff5f5d..bfeda62d3 100644 --- a/packages/mcp-server/src/mcp.ts +++ b/packages/mcp-server/src/mcp.ts @@ -767,7 +767,11 @@ function registerIapKitTools(server: McpServer) { { productId: PRODUCT_ID_PARAM, platform: z.enum(["IOS", "Android"]), - format: z.enum(["toml", "json", "text"]), + // Opaque: IAPKit owns this value space, so a stale enum here would + // reject a format the server already accepts. + format: kitTextParam("format").describe( + "Payload format, currently toml, json, or text. Forwarded as-is for IAPKit to validate.", + ), body: z .string() .max(MAX_CLIENT_PAYLOAD_BYTES) diff --git a/packages/mcp-server/test/http.test.ts b/packages/mcp-server/test/http.test.ts index fe8497f40..5deb39df6 100644 --- a/packages/mcp-server/test/http.test.ts +++ b/packages/mcp-server/test/http.test.ts @@ -523,6 +523,66 @@ describe("remote MCP HTTP server", () => { } }); + it("forwards an unrecognized client payload format instead of rejecting it", async () => { + const apiKey = "openiap-kit_sk_payload_format"; + const previousBaseUrl = process.env.IAPKIT_BASE_URL; + let forwardedFormat: unknown; + process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + forwardedFormat = JSON.parse(raw).format; + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + id: "payload_2", + created: false, + changed: true, + version: 2, + updatedAt: 456, + }), + ); + }); + }); + + try { + const { baseUrl, sessionId } = await initializeMcpSession(apiKey); + const response = await postMcp( + baseUrl, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "iapkit_set_client_payload", + arguments: { + productId: "premium_monthly", + platform: "IOS", + // A format IAPKit could add after this SDK shipped. + format: "yaml", + body: "rule: premium", + }, + }, + }, + sessionId, + { authorization: `Bearer ${apiKey}` }, + ); + const event = parseSseJson(await response.text()); + + expect(event.result.isError).toBeFalsy(); + expect(forwardedFormat).toBe("yaml"); + expect(JSON.parse(event.result.content[0].text)).toMatchObject({ + changed: true, + version: 2, + }); + } finally { + if (previousBaseUrl === undefined) delete process.env.IAPKIT_BASE_URL; + else process.env.IAPKIT_BASE_URL = previousBaseUrl; + } + }); + it("posts UTF-8-safe synthetic Android webhook payloads", async () => { const secretKey = "openiap-kit_sk_webhook_admin"; const publishableKey = "openiap-kit_pk_webhook_client"; diff --git a/scripts/audit-kit-spec-contract.mjs b/scripts/audit-kit-spec-contract.mjs new file mode 100644 index 000000000..a7d6b4888 --- /dev/null +++ b/scripts/audit-kit-spec-contract.mjs @@ -0,0 +1,280 @@ +#!/usr/bin/env node + +// Compares IAPKit's response enums against the spec every SDK generates from. +// +// Covers the /v1 verify response and kit's write path, which declares the same +// client-payload format set in four more files across a tsconfig split that +// stops them sharing a constant. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export const SCHEMA_FILE = "packages/gql/src/type.graphql"; +export const CONVEX_STATE_FILE = + "packages/kit/convex/purchases/purchaseState.ts"; +export const RESPONSE_SCHEMA_FILE = + "packages/kit/server/api/v1/route-response-schemas.ts"; + +// kit accepts a client-payload format on write and returns it on read. A format +// added to only one side is accepted then silently dropped by +// enforceVerifyResponseContract, so every declaration is compared to the spec. +export const WRITE_PATH_FORMAT_FILES = [ + "packages/kit/convex/schema.ts", + "packages/kit/convex/products/query.ts", + "packages/kit/convex/products/mutation.ts", + "packages/kit/server/api/v1/products.ts", +]; + +const read = (relativePath) => + fs.readFileSync(path.join(root, relativePath), "utf8"); + +// Source-text parsers can lie by matching the wrong declaration or reading a +// commented-out value, either of which reports agreement over a drifted repo. + +const matchExactlyOnce = (source, pattern, label) => { + const matches = [...source.matchAll(new RegExp(pattern, "g"))]; + if (matches.length === 0) throw new Error(`${label} not found`); + if (matches.length > 1) { + throw new Error( + `${label} matched ${matches.length} times β€” the anchor is ambiguous, so the audit cannot tell which declaration is the contract`, + ); + } + return matches[0]; +}; + +const stripComments = (source) => + source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); + +const nonEmpty = (values, label) => { + if (values.length === 0) throw new Error(`${label} parsed to an empty list`); + return values; +}; + +/** GraphQL enum members, with docstrings and comments stripped. */ +export const parseGraphqlEnum = (source, name) => { + const block = matchExactlyOnce( + source, + `\\benum\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, + `enum ${name}`, + ); + return nonEmpty( + block[1] + .replace(/"""[\s\S]*?"""/g, "") + .split("\n") + .map((line) => line.replace(/#.*$/, "").trim()) + .filter((line) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(line)), + `enum ${name}`, + ); +}; + +/** `export enum Name { KEY = "VALUE" }` β€” the string values reach the wire. */ +export const parseTypescriptEnum = (source, name) => { + const block = matchExactlyOnce( + source, + `\\bexport enum\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, + `enum ${name}`, + ); + return nonEmpty( + [...stripComments(block[1]).matchAll(/=\s*"([^"]+)"/g)].map( + (match) => match[1], + ), + `enum ${name}`, + ); +}; + +/** The `unifiedPurchaseStates` table documenting `/v1/purchase/verify`. */ +export const parseDocumentedStates = (source) => { + const block = matchExactlyOnce( + source, + `const unifiedPurchaseStates = \\[([\\s\\S]*?)\\n\\] as const;`, + "unifiedPurchaseStates table", + ); + return nonEmpty( + [...stripComments(block[1]).matchAll(/name:\s*"([^"]+)"/g)].map( + (match) => match[1], + ), + "unifiedPurchaseStates table", + ); +}; + +/** + * Literal members of a valibot union. `anchor` must name the owning + * declaration, not just the field, so a second field of the same name + * elsewhere in the file is a loud failure rather than a wrong answer. + */ +export const parseValibotLiteralUnion = (source, anchor) => { + const block = matchExactlyOnce( + source, + `${anchor}v\\.union\\(\\[([\\s\\S]*?)\\]\\)`, + `valibot union after ${anchor.trim()}`, + ); + return nonEmpty( + [...stripComments(block[1]).matchAll(/v\.literal\("([^"]+)"\)/g)].map( + (match) => match[1], + ), + `valibot union after ${anchor.trim()}`, + ); +}; + +const balancedGroupAt = (source, openIndex) => { + const open = source[openIndex]; + const close = open === "(" ? ")" : "]"; + let level = 0; + for (let i = openIndex; i < source.length; i += 1) { + if (source[i] === open) level += 1; + else if (source[i] === close) { + level -= 1; + if (level === 0) return source.slice(openIndex, i + 1); + } + } + return source.slice(openIndex); +}; + +/** + * Client-payload format sets declared in a file, in each of the shapes kit uses: + * a valibot union, a Set literal, and a TypeScript union type. + */ +export const parseFormatDeclarations = (source, knownFormat = "toml") => { + const clean = stripComments(source); + const groups = []; + + for (const opener of ["v.union(", "new Set(", "= ["]) { + let at = clean.indexOf(opener); + while (at !== -1) { + const openIndex = clean.indexOf(opener.endsWith("[") ? "[" : "(", at); + groups.push(balancedGroupAt(clean, openIndex)); + at = clean.indexOf(opener, at + 1); + } + } + for (const match of clean.matchAll( + /"[a-z][a-z0-9_-]*"(?:\s*\|\s*"[a-z][a-z0-9_-]*")+/g, + )) { + groups.push(match[0]); + } + + const declarations = groups + .map((group) => [ + ...new Set( + [...group.matchAll(/"([a-z][a-z0-9_-]*)"/g)].map((match) => match[1]), + ), + ]) + .filter((values) => values.includes(knownFormat)); + + if (declarations.length === 0) { + throw new Error("no client payload format declaration found"); + } + return declarations; +}; + +const compare = (label, expected, actual) => { + const failures = []; + const missing = expected.filter((value) => !actual.includes(value)); + const extra = actual.filter((value) => !expected.includes(value)); + if (missing.length > 0) { + failures.push(`${label}: missing ${JSON.stringify(missing)}`); + } + if (extra.length > 0) { + failures.push(`${label}: unexpected ${JSON.stringify(extra)}`); + } + return failures; +}; + +export const collectContractFailures = ({ + schema = read(SCHEMA_FILE), + convexState = read(CONVEX_STATE_FILE), + responseSchema: rawResponseSchema = read(RESPONSE_SCHEMA_FILE), + writePathFormatSources = Object.fromEntries( + WRITE_PATH_FORMAT_FILES.map((file) => [file, read(file)]), + ), +} = {}) => { + // Anchors match raw text, so a comment inside a declaration would make one + // miss and block the deploy gate. + const responseSchema = stripComments(rawResponseSchema); + const specStates = parseGraphqlEnum(schema, "IapkitPurchaseState"); + // GraphQL members are PascalCase for these two; the wire values are lowercase. + const specFormats = parseGraphqlEnum(schema, "IapkitClientPayloadFormat").map( + (member) => member.toLowerCase(), + ); + const specStores = parseGraphqlEnum(schema, "IapStore").map((member) => + member.toLowerCase(), + ); + + return [ + ...compare( + `${CONVEX_STATE_FILE} HarmonizedPurchaseState vs ${SCHEMA_FILE} IapkitPurchaseState`, + specStates, + parseTypescriptEnum(convexState, "HarmonizedPurchaseState"), + ), + ...compare( + `${RESPONSE_SCHEMA_FILE} unifiedPurchaseStates vs ${SCHEMA_FILE} IapkitPurchaseState`, + specStates, + parseDocumentedStates(responseSchema), + ), + ...compare( + `${RESPONSE_SCHEMA_FILE} clientPayload format vs ${SCHEMA_FILE} IapkitClientPayloadFormat`, + specFormats, + // Anchored on the owning declaration; `format:` alone is ambiguous. + parseValibotLiteralUnion( + responseSchema, + "clientPayloadSchema = v\\.object\\(\\{\\s*format:\\s*", + ), + ), + // Write path: a format kit accepts on write but the response schema omits + // is silently dropped on read, so every declaration must match the spec. + ...Object.entries(writePathFormatSources).flatMap(([file, source]) => + parseFormatDeclarations(source).flatMap((values, index) => + compare( + `${file} client payload format declaration ${index + 1} vs ${SCHEMA_FILE} IapkitClientPayloadFormat`, + specFormats, + values, + ), + ), + ), + // Stores are one-directional: kit may verify fewer stores than the spec + // names, but never one the spec omits β€” no SDK could ask for it. + ...parseValibotLiteralUnion(responseSchema, "const verifyStoreSchema = ") + .filter((store) => !specStores.includes(store)) + .map( + (store) => + `${RESPONSE_SCHEMA_FILE} verifyStoreSchema: ${JSON.stringify(store)} is not in ${SCHEMA_FILE} IapStore`, + ), + ]; +}; + +export const runAudit = (sources) => { + // Parsers signal drift by throwing; route that through the guidance below. + let failures; + try { + failures = collectContractFailures(sources); + } catch (error) { + failures = [`could not read a declaration: ${error.message}`]; + } + if (failures.length > 0) { + console.error("IAPKit spec contract audit failed:\n"); + for (const failure of failures) console.error(`- ${failure}`); + console.error( + "\nThe /v1 verify response carries enums that already-published SDKs decode.", + ); + console.error( + "Change packages/gql/src/type.graphql first, regenerate, and confirm every", + ); + console.error( + "SDK degrades unknown values instead of failing the receipt.", + ); + return false; + } + + console.log( + "IAPKit spec contract audit passed (purchase states, client payload formats, verify stores).", + ); + return true; +}; + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain && !runAudit()) process.exitCode = 1; diff --git a/scripts/audit-kit-spec-contract.test.mjs b/scripts/audit-kit-spec-contract.test.mjs new file mode 100644 index 000000000..98136f9f5 --- /dev/null +++ b/scripts/audit-kit-spec-contract.test.mjs @@ -0,0 +1,299 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + collectContractFailures, + parseFormatDeclarations, + runAudit, + parseDocumentedStates, + parseGraphqlEnum, + parseTypescriptEnum, + parseValibotLiteralUnion, +} from "./audit-kit-spec-contract.mjs"; + +const SCHEMA = ` +enum IapStore { + Unknown + Apple + Google +} + +""" +Unified purchase states from IAPKit verification response. +""" +enum IapkitPurchaseState { + """ + User is entitled to the product. + """ + ENTITLED + # trailing comment + EXPIRED +} + +enum IapkitClientPayloadFormat { + Toml + Json +} +`; + +const CONVEX_STATE = ` +export enum HarmonizedPurchaseState { + // Purchase is complete and valid + ENTITLED = "ENTITLED", + EXPIRED = "EXPIRED", +} +`; + +const RESPONSE_SCHEMA = ` +const unifiedPurchaseStates = [ + { name: "ENTITLED", description: "Purchase is complete and active." }, + { name: "EXPIRED", description: "Entitlement has expired." }, +] as const; + +const verifyStoreSchema = v.union([v.literal("apple"), v.literal("google")]); + +const clientPayloadSchema = v.object({ + format: v.union([v.literal("toml"), v.literal("json")]), + body: v.string(), +}); +`; + +const CLIENT_PAYLOAD_FORMAT_ANCHOR = + "clientPayloadSchema = v\\.object\\(\\{\\s*format:\\s*"; + +const sources = (overrides = {}) => ({ + schema: SCHEMA, + convexState: CONVEX_STATE, + responseSchema: RESPONSE_SCHEMA, + writePathFormatSources: WRITE_PATH, + ...overrides, +}); + +test("parsers read each declaration style", () => { + assert.deepEqual(parseGraphqlEnum(SCHEMA, "IapkitPurchaseState"), [ + "ENTITLED", + "EXPIRED", + ]); + assert.deepEqual( + parseTypescriptEnum(CONVEX_STATE, "HarmonizedPurchaseState"), + ["ENTITLED", "EXPIRED"], + ); + assert.deepEqual(parseDocumentedStates(RESPONSE_SCHEMA), [ + "ENTITLED", + "EXPIRED", + ]); + assert.deepEqual( + parseValibotLiteralUnion(RESPONSE_SCHEMA, "const verifyStoreSchema = "), + ["apple", "google"], + ); + assert.deepEqual( + parseValibotLiteralUnion(RESPONSE_SCHEMA, CLIENT_PAYLOAD_FORMAT_ANCHOR), + ["toml", "json"], + ); +}); + +// The dangerous failure is agreeing over a drifted repo; both cases below +// returned no failures before the anchors were qualified. + +test("a second format union cannot be mistaken for the contract", () => { + const withDecoy = RESPONSE_SCHEMA.replace( + "const unifiedPurchaseStates", + 'const decoySchema = v.object({\n format: v.union([v.literal("toml"), v.literal("json")]),\n});\n\nconst unifiedPurchaseStates', + ).replace( + 'v.literal("json")]),\n body:', + 'v.literal("json"), v.literal("yaml")]),\n body:', + ); + + const failures = collectContractFailures( + sources({ responseSchema: withDecoy }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /clientPayload format.*unexpected.*yaml/); +}); + +test("an ambiguous anchor throws instead of picking a declaration", () => { + const twice = `${RESPONSE_SCHEMA}\nconst verifyStoreSchema = v.union([v.literal("apple")]);\n`; + assert.throws( + () => parseValibotLiteralUnion(twice, "const verifyStoreSchema = "), + /matched 2 times/, + ); +}); + +test("a commented-out documented state is not counted", () => { + const failures = collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + ' { name: "EXPIRED", description: "Entitlement has expired." },', + ' // { name: "EXPIRED", description: "Entitlement has expired." },', + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /unifiedPurchaseStates.*missing.*EXPIRED/); +}); + +test("a commented-out literal is not counted", () => { + const failures = collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + 'v.union([v.literal("toml"), v.literal("json")])', + 'v.union([v.literal("toml") /* , v.literal("json") */])', + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /clientPayload format.*missing.*json/); +}); + +test("a comment above the anchored field does not break the audit", () => { + // The audit gates the kit deploy; a doc edit must not block a release. + assert.deepEqual( + collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + "const clientPayloadSchema = v.object({\n format:", + "const clientPayloadSchema = v.object({\n // public app data\n format:", + ), + }), + ), + [], + ); +}); + +test("an unreadable declaration is reported, not thrown", () => { + // deploy-kit.yml gates on this, so the operator has to see the guidance. + const errors = []; + const originalError = console.error; + console.error = (...args) => errors.push(args.join(" ")); + try { + assert.equal( + runAudit(sources({ responseSchema: "const nothing = 1;\n" })), + false, + ); + } finally { + console.error = originalError; + } + assert.match(errors.join("\n"), /could not read a declaration/); + assert.match(errors.join("\n"), /type\.graphql/); +}); + +test("a declaration that parses to nothing is a failure, not agreement", () => { + assert.throws( + () => + parseTypescriptEnum("export enum Empty {\n // nothing\n}\n", "Empty"), + /parsed to an empty list/, + ); +}); + +const WRITE_PATH = { + "packages/kit/convex/schema.ts": + 'format: v.union(v.literal("toml"), v.literal("json")),\n', +}; + +test("write-path format declarations are read in every shape kit uses", () => { + assert.deepEqual( + parseFormatDeclarations( + 'const a = v.union(v.literal("toml"), v.literal("json"));\n' + + 'const b = new Set(["toml", "json"]);\n' + + 'type C = "toml" | "json";\n', + ), + [ + ["toml", "json"], + ["toml", "json"], + ["toml", "json"], + ], + ); +}); + +test("a format accepted on write but absent from the response schema fails", () => { + // enforceVerifyResponseContract would silently drop it on read. + const failures = collectContractFailures( + sources({ + writePathFormatSources: { + "packages/kit/convex/schema.ts": + 'format: v.union(v.literal("toml"), v.literal("json"), v.literal("yaml")),\n', + }, + }), + ); + assert.equal(failures.length, 1); + assert.match( + failures[0], + /schema\.ts client payload format.*unexpected.*yaml/, + ); +}); + +test("aligned declarations produce no failures", () => { + assert.deepEqual(collectContractFailures(sources()), []); +}); + +test("a state added only to kit's persisted enum fails", () => { + const failures = collectContractFailures( + sources({ + convexState: CONVEX_STATE.replace( + `EXPIRED = "EXPIRED",`, + `EXPIRED = "EXPIRED",\n REFUNDED = "REFUNDED",`, + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /HarmonizedPurchaseState.*unexpected.*REFUNDED/); +}); + +test("a state added to the spec but not to kit fails", () => { + const failures = collectContractFailures( + sources({ + schema: SCHEMA.replace(" EXPIRED\n", " EXPIRED\n PENDING\n"), + }), + ); + assert.equal(failures.length, 2); + for (const failure of failures) assert.match(failure, /missing.*PENDING/); +}); + +test("a documented state kit cannot emit fails", () => { + const failures = collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + `{ name: "EXPIRED", description: "Entitlement has expired." },`, + `{ name: "EXPIRED", description: "Entitlement has expired." },\n { name: "CONSUMED", description: "Fulfilled." },`, + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /unifiedPurchaseStates.*unexpected.*CONSUMED/); +}); + +test("a client payload format the SDKs cannot decode fails", () => { + const failures = collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + `v.union([v.literal("toml"), v.literal("json")])`, + `v.union([v.literal("toml"), v.literal("json"), v.literal("yaml")])`, + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /clientPayload format.*unexpected.*yaml/); +}); + +test("a verify store the spec does not name fails", () => { + const failures = collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + `v.literal("google")]`, + `v.literal("google"), v.literal("steam")]`, + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /verifyStoreSchema.*"steam".*IapStore/); +}); + +test("kit may verify fewer stores than the spec names", () => { + assert.deepEqual( + collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace(`, v.literal("google")]`, "]"), + }), + ), + [], + ); +}); diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index c93be2459..bcb0997f8 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -1645,7 +1645,10 @@ function checkConformanceSuite() { execFileSync( process.execPath, [ - path.resolve(root, "packages/conformance/scripts/generate-behavior-ids.mjs"), + path.resolve( + root, + "packages/conformance/scripts/generate-behavior-ids.mjs", + ), "--check", ], { stdio: "pipe" }, @@ -1686,7 +1689,8 @@ function checkConformanceNotPublished() { ); } - const rnFiles = readJson("libraries/react-native-iap/package.json").files ?? []; + const rnFiles = + readJson("libraries/react-native-iap/package.json").files ?? []; if (!rnFiles.includes("!**/__tests__")) { fail( 'libraries/react-native-iap/package.json "files" must keep "!**/__tests__" so conformance fixtures are not published', @@ -1695,8 +1699,13 @@ function checkConformanceNotPublished() { // The podspec ships Sources only; Tests holds the Apple conformance suite. const podspec = read("packages/apple/openiap.podspec"); - if (!/source_files\s*=.*Sources/.test(podspec) || /source_files\s*=.*Tests/.test(podspec)) { - fail("packages/apple/openiap.podspec must publish Sources only, never Tests"); + if ( + !/source_files\s*=.*Sources/.test(podspec) || + /source_files\s*=.*Tests/.test(podspec) + ) { + fail( + "packages/apple/openiap.podspec must publish Sources only, never Tests", + ); } // conformanceTest belongs to unit-test variants; wiring it into a shipped @@ -2508,7 +2517,7 @@ function checkIapkitAmazonContractWiring() { "packages/apple/Sources/OpenIapModule.swift", [ "expectedProductId: amazon.expectedProductId", - "let environment = try Self.iapkitEnvironment", + "let environment = Self.iapkitEnvironment", "environment: environment", ], "Apple IAPKit Amazon verification contract", @@ -2517,7 +2526,7 @@ function checkIapkitAmazonContractWiring() { "packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt", [ 'amazon.expectedProductId?.let { put("expectedProductId", it) }', - 'it == "Sandbox" || it == "Production"', + 'parsed["environment"] as? String', "environment = environment", ], "Google IAPKit Amazon verification contract", @@ -2544,7 +2553,10 @@ function checkIapkitAmazonContractWiring() { for (const [file, needles, label] of [ [ "libraries/react-native-iap/ios/HybridRnIap.swift", - ['amazonDict["expectedProductId"]', "environment: RnIapHelper.wrapString"], + [ + 'amazonDict["expectedProductId"]', + "environment: RnIapHelper.wrapString", + ], "React Native iOS IAPKit bridge", ], [ @@ -2554,12 +2566,20 @@ function checkIapkitAmazonContractWiring() { ], [ "libraries/react-native-iap/src/vega-adapter.ts", - ["expectedProductId: amazon.expectedProductId", "environment !== 'Production'"], + [ + "expectedProductId: amazon.expectedProductId", + "const rawEnvironment = json.environment", + "...(environment == null ? {} : {environment})", + ], "React Native Vega IAPKit bridge", ], [ "libraries/expo-iap/src/vega-adapter.ts", - ["expectedProductId: amazon.expectedProductId", "environment !== 'Production'"], + [ + "expectedProductId: amazon.expectedProductId", + "const rawEnvironment = json.environment", + "...(environment == null ? {} : {environment})", + ], "Expo Vega IAPKit bridge", ], ]) { @@ -2569,8 +2589,8 @@ function checkIapkitAmazonContractWiring() { "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", [ "'expectedProductId':", - "environmentValue != 'Production'", - "environment: environmentValue as String?", + "final environmentValue = itemMap['environment']", + "environment: environment,", ], "Flutter IAPKit Amazon contract", ); @@ -2599,13 +2619,33 @@ function checkIapkitAmazonContractWiring() { "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt", [ "expectedProductId = amazon.expectedProductId", - "environment = androidResult.environment", + "androidResult.toKmpIapkitResult()", ], "KMP Android IAPKit Amazon contract", ); + expectIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt", + ["androidResult.toKmpIapkitResult()"], + "KMP Amazon store IAPKit response contract", + ); + expectIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt", + [ + "environment = environment", + "IapkitClientPayloadFormat.entries", + "getOrDefault(IapkitPurchaseState.Unknown)", + "getOrDefault(IapStore.Unknown)", + ], + "KMP Android IAPKit result mapping degrades unknown values", + ); + expectNotIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt", + ["RequestVerifyPurchaseWithIapkitResult.fromJson"], + "KMP Amazon store must not round-trip through the generated decoder", + ); expectIncludes( "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt", - ['"Sandbox", "Production"', "environment = environment"], + ['map["environment"] as? String', "environment = environment"], "KMP iOS IAPKit response contract", ); } @@ -8511,16 +8551,27 @@ function checkFrameworkDependencyHygiene() { ["configuredVersion('kotlinVersion', 'NitroIap_kotlinVersion')"], "React Native Android build.gradle must read Kotlin fallback from gradle.properties", ); + // Compile-time constants, not a runtime bundle lookup: SwiftPM copies a + // resource symlink verbatim and it dangles inside the built bundle. expectIncludes( "packages/apple/Sources/OpenIapVersion.swift", + ["OpenIapGeneratedVersion.apple", "OpenIapGeneratedVersion.spec"], + "Apple OpenIAP runtime version", + ); + expectNotIncludes( + "packages/apple/Sources/OpenIapVersion.swift", + ["Bundle.module", "fatalError"], + "Apple OpenIAP version must not resolve at runtime", + ); + expectIncludes( + "packages/apple/Sources/OpenIapGeneratedVersion.swift", [ - 'Bundle.module.url(forResource: "openiap-versions", withExtension: "json")', - "cocoaPodsVersionURL()", - 'bundle.url(forResource: "OpenIAP", withExtension: "bundle")', - 'version(for: "apple")', - 'version(for: "spec")', + "// Generated by scripts/sync-versions.sh", + `static let spec = "${versions.spec}"`, + `static let apple = "${versions.apple}"`, + `static let google = "${versions.google}"`, ], - "Apple OpenIAP runtime version", + "Apple generated version constants", ); expectIncludes( "packages/apple/openiap.podspec", diff --git a/scripts/sync-versions.sh b/scripts/sync-versions.sh index 5870bb3ec..b5e78e887 100755 --- a/scripts/sync-versions.sh +++ b/scripts/sync-versions.sh @@ -188,6 +188,37 @@ sync_docs_version_metadata # Native packages can use symlinks create_symlink "packages/apple/Sources/openiap-versions.json" "../../../openiap-versions.json" + +# Compile-time constants for Swift. The JSON above stays the SSOT, but SwiftPM +# copies a resource symlink verbatim and it dangles inside the built bundle, so +# reading it at runtime works in no distribution channel. +generate_apple_version_source() { + local target="packages/apple/Sources/OpenIapGeneratedVersion.swift" + python3 - "$target" <<'PYGEN' +import json +import sys + +with open("openiap-versions.json", encoding="utf-8") as file: + versions = json.load(file) + +lines = [ + "// Generated by scripts/sync-versions.sh from openiap-versions.json.", + "// Do not edit.", + "", + "enum OpenIapGeneratedVersion {", +] +for key in ("spec", "apple", "google"): + value = versions[key] + lines.append(f' static let {key} = "{value}"') +lines.append("}") + +with open(sys.argv[1], "w", encoding="utf-8") as file: + file.write("\n".join(lines) + "\n") +PYGEN + echo " βœ“ $target (generated)" +} + +generate_apple_version_source create_symlink "packages/google/openiap-versions.json" "../../openiap-versions.json" # Libraries use symlinks to root openiap-versions.json