diff --git a/.claude/commands/e2e-tests.md b/.claude/commands/e2e-tests.md index 11b2344db..8f97d05e4 100644 --- a/.claude/commands/e2e-tests.md +++ b/.claude/commands/e2e-tests.md @@ -27,16 +27,17 @@ When `e2e-tests` is requested without a narrower scope, run every applicable row and report every unavailable row as `BLOCKED` or `UNSUPPORTED` with the exact missing command, tool, device, or store prerequisite. -| Target | Android / Play | FireOS / Amazon | Horizon | iOS | VegaOS | Onside | -| ------------------------ | ------------------- | ------------------- | ---------- | ------------------- | --------- | ---------- | -| `packages/google` native | build + tests | build + tests | build-only | n/a | n/a | n/a | -| `packages/apple` native | n/a | n/a | n/a | build + tests | n/a | n/a | -| `react-native-iap` | build + device flow | build + device flow | build-only | build + device flow | RN only | n/a | -| `expo-iap` | build + device flow | build + device flow | build-only | build + device flow | Expo only | build-only | -| `flutter_inapp_purchase` | build + device flow | build + device flow | build-only | build + device flow | n/a | n/a | -| `kmp-iap` | build + device flow | build + device flow | build-only | build + device flow | n/a | n/a | -| `maui-iap` | build + device flow | build + device flow | build-only | build + device flow | n/a | n/a | -| `godot-iap` | build + device flow | n/a | n/a | build + device flow | n/a | n/a | +| Target | Android / Play | FireOS / Amazon | Horizon | iOS | VegaOS | Onside | +| --------------------------- | ------------------- | ------------------- | ---------- | ------------------- | --------- | ---------- | +| `packages/kit` local IAPKit | Martie live receipt | n/a | n/a | Martie live receipt | n/a | n/a | +| `packages/google` native | build + tests | build + tests | build-only | n/a | n/a | n/a | +| `packages/apple` native | n/a | n/a | n/a | build + tests | n/a | n/a | +| `react-native-iap` | build + device flow | build + device flow | build-only | build + device flow | RN only | n/a | +| `expo-iap` | build + device flow | build + device flow | build-only | build + device flow | Expo only | build-only | +| `flutter_inapp_purchase` | build + device flow | build + device flow | build-only | build + device flow | n/a | n/a | +| `kmp-iap` | build + device flow | build + device flow | build-only | build + device flow | n/a | n/a | +| `maui-iap` | build + device flow | build + device flow | build-only | build + device flow | n/a | n/a | +| `godot-iap` | build + device flow | n/a | n/a | build + device flow | n/a | n/a | Notes: @@ -48,6 +49,9 @@ Notes: - KMP and MAUI must still appear in the final report for FireOS/Horizon. Use the store-specific commands below; do not count the Play Android build as FireOS or Horizon coverage. +- The local IAPKit row uses the React Native or Expo example whose application + id / bundle id is `dev.hyo.martie`. It is a live sandbox receipt vertical, + never a placeholder-receipt CI smoke. ## Rules @@ -76,6 +80,107 @@ Notes: a Vega-only target/package manifest; normal iOS/Android manifests must not require Kepler packages. +## Local (IAPKit) Receipt Vertical + +Run this row as part of every full E2E regression. When the request is narrowed +to IAPKit, run this row plus the focused package/example checks that support it; +do not rerun unrelated framework/store rows. + +Prerequisites: + +- A connected iPhone or Google Play-capable Android phone with a sandbox/tester + account and the `dev.hyo.martie` catalog. +- A valid API key issued by the same real Martie Convex deployment that the + local server will use. Never use the placeholder smoke URL for a receipt. +- A device-reachable local URL. For Android over USB, reuse an existing + `tcp:3100` reverse mapping or create one with the ownership-tracking snippet + below, then use `http://127.0.0.1:3100`. For a physical iPhone, use the Mac's + current LAN IP, for example `http://192.168.0.4:3100`; do not use iPhone + localhost. + +Before adding an Android reverse rule, record whether the exact mapping already +exists. Keep `IAPKIT_REVERSE_CREATED` in the shell that will perform cleanup: + +```bash +existing_reverse_rules="$(adb -s "$ANDROID_SERIAL" reverse --list)" +IAPKIT_REVERSE_BLOCKED=0 +if printf '%s\n' "$existing_reverse_rules" | \ + grep -Eq '(^|[[:space:]])tcp:3100[[:space:]]+tcp:3100($|[[:space:]])'; then + IAPKIT_REVERSE_CREATED=0 +elif adb -s "$ANDROID_SERIAL" reverse --no-rebind tcp:3100 tcp:3100; then + IAPKIT_REVERSE_CREATED=1 +else + IAPKIT_REVERSE_CREATED=0 + IAPKIT_REVERSE_BLOCKED=1 + echo 'BLOCKED: could not create tcp:3100 reverse mapping without rebinding' >&2 +fi +if [ "$IAPKIT_REVERSE_BLOCKED" != "0" ]; then + exit 1 +fi +``` + +If `--no-rebind` fails, stop this row as `BLOCKED` or select a different free +port. Do not overwrite the existing mapping. + +Build and start the compiled IAPKit server in a dedicated terminal: + +```bash +cd packages/kit +: "${CONVEX_URL:?Set the Martie Convex deployment URL}" +VITE_KIT_CONVEX_URL="$CONVEX_URL" bun run build:all +CONVEX_URL="$CONVEX_URL" \ +VITE_KIT_CONVEX_URL="$CONVEX_URL" \ +STATIC_ROOT="$PWD/dist" \ +PORT=3100 \ +KIT_DEBUG_VERIFY_LOGS=1 \ +./openiap-kit-server +``` + +Configure and rebuild one Martie example. React Native reads +`IAPKIT_API_KEY` / `IAPKIT_BASE_URL`; Expo reads +`EXPO_PUBLIC_IAPKIT_API_KEY` / `EXPO_PUBLIC_IAPKIT_BASE_URL`. The URL must be +the device-reachable local origin above, without `/v1/purchase/verify`. + +On the example purchase screen, choose **Local (IAPKit)**, not **Local +(Device)** or hosted **IAPKit**. Fetch the Martie catalog, buy a visible +consumable or subscription, and approve the sandbox dialog only when +authorized. Require all of the following before PASS: + +1. The example receives the purchase token/JWS and calls + `verifyPurchaseWithProvider({ provider: 'iapkit' })` with the local + `baseUrl`. +2. The local server emits a matching structured `verify_request` line for + `POST /v1/purchase/verify`, including a correlation id, the expected store, + HTTP 200, and `isValid: true`. +3. The app displays/logs `isValid: true` and the expected IAPKit state/store, + then successfully finishes or consumes the transaction. +4. The purchases view backed by that same Martie Convex deployment shows the + same store/product purchase. Use the Dev Convex Data view for a Dev + deployment; do not look for Dev rows in the production-backed hosted UI. + +If the device, sandbox account, Martie catalog, API key, Convex deployment, or +purchase approval is unavailable, report this row as `BLOCKED` with that exact +prerequisite. A server health check, build, mocked receipt, or HTTP 400 route +probe does not count as a live receipt PASS. + +Always after the row, including `PASS`, `FAIL`, or `BLOCKED`, stop the local +server and log streams. Remove the reverse rule only when this run created it: + +```bash +if [ "${IAPKIT_REVERSE_CREATED:-0}" = "1" ]; then + current_reverse_rules="$(adb -s "$ANDROID_SERIAL" reverse --list 2>/dev/null)" + if printf '%s\n' "$current_reverse_rules" | \ + grep -Eq '(^|[[:space:]])tcp:3100[[:space:]]+tcp:3100($|[[:space:]])'; then + adb -s "$ANDROID_SERIAL" reverse --remove tcp:3100 + else + echo 'SKIP: tcp:3100 reverse mapping changed before cleanup' >&2 + fi +fi +``` + +Leave reused and unrelated reverse rules, project credentials, and products +unchanged. + ## Preflight Run from the repo root: @@ -761,6 +866,8 @@ packages/google Play | local Gradle | compile/test | PASS | ... packages/google Fire | local Gradle | compile/test | PASS | ... packages/google Horz | local Gradle | compile | PASS | build-only packages/apple iOS | local SwiftPM | build/test | PASS | ... +Local (IAPKit) Android | {serial} | Martie purchase/verify/finish | PASS | corrId=... +Local (IAPKit) iOS | {UDID} | Martie purchase/verify/finish | PASS | corrId=... RN Android | {serial} | build/install/purchase | PASS | ... RN FireOS | {serial} | build/install/purchase | PASS | ... RN Horizon | local Gradle | build | PASS | build-only diff --git a/.codex/skills/iapkit-e2e-martie/SKILL.md b/.codex/skills/iapkit-e2e-martie/SKILL.md new file mode 100644 index 000000000..ce03e018f --- /dev/null +++ b/.codex/skills/iapkit-e2e-martie/SKILL.md @@ -0,0 +1,255 @@ +--- +name: iapkit-e2e-martie +description: Run IAPKit local receipt-validation E2E with the dev.hyo.martie React Native or Expo examples, the compiled packages/kit server, real Convex, and Apple or Google sandbox purchases. Use when verifying purchase-token or JWS routing, Local (IAPKit) baseUrl behavior, local server logs, receipt validity, transaction finishing, or the Martie purchases view; distinguish safe smoke checks from approval-gated live purchase verticals. +--- + +# IAPKit Martie Receipt E2E + +Verify the complete mobile purchase-to-local-IAPKit receipt path with the +OpenIAP example apps. Do not treat product sync, public-page smoke, or mocked +receipts as a substitute for this vertical. + +## Targets + +- OpenIAP repo: `$OPENIAP_REPO` (the current checkout) +- IAPKit server: `$OPENIAP_REPO/packages/kit` +- React Native fixture: `$OPENIAP_REPO/libraries/react-native-iap/example` +- Expo fixture: `$OPENIAP_REPO/libraries/expo-iap/example` +- App bundle/application id: `dev.hyo.martie` +- IAPKit project: organization `hyo-dev`, project `martie` +- Purchases evidence: the dashboard or Convex Data view backed by the exact + Martie Convex deployment used by the local server +- Local receipt endpoint: `POST /v1/purchase/verify` + +Use one example framework and one store per live run. Prefer the already +installed, store-compatible example and record which framework, device, and +store produced the evidence. + +## Keep Smoke and Live Receipt Results Separate + +### Server smoke + +Server smoke is safe and does not open a store purchase dialog. It may use the +placeholder Convex URL and a synthetic malformed request to prove that the +compiled binary boots and routes `/v1/purchase/verify` through authentication +and validation. + +Run: + +```bash +OPENIAP_REPO="${OPENIAP_REPO:-$(git rev-parse --show-toplevel)}" +cd "$OPENIAP_REPO/packages/kit" +bun run typecheck +bun run test +bun run smoke:server +``` + +Report this lane only as `SMOKE PASS` or `SMOKE FAIL`. A health check, HTTP 400 +probe, mocked receipt, or public-page browser check never proves a live receipt. + +### Live receipt vertical + +The live lane purchases a Martie sandbox product on a mobile device, sends the +real token or JWS to the locally running compiled IAPKit server, verifies it +against the store through the real Martie Convex deployment, and finishes the +transaction. Report `LIVE RECEIPT PASS` only when every assertion below is +satisfied. + +## Safety Gates + +- Require explicit user approval for the live sandbox purchase in the current + run. Stop immediately before pressing Purchase/Subscribe or confirming the + store sheet when that approval has not yet been given. Permission to build, + install, launch, fetch products, or run smoke does not authorize a purchase. +- Do not create, reveal, rotate, revoke, or regenerate an IAPKit API key without + separate explicit approval. Use an existing Martie project key and redact it + from commands, logs, screenshots, and reports. +- Do not create, edit, push, pull, or delete store products as part of this + receipt workflow. Store-catalog mutation is a different E2E scope. +- Use sandbox/test accounts only. Do not claim that sandbox means no external + side effect; the store still creates purchase/transaction state. +- Prefer a repeatable consumable. Never leave a purchased transaction + unfinished merely to preserve test evidence. + +## Preflight + +1. Read `$OPENIAP_REPO/AGENTS.md`, `packages/kit/CONVENTION.md`, the selected + framework's `CLAUDE.md`, and the `Local (IAPKit) Receipt Vertical` section of + `.claude/commands/e2e-tests.md`. +2. Run `git status --short --branch` and preserve all existing changes. +3. Confirm port `3100` is free and identify the device: + - Android: `adb devices -l` + - iOS: `xcrun devicectl list devices` +4. Confirm the device is signed into the correct sandbox/tester account, can + load the store, and can install or launch `dev.hyo.martie`. +5. Confirm an existing Martie IAPKit API key and the exact Martie Convex + deployment that issued it are available. A placeholder or cross-deployment + key/URL pair blocks the live lane. +6. Confirm the selected example exposes the distinct **Local (Device)**, + **Local (IAPKit)**, **IAPKit**, and **None (Skip)** choices in that order, + then fetches the Martie catalog before requesting purchase. + +Treat a missing device, store account, catalog, API key, real Convex URL, or +network route as `BLOCKED`, not passed. + +## Make the Local Server Reachable + +Build and start the compiled server in a dedicated terminal with the real +Martie Convex deployment: + +```bash +OPENIAP_REPO="${OPENIAP_REPO:-$(git rev-parse --show-toplevel)}" +cd "$OPENIAP_REPO/packages/kit" +: "${CONVEX_URL:?Set the real Martie Convex deployment URL}" +VITE_KIT_CONVEX_URL="$CONVEX_URL" bun run build:all +CONVEX_URL="$CONVEX_URL" \ +VITE_KIT_CONVEX_URL="$CONVEX_URL" \ +STATIC_ROOT="$PWD/dist" \ +PORT=3100 \ +KIT_DEBUG_VERIFY_LOGS=1 \ +./openiap-kit-server +``` + +Verify `/health` from the host, then verify the same origin from the device or +an equivalent device-side network probe. + +- Android over USB: inspect the current reverse mappings and reuse an existing + `tcp:3100` to `tcp:3100` mapping. If it is absent, create it and record that + this run owns it: + + ```bash + existing_reverse_rules="$(adb -s "$ANDROID_SERIAL" reverse --list)" + IAPKIT_REVERSE_BLOCKED=0 + if printf '%s\n' "$existing_reverse_rules" | \ + grep -Eq '(^|[[:space:]])tcp:3100[[:space:]]+tcp:3100($|[[:space:]])'; then + IAPKIT_REVERSE_CREATED=0 + elif adb -s "$ANDROID_SERIAL" reverse --no-rebind tcp:3100 tcp:3100; then + IAPKIT_REVERSE_CREATED=1 + else + IAPKIT_REVERSE_CREATED=0 + IAPKIT_REVERSE_BLOCKED=1 + echo 'BLOCKED: could not create tcp:3100 reverse mapping without rebinding' >&2 + fi + if [ "$IAPKIT_REVERSE_BLOCKED" != "0" ]; then + exit 1 + fi + ``` + + Keep `IAPKIT_REVERSE_CREATED` in the shell used for cleanup and configure + `http://127.0.0.1:3100` in the app. If `--no-rebind` fails, stop as `BLOCKED` + or select a different free port; never overwrite the existing mapping. + +- Physical iPhone: use the Mac's current LAN IP, for example + `http://192.168.0.4:3100`. Device localhost points to the iPhone, not the Mac. +- Android without `adb reverse`: use the Mac LAN IP and keep both devices on a + mutually reachable network. +- Simulator/emulator addresses differ from physical-device addresses. Discover + the route instead of copying a stale IP. + +The configured base URL is an origin only; do not append +`/v1/purchase/verify`. If HTTP cleartext is blocked in a non-debug build, fix or +use the example's intended debug networking configuration rather than claiming +the server is unreachable. + +## Configure and Rebuild One Example + +React Native environment: + +```text +IAPKIT_API_KEY= +IAPKIT_BASE_URL= +``` + +Expo environment: + +```text +EXPO_PUBLIC_IAPKIT_API_KEY= +EXPO_PUBLIC_IAPKIT_BASE_URL= +``` + +Keep secrets in ignored local environment files or the process environment. +Rebuild/reinstall the native app after changing these build-time values; do not +assume a JavaScript reload changed the native verification payload. Confirm the +screen label is **Local (IAPKit)**, not **Local (Device)** or **IAPKit**, before +purchase. + +## Martie Catalog + +- `dev.hyo.martie.10bulbs`: consumable; preferred repeatable receipt fixture +- `dev.hyo.martie.30bulbs`: consumable fallback +- `dev.hyo.martie.certified`: non-consumable +- `dev.hyo.martie.premium`: subscription +- `dev.hyo.martie.premium_year`: yearly subscription + +Fetch visible store products first. Do not infer availability from constants +alone. Prefer `10bulbs`; use a subscription only when subscription behavior is +in scope and the tester can safely create that sandbox state. + +## Run the Approval-Gated Live Vertical + +1. Start log capture for the selected example and the local IAPKit server. +2. Launch `dev.hyo.martie`, open Purchase Flow or Subscription Flow, and select + **Local (IAPKit)**. +3. Fetch products and record the visible SKU and localized price. +4. Obtain explicit approval if it is not already present for this exact live + purchase run. +5. Purchase the selected sandbox SKU and complete the store sheet. +6. Match the app, local server, and same-deployment purchases evidence before + stopping logs. + +Require all of these assertions for `LIVE RECEIPT PASS`: + +1. The app receives a real purchase token/JWS and calls + `verifyPurchaseWithProvider({ provider: 'iapkit' })` with the configured + local `baseUrl`. +2. The local server emits a matching structured `verify_request` entry for + `POST /v1/purchase/verify` with a correlation id, expected store, HTTP 200, + and `isValid: true`. Use debug logs only to correlate; never expose receipt + or API-key material. +3. The app reports `isValid: true` with the expected IAPKit state/store and then + successfully finishes, acknowledges, or consumes the transaction as + appropriate. +4. The purchases view backed by the same Martie Convex deployment shows the + matching store, SKU, and purchase time. For a Dev deployment, use its Convex + Data view or a dashboard explicitly connected to Dev; the production-backed + hosted UI will not contain the Dev row. Correlate identifiers where exposed. + +If the request reaches the hosted endpoint, lacks `baseUrl`, never appears in +the local structured log, or cannot be correlated with the same-deployment +purchases evidence, fail the local vertical even when the store purchase itself +succeeds. + +## Cleanup and Reporting + +- Stop the local server and log streams. +- Remove the Android reverse mapping only when this run created it: + + ```bash + if [ "${IAPKIT_REVERSE_CREATED:-0}" = "1" ]; then + current_reverse_rules="$(adb -s "$ANDROID_SERIAL" reverse --list 2>/dev/null)" + if printf '%s\n' "$current_reverse_rules" | \ + grep -Eq '(^|[[:space:]])tcp:3100[[:space:]]+tcp:3100($|[[:space:]])'; then + adb -s "$ANDROID_SERIAL" reverse --remove tcp:3100 + else + echo 'SKIP: tcp:3100 reverse mapping changed before cleanup' >&2 + fi + fi + ``` + + Reused and unrelated mappings must remain unchanged. + +- Leave store products and project credentials unchanged. +- Preserve sufficient redacted evidence to distinguish server smoke from the + live receipt result. + +Report: + +- framework, store, device identifier, app id, and SKU; +- local origin without secrets, server build/start result, and Convex target + classification (`real Martie`, never the secret value); +- smoke result separately from live receipt result; +- local correlation id, HTTP status, `isValid`, state, store, and transaction + finish/consume result; +- same-deployment Martie purchases correlation result; +- every `BLOCKED` prerequisite or failure, without upgrading partial evidence + to PASS. diff --git a/.codex/skills/iapkit-e2e-martie/agents/openai.yaml b/.codex/skills/iapkit-e2e-martie/agents/openai.yaml new file mode 100644 index 000000000..a041031ca --- /dev/null +++ b/.codex/skills/iapkit-e2e-martie/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "IAPKit Martie E2E" + short_description: "Verify local IAPKit receipts with Martie" + default_prompt: "Use $iapkit-e2e-martie to run the Martie example purchase-to-Local (IAPKit) receipt E2E." diff --git a/.codex/skills/review-self/SKILL.md b/.codex/skills/review-self/SKILL.md index 443d5b799..ba7e74b5b 100644 --- a/.codex/skills/review-self/SKILL.md +++ b/.codex/skills/review-self/SKILL.md @@ -93,7 +93,8 @@ default. Use `$openiap-workflows` as the router for `.claude/commands/`: Use the more specific local skill when its domain matches: `$generate-doc` for OpenIAP docs and release notes, `$iapkit-e2e-petgu` for Petgu product-sync E2E, -and `$opencollective-steward` for OpenCollective work. +`$iapkit-e2e-martie` for Martie local-receipt E2E, and +`$opencollective-steward` for OpenCollective work. ## Recheck Every Five Minutes diff --git a/.github/workflows/release-google.yml b/.github/workflows/release-google.yml index d00187473..6353829e3 100644 --- a/.github/workflows/release-google.yml +++ b/.github/workflows/release-google.yml @@ -197,6 +197,7 @@ jobs: git add openiap-versions.json packages/*/openiap-versions.json git add packages/docs/src/generated/version-metadata.json git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json + git add libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap if git diff --staged --quiet; then echo "No version changes to commit" @@ -216,7 +217,7 @@ jobs: # 2.1.3 → 2.1.2). for conflict_file in $(git diff --name-only --diff-filter=U); do case "$conflict_file" in - openiap-versions.json|packages/*/openiap-versions.json|packages/gql/package.json|packages/docs/package.json|packages/google/package.json|packages/apple/package.json) + openiap-versions.json|packages/*/openiap-versions.json|packages/gql/package.json|packages/docs/package.json|packages/google/package.json|packages/apple/package.json|libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap) ;; packages/docs/src/generated/version-metadata.json) ;; @@ -234,6 +235,7 @@ jobs: git add openiap-versions.json packages/*/openiap-versions.json git add packages/docs/src/generated/version-metadata.json git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json + git add libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap GIT_EDITOR=true git rebase --continue || { echo "❌ Rebase continue failed"; exit 1; } fi diff --git a/bun.lock b/bun.lock index 8cebe4ceb..6518fcbf1 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "packages/docs": { "name": "@hyodotdev/openiap-docs", - "version": "2.3.0", + "version": "2.3.1", "dependencies": { "@preact/signals-react": "^3.2.1", "@types/prismjs": "^1.26.5", @@ -64,7 +64,7 @@ }, "packages/gql": { "name": "@hyodotdev/openiap-gql", - "version": "2.3.0", + "version": "2.3.1", "devDependencies": { "@graphql-codegen/add": "^6.0.0", "@graphql-codegen/cli": "^6.0.0", diff --git a/libraries/expo-iap/example/.env.example b/libraries/expo-iap/example/.env.example index 0ab5d1869..51f6d25f0 100644 --- a/libraries/expo-iap/example/.env.example +++ b/libraries/expo-iap/example/.env.example @@ -1,6 +1,7 @@ # IAPKit Configuration -# Get your API key from https://kit.openiap.dev +# Hosted keys: https://kit.openiap.dev +# Local (IAPKit): use a key issued by the same Convex deployment as the server. EXPO_PUBLIC_IAPKIT_API_KEY=your_iapkit_api_key_here -# Use your Mac's LAN IP for Vega / Fire TV device testing. +# Required when selecting Local (IAPKit). Use your Mac's LAN IP on a device. # Example: http://192.168.0.10:3100 EXPO_PUBLIC_IAPKIT_BASE_URL= diff --git a/libraries/expo-iap/example/README.md b/libraries/expo-iap/example/README.md index 918fa64d8..3a95fb98b 100644 --- a/libraries/expo-iap/example/README.md +++ b/libraries/expo-iap/example/README.md @@ -18,6 +18,25 @@ npm run ios npm run android ``` +## Purchase Verification + +Create the ignored environment file before testing IAPKit: + +```bash +cp .env.example .env +``` + +For hosted IAPKit, get a key from the [IAPKit dashboard](https://kit.openiap.dev). Set `EXPO_PUBLIC_IAPKIT_API_KEY` to a key issued by the IAPKit/Convex deployment that the selected server uses. For **Local (IAPKit)**, the key and local server must target the same Convex deployment. Also set `EXPO_PUBLIC_IAPKIT_BASE_URL` to the device-reachable HTTP(S) origin only; do not append `/v1/purchase/verify`. A physical iPhone must use the Mac's LAN address. An Android device connected over USB can use `http://127.0.0.1:3100`: inspect `adb -s "$ANDROID_SERIAL" reverse --list`, reuse an exact `tcp:3100` mapping when present, or create it with `adb -s "$ANDROID_SERIAL" reverse --no-rebind tcp:3100 tcp:3100`. Record whether this run created the rule and remove only that rule during cleanup; if `--no-rebind` fails, use another port instead of overwriting an existing mapping. + +The purchase and subscription screens list verification in this order: + +1. **Local (Device)** — direct Apple/Google verification on the device. +2. **Local (IAPKit)** — IAPKit provider routed to the configured local origin. +3. **IAPKit** — hosted IAPKit; the local URL is deliberately omitted. +4. **None (Skip)** — skip verification. + +With both values configured, the example defaults to **Local (IAPKit)**. With only the key, it defaults to hosted **IAPKit**; without a key, it defaults to **None (Skip)**. + ## 📱 Example Structure This example provides two focused implementations, each demonstrating best practices for specific use cases: @@ -77,7 +96,10 @@ const result = await requestPurchase({ if (isAndroidPurchaseArray(result)) { // TypeScript knows this is ProductPurchaseAndroid[] const purchase = result[0]; - console.log('Android token available:', Boolean(purchase.purchaseTokenAndroid)); + console.log( + 'Android token available:', + Boolean(purchase.purchaseTokenAndroid), + ); } else if (isIosPurchase(result)) { // TypeScript knows this is ProductPurchaseIos console.log('iOS Transaction ID:', result.transactionId); diff --git a/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx b/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx index c57a4d56c..b7ecd3af1 100644 --- a/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx +++ b/libraries/expo-iap/example/__tests__/purchase-flow.test.tsx @@ -1,13 +1,22 @@ import React from 'react'; -import {render, fireEvent, waitFor} from '@testing-library/react-native'; +import {act, render, fireEvent, waitFor} from '@testing-library/react-native'; import PurchaseFlow from '../app/purchase-flow'; import {requestPurchase, getStorefront} from '../../src'; +const mockShowActionSheetWithOptions = jest.fn(); + +jest.mock('@expo/react-native-action-sheet', () => ({ + useActionSheet: () => ({ + showActionSheetWithOptions: mockShowActionSheetWithOptions, + }), +})); + // Mock expo-constants jest.mock('expo-constants', () => ({ expoConfig: { extra: { iapkitApiKey: 'test-api-key', + iapkitBaseUrl: 'http://192.168.0.10:3100', }, }, })); @@ -16,11 +25,16 @@ jest.mock('expo-constants', () => ({ const mockFetchProducts = jest.fn(); const mockGetAvailablePurchases = jest.fn(); const mockFinishTransaction = jest.fn(); +const mockVerifyPurchase = jest.fn(); +const mockVerifyPurchaseWithProvider = jest.fn(); +let mockOnPurchaseSuccess: + | ((purchase: Record) => Promise | void) + | undefined; const mockUseIAP = { connected: true, products: [ { - id: 'test.product.1', + id: 'dev.hyo.martie.10bulbs', title: 'Test Product', description: 'Test Description', price: '$0.99', @@ -33,10 +47,17 @@ const mockUseIAP = { fetchProducts: mockFetchProducts, finishTransaction: mockFinishTransaction, getAvailablePurchases: mockGetAvailablePurchases, + verifyPurchase: mockVerifyPurchase, + verifyPurchaseWithProvider: mockVerifyPurchaseWithProvider, }; jest.mock('../../src', () => ({ - useIAP: jest.fn(() => mockUseIAP), + useIAP: jest.fn( + (options?: {onPurchaseSuccess?: typeof mockOnPurchaseSuccess}) => { + mockOnPurchaseSuccess = options?.onPurchaseSuccess; + return mockUseIAP; + }, + ), requestPurchase: jest.fn(() => Promise.resolve()), getAppTransactionIOS: jest.fn(), getStorefront: jest.fn(), @@ -45,9 +66,19 @@ jest.mock('../../src', () => ({ describe('PurchaseFlow Component', () => { beforeEach(() => { jest.clearAllMocks(); + mockShowActionSheetWithOptions.mockReset(); mockFetchProducts.mockResolvedValue([]); mockGetAvailablePurchases.mockResolvedValue([]); mockFinishTransaction.mockResolvedValue(undefined); + mockVerifyPurchase.mockResolvedValue({}); + mockVerifyPurchaseWithProvider.mockResolvedValue({ + iapkit: { + isValid: true, + state: 'purchased', + store: 'apple', + }, + }); + mockOnPurchaseSuccess = undefined; (getStorefront as jest.Mock).mockResolvedValue('US'); }); @@ -77,6 +108,28 @@ describe('PurchaseFlow Component', () => { expect(getByText('Test Product')).toBeDefined(); // The price is rendered by getProductDisplayPrice which returns displayPrice expect(getByText('Test Description')).toBeDefined(); + expect(getByText('Local (IAPKit)')).toBeDefined(); + }); + + it('shows verification choices in the requested order', async () => { + const {getByText} = render(); + await waitFor(() => expect(getStorefront).toHaveBeenCalled()); + + fireEvent.press(getByText('Local (IAPKit)')); + + expect(mockShowActionSheetWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ + options: [ + 'Local (Device)', + 'Local (IAPKit)', + 'IAPKit', + 'None (Skip)', + 'Cancel', + ], + cancelButtonIndex: 4, + }), + expect.any(Function), + ); }); it('should fetch and show storefront information', async () => { @@ -98,10 +151,106 @@ describe('PurchaseFlow Component', () => { // The actual call includes store-specific request structure expect(requestPurchase).toHaveBeenCalledWith({ request: { - apple: {sku: 'test.product.1', quantity: 1}, - google: {skus: ['test.product.1']}, + apple: {sku: 'dev.hyo.martie.10bulbs', quantity: 1}, + google: {skus: ['dev.hyo.martie.10bulbs']}, }, type: 'in-app', }); }); + + it('routes Local (IAPKit) through the configured local server', async () => { + render(); + + await act(async () => { + await mockOnPurchaseSuccess?.({ + id: 'transaction-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'apple-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }); + }); + + expect(mockVerifyPurchase).not.toHaveBeenCalled(); + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledWith({ + provider: 'iapkit', + iapkit: { + apiKey: 'test-api-key', + baseUrl: 'http://192.168.0.10:3100', + apple: {jws: 'apple-jws'}, + }, + }); + expect( + mockVerifyPurchaseWithProvider.mock.invocationCallOrder[0], + ).toBeLessThan(mockFinishTransaction.mock.invocationCallOrder[0]!); + }); + + it('keeps Local (Device) on direct Apple/Google verification', async () => { + mockShowActionSheetWithOptions.mockImplementation( + (_options: unknown, callback: (index?: number) => void) => callback(0), + ); + const {getByText} = render(); + + fireEvent.press(getByText('Local (IAPKit)')); + await waitFor(() => { + expect(getByText('Local (Device)')).toBeDefined(); + }); + + await act(async () => { + await mockOnPurchaseSuccess?.({ + id: 'transaction-device-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'device-apple-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }); + }); + + expect(mockVerifyPurchase).toHaveBeenCalledWith({ + apple: {sku: 'dev.hyo.martie.10bulbs'}, + google: { + sku: 'dev.hyo.martie.10bulbs', + packageName: 'dev.hyo.martie', + purchaseToken: 'device-apple-jws', + accessToken: '', + }, + }); + expect(mockVerifyPurchaseWithProvider).not.toHaveBeenCalled(); + expect(mockVerifyPurchase.mock.invocationCallOrder[0]).toBeLessThan( + mockFinishTransaction.mock.invocationCallOrder[0]!, + ); + }); + + it('omits the local base URL when hosted IAPKit is selected', async () => { + mockShowActionSheetWithOptions.mockImplementation( + (_options: unknown, callback: (index?: number) => void) => callback(2), + ); + const {getByText} = render(); + + fireEvent.press(getByText('Local (IAPKit)')); + await waitFor(() => { + expect(getByText('IAPKit')).toBeDefined(); + }); + + await act(async () => { + await mockOnPurchaseSuccess?.({ + id: 'transaction-2', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'hosted-apple-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }); + }); + + expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledWith({ + provider: 'iapkit', + iapkit: { + apiKey: 'test-api-key', + apple: {jws: 'hosted-apple-jws'}, + }, + }); + }); }); diff --git a/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx b/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx index 4caf4e4e7..35270f937 100644 --- a/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx +++ b/libraries/expo-iap/example/__tests__/subscription-flow.test.tsx @@ -2,11 +2,20 @@ import React from 'react'; import {act, render, fireEvent, waitFor} from '@testing-library/react-native'; import {Alert, Platform} from 'react-native'; +const mockShowActionSheetWithOptions = jest.fn(); + +jest.mock('@expo/react-native-action-sheet', () => ({ + useActionSheet: () => ({ + showActionSheetWithOptions: mockShowActionSheetWithOptions, + }), +})); + // Mock expo-constants jest.mock('expo-constants', () => ({ expoConfig: { extra: { iapkitApiKey: 'test-api-key', + iapkitBaseUrl: 'http://192.168.0.10:3100', }, }, })); @@ -34,10 +43,11 @@ const mockVerifyPurchaseWithProvider = jest ) .mockName('verifyPurchaseWithProvider'); let mockOnPurchaseSuccess: - ((purchase: Record) => Promise | void) | undefined; + | ((purchase: Record) => Promise | void) + | undefined; const createMockSubscription = (overrides = {}) => ({ - id: 'test.subscription.1', + id: 'dev.hyo.martie.premium', title: 'Test Subscription', description: 'Test Description', price: '$9.99', @@ -57,7 +67,7 @@ const createMockSubscription = (overrides = {}) => ({ }); const createMockAndroidSubscription = () => ({ - id: 'test.android.subscription', + id: 'dev.hyo.martie.premium', title: 'Android Subscription', description: 'Android Test Description', displayPrice: '$4.99', @@ -98,6 +108,7 @@ async function renderConnectedSubscriptionFlow() { describe('SubscriptionFlow Component', () => { beforeEach(() => { jest.clearAllMocks(); + mockShowActionSheetWithOptions.mockReset(); mockFetchProducts.mockResolvedValue([createMockSubscription()]); mockGetActiveSubscriptions.mockResolvedValue([]); mockFinishTransaction.mockResolvedValue(undefined); @@ -130,6 +141,27 @@ describe('SubscriptionFlow Component', () => { it('should render without crashing', async () => { const {getByText} = await renderConnectedSubscriptionFlow(); expect(getByText('Subscription Flow')).toBeDefined(); + expect(getByText('Local (IAPKit)')).toBeDefined(); + }); + + it('shows verification choices in the requested order', async () => { + const {getByText} = await renderConnectedSubscriptionFlow(); + + fireEvent.press(getByText('Local (IAPKit)')); + + expect(mockShowActionSheetWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ + options: [ + 'Local (Device)', + 'Local (IAPKit)', + 'IAPKit', + 'None (Skip)', + 'Cancel', + ], + cancelButtonIndex: 4, + }), + expect.any(Function), + ); }); it('should show connected status', async () => { @@ -162,7 +194,7 @@ describe('SubscriptionFlow Component', () => { it('should display active subscriptions when available', async () => { const activeSubscription = { - productId: 'test.subscription.1', + productId: 'dev.hyo.martie.premium', isActive: true, expirationDateIOS: new Date(Date.now() + 86400000), environmentIOS: 'Production', @@ -184,12 +216,12 @@ describe('SubscriptionFlow Component', () => { const {getByText} = await renderConnectedSubscriptionFlow(); expect(getByText('Current Subscription Status')).toBeDefined(); expect(getByText('✅ Active')).toBeDefined(); - expect(getByText('test.subscription.1')).toBeDefined(); + expect(getByText('dev.hyo.martie.premium')).toBeDefined(); }); it('should show expiration warning for soon-to-expire subscriptions', async () => { const expiringSubscription = { - productId: 'test.subscription.1', + productId: 'dev.hyo.martie.premium', isActive: true, expirationDateIOS: new Date(Date.now() + 86400000), willExpireSoon: true, @@ -219,7 +251,7 @@ describe('SubscriptionFlow Component', () => { }); const androidActiveSubscription = { - productId: 'test.android.subscription', + productId: 'dev.hyo.martie.premium', isActive: true, autoRenewingAndroid: false, willExpireSoon: true, @@ -252,7 +284,7 @@ describe('SubscriptionFlow Component', () => { getActiveSubscriptions: mockGetActiveSubscriptions, activeSubscriptions: [ { - productId: 'test.subscription.1', + productId: 'dev.hyo.martie.premium', isActive: true, expirationDateIOS: new Date(Date.now() + 86400000), }, @@ -387,7 +419,16 @@ describe('SubscriptionFlow Component', () => { }); expect(mockFinishTransaction).toHaveBeenCalledTimes(1); + expect(mockVerifyPurchase).not.toHaveBeenCalled(); expect(mockVerifyPurchaseWithProvider).toHaveBeenCalledTimes(2); + expect(mockVerifyPurchaseWithProvider.mock.calls[0]?.[0]).toEqual({ + provider: 'iapkit', + iapkit: { + apiKey: 'test-api-key', + baseUrl: 'http://192.168.0.10:3100', + google: {purchaseToken: 'android-token'}, + }, + }); expect(mockVerifyPurchaseWithProvider.mock.calls[1]?.[0]).toEqual( mockVerifyPurchaseWithProvider.mock.calls[0]?.[0], ); @@ -398,4 +439,45 @@ describe('SubscriptionFlow Component', () => { mockVerifyPurchaseWithProvider.mock.invocationCallOrder[1]!, ); }); + + it('keeps Local (Device) subscription verification direct', async () => { + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + mockShowActionSheetWithOptions.mockImplementation( + (_options: unknown, callback: (index?: number) => void) => callback(0), + ); + const {getByText} = await renderConnectedSubscriptionFlow(); + + fireEvent.press(getByText('Local (IAPKit)')); + await waitFor(() => { + expect(getByText('Local (Device)')).toBeDefined(); + }); + + await act(async () => { + await mockOnPurchaseSuccess?.({ + id: 'transaction-device-sub-1', + platform: 'ios', + productId: 'dev.hyo.martie.premium', + purchaseToken: 'device-sub-jws', + transactionDate: Date.now(), + }); + }); + + expect(mockVerifyPurchase).toHaveBeenCalledWith({ + apple: {sku: 'dev.hyo.martie.premium'}, + google: { + sku: 'dev.hyo.martie.premium', + packageName: 'dev.hyo.martie', + purchaseToken: 'device-sub-jws', + accessToken: '', + isSub: true, + }, + }); + expect(mockVerifyPurchaseWithProvider).not.toHaveBeenCalled(); + expect(mockVerifyPurchase.mock.invocationCallOrder[0]).toBeLessThan( + mockFinishTransaction.mock.invocationCallOrder[0]!, + ); + }); }); diff --git a/libraries/expo-iap/example/__tests__/vega-runtime.test.ts b/libraries/expo-iap/example/__tests__/vega-runtime.test.ts index c16721118..a3a6a2f45 100644 --- a/libraries/expo-iap/example/__tests__/vega-runtime.test.ts +++ b/libraries/expo-iap/example/__tests__/vega-runtime.test.ts @@ -13,6 +13,7 @@ jest.mock('expo-constants', () => ({ import { createIapkitVerificationPayload, getDefaultVerificationMethod, + resolveIapkitVerificationBaseUrl, } from '../src/utils/vegaRuntime'; import type {Purchase} from '../../src/types'; @@ -26,6 +27,7 @@ describe('Vega runtime example helpers', () => { store: 'amazon', } as Purchase, 'receipt-1', + 'http://localhost:3100', ); expect(payload).toMatchObject({ @@ -47,6 +49,7 @@ describe('Vega runtime example helpers', () => { store: 'google', } as Purchase, 'token-1', + 'http://localhost:3100', ); expect(payload).toMatchObject({ @@ -58,7 +61,44 @@ describe('Vega runtime example helpers', () => { }); }); - it('defaults to IAPKit verification when an API key is configured', () => { - expect(getDefaultVerificationMethod()).toBe('iapkit'); + it('defaults to local IAPKit when a key and local URL are configured', () => { + expect(getDefaultVerificationMethod()).toBe('iapkit-localhost'); + }); + + it('defaults to hosted IAPKit when only an API key is configured', () => { + expect(getDefaultVerificationMethod('test-api-key', '')).toBe('iapkit'); + }); + + it('does not enable verification without an API key', () => { + expect(getDefaultVerificationMethod('', 'http://localhost:3100')).toBe( + 'ignore', + ); + }); + + it('omits the configured local URL for hosted IAPKit', () => { + const baseUrl = resolveIapkitVerificationBaseUrl( + 'iapkit', + 'http://localhost:3100', + ); + const payload = createIapkitVerificationPayload( + { + id: 'token-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'token-1', + store: 'google', + } as Purchase, + 'token-1', + baseUrl, + ); + + expect(payload).not.toHaveProperty('baseUrl'); + }); + + it('requires an explicit base URL for local IAPKit', () => { + expect(() => + resolveIapkitVerificationBaseUrl('iapkit-localhost', ' '), + ).toThrow( + 'EXPO_PUBLIC_IAPKIT_BASE_URL not configured for Local (IAPKit) verification', + ); }); }); diff --git a/libraries/expo-iap/example/app/purchase-flow.tsx b/libraries/expo-iap/example/app/purchase-flow.tsx index b7b48c273..a47fc5ae3 100644 --- a/libraries/expo-iap/example/app/purchase-flow.tsx +++ b/libraries/expo-iap/example/app/purchase-flow.tsx @@ -38,11 +38,11 @@ import { createIapkitVerificationPayload, getDefaultVerificationMethod, getPurchaseCleanupKey, + resolveIapkitVerificationBaseUrl, showNativeAlert, + type VerificationMethod, } from '../src/utils/vegaRuntime'; -type VerificationMethod = 'ignore' | 'local' | 'iapkit'; - const CONSUMABLE_PRODUCT_ID_SET = new Set(CONSUMABLE_PRODUCT_IDS); const NON_CONSUMABLE_PRODUCT_ID_SET = new Set(NON_CONSUMABLE_PRODUCT_IDS); @@ -311,10 +311,12 @@ function PurchaseFlow({ > {verificationMethod === 'ignore' - ? '❌ None (Skip)' + ? 'None (Skip)' : verificationMethod === 'local' - ? '📱 Local (Device)' - : '☁️ IAPKit (Server)'} + ? 'Local (Device)' + : verificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'} Tap to change @@ -746,7 +748,7 @@ function PurchaseFlow({ * 1. initConnection - Store connection (handled by useIAP) * 2. subscribeEvent - Event subscription (onPurchaseSuccess/onPurchaseError) * 3. requestPurchase - 3 options: Apple, Google, Google with offers - * 4. verifyPurchase - 3 methods: ignore, local, iapkit + * 4. verify purchase - local device | local IAPKit | hosted IAPKit | skip * 5. grant entitlement - Update availablePurchases state * 6. finish transaction - Call finishTransaction to complete * ============================================================ @@ -828,10 +830,11 @@ function PurchaseFlowContainer() { } // ------------------------------------------------------------ - // Step 4: verifyPurchase - 3 methods available + // Step 4: four verification selections // - ignore: Skip verification (for testing) - // - local: Verify with Apple/Google directly - // - iapkit: Verify using IAPKit service + // - local: Direct Apple/Google verification on the device + // - iapkit-localhost: IAPKit provider through the local server + // - iapkit: IAPKit provider through the hosted service // ------------------------------------------------------------ const currentVerificationMethod = verificationMethodRef.current; console.log('[PurchaseFlow] About to verify purchase:', { @@ -843,63 +846,60 @@ function PurchaseFlowContainer() { if (currentVerificationMethod !== 'ignore' && productId) { setIsProcessing(true); try { - // Option 1: Local verification (device-based) if (currentVerificationMethod === 'local') { - console.log('[PurchaseFlow] Verifying with local method...'); - // All platform options can be provided - the library handles platform detection internally + console.log('[PurchaseFlow] Verifying with Local (Device)...'); await verifyPurchase({ apple: {sku: productId}, google: { sku: productId, packageName: 'dev.hyo.martie', - purchaseToken: purchase.purchaseToken ?? '', // Required for production - accessToken: '', // Requires server-issued OAuth token + purchaseToken: purchase.purchaseToken ?? '', + accessToken: '', // Requires a server-issued OAuth token. }, - // horizon: { sku: productId, userId: '', accessToken: '' } }); - console.log('[PurchaseFlow] Local verification completed'); - // Option 2: IAPKit verification (server-based) - } else if (currentVerificationMethod === 'iapkit') { - console.log('[PurchaseFlow] Verifying with IAPKit...'); + console.log('[PurchaseFlow] Local (Device) verification completed'); + } else { + const verificationLabel = + currentVerificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'; console.log( - '[PurchaseFlow] purchase.purchaseToken:', - purchase.purchaseToken && - typeof purchase.purchaseToken === 'string' - ? `✓ Present (${purchase.purchaseToken.length} chars)` - : '✗ Missing or empty', + `[PurchaseFlow] Verifying with ${verificationLabel}...`, ); const jwsOrToken = purchase.purchaseToken ?? ''; if (!jwsOrToken) { - console.log( - '[PurchaseFlow] No purchaseToken/JWS available for verification', - ); throw new Error( 'No purchase token available for IAPKit verification', ); } + const baseUrl = resolveIapkitVerificationBaseUrl( + currentVerificationMethod, + ); const iapkitPayload = createIapkitVerificationPayload( purchase, jwsOrToken, + baseUrl, ); const verifyRequest: VerifyPurchaseWithProviderProps = { provider: 'iapkit', iapkit: iapkitPayload, }; - console.log('[PurchaseFlow] Sending IAPKit verification request'); + console.log( + `[PurchaseFlow] Sending ${verificationLabel} verification request`, + ); const result = await verifyPurchaseWithProvider(verifyRequest); console.log('[PurchaseFlow] IAPKit verification result:', result); - // Show verification result to user if (result.iapkit) { const iapkitResult = result.iapkit; const statusEmoji = iapkitResult.isValid ? '✅' : '⚠️'; const stateText = iapkitResult.state || 'unknown'; showNativeAlert( - `${statusEmoji} IAPKit Verification`, + `${statusEmoji} ${verificationLabel} Verification`, `Valid: ${iapkitResult.isValid}\nState: ${stateText}\nStore: ${ iapkitResult.store || 'unknown' }`, @@ -1118,12 +1118,13 @@ function PurchaseFlowContainer() { const handleChangeVerificationMethod = useCallback(() => { const options = [ - 'None (Skip)', 'Local (Device)', - 'IAPKit (Server)', + 'Local (IAPKit)', + 'IAPKit', + 'None (Skip)', 'Cancel', ]; - const cancelButtonIndex = 3; + const cancelButtonIndex = 4; showActionSheetWithOptions( { @@ -1134,11 +1135,13 @@ function PurchaseFlowContainer() { }, (buttonIndex) => { if (buttonIndex === 0) { - setVerificationMethod('ignore'); - } else if (buttonIndex === 1) { setVerificationMethod('local'); + } else if (buttonIndex === 1) { + setVerificationMethod('iapkit-localhost'); } else if (buttonIndex === 2) { setVerificationMethod('iapkit'); + } else if (buttonIndex === 3) { + setVerificationMethod('ignore'); } }, ); diff --git a/libraries/expo-iap/example/app/subscription-flow.tsx b/libraries/expo-iap/example/app/subscription-flow.tsx index b9258de85..05d90bcc5 100644 --- a/libraries/expo-iap/example/app/subscription-flow.tsx +++ b/libraries/expo-iap/example/app/subscription-flow.tsx @@ -36,11 +36,11 @@ import { createIapkitVerificationPayload, getDefaultVerificationMethod, getPurchaseCleanupKey, + resolveIapkitVerificationBaseUrl, showNativeAlert, + type VerificationMethod, } from '../src/utils/vegaRuntime'; -type VerificationMethod = 'ignore' | 'local' | 'iapkit'; - // Subscription tier mapping - defined outside component to avoid recreation const TIER_MAP: Record = { 'dev.hyo.martie.premium': 1, // Monthly tier @@ -863,10 +863,12 @@ function SubscriptionFlow({ > {verificationMethod === 'ignore' - ? '❌ None (Skip)' + ? 'None (Skip)' : verificationMethod === 'local' - ? '📱 Local (Device)' - : '☁️ IAPKit (Server)'} + ? 'Local (Device)' + : verificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'} ▼ @@ -1504,7 +1506,7 @@ function SubscriptionFlow({ * 1. initConnection - Store connection (useIAP handles automatically) * 2. subscribeEvent - Listen for purchase events (onPurchaseSuccess/Error) * 3. requestPurchase - Apple: {sku}, Google: {skus, subscriptionOffers} - * 4. verifyPurchase - ignore | local | iapkit + * 4. verify purchase - local device | local IAPKit | hosted IAPKit | skip * 5. grant entitlement - Update activeSubscriptions state * 6. finish transaction - finishTransaction({purchase, isConsumable: false}) * @@ -1742,10 +1744,11 @@ function SubscriptionFlowContainer() { setPurchaseResult('Subscription received; finishing transaction...'); // ------------------------------------------------------------ - // Step 4: verifyPurchase - 3 methods available - // - ignore: Skip verification (for testing only) - // - local: Verify with Apple/Google directly (client-side) - // - iapkit: Verify using IAPKit service (server-side, recommended) + // Step 4: four verification selections + // - ignore: Skip verification (for testing) + // - local: Direct Apple/Google verification on the device + // - iapkit-localhost: IAPKit provider through the local server + // - iapkit: IAPKit provider through the hosted service // // Server-side validation recommended for: // iOS: App Store Server API + Server Notifications V2 @@ -1763,43 +1766,43 @@ function SubscriptionFlowContainer() { setIsProcessing(true); try { if (currentVerificationMethod === 'local') { - console.log('[SubscriptionFlow] Verifying with local method...'); - // All platform options can be provided - the library handles platform detection internally + console.log('[SubscriptionFlow] Verifying with Local (Device)...'); await verifyPurchase({ apple: {sku: productId}, google: { sku: productId, packageName: 'dev.hyo.martie', purchaseToken: purchase.purchaseToken ?? '', - accessToken: '', // ⚠️ Requires server-issued OAuth token + accessToken: '', // Requires a server-issued OAuth token. isSub: true, }, - // horizon: { sku: productId, userId: '', accessToken: '' } }); - console.log('[SubscriptionFlow] Local verification completed'); - } else if (currentVerificationMethod === 'iapkit') { - console.log('[SubscriptionFlow] Verifying with IAPKit...'); console.log( - '[SubscriptionFlow] purchase.purchaseToken:', - purchase.purchaseToken && - typeof purchase.purchaseToken === 'string' - ? `✓ Present (${purchase.purchaseToken.length} chars)` - : '✗ Missing or empty', + '[SubscriptionFlow] Local (Device) verification completed', + ); + } else { + const verificationLabel = + currentVerificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'; + console.log( + `[SubscriptionFlow] Verifying with ${verificationLabel}...`, ); const jwsOrToken = purchase.purchaseToken ?? ''; if (!jwsOrToken) { - console.log( - '[SubscriptionFlow] No purchaseToken/JWS available for verification', - ); throw new Error( 'No purchase token available for IAPKit verification', ); } + const baseUrl = resolveIapkitVerificationBaseUrl( + currentVerificationMethod, + ); const iapkitPayload = createIapkitVerificationPayload( purchase, jwsOrToken, + baseUrl, ); const verifyRequest: VerifyPurchaseWithProviderProps = { provider: 'iapkit', @@ -1807,7 +1810,7 @@ function SubscriptionFlowContainer() { }; iapkitVerifyRequest = verifyRequest; console.log( - '[SubscriptionFlow] Sending IAPKit verification request', + `[SubscriptionFlow] Sending ${verificationLabel} verification request`, ); const result = await verifyPurchaseWithProvider(verifyRequest); @@ -1816,14 +1819,13 @@ function SubscriptionFlowContainer() { result, ); - // Show verification result to user if (result.iapkit) { const iapkitResult = result.iapkit; const statusEmoji = iapkitResult.isValid ? '✅' : '⚠️'; const stateText = iapkitResult.state || 'unknown'; showNativeAlert( - `${statusEmoji} IAPKit Verification`, + `${statusEmoji} ${verificationLabel} Verification`, `Valid: ${iapkitResult.isValid}\nState: ${stateText}\nStore: ${ iapkitResult.store || 'unknown' }`, @@ -2191,35 +2193,40 @@ function SubscriptionFlowContainer() { const handleChangeVerificationMethod = useCallback(() => { const options = [ - 'Ignore Verification', - 'Local Verification', - 'IAPKit Verification', + 'Local (Device)', + 'Local (IAPKit)', + 'IAPKit', + 'None (Skip)', 'Cancel', ]; - const cancelButtonIndex = 3; + const cancelButtonIndex = 4; showActionSheetWithOptions( { title: 'Select Purchase Verification Method', message: 'Choose how to verify purchases after successful transactions.\n\n' + - '• Ignore: Skip verification (for testing)\n' + - '• Local: Verify with Apple/Google directly\n' + - '• IAPKit: Verify using IAPKit service', + '• Local (Device): Verify directly with Apple or Google\n' + + '• Local (IAPKit): Verify through your configured local server\n' + + '• IAPKit: Verify through kit.openiap.dev\n' + + '• None (Skip): Skip verification (for testing)', options, cancelButtonIndex, }, (selectedIndex?: number) => { switch (selectedIndex) { case 0: - setVerificationMethod('ignore'); + setVerificationMethod('local'); break; case 1: - setVerificationMethod('local'); + setVerificationMethod('iapkit-localhost'); break; case 2: setVerificationMethod('iapkit'); break; + case 3: + setVerificationMethod('ignore'); + break; } }, ); diff --git a/libraries/expo-iap/example/src/utils/vegaRuntime.ts b/libraries/expo-iap/example/src/utils/vegaRuntime.ts index 9678d8cb1..8543ca2b1 100644 --- a/libraries/expo-iap/example/src/utils/vegaRuntime.ts +++ b/libraries/expo-iap/example/src/utils/vegaRuntime.ts @@ -7,16 +7,18 @@ import type { export type IapkitVerificationPayload = NonNullable< VerifyPurchaseWithProviderProps['iapkit'] -> & { - baseUrl?: string | null; -}; +>; type ExpoExtraWithIapkit = { iapkitApiKey?: string; iapkitBaseUrl?: string; }; -export type VerificationMethod = 'ignore' | 'local' | 'iapkit'; +export type VerificationMethod = + | 'ignore' + | 'local' + | 'iapkit-localhost' + | 'iapkit'; function getConfiguredIapkitApiKey(): string | undefined { const extra = Constants.expoConfig?.extra as ExpoExtraWithIapkit | undefined; @@ -28,8 +30,15 @@ function getConfiguredIapkitBaseUrl(): string | undefined { return extra?.iapkitBaseUrl ?? process.env.EXPO_PUBLIC_IAPKIT_BASE_URL; } -export function getDefaultVerificationMethod(): VerificationMethod { - return getConfiguredIapkitApiKey()?.trim() ? 'iapkit' : 'ignore'; +export function getDefaultVerificationMethod( + apiKey: string | null | undefined = getConfiguredIapkitApiKey(), + baseUrl: string | null | undefined = getConfiguredIapkitBaseUrl(), +): VerificationMethod { + if (!apiKey?.trim()) { + return 'ignore'; + } + + return baseUrl?.trim() ? 'iapkit-localhost' : 'iapkit'; } function withIapkitEndpoint( @@ -46,6 +55,24 @@ function withIapkitEndpoint( }; } +export function resolveIapkitVerificationBaseUrl( + method: 'iapkit-localhost' | 'iapkit', + configuredBaseUrl: string | null | undefined = getConfiguredIapkitBaseUrl(), +): string | undefined { + if (method === 'iapkit') { + return undefined; + } + + const baseUrl = configuredBaseUrl?.trim(); + if (!baseUrl) { + throw new Error( + 'EXPO_PUBLIC_IAPKIT_BASE_URL not configured for Local (IAPKit) verification', + ); + } + + return baseUrl; +} + export type TvRemoteEvent = { eventKeyAction?: number; eventType?: string; @@ -75,16 +102,20 @@ export function showNativeAlert(title: string, message?: string): void { export function createIapkitVerificationPayload( purchase: Purchase, purchaseToken: string, - baseUrl: string | null | undefined = getConfiguredIapkitBaseUrl(), + baseUrl?: string | null, ): IapkitVerificationPayload { const apiKey = getConfiguredIapkitApiKey()?.trim(); + if (!apiKey) { + throw new Error('EXPO_PUBLIC_IAPKIT_API_KEY not configured'); + } + const purchaseStore = ( (purchase as Purchase & {store?: string | null}).store ?? '' ).toLowerCase(); if (purchaseStore === 'amazon') { return withIapkitEndpoint( { - ...(apiKey ? {apiKey} : {}), + apiKey, amazon: { receiptId: purchaseToken, sandbox: __DEV__, @@ -100,13 +131,13 @@ export function createIapkitVerificationPayload( return withIapkitEndpoint( isApplePurchase ? { - ...(apiKey ? {apiKey} : {}), + apiKey, apple: { jws: purchaseToken, }, } : { - ...(apiKey ? {apiKey} : {}), + apiKey, google: { purchaseToken, }, diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index 6be06e301..ca9b5c6aa 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -1117,9 +1117,23 @@ describe('Amazon Vega Expo adapter', () => { } }); - it('supports custom IAPKit base URLs for Vega verification', async () => { + it.each([ + ['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'], + [ + 'https://[2001:db8::1]:65535///', + 'https://[2001:db8::1]:65535/v1/purchase/verify', + ], + ])('supports custom IAPKit base URL %s', async (baseUrl, expectedUrl) => { const service = createService(); const originalFetch = globalThis.fetch; + const originalUrl = globalThis.URL; + class KeplerUrl { + get protocol(): never { + throw new Error('URL.protocol is not implemented on Kepler'); + } + } const fetchMock = jest.fn( async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json({ @@ -1129,6 +1143,7 @@ describe('Amazon Vega Expo adapter', () => { }), ) as unknown as jest.MockedFunction; globalThis.fetch = fetchMock; + globalThis.URL = KeplerUrl as unknown as typeof URL; try { const module = createExpoIapVegaModule(service); @@ -1137,20 +1152,72 @@ describe('Amazon Vega Expo adapter', () => { provider: 'iapkit', iapkit: { apiKey: 'kit-key', - baseUrl: 'http://localhost:3100/', + baseUrl, amazon: { userId: 'amazon-user', receiptId: 'receipt-vega-1', }, }, - } as Parameters[0] & { - iapkit: {baseUrl: string}; }); - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3100/v1/purchase/verify', - expect.any(Object), - ); + expect(fetchMock).toHaveBeenCalledWith(expectedUrl, expect.any(Object)); + } finally { + globalThis.fetch = originalFetch; + globalThis.URL = originalUrl; + } + }); + + it.each([ + 'ftp://localhost:3100', + 'http://user:pass@localhost:3100', + 'http://localhost:3100/path', + 'http://localhost:3100?debug=1', + 'http://localhost:3100\\path', + 'http://localhost:0', + 'http://localhost:99999', + 'http://[]:3100', + 'http://[garbage]:3100', + 'http://[:::]:3100', + 'http://[deadbeef]:3100', + 'http://[1::2::3]:3100', + 'http://[1:2:3:4:5:6:7:8:9]:3100', + 'http://[::ffff:999.1.1.1]:3100', + 'http://[::1', + 'http://::1:3100', + 'http://127.00.0.1:3100', + 'http://0x7f.0.0.1:3100', + 'http://0x7f000001:3100', + 'http://0x7f.0.0.1.:3100', + 'http://example.123:3100', + 'http://example.0x7f:3100', + 'http://[::ffff:192.168.001.1]:3100', + 'http://999.999.999.999:3100', + 'http://%:3100', + ])('rejects non-origin IAPKit base URL %s', async (baseUrl) => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn() as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createExpoIapVegaModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + baseUrl, + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.DeveloperError, + message: 'IAPKit baseUrl must be a valid HTTP(S) origin', + }); + expect(fetchMock).not.toHaveBeenCalled(); } finally { globalThis.fetch = originalFetch; } diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 1ec00503c..76b2df063 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1978,6 +1978,13 @@ export interface RequestVerifyPurchaseWithIapkitProps { apiKey?: (string | null); /** Apple App Store verification parameters. */ apple?: (RequestVerifyPurchaseWithIapkitAppleProps | null); + /** + * Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + * Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + * Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + * The apiKey must be issued by the same IAPKit/Convex deployment as this server. + */ + baseUrl?: (string | null); /** Google Play Store verification parameters. */ google?: (RequestVerifyPurchaseWithIapkitGoogleProps | null); } diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts index b8a48152a..80435e910 100644 --- a/libraries/expo-iap/src/vega-adapter.ts +++ b/libraries/expo-iap/src/vega-adapter.ts @@ -188,6 +188,129 @@ function createVegaError( return error; } +function isValidIpv4Address(address: string): boolean { + const octets = address.split('.'); + return ( + octets.length === 4 && + octets.every( + (octet) => + /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255, + ) + ); +} + +function isValidIpv6Address(address: string): boolean { + let ipv6Part = address; + let ipv4GroupCount = 0; + + if (address.includes('.')) { + const lastColon = address.lastIndexOf(':'); + if (lastColon < 0 || !isValidIpv4Address(address.slice(lastColon + 1))) { + return false; + } + const ipv6Prefix = address.slice(0, lastColon); + ipv6Part = ipv6Prefix.endsWith(':') ? `${ipv6Prefix}:` : ipv6Prefix; + ipv4GroupCount = 2; + } + + if ( + !ipv6Part.includes(':') || + !/^[0-9a-f:]+$/i.test(ipv6Part) || + ipv6Part.includes(':::') + ) { + return false; + } + + const compressionIndex = ipv6Part.indexOf('::'); + const hasCompression = compressionIndex >= 0; + if ( + (hasCompression && ipv6Part.indexOf('::', compressionIndex + 2) >= 0) || + (!hasCompression && (ipv6Part.startsWith(':') || ipv6Part.endsWith(':'))) + ) { + return false; + } + + const sections = hasCompression ? ipv6Part.split('::') : [ipv6Part]; + const groups: string[] = []; + for (const section of sections) { + if (section.length > 0) groups.push(...section.split(':')); + } + if (!groups.every((group) => /^[0-9a-f]{1,4}$/i.test(group))) { + return false; + } + + const groupCount = groups.length + ipv4GroupCount; + return hasCompression ? groupCount < 8 : groupCount === 8; +} + +function isValidHostname(hostname: string): boolean { + if (/^[0-9.]+$/.test(hostname)) { + return isValidIpv4Address(hostname); + } + + const normalizedHostname = hostname.endsWith('.') + ? hostname.slice(0, -1) + : hostname; + if (normalizedHostname.length === 0 || normalizedHostname.length > 253) { + return false; + } + const hostnameLabels = normalizedHostname.split('.'); + const lastLabel = hostnameLabels[hostnameLabels.length - 1]!; + if (/^(?:[0-9]+|0x[0-9a-f]+)$/i.test(lastLabel)) { + return false; + } + + return normalizedHostname + .split('.') + .every( + (label) => + label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label), + ); +} + +function getIapkitVerifyUrl(baseUrl?: string | null): string { + const requestedBaseUrl = + typeof baseUrl === 'string' && baseUrl.trim().length > 0 + ? baseUrl.trim() + : IAPKIT_DEFAULT_BASE_URL; + const normalizedBaseUrl = requestedBaseUrl.replace(/\/+$/, ''); + // Kepler's URL polyfill throws for standard getters such as protocol, + // host, and pathname. Parse the small origin-only contract directly. + const originMatch = /^(https?):\/\/([^/?#@\s\\]+)$/i.exec(normalizedBaseUrl); + if (!originMatch) { + throw createVegaError( + ErrorCode.DeveloperError, + 'IAPKit baseUrl must be a valid HTTP(S) origin', + ); + } + + const authority = originMatch[2]!; + const isBracketedIpv6 = authority.startsWith('['); + const authorityMatch = isBracketedIpv6 + ? /^\[([^\]]+)\](?::([0-9]+))?$/.exec(authority) + : /^([^:]+)(?::([0-9]+))?$/.exec(authority); + const host = authorityMatch?.[1]; + const requestedPort = authorityMatch?.[2]; + const portNumber = requestedPort ? Number(requestedPort) : null; + const hasValidHost = + typeof host === 'string' && + (isBracketedIpv6 ? isValidIpv6Address(host) : isValidHostname(host)); + const hasValidPort = + portNumber === null || + (Number.isInteger(portNumber) && portNumber >= 1 && portNumber <= 65535); + if (!authorityMatch || !hasValidHost || !hasValidPort) { + throw createVegaError( + ErrorCode.DeveloperError, + 'IAPKit baseUrl must be a valid HTTP(S) origin', + ); + } + + const scheme = originMatch[1]!.toLowerCase(); + const serializedHost = isBracketedIpv6 ? `[${host!}]` : host!; + const port = requestedPort ? `:${requestedPort}` : ''; + return `${scheme}://${serializedHost}${port}${IAPKIT_VERIFY_PATH}`; +} + function toPurchaseErrorPayload( error: unknown, fallbackMessage: string, @@ -936,27 +1059,6 @@ export function createExpoIapVegaModule( const verifyWithIapkit = async ( options: VerifyPurchaseWithProviderProps, ): Promise => { - type IapkitEndpointOptions = NonNullable< - VerifyPurchaseWithProviderProps['iapkit'] - > & { - baseUrl?: string | null; - }; - - function iapkitVerifyUrl( - iapkit: VerifyPurchaseWithProviderProps['iapkit'], - ): string { - const endpointOptions = iapkit as - | IapkitEndpointOptions - | null - | undefined; - const baseUrl = - typeof endpointOptions?.baseUrl === 'string' && - endpointOptions.baseUrl.trim().length > 0 - ? endpointOptions.baseUrl.trim() - : IAPKIT_DEFAULT_BASE_URL; - return `${baseUrl.replace(/\/+$/, '')}${IAPKIT_VERIFY_PATH}`; - } - function normalizeIapkitState(state: unknown): IapkitPurchaseState { const normalized = typeof state === 'string' @@ -1111,6 +1213,7 @@ export function createExpoIapVegaModule( const apiKey = typeof iapkit?.apiKey === 'string' ? iapkit.apiKey.trim() : ''; + const verificationUrl = getIapkitVerifyUrl(iapkit?.baseUrl); let response: Response; try { const controller = new AbortController(); @@ -1118,7 +1221,7 @@ export function createExpoIapVegaModule( () => controller.abort(), IAPKIT_VERIFY_TIMEOUT_MS, ); - response = await fetch(iapkitVerifyUrl(iapkit), { + response = await fetch(verificationUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt index 82cd8e39d..369fcb96f 100644 --- a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt @@ -1318,6 +1318,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act (params["iapkit"] as? Map<*, *>)?.let { iapkit -> val iapkitMap = mutableMapOf() (iapkit["apiKey"] as? String)?.let { iapkitMap["apiKey"] = it } + (iapkit["baseUrl"] as? String)?.let { iapkitMap["baseUrl"] = it } ((iapkit["google"] as? Map<*, *>)?.get("purchaseToken") as? String)?.let { purchaseToken -> iapkitMap["google"] = mapOf("purchaseToken" to purchaseToken) } diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index 6b23da37d..bc81f48e4 100644 --- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -990,6 +990,9 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { if let apiKey = iapkit["apiKey"] as? String { iapkitDict["apiKey"] = apiKey } + if let baseUrl = iapkit["baseUrl"] as? String { + iapkitDict["baseUrl"] = baseUrl + } if let jws = (iapkit["apple"] as? [String: Any])?["jws"] as? String { iapkitDict["apple"] = ["jws": jws] } diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 18f8482f9..141a29d21 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -2036,6 +2036,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { args['iapkit'] = { if (iapkit.apiKey != null) 'apiKey': iapkit.apiKey, if (iapkit.apple != null) 'apple': {'jws': iapkit.apple!.jws}, + if (iapkit.baseUrl != null) 'baseUrl': iapkit.baseUrl, if (iapkit.google != null) 'google': {'purchaseToken': iapkit.google!.purchaseToken}, if (iapkit.amazon != null) diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index df6f95736..9b3fae0db 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -5476,6 +5476,7 @@ class RequestVerifyPurchaseWithIapkitProps { this.amazon, this.apiKey, this.apple, + this.baseUrl, this.google, }); @@ -5485,6 +5486,11 @@ class RequestVerifyPurchaseWithIapkitProps { final String? apiKey; /// Apple App Store verification parameters. final RequestVerifyPurchaseWithIapkitAppleProps? apple; + /// Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + /// Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + /// Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + /// The apiKey must be issued by the same IAPKit/Convex deployment as this server. + final String? baseUrl; /// Google Play Store verification parameters. final RequestVerifyPurchaseWithIapkitGoogleProps? google; @@ -5493,6 +5499,7 @@ class RequestVerifyPurchaseWithIapkitProps { amazon: json['amazon'] != null ? RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(json['amazon'] as Map) : null, apiKey: json['apiKey'] as String?, apple: json['apple'] != null ? RequestVerifyPurchaseWithIapkitAppleProps.fromJson(json['apple'] as Map) : null, + baseUrl: json['baseUrl'] as String?, google: json['google'] != null ? RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(json['google'] as Map) : null, ); } @@ -5502,6 +5509,7 @@ class RequestVerifyPurchaseWithIapkitProps { 'amazon': amazon?.toJson(), 'apiKey': apiKey, 'apple': apple?.toJson(), + 'baseUrl': baseUrl, 'google': google?.toJson(), }; } diff --git a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index 64837f86d..c2b81c7f8 100644 --- a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -923,6 +923,9 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { if let apiKey = iapkit["apiKey"] as? String { iapkitDict["apiKey"] = apiKey } + if let baseUrl = iapkit["baseUrl"] as? String { + iapkitDict["baseUrl"] = baseUrl + } if let jws = (iapkit["apple"] as? [String: Any])?["jws"] as? String { iapkitDict["apple"] = ["jws": jws] } 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 964d40a40..324e4a222 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 @@ -2448,6 +2448,57 @@ void main() { expect(result.iapkit!.store, types.IapStore.Apple); }); + test('forwards custom IAPKit baseUrl to the native payload', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); + switch (call.method) { + case 'initConnection': + return true; + case 'verifyPurchaseWithProvider': + return { + 'provider': 'iapkit', + 'iapkit': { + 'isValid': true, + 'state': 'entitled', + 'store': 'apple', + }, + }; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + + await iap.initConnection(); + + await iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + apiKey: 'test-api-key', + apple: types.RequestVerifyPurchaseWithIapkitAppleProps( + jws: 'test-jws-token', + ), + baseUrl: 'http://127.0.0.1:4174', + ), + ); + + final verifyCall = calls.singleWhere( + (MethodCall call) => call.method == 'verifyPurchaseWithProvider', + ); + final payload = Map.from( + verifyCall.arguments as Map, + ); + final iapkitPayload = Map.from( + payload['iapkit'] as Map, + ); + + expect(iapkitPayload['baseUrl'], 'http://127.0.0.1:4174'); + }); + test('sends correct payload for Android verification', () async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger diff --git a/libraries/flutter_inapp_purchase/test/iapkit_base_url_bridge_test.dart b/libraries/flutter_inapp_purchase/test/iapkit_base_url_bridge_test.dart new file mode 100644 index 000000000..81d56066b --- /dev/null +++ b/libraries/flutter_inapp_purchase/test/iapkit_base_url_bridge_test.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('native plugins forward the IAPKit baseUrl', () { + final ios = File( + 'ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift', + ).readAsStringSync(); + final macos = File( + 'macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift', + ).readAsStringSync(); + final android = File( + 'android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt', + ).readAsStringSync(); + + const appleForwarding = 'if let baseUrl = iapkit["baseUrl"] as? String {'; + expect(ios, contains(appleForwarding)); + expect(ios, contains('iapkitDict["baseUrl"] = baseUrl')); + expect(macos, contains(appleForwarding)); + expect(macos, contains('iapkitDict["baseUrl"] = baseUrl')); + expect( + android, + contains( + '(iapkit["baseUrl"] as? String)?.let { iapkitMap["baseUrl"] = it }', + ), + ); + }); +} diff --git a/libraries/godot-iap/Example/tests/test_native_extension.gd b/libraries/godot-iap/Example/tests/test_native_extension.gd new file mode 100644 index 000000000..f4bab2ee9 --- /dev/null +++ b/libraries/godot-iap/Example/tests/test_native_extension.gd @@ -0,0 +1,32 @@ +extends SceneTree +## Verifies that the tracked macOS framework pair can be loaded by Godot. +## Run with: godot --headless --script tests/test_native_extension.gd + + +func _init() -> void: + if OS.get_name() != "macOS": + print("SKIP: Native GDExtension load check is macOS-only") + quit(0) + return + + if not ClassDB.class_exists("GodotIap"): + _fail("GodotIap GDExtension class was not registered") + return + + if not ClassDB.can_instantiate("GodotIap"): + _fail("GodotIap GDExtension class cannot be instantiated") + return + + var native_plugin = ClassDB.instantiate("GodotIap") + if native_plugin == null: + _fail("GodotIap GDExtension instantiation returned null") + return + + native_plugin = null + print("PASS: GodotIap GDExtension loaded and instantiated") + quit(0) + + +func _fail(message: String) -> void: + push_error(message) + quit(1) diff --git a/libraries/godot-iap/Makefile b/libraries/godot-iap/Makefile index 3452b9438..bcdccb849 100644 --- a/libraries/godot-iap/Makefile +++ b/libraries/godot-iap/Makefile @@ -21,6 +21,7 @@ EXAMPLE_DIR := $(PROJECT_ROOT)/Example ADDON_DIR := $(PROJECT_ROOT)/addons/godot-iap BIN_DIR := $(ADDON_DIR)/bin IOS_EXPORT_DIR := $(EXAMPLE_DIR)/ios +APPLE_FRAMEWORK_INSTALLER := $(PROJECT_ROOT)/scripts/install_apple_framework.sh # Godot executable GODOT ?= /Applications/Godot.app/Contents/MacOS/Godot @@ -106,9 +107,12 @@ ios-build: @echo "$(GREEN)Building iOS frameworks...$(NC)" @cd $(IOS_GDEXT_DIR) && xcodebuild -scheme GodotIap -sdk iphoneos -destination 'generic/platform=iOS' -configuration Release -derivedDataPath .build-xcode build @echo "$(GREEN)Copying frameworks to addon...$(NC)" - @rm -rf $(BIN_DIR)/ios/*.framework - @cp -R $(IOS_GDEXT_DIR)/.build-xcode/Build/Products/Release-iphoneos/PackageFrameworks/GodotIap.framework $(BIN_DIR)/ios/ - @cp -R $(IOS_GDEXT_DIR)/.build-xcode/Build/Products/Release-iphoneos/PackageFrameworks/SwiftGodotRuntime.framework $(BIN_DIR)/ios/ + @$(APPLE_FRAMEWORK_INSTALLER) \ + $(IOS_GDEXT_DIR)/.build-xcode/Build/Products/Release-iphoneos/PackageFrameworks/SwiftGodotRuntime.framework \ + $(BIN_DIR)/ios/SwiftGodotRuntime.framework ios + @$(APPLE_FRAMEWORK_INSTALLER) \ + $(IOS_GDEXT_DIR)/.build-xcode/Build/Products/Release-iphoneos/PackageFrameworks/GodotIap.framework \ + $(BIN_DIR)/ios/GodotIap.framework ios @echo "$(GREEN)✓ Frameworks copied$(NC)" # Build macOS plugin (automated with xcodebuild) @@ -120,28 +124,12 @@ macos-build: @echo "$(GREEN)Building macOS frameworks...$(NC)" @cd "$(IOS_GDEXT_DIR)" && xcodebuild -scheme GodotIap -sdk macosx -destination 'platform=macOS' ARCHS="$(MACOS_ARCHS)" PRODUCT_BUNDLE_IDENTIFIER="dev.hyo.godot-iap.GodotIap" -configuration Release -derivedDataPath .build-xcode-macos build @echo "$(GREEN)Copying frameworks to addon...$(NC)" - @rm -rf "$(BIN_DIR)/macos/"*.framework - @cp -R "$(IOS_GDEXT_DIR)/.build-xcode-macos/Build/Products/Release/PackageFrameworks/GodotIap.framework" "$(BIN_DIR)/macos/" - @cp -R "$(IOS_GDEXT_DIR)/.build-xcode-macos/Build/Products/Release/PackageFrameworks/SwiftGodotRuntime.framework" "$(BIN_DIR)/macos/" - @echo "$(GREEN)Fixing macOS framework rpaths...$(NC)" - @if [ -f "$(BIN_DIR)/macos/GodotIap.framework/Versions/A/GodotIap" ]; then \ - GODOT_IAP_BINARY="$(BIN_DIR)/macos/GodotIap.framework/Versions/A/GodotIap"; \ - GODOT_IAP_RPATH="@loader_path/../../../"; \ - elif [ -f "$(BIN_DIR)/macos/GodotIap.framework/GodotIap" ]; then \ - GODOT_IAP_BINARY="$(BIN_DIR)/macos/GodotIap.framework/GodotIap"; \ - GODOT_IAP_RPATH="@loader_path/../"; \ - else \ - echo "$(RED)GodotIap macOS framework binary not found.$(NC)"; \ - exit 1; \ - fi; \ - install_name_tool -delete_rpath @loader_path/../../../ "$$GODOT_IAP_BINARY" 2>/dev/null || true; \ - install_name_tool -delete_rpath @loader_path/../ "$$GODOT_IAP_BINARY" 2>/dev/null || true; \ - install_name_tool -add_rpath "$$GODOT_IAP_RPATH" "$$GODOT_IAP_BINARY" - @echo "$(GREEN)Signing macOS frameworks...$(NC)" - @codesign --force --deep --sign - --timestamp=none "$(BIN_DIR)/macos/SwiftGodotRuntime.framework" - @codesign --force --deep --sign - --timestamp=none "$(BIN_DIR)/macos/GodotIap.framework" - @codesign --verify --deep --strict --verbose=2 "$(BIN_DIR)/macos/SwiftGodotRuntime.framework" - @codesign --verify --deep --strict --verbose=2 "$(BIN_DIR)/macos/GodotIap.framework" + @$(APPLE_FRAMEWORK_INSTALLER) \ + $(IOS_GDEXT_DIR)/.build-xcode-macos/Build/Products/Release/PackageFrameworks/SwiftGodotRuntime.framework \ + $(BIN_DIR)/macos/SwiftGodotRuntime.framework macos + @$(APPLE_FRAMEWORK_INSTALLER) \ + $(IOS_GDEXT_DIR)/.build-xcode-macos/Build/Products/Release/PackageFrameworks/GodotIap.framework \ + $(BIN_DIR)/macos/GodotIap.framework macos @echo "$(GREEN)Updating macOS GDExtension metadata...$(NC)" @MACOS_X86_LIBRARY=""; \ MACOS_X86_DEPENDENCY=""; \ @@ -191,9 +179,10 @@ all: android ios macos @echo "" @echo "$(GREEN)All builds complete!$(NC)" -# Run GDScript unit tests (types only - no native plugin required) +# Run GDScript unit tests and verify the tracked macOS native framework pair test: @echo "$(GREEN)Running GDScript unit tests...$(NC)" + @cd $(EXAMPLE_DIR) && $(GODOT) --headless --script tests/test_native_extension.gd @cd $(EXAMPLE_DIR) && $(GODOT) --headless --script tests/test_types_only.gd @cd $(EXAMPLE_DIR) && $(GODOT) --headless --script tests/test_godot_iap.gd @echo "$(GREEN)✓ Tests complete$(NC)" diff --git a/libraries/godot-iap/addons/godot-iap/android/GodotIap.debug.aar b/libraries/godot-iap/addons/godot-iap/android/GodotIap.debug.aar index 5d2cb18b4..3e85d5f0b 100644 Binary files a/libraries/godot-iap/addons/godot-iap/android/GodotIap.debug.aar and b/libraries/godot-iap/addons/godot-iap/android/GodotIap.debug.aar differ diff --git a/libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap b/libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap index fa6b1b0c6..94ad9658a 100644 --- a/libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap +++ b/libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap @@ -5,4 +5,4 @@ binary="GodotIap.release.aar" [dependencies] local=[] -remote=["io.github.hyochan.openiap:openiap-google:2.3.0-rc.1", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"] +remote=["io.github.hyochan.openiap:openiap-google:2.3.1", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"] diff --git a/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar b/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar index 3944a37ca..cbac7d279 100644 Binary files a/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar and b/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar differ diff --git a/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap b/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap index 616c0a0f6..5a93b5710 100755 Binary files a/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap and b/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap differ diff --git a/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime b/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime index 91679d37f..73945b1c6 100755 Binary files a/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime and b/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime differ diff --git a/libraries/godot-iap/addons/godot-iap/bin/macos/GodotIap.framework/GodotIap b/libraries/godot-iap/addons/godot-iap/bin/macos/GodotIap.framework/GodotIap index 52c4b7ac8..0dd5b2c96 100755 Binary files a/libraries/godot-iap/addons/godot-iap/bin/macos/GodotIap.framework/GodotIap and b/libraries/godot-iap/addons/godot-iap/bin/macos/GodotIap.framework/GodotIap differ diff --git a/libraries/godot-iap/addons/godot-iap/bin/macos/SwiftGodotRuntime.framework/SwiftGodotRuntime b/libraries/godot-iap/addons/godot-iap/bin/macos/SwiftGodotRuntime.framework/SwiftGodotRuntime index 12ceaf54d..0a0846abe 100755 Binary files a/libraries/godot-iap/addons/godot-iap/bin/macos/SwiftGodotRuntime.framework/SwiftGodotRuntime and b/libraries/godot-iap/addons/godot-iap/bin/macos/SwiftGodotRuntime.framework/SwiftGodotRuntime differ diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 3d6681169..248d9ca35 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -5139,6 +5139,8 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null + ## Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + var base_url: Variant = null ## Apple App Store verification parameters. var apple: RequestVerifyPurchaseWithIapkitAppleProps ## Google Play Store verification parameters. @@ -5150,6 +5152,8 @@ class RequestVerifyPurchaseWithIapkitProps: var obj = RequestVerifyPurchaseWithIapkitProps.new() if data.has("apiKey") and data["apiKey"] != null: obj.api_key = data["apiKey"] + if data.has("baseUrl") and data["baseUrl"] != null: + obj.base_url = data["baseUrl"] if data.has("apple") and data["apple"] != null: if data["apple"] is Dictionary: obj.apple = RequestVerifyPurchaseWithIapkitAppleProps.from_dict(data["apple"]) @@ -5171,6 +5175,8 @@ class RequestVerifyPurchaseWithIapkitProps: var dict = {} if api_key != null: dict["apiKey"] = api_key + if base_url != null: + dict["baseUrl"] = base_url if apple != null: if apple.has_method("to_dict"): dict["apple"] = apple.to_dict() diff --git a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt index c82039f44..1c002675d 100644 --- a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt +++ b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt @@ -25,6 +25,25 @@ import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunc import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalLinkParams +internal fun normalizeVerifyPurchaseWithProviderProps( + props: Map, +): Map { + if (props["iapkit"] != null) return props + + val legacyIapkit = linkedMapOf() + listOf("amazon", "apiKey", "apple", "baseUrl", "google").forEach { key -> + if (props[key] != null) { + legacyIapkit[key] = props[key] + } + } + if (legacyIapkit.isEmpty()) return props + + return linkedMapOf( + "provider" to (props["provider"] ?: PurchaseVerificationProvider.Iapkit.toJson()), + "iapkit" to legacyIapkit, + ) +} + /** * GodotIap - Godot plugin for in-app purchases using OpenIAP * @@ -1230,24 +1249,9 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { } } - fun normalizeProviderProps(props: Map): Map { - if (props["iapkit"] != null) return props - - val legacyIapkit = linkedMapOf() - listOf("amazon", "apiKey", "apple", "google").forEach { key -> - if (props[key] != null) { - legacyIapkit[key] = props[key] - } - } - if (legacyIapkit.isEmpty()) return props - - return linkedMapOf( - "provider" to (props["provider"] ?: PurchaseVerificationProvider.Iapkit.toJson()), - "iapkit" to legacyIapkit - ) - } - - val propsMap = normalizeProviderProps(jsonBridge.objectToMap(JSONObject(propsJson))) + val propsMap = normalizeVerifyPurchaseWithProviderProps( + jsonBridge.objectToMap(JSONObject(propsJson)), + ) val providerProps = VerifyPurchaseWithProviderProps.fromJson(propsMap) ?: throw IllegalArgumentException("Invalid verifyPurchaseWithProvider options") diff --git a/libraries/godot-iap/android/src/test/java/dev/hyo/godotiap/GodotIapVerificationBridgeTest.kt b/libraries/godot-iap/android/src/test/java/dev/hyo/godotiap/GodotIapVerificationBridgeTest.kt new file mode 100644 index 000000000..9ca849f1d --- /dev/null +++ b/libraries/godot-iap/android/src/test/java/dev/hyo/godotiap/GodotIapVerificationBridgeTest.kt @@ -0,0 +1,41 @@ +package dev.hyo.godotiap + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class GodotIapVerificationBridgeTest { + @Test + fun `legacy iapkit fields include custom base url`() { + val props = linkedMapOf( + "provider" to "iapkit", + "apiKey" to "test-api-key", + "baseUrl" to "http://10.0.2.2:4174", + "google" to mapOf("purchaseToken" to "purchase-token"), + ) + + val normalized = normalizeVerifyPurchaseWithProviderProps(props) + val iapkit = normalized["iapkit"] as Map<*, *> + + assertEquals("iapkit", normalized["provider"]) + assertEquals("test-api-key", iapkit["apiKey"]) + assertEquals("http://10.0.2.2:4174", iapkit["baseUrl"]) + assertEquals( + mapOf("purchaseToken" to "purchase-token"), + iapkit["google"], + ) + } + + @Test + fun `nested iapkit payload is passed through unchanged`() { + val props = linkedMapOf( + "provider" to "iapkit", + "iapkit" to mapOf( + "baseUrl" to "http://10.0.2.2:4174", + "google" to mapOf("purchaseToken" to "purchase-token"), + ), + ) + + assertSame(props, normalizeVerifyPurchaseWithProviderProps(props)) + } +} diff --git a/libraries/godot-iap/scripts/install_apple_framework.sh b/libraries/godot-iap/scripts/install_apple_framework.sh new file mode 100755 index 000000000..4894413d1 --- /dev/null +++ b/libraries/godot-iap/scripts/install_apple_framework.sh @@ -0,0 +1,104 @@ +#!/bin/sh + +set -eu + +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +SOURCE_FRAMEWORK=$1 +DESTINATION_FRAMEWORK=$2 +PLATFORM=$3 + +case "$PLATFORM" in + ios|macos) ;; + *) + echo "Unsupported Apple platform: $PLATFORM" >&2 + exit 2 + ;; +esac + +case "$SOURCE_FRAMEWORK:$DESTINATION_FRAMEWORK" in + *.framework:*.framework) ;; + *) + echo "Source and destination must both be .framework paths" >&2 + exit 2 + ;; +esac + +FRAMEWORK_NAME=$(basename "$SOURCE_FRAMEWORK" .framework) +DESTINATION_NAME=$(basename "$DESTINATION_FRAMEWORK" .framework) + +if [ "$FRAMEWORK_NAME" != "$DESTINATION_NAME" ]; then + echo "Source and destination framework names must match: $FRAMEWORK_NAME != $DESTINATION_NAME" >&2 + exit 2 +fi + +if [ -f "$SOURCE_FRAMEWORK/$FRAMEWORK_NAME" ]; then + SOURCE_BINARY="$SOURCE_FRAMEWORK/$FRAMEWORK_NAME" +elif [ -f "$SOURCE_FRAMEWORK/Versions/A/$FRAMEWORK_NAME" ]; then + SOURCE_BINARY="$SOURCE_FRAMEWORK/Versions/A/$FRAMEWORK_NAME" +else + echo "Framework binary not found: $SOURCE_FRAMEWORK" >&2 + exit 1 +fi + +if [ -f "$SOURCE_FRAMEWORK/Info.plist" ]; then + SOURCE_INFO_PLIST="$SOURCE_FRAMEWORK/Info.plist" +elif [ -f "$SOURCE_FRAMEWORK/Versions/A/Resources/Info.plist" ]; then + SOURCE_INFO_PLIST="$SOURCE_FRAMEWORK/Versions/A/Resources/Info.plist" +else + echo "Framework Info.plist not found: $SOURCE_FRAMEWORK" >&2 + exit 1 +fi + +# Keep the checked-in plist when rebuilding an existing framework. Xcode writes +# machine- and SDK-specific metadata into generated plists even when the bundle +# contract is unchanged, which otherwise creates unrelated binary churn. +PRESERVED_INFO_PLIST="" +cleanup() { + if [ -n "$PRESERVED_INFO_PLIST" ]; then + rm -f "$PRESERVED_INFO_PLIST" + fi +} +trap cleanup EXIT HUP INT TERM + +if [ -f "$DESTINATION_FRAMEWORK/Info.plist" ]; then + PRESERVED_INFO_PLIST=$(mktemp "${TMPDIR:-/tmp}/godot-iap-info.XXXXXX") + cp "$DESTINATION_FRAMEWORK/Info.plist" "$PRESERVED_INFO_PLIST" +fi + +# Normalize both unversioned iOS frameworks and Xcode's versioned macOS +# frameworks to the flat layout referenced by godot_iap.gdextension. +rm -rf "$DESTINATION_FRAMEWORK" +mkdir -p "$DESTINATION_FRAMEWORK" +cp "$SOURCE_BINARY" "$DESTINATION_FRAMEWORK/$FRAMEWORK_NAME" +chmod 755 "$DESTINATION_FRAMEWORK/$FRAMEWORK_NAME" + +if [ -n "$PRESERVED_INFO_PLIST" ]; then + cp "$PRESERVED_INFO_PLIST" "$DESTINATION_FRAMEWORK/Info.plist" +else + cp "$SOURCE_INFO_PLIST" "$DESTINATION_FRAMEWORK/Info.plist" + plutil -convert xml1 "$DESTINATION_FRAMEWORK/Info.plist" +fi + +if [ "$PLATFORM" = "macos" ]; then + DESTINATION_BINARY="$DESTINATION_FRAMEWORK/$FRAMEWORK_NAME" + install_name_tool -id "@rpath/$FRAMEWORK_NAME.framework/$FRAMEWORK_NAME" "$DESTINATION_BINARY" + + VERSIONED_RUNTIME="@rpath/SwiftGodotRuntime.framework/Versions/A/SwiftGodotRuntime" + FLAT_RUNTIME="@rpath/SwiftGodotRuntime.framework/SwiftGodotRuntime" + if otool -L "$DESTINATION_BINARY" | grep -Fq "$VERSIONED_RUNTIME"; then + install_name_tool -change "$VERSIONED_RUNTIME" "$FLAT_RUNTIME" "$DESTINATION_BINARY" + fi + + if [ "$FRAMEWORK_NAME" = "GodotIap" ]; then + install_name_tool -delete_rpath "@loader_path/../../../" "$DESTINATION_BINARY" 2>/dev/null || true + install_name_tool -delete_rpath "@loader_path/../" "$DESTINATION_BINARY" 2>/dev/null || true + install_name_tool -add_rpath "@loader_path/../" "$DESTINATION_BINARY" + fi + + codesign --force --deep --sign - --timestamp=none "$DESTINATION_FRAMEWORK" + codesign --verify --deep --strict --verbose=2 "$DESTINATION_FRAMEWORK" +fi 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 1f6389179..960c00e48 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 @@ -2274,6 +2274,7 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase { userId = amazon.userId ) }, + baseUrl = iapkitOptions.baseUrl, google = googleOptions?.let { google -> AndroidVerifyPurchaseWithIapkitGoogleProps( purchaseToken = google.purchaseToken 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 new file mode 100644 index 000000000..e0dad4555 --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt @@ -0,0 +1,16 @@ +package io.github.hyochan.kmpiap + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class IapkitBaseUrlBridgeTest { + @Test + fun playBridgeForwardsBaseUrlToOpenIap() { + val source = File( + "src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt" + ).readText() + + assertTrue(source.contains("baseUrl = iapkitOptions.baseUrl")) + } +} 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 6b4975d6c..5e431f92f 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 @@ -5683,6 +5683,13 @@ public data class RequestVerifyPurchaseWithIapkitProps( * Apple App Store verification parameters. */ val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null, + /** + * Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + * Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + * Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + * The apiKey must be issued by the same IAPKit/Convex deployment as this server. + */ + val baseUrl: String? = null, /** * Google Play Store verification parameters. */ @@ -5694,6 +5701,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( amazon = (json["amazon"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(it) }, apiKey = json["apiKey"] as? String, apple = (json["apple"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAppleProps.fromJson(it) }, + baseUrl = json["baseUrl"] as? String, google = (json["google"] as? Map)?.let { RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(it) }, ) } @@ -5703,6 +5711,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( "amazon" to amazon?.toJson(), "apiKey" to apiKey, "apple" to apple?.toJson(), + "baseUrl" to baseUrl, "google" to google?.toJson(), ) } diff --git a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt index 9cc40cf04..8f384f4d1 100644 --- a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt +++ b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/VerificationTest.kt @@ -448,10 +448,12 @@ class VerificationTest { fun testRequestVerifyPurchaseWithIapkitPropsToJson() { val props = RequestVerifyPurchaseWithIapkitProps( apiKey = "key123", + baseUrl = "http://192.168.0.4:3100", apple = RequestVerifyPurchaseWithIapkitAppleProps(jws = "jws-value") ) val json = props.toJson() assertEquals("key123", json["apiKey"]) + assertEquals("http://192.168.0.4:3100", json["baseUrl"]) assertNotNull(json["apple"]) } 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 fd6c8fae3..b4bc7d802 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,6 +1071,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { openIapModule.verifyPurchaseWithProviderObjCWithProvider( provider = provider, apiKey = apiKey, + baseUrl = iapkit.baseUrl, jws = jws ) { result, error -> if (error != null) { diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index 9ce1179aa..c09558eb2 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -4581,6 +4581,12 @@ public sealed record RequestVerifyPurchaseWithIapkitProps /// API key used for the Authorization header (Bearer {apiKey}). [JsonPropertyName("apiKey")] public string? ApiKey { get; init; } + /// Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + /// Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + /// Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + /// The apiKey must be issued by the same IAPKit/Convex deployment as this server. + [JsonPropertyName("baseUrl")] + public string? BaseUrl { get; init; } /// Apple App Store verification parameters. [JsonPropertyName("apple")] public RequestVerifyPurchaseWithIapkitAppleProps? Apple { get; init; } diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 5bc76d0f3..f6ec12cff 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -1462,6 +1462,7 @@ class HybridRnIap : HybridRnIapSpec() { // Use provided apiKey, or fallback to host app AndroidManifest meta-data. val apiKey = iapkit.apiKey.unwrapString() ?: getIapkitApiKeyFromManifest() apiKey?.let { iapkitMap["apiKey"] = it } + iapkit.baseUrl.unwrapString()?.let { iapkitMap["baseUrl"] = it } (iapkit.google as? Variant_NullType_NitroVerifyPurchaseWithIapkitGoogleProps.Second)?.value?.let { google -> iapkitMap["google"] = mapOf("purchaseToken" to google.purchaseToken) } diff --git a/libraries/react-native-iap/example/.env.example b/libraries/react-native-iap/example/.env.example index 9410244b4..54eab9fb0 100644 --- a/libraries/react-native-iap/example/.env.example +++ b/libraries/react-native-iap/example/.env.example @@ -1,6 +1,7 @@ # IAPKit Configuration -# Get your API key from https://kit.openiap.dev +# Hosted keys: https://kit.openiap.dev +# Local (IAPKit): use a key issued by the same Convex deployment as the server. IAPKIT_API_KEY=your_iapkit_api_key_here -# Use your Mac's LAN IP for Vega / Fire TV device testing. +# Required when selecting Local (IAPKit). Use your Mac's LAN IP on a device. # Example: http://192.168.0.10:3100 IAPKIT_BASE_URL= diff --git a/libraries/react-native-iap/example/README.md b/libraries/react-native-iap/example/README.md index c40337f5d..eeab3aebb 100644 --- a/libraries/react-native-iap/example/README.md +++ b/libraries/react-native-iap/example/README.md @@ -29,6 +29,25 @@ This project is part of a **Yarn workspace** structure. All commands should be r yarn example:start ``` +### Purchase Verification + +Create the ignored environment file from the example before testing IAPKit: + +```sh +cp example/.env.example example/.env +``` + +For hosted IAPKit, get a key from the [IAPKit dashboard](https://kit.openiap.dev). Set `IAPKIT_API_KEY` to a key issued by the IAPKit/Convex deployment that the selected server uses. For **Local (IAPKit)**, the key and local server must target the same Convex deployment. Also set `IAPKIT_BASE_URL` to the device-reachable HTTP(S) origin only; do not append `/v1/purchase/verify`. A physical iPhone must use the Mac's LAN address. An Android device connected over USB can use `http://127.0.0.1:3100`: inspect `adb -s "$ANDROID_SERIAL" reverse --list`, reuse an exact `tcp:3100` mapping when present, or create it with `adb -s "$ANDROID_SERIAL" reverse --no-rebind tcp:3100 tcp:3100`. Record whether this run created the rule and remove only that rule during cleanup; if `--no-rebind` fails, use another port instead of overwriting an existing mapping. + +The purchase and subscription screens list verification in this order: + +1. **Local (Device)** — direct Apple/Google verification on the device. +2. **Local (IAPKit)** — IAPKit provider routed to `IAPKIT_BASE_URL`. +3. **IAPKit** — hosted IAPKit; the local URL is deliberately omitted. +4. **None (Skip)** — skip verification. + +With both values configured, the example defaults to **Local (IAPKit)**. With only the key, it defaults to hosted **IAPKit**; without a key, it defaults to **None (Skip)**. + ### VSCode Integration If you're using **VSCode**, you can use the pre-configured **launch.json** configurations for easy development: diff --git a/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx b/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx index 8731230a5..5e1d91e99 100644 --- a/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/PurchaseFlow.test.tsx @@ -1,10 +1,19 @@ import {render, fireEvent, waitFor, act} from '@testing-library/react-native'; -import {Alert} from 'react-native'; +import {ActionSheetIOS, Alert, Platform} from 'react-native'; import PurchaseFlow from '../../screens/PurchaseFlow'; import * as RNIap from 'react-native-iap'; import {PRODUCT_IDS} from '../../src/utils/constants'; import {ErrorCode} from 'react-native-iap'; +jest.mock( + '@env', + () => ({ + IAPKIT_API_KEY: 'test-api-key', + IAPKIT_BASE_URL: 'http://192.168.0.10:3100', + }), + {virtual: true}, +); + describe('PurchaseFlow Screen', () => { const requestPurchaseMock = RNIap.requestPurchase as jest.Mock; const alertSpy = jest.spyOn(Alert, 'alert'); @@ -238,6 +247,7 @@ describe('PurchaseFlow Screen', () => { beforeEach(() => { jest.clearAllMocks(); + Platform.OS = 'ios'; mockIapState(); }); @@ -267,6 +277,7 @@ describe('PurchaseFlow Screen', () => { expect(getByText('10 Bulbs')).toBeTruthy(); expect(getByText('30 Bulbs')).toBeTruthy(); + expect(getByText('Local (IAPKit)')).toBeTruthy(); }); it('initiates purchase when purchase button pressed', () => { @@ -289,12 +300,141 @@ describe('PurchaseFlow Screen', () => { }); }); + it('routes Local (IAPKit) through the configured local server', async () => { + const {finishTransaction, verifyPurchase, verifyPurchaseWithProvider} = + mockIapState(); + + render(); + + await act(async () => { + await onPurchaseSuccess?.({ + id: 'transaction-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'apple-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }); + }); + + expect(verifyPurchase).not.toHaveBeenCalled(); + expect(verifyPurchaseWithProvider).toHaveBeenCalledWith({ + provider: 'iapkit', + iapkit: { + apiKey: 'test-api-key', + baseUrl: 'http://192.168.0.10:3100', + apple: {jws: 'apple-jws'}, + }, + }); + expect(verifyPurchaseWithProvider.mock.invocationCallOrder[0]).toBeLessThan( + finishTransaction.mock.invocationCallOrder[0]!, + ); + }); + + it('keeps Local (Device) on direct Apple/Google verification', async () => { + const selectorSpy = jest + .spyOn(ActionSheetIOS, 'showActionSheetWithOptions') + .mockImplementation((_options, callback) => callback(0)); + const {finishTransaction, verifyPurchase, verifyPurchaseWithProvider} = + mockIapState(); + const {getByText} = render(); + + fireEvent.press(getByText('Local (IAPKit)')); + await waitFor(() => { + expect(getByText('Local (Device)')).toBeTruthy(); + }); + + await act(async () => { + await onPurchaseSuccess?.({ + id: 'transaction-device-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'device-apple-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }); + }); + + expect(verifyPurchase).toHaveBeenCalledWith({ + apple: {sku: 'dev.hyo.martie.10bulbs'}, + google: { + sku: 'dev.hyo.martie.10bulbs', + accessToken: 'YOUR_OAUTH_ACCESS_TOKEN', + packageName: 'dev.hyo.martie', + purchaseToken: 'device-apple-jws', + isSub: false, + }, + }); + expect(verifyPurchaseWithProvider).not.toHaveBeenCalled(); + expect(verifyPurchase.mock.invocationCallOrder[0]).toBeLessThan( + finishTransaction.mock.invocationCallOrder[0]!, + ); + + selectorSpy.mockRestore(); + }); + + it('omits the local base URL when hosted IAPKit is selected', async () => { + const selectorSpy = jest + .spyOn(ActionSheetIOS, 'showActionSheetWithOptions') + .mockImplementation((_options, callback) => callback(2)); + const {verifyPurchaseWithProvider} = mockIapState(); + const {getByText} = render(); + + fireEvent.press(getByText('Local (IAPKit)')); + await waitFor(() => { + expect(getByText('IAPKit')).toBeTruthy(); + }); + + await act(async () => { + await onPurchaseSuccess?.({ + id: 'transaction-2', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'hosted-apple-jws', + store: 'apple', + transactionDate: Date.now(), + purchaseState: 'purchased', + }); + }); + + expect(verifyPurchaseWithProvider).toHaveBeenCalledWith({ + provider: 'iapkit', + iapkit: { + apiKey: 'test-api-key', + apple: {jws: 'hosted-apple-jws'}, + }, + }); + + selectorSpy.mockRestore(); + }); + + it('renders Android verification choices together in the requested order', async () => { + Platform.OS = 'android'; + const {getAllByTestId, getByText} = render(); + + fireEvent.press(getByText('Local (IAPKit)')); + + const options = getAllByTestId('verification-method-option'); + expect(options.map((option) => option.props.accessibilityLabel)).toEqual([ + 'Local (Device)', + 'Local (IAPKit)', + 'IAPKit', + 'None (Skip)', + ]); + + fireEvent.press(options[3]!); + await waitFor(() => { + expect(getByText('None (Skip)')).toBeTruthy(); + }); + }); + it('updates state on purchase success callback', async () => { const {finishTransaction} = mockIapState(); const {getByText, queryByText} = render(); - expect(queryByText(/Purchase completed and finished successfully/)).toBeNull(); + expect( + queryByText(/Purchase completed and finished successfully/), + ).toBeNull(); await act(async () => { await onPurchaseSuccess?.({ diff --git a/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx b/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx index 999469cff..c584c150c 100644 --- a/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/SubscriptionFlow.test.tsx @@ -1,5 +1,5 @@ import {render, fireEvent, waitFor, act} from '@testing-library/react-native'; -import {Alert, Platform} from 'react-native'; +import {ActionSheetIOS, Alert, Platform} from 'react-native'; import SubscriptionFlow from '../../screens/SubscriptionFlow'; import * as RNIap from 'react-native-iap'; import {SUBSCRIPTION_PRODUCT_IDS} from '../../src/utils/constants'; @@ -8,7 +8,7 @@ jest.mock( '@env', () => ({ IAPKIT_API_KEY: 'test-api-key', - IAPKIT_BASE_URL: '', + IAPKIT_BASE_URL: 'http://192.168.0.10:3100', }), {virtual: true}, ); @@ -125,6 +125,7 @@ describe('SubscriptionFlow Screen', () => { expect(getByText('Premium Subscription')).toBeTruthy(); expect(getByText('$9.99/month')).toBeTruthy(); + expect(getByText('Local (IAPKit)')).toBeTruthy(); }); it('initiates subscription purchase when button pressed', () => { @@ -200,9 +201,51 @@ describe('SubscriptionFlow Screen', () => { ); }); + it('keeps Local (Device) subscription verification direct', async () => { + const selectorSpy = jest + .spyOn(ActionSheetIOS, 'showActionSheetWithOptions') + .mockImplementation((_options, callback) => callback(0)); + const {finishTransaction, verifyPurchase, verifyPurchaseWithProvider} = + mockIapState(); + const {getByText} = render(); + + fireEvent.press(getByText('Local (IAPKit)')); + await waitFor(() => { + expect(getByText('Local (Device)')).toBeTruthy(); + }); + + await act(async () => { + await onPurchaseSuccess?.({ + id: 'transaction-device-sub-1', + platform: 'ios', + productId: 'dev.hyo.martie.premium', + purchaseToken: 'device-sub-jws', + transactionDate: Date.now(), + }); + }); + + expect(verifyPurchase).toHaveBeenCalledWith({ + apple: {sku: 'dev.hyo.martie.premium'}, + google: { + sku: 'dev.hyo.martie.premium', + accessToken: 'YOUR_OAUTH_ACCESS_TOKEN', + packageName: 'dev.hyo.martie', + purchaseToken: 'device-sub-jws', + isSub: true, + }, + }); + expect(verifyPurchaseWithProvider).not.toHaveBeenCalled(); + expect(verifyPurchase.mock.invocationCallOrder[0]).toBeLessThan( + finishTransaction.mock.invocationCallOrder[0]!, + ); + + selectorSpy.mockRestore(); + }); + it('re-verifies the Android IAPKit snapshot after finishing', async () => { Platform.OS = 'android'; - const {finishTransaction, verifyPurchaseWithProvider} = mockIapState(); + const {finishTransaction, verifyPurchase, verifyPurchaseWithProvider} = + mockIapState(); render(); @@ -217,7 +260,16 @@ describe('SubscriptionFlow Screen', () => { }); expect(finishTransaction).toHaveBeenCalledTimes(1); + expect(verifyPurchase).not.toHaveBeenCalled(); expect(verifyPurchaseWithProvider).toHaveBeenCalledTimes(2); + expect(verifyPurchaseWithProvider.mock.calls[0]?.[0]).toEqual({ + provider: 'iapkit', + iapkit: { + apiKey: 'test-api-key', + baseUrl: 'http://192.168.0.10:3100', + google: {purchaseToken: 'android-token'}, + }, + }); expect(verifyPurchaseWithProvider.mock.calls[1]?.[0]).toEqual( verifyPurchaseWithProvider.mock.calls[0]?.[0], ); diff --git a/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts b/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts index 74163c746..d528674b2 100644 --- a/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts +++ b/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts @@ -1,5 +1,9 @@ import type {Purchase} from 'react-native-iap'; -import {createIapkitVerificationPayload} from '../../src/utils/vegaRuntime'; +import {getDefaultVerificationMethod} from '../../src/hooks/useVerificationMethod'; +import { + createIapkitVerificationPayload, + resolveIapkitVerificationBaseUrl, +} from '../../src/utils/vegaRuntime'; describe('Vega runtime example helpers', () => { it('uses Amazon receipt verification when purchase store is Amazon', () => { @@ -64,4 +68,41 @@ describe('Vega runtime example helpers', () => { }, }); }); + + it('selects local IAPKit by default only when key and URL are configured', () => { + expect( + getDefaultVerificationMethod('test-api-key', 'http://192.168.0.10:3100'), + ).toBe('iapkit-localhost'); + expect(getDefaultVerificationMethod('test-api-key', '')).toBe('iapkit'); + expect(getDefaultVerificationMethod('', 'http://192.168.0.10:3100')).toBe( + 'ignore', + ); + }); + + it('keeps hosted IAPKit free of a configured local base URL', () => { + expect( + resolveIapkitVerificationBaseUrl('iapkit', 'http://192.168.0.10:3100'), + ).toBeUndefined(); + }); + + it('requires an explicit base URL for local IAPKit', () => { + expect(() => + resolveIapkitVerificationBaseUrl('iapkit-localhost', ' '), + ).toThrow('IAPKIT_BASE_URL not configured for Local (IAPKit) verification'); + }); + + it('requires an API key for every IAPKit verification', () => { + expect(() => + createIapkitVerificationPayload( + { + id: 'token-1', + productId: 'dev.hyo.martie.10bulbs', + purchaseToken: 'token-1', + store: 'google', + } as unknown as Purchase, + 'token-1', + ' ', + ), + ).toThrow('IAPKIT_API_KEY not configured'); + }); }); diff --git a/libraries/react-native-iap/example/screens/PurchaseFlow.tsx b/libraries/react-native-iap/example/screens/PurchaseFlow.tsx index 8aa1cb17c..321ec8bc5 100644 --- a/libraries/react-native-iap/example/screens/PurchaseFlow.tsx +++ b/libraries/react-native-iap/example/screens/PurchaseFlow.tsx @@ -33,6 +33,7 @@ import { import { createIapkitVerificationPayload, getPurchaseCleanupKey, + resolveIapkitVerificationBaseUrl, showNativeAlert, } from '../src/utils/vegaRuntime'; import type { @@ -42,6 +43,7 @@ import type { VerifyPurchaseWithProviderProps, } from 'react-native-iap'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; +import VerificationMethodSelectorModal from '../src/components/VerificationMethodSelectorModal'; const CONSUMABLE_PRODUCT_ID_SET = new Set(CONSUMABLE_PRODUCT_IDS); const NON_CONSUMABLE_PRODUCT_ID_SET = new Set(NON_CONSUMABLE_PRODUCT_IDS); @@ -211,10 +213,12 @@ function PurchaseFlow({ > {verificationMethod === 'ignore' - ? '❌ None (Skip)' + ? 'None (Skip)' : verificationMethod === 'local' - ? '📱 Local (Device)' - : '☁️ IAPKit (Server)'} + ? 'Local (Device)' + : verificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'} Tap to change @@ -561,8 +565,9 @@ function PurchaseFlow({ * Option C: Cross-platform using `request` object (recommended) * * 4. VERIFY PURCHASE - * - Local verification: Direct API call to Apple/Google - * - IAPKit verification: Server-side verification via IAPKit service + * - Local (Device): Direct Apple/Google verification on the device + * - Local (IAPKit): Verify through a locally running IAPKit server + * - IAPKit: Verify through kit.openiap.dev * - Skip verification: For testing only (not recommended for production) * * 5. GRANT ENTITLEMENT @@ -591,8 +596,13 @@ function PurchaseFlowContainer() { const { verificationMethod, verificationMethodRef, + verificationMethodSelectorVisible, + hideVerificationMethodSelector, + selectVerificationMethod, showVerificationMethodSelector, - } = useVerificationMethod(getDefaultVerificationMethod(IAPKIT_API_KEY)); + } = useVerificationMethod( + getDefaultVerificationMethod(IAPKIT_API_KEY, IAPKIT_BASE_URL), + ); const cleanupPurchaseKeysRef = useRef(new Set()); // ────────────────────────────────────────────────────────────────────────── @@ -647,8 +657,9 @@ function PurchaseFlowContainer() { // ────────────────────────────────────────────────────────────────────── // Choose verification method based on user selection: // - 'ignore': Skip verification (testing only) - // - 'local': Direct API verification with Apple/Google - // - 'iapkit': Server-side verification via IAPKit + // - 'local': Direct Apple/Google verification on the device + // - 'iapkit-localhost': IAPKit provider through the local server + // - 'iapkit': IAPKit provider through the hosted service const currentVerificationMethod = verificationMethodRef.current; console.log('[PurchaseFlow] About to verify purchase:', { verificationMethod: currentVerificationMethod, @@ -659,86 +670,69 @@ function PurchaseFlowContainer() { if (currentVerificationMethod !== 'ignore' && productId) { setIsProcessing(true); try { - // ── Option A: Local Verification ────────────────────────────────── if (currentVerificationMethod === 'local') { - console.log('[PurchaseFlow] Verifying with local method...'); - // Platform-specific verification API - // Provide all platform options - library handles platform detection - // - // ⚠️ SECURITY WARNING: The accessToken below is a PLACEHOLDER. - // NEVER ship OAuth tokens directly in your app bundle! - // In production, your mobile app should: - // 1. Send the purchaseToken to YOUR backend server - // 2. Your backend authenticates with Google Play Developer API - // 3. Your backend returns the verification result to the app - // This example uses a placeholder for demonstration purposes only. + console.log('[PurchaseFlow] Verifying with Local (Device)...'); + // This token is intentionally a placeholder. Production apps must + // obtain Google Play API credentials from their backend. await verifyPurchase({ apple: {sku: productId}, google: { sku: productId, - // PLACEHOLDER - Replace with token fetched from your backend accessToken: 'YOUR_OAUTH_ACCESS_TOKEN', packageName: 'dev.hyo.martie', purchaseToken: purchase.purchaseToken ?? '', isSub: false, }, - // horizon: { sku: productId, userId: '...', accessToken: '...' } }); - console.log('[PurchaseFlow] Local verification completed'); - } - // ── Option B: IAPKit Server Verification ────────────────────────── - else if (currentVerificationMethod === 'iapkit') { - console.log('[PurchaseFlow] Verifying with IAPKit...'); - // NOTE: Set your API key in .env file as IAPKIT_API_KEY - const apiKey = IAPKIT_API_KEY; - + console.log('[PurchaseFlow] Local (Device) verification completed'); + } else { + const verificationLabel = + currentVerificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'; console.log( - '[PurchaseFlow] API Key loaded:', - apiKey ? '✓ Present' : '✗ Missing', - ); - console.log( - '[PurchaseFlow] purchase.purchaseToken:', - purchase.purchaseToken - ? `✓ Present (${purchase.purchaseToken.length} chars)` - : '✗ Missing or empty', + `[PurchaseFlow] Verifying with ${verificationLabel}...`, ); + const apiKey = IAPKIT_API_KEY?.trim(); if (!apiKey) { throw new Error('IAPKIT_API_KEY not configured'); } const jwsOrToken = purchase.purchaseToken ?? ''; if (!jwsOrToken) { - console.log( - '[PurchaseFlow] No purchaseToken/JWS available for verification', - ); throw new Error( 'No purchase token available for IAPKit verification', ); } + const baseUrl = resolveIapkitVerificationBaseUrl( + currentVerificationMethod, + IAPKIT_BASE_URL, + ); const iapkitPayload = createIapkitVerificationPayload( purchase, jwsOrToken, apiKey, - IAPKIT_BASE_URL, + baseUrl, ); const verifyRequest: VerifyPurchaseWithProviderProps = { provider: 'iapkit', iapkit: iapkitPayload, }; - console.log('[PurchaseFlow] Sending IAPKit verification request'); + console.log( + `[PurchaseFlow] Sending ${verificationLabel} verification request`, + ); const result = await verifyPurchaseWithProvider(verifyRequest); console.log('[PurchaseFlow] IAPKit verification result:', result); - // Show verification result to user if (result.iapkit) { const statusEmoji = result.iapkit.isValid ? '✅' : '⚠️'; const stateText = result.iapkit.state || 'unknown'; showNativeAlert( - `${statusEmoji} IAPKit Verification`, + `${statusEmoji} ${verificationLabel} Verification`, `Valid: ${result.iapkit.isValid}\nState: ${stateText}\nStore: ${ result.iapkit.store || 'unknown' }`, @@ -970,19 +964,27 @@ function PurchaseFlowContainer() { }, [fetchStorefront]); return ( - + <> + + + ); } diff --git a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx index 3ebf9afe4..1b99383d3 100644 --- a/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx +++ b/libraries/react-native-iap/example/screens/SubscriptionFlow.tsx @@ -35,9 +35,11 @@ import { import { createIapkitVerificationPayload, getPurchaseCleanupKey, + resolveIapkitVerificationBaseUrl, showNativeAlert, } from '../src/utils/vegaRuntime'; import PurchaseSummaryRow from '../src/components/PurchaseSummaryRow'; +import VerificationMethodSelectorModal from '../src/components/VerificationMethodSelectorModal'; import {IAPKIT_API_KEY, IAPKIT_BASE_URL} from '@env'; type ExtendedPurchase = Purchase & { @@ -830,10 +832,12 @@ function SubscriptionFlow({ > {verificationMethod === 'ignore' - ? '❌ None (Skip)' + ? 'None (Skip)' : verificationMethod === 'local' - ? '📱 Local (Device)' - : '☁️ IAPKit (Server)'} + ? 'Local (Device)' + : verificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'} Tap to change @@ -1634,8 +1638,13 @@ function SubscriptionFlowContainer() { const { verificationMethod, verificationMethodRef, + verificationMethodSelectorVisible, + hideVerificationMethodSelector, + selectVerificationMethod, showVerificationMethodSelector, - } = useVerificationMethod(getDefaultVerificationMethod(IAPKIT_API_KEY)); + } = useVerificationMethod( + getDefaultVerificationMethod(IAPKIT_API_KEY, IAPKIT_BASE_URL), + ); const lastSuccessAtRef = useRef(0); const connectedRef = useRef(false); @@ -1736,8 +1745,9 @@ function SubscriptionFlowContainer() { // ────────────────────────────────────────────────────────────────────── // Choose verification method: // - 'ignore': Skip verification (testing only - NOT for production) - // - 'local': Direct API verification with Apple/Google - // - 'iapkit': Server-side verification via IAPKit (recommended) + // - 'local': Direct Apple/Google verification on the device + // - 'iapkit-localhost': IAPKit provider through the local server + // - 'iapkit': IAPKit provider through the hosted service // // ⚠️ Server-side validation is recommended for production: // - iOS: App Store Server API + App Store Server Notifications V2 @@ -1754,58 +1764,52 @@ function SubscriptionFlowContainer() { setIsProcessing(true); try { if (currentVerificationMethod === 'local') { - console.log('[SubscriptionFlow] Verifying with local method...'); - // New platform-specific verification API - provide all platform options - // The library internally handles which options to use based on platform + console.log('[SubscriptionFlow] Verifying with Local (Device)...'); + // Production apps must obtain Google Play API credentials from + // their backend rather than bundling them in the client. await verifyPurchase({ apple: {sku: productId}, google: { sku: productId, - // NOTE: accessToken must be obtained from your backend server - // that has authenticated with Google Play Developer API accessToken: 'YOUR_OAUTH_ACCESS_TOKEN', packageName: 'dev.hyo.martie', purchaseToken: purchase.purchaseToken ?? '', isSub: true, }, - // horizon: { sku: productId, userId: '...', accessToken: '...' } }); - console.log('[SubscriptionFlow] Local verification completed'); - } else if (currentVerificationMethod === 'iapkit') { - console.log('[SubscriptionFlow] Verifying with IAPKit...'); - // NOTE: Set your API key in .env file as IAPKIT_API_KEY - const apiKey = IAPKIT_API_KEY; - console.log( - '[SubscriptionFlow] API Key loaded:', - apiKey ? '✓ Present' : '✗ Missing', + '[SubscriptionFlow] Local (Device) verification completed', ); + } else { + const verificationLabel = + currentVerificationMethod === 'iapkit-localhost' + ? 'Local (IAPKit)' + : 'IAPKit'; console.log( - '[SubscriptionFlow] purchase.purchaseToken:', - purchase.purchaseToken - ? `✓ Present (${purchase.purchaseToken.length} chars)` - : '✗ Missing or empty', + `[SubscriptionFlow] Verifying with ${verificationLabel}...`, ); + const apiKey = IAPKIT_API_KEY?.trim(); if (!apiKey) { throw new Error('IAPKIT_API_KEY not configured'); } const jwsOrToken = purchase.purchaseToken ?? ''; if (!jwsOrToken) { - console.log( - '[SubscriptionFlow] No purchaseToken/JWS available for verification', - ); throw new Error( 'No purchase token available for IAPKit verification', ); } + const baseUrl = resolveIapkitVerificationBaseUrl( + currentVerificationMethod, + IAPKIT_BASE_URL, + ); const iapkitPayload = createIapkitVerificationPayload( purchase, jwsOrToken, apiKey, - IAPKIT_BASE_URL, + baseUrl, ); const verifyRequest: VerifyPurchaseWithProviderProps = { provider: 'iapkit', @@ -1813,7 +1817,7 @@ function SubscriptionFlowContainer() { }; iapkitVerifyRequest = verifyRequest; console.log( - '[SubscriptionFlow] Sending IAPKit verification request', + `[SubscriptionFlow] Sending ${verificationLabel} verification request`, ); const result = await verifyPurchaseWithProvider(verifyRequest); @@ -1822,13 +1826,12 @@ function SubscriptionFlowContainer() { result, ); - // Show verification result to user if (result.iapkit) { const statusEmoji = result.iapkit.isValid ? '✅' : '⚠️'; const stateText = result.iapkit.state || 'unknown'; showNativeAlert( - `${statusEmoji} IAPKit Verification`, + `${statusEmoji} ${verificationLabel} Verification`, `Valid: ${result.iapkit.isValid}\nState: ${stateText}\nStore: ${ result.iapkit.store || 'unknown' }`, @@ -2394,25 +2397,33 @@ function SubscriptionFlowContainer() { }, []); return ( - + <> + + + ); } diff --git a/libraries/react-native-iap/example/src/components/VerificationMethodSelectorModal.tsx b/libraries/react-native-iap/example/src/components/VerificationMethodSelectorModal.tsx new file mode 100644 index 000000000..65fdb7007 --- /dev/null +++ b/libraries/react-native-iap/example/src/components/VerificationMethodSelectorModal.tsx @@ -0,0 +1,182 @@ +import {Modal, Pressable, StyleSheet, Text, View} from 'react-native'; +import type {VerificationMethod} from '../hooks/useVerificationMethod'; + +const VERIFICATION_METHOD_OPTIONS: readonly { + description: string; + label: string; + value: VerificationMethod; +}[] = [ + { + value: 'local', + label: 'Local (Device)', + description: 'Verify directly with Apple or Google on this device.', + }, + { + value: 'iapkit-localhost', + label: 'Local (IAPKit)', + description: 'Route verification through your local IAPKit server.', + }, + { + value: 'iapkit', + label: 'IAPKit', + description: 'Verify through the hosted IAPKit service.', + }, + { + value: 'ignore', + label: 'None (Skip)', + description: 'Skip verification for this example flow.', + }, +]; + +interface VerificationMethodSelectorModalProps { + onDismiss: () => void; + onSelect: (method: VerificationMethod) => void; + selectedMethod: VerificationMethod; + visible: boolean; +} + +export default function VerificationMethodSelectorModal({ + onDismiss, + onSelect, + selectedMethod, + visible, +}: VerificationMethodSelectorModalProps) { + return ( + + + + + Select Verification Method + + Choose how to verify purchases after completion + + {VERIFICATION_METHOD_OPTIONS.map((option) => { + const isSelected = selectedMethod === option.value; + return ( + onSelect(option.value)} + style={({pressed}) => [ + styles.option, + isSelected && styles.optionSelected, + pressed && styles.optionPressed, + ]} + testID="verification-method-option" + > + + {option.label} + + {option.description} + + + {isSelected ? ✓ : null} + + ); + })} + [ + styles.cancelButton, + pressed && styles.optionPressed, + ]} + > + Cancel + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.45)', + }, + sheet: { + backgroundColor: '#ffffff', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingBottom: 24, + paddingHorizontal: 20, + paddingTop: 20, + }, + title: { + color: '#111827', + fontSize: 20, + fontWeight: '700', + }, + message: { + color: '#6b7280', + fontSize: 14, + marginBottom: 16, + marginTop: 4, + }, + option: { + alignItems: 'center', + borderColor: '#e5e7eb', + borderRadius: 12, + borderWidth: 1, + flexDirection: 'row', + marginBottom: 8, + minHeight: 64, + paddingHorizontal: 14, + paddingVertical: 10, + }, + optionSelected: { + backgroundColor: '#eef6ff', + borderColor: '#007aff', + }, + optionPressed: { + opacity: 0.7, + }, + optionCopy: { + flex: 1, + }, + optionLabel: { + color: '#111827', + fontSize: 16, + fontWeight: '600', + }, + optionDescription: { + color: '#6b7280', + fontSize: 12, + marginTop: 2, + }, + checkmark: { + color: '#007aff', + fontSize: 20, + fontWeight: '700', + marginLeft: 12, + }, + cancelButton: { + alignItems: 'center', + marginTop: 4, + paddingVertical: 12, + }, + cancelText: { + color: '#007aff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/libraries/react-native-iap/example/src/hooks/useVerificationMethod.ts b/libraries/react-native-iap/example/src/hooks/useVerificationMethod.ts index 59357c97c..157052dc1 100644 --- a/libraries/react-native-iap/example/src/hooks/useVerificationMethod.ts +++ b/libraries/react-native-iap/example/src/hooks/useVerificationMethod.ts @@ -1,18 +1,32 @@ import {useState, useCallback, useRef, useEffect} from 'react'; -import {Platform, ActionSheetIOS, Alert} from 'react-native'; +import {Platform, ActionSheetIOS} from 'react-native'; -export type VerificationMethod = 'ignore' | 'local' | 'iapkit'; +export type VerificationMethod = + | 'ignore' + | 'local' + | 'iapkit-localhost' + | 'iapkit'; export function getDefaultVerificationMethod( iapkitApiKey?: string | null, + iapkitBaseUrl?: string | null, ): VerificationMethod { - return iapkitApiKey?.trim() ? 'iapkit' : 'ignore'; + if (!iapkitApiKey?.trim()) { + return 'ignore'; + } + + return iapkitBaseUrl?.trim() ? 'iapkit-localhost' : 'iapkit'; } interface UseVerificationMethodReturn { verificationMethod: VerificationMethod; verificationMethodRef: React.MutableRefObject; - setVerificationMethod: React.Dispatch>; + setVerificationMethod: React.Dispatch< + React.SetStateAction + >; + verificationMethodSelectorVisible: boolean; + hideVerificationMethodSelector: () => void; + selectVerificationMethod: (method: VerificationMethod) => void; showVerificationMethodSelector: () => void; getVerificationMethodLabel: () => string; } @@ -25,6 +39,8 @@ export function useVerificationMethod( ): UseVerificationMethodReturn { const [verificationMethod, setVerificationMethod] = useState(initialMethod); + const [verificationMethodSelectorVisible, setSelectorVisible] = + useState(false); const verificationMethodRef = useRef(verificationMethod); // Keep ref in sync with state @@ -32,12 +48,23 @@ export function useVerificationMethod( verificationMethodRef.current = verificationMethod; }, [verificationMethod]); + const hideVerificationMethodSelector = useCallback(() => { + setSelectorVisible(false); + }, []); + + const selectVerificationMethod = useCallback((method: VerificationMethod) => { + setVerificationMethod(method); + setSelectorVisible(false); + }, []); + const getVerificationMethodLabel = useCallback((): string => { switch (verificationMethod) { case 'ignore': - return 'None'; + return 'None (Skip)'; case 'local': - return 'Local'; + return 'Local (Device)'; + case 'iapkit-localhost': + return 'Local (IAPKit)'; case 'iapkit': return 'IAPKit'; default: @@ -46,57 +73,45 @@ export function useVerificationMethod( }, [verificationMethod]); const showVerificationMethodSelector = useCallback(() => { - const options = [ - 'None (Skip)', - 'Local (Device)', - 'IAPKit (Server)', - 'Cancel', - ]; - const cancelButtonIndex = 3; - if (Platform.OS === 'ios') { + const options = [ + 'Local (Device)', + 'Local (IAPKit)', + 'IAPKit', + 'None (Skip)', + 'Cancel', + ]; ActionSheetIOS.showActionSheetWithOptions( { options, - cancelButtonIndex, + cancelButtonIndex: 4, title: 'Select Verification Method', message: 'Choose how to verify purchases after completion', }, (buttonIndex) => { if (buttonIndex === 0) { - setVerificationMethod('ignore'); + selectVerificationMethod('local'); } else if (buttonIndex === 1) { - setVerificationMethod('local'); + selectVerificationMethod('iapkit-localhost'); } else if (buttonIndex === 2) { - setVerificationMethod('iapkit'); + selectVerificationMethod('iapkit'); + } else if (buttonIndex === 3) { + selectVerificationMethod('ignore'); } }, ); } else { - // For Android, use simple Alert with buttons - Alert.alert( - 'Select Verification Method', - 'Choose how to verify purchases after completion', - [ - {text: 'None (Skip)', onPress: () => setVerificationMethod('ignore')}, - { - text: 'Local (Device)', - onPress: () => setVerificationMethod('local'), - }, - { - text: 'IAPKit (Server)', - onPress: () => setVerificationMethod('iapkit'), - }, - {text: 'Cancel', style: 'cancel'}, - ], - ); + setSelectorVisible(true); } - }, []); + }, [selectVerificationMethod]); return { verificationMethod, verificationMethodRef, setVerificationMethod, + verificationMethodSelectorVisible, + hideVerificationMethodSelector, + selectVerificationMethod, showVerificationMethodSelector, getVerificationMethodLabel, }; diff --git a/libraries/react-native-iap/example/src/utils/vegaRuntime.ts b/libraries/react-native-iap/example/src/utils/vegaRuntime.ts index 0eca5a807..4756c421b 100644 --- a/libraries/react-native-iap/example/src/utils/vegaRuntime.ts +++ b/libraries/react-native-iap/example/src/utils/vegaRuntime.ts @@ -3,9 +3,7 @@ import type {Purchase, VerifyPurchaseWithProviderProps} from 'react-native-iap'; export type IapkitVerificationPayload = NonNullable< VerifyPurchaseWithProviderProps['iapkit'] -> & { - baseUrl?: string | null; -}; +>; function withIapkitEndpoint( payload: IapkitVerificationPayload, @@ -21,6 +19,24 @@ function withIapkitEndpoint( }; } +export function resolveIapkitVerificationBaseUrl( + method: 'iapkit-localhost' | 'iapkit', + configuredBaseUrl?: string | null, +): string | undefined { + if (method === 'iapkit') { + return undefined; + } + + const baseUrl = configuredBaseUrl?.trim(); + if (!baseUrl) { + throw new Error( + 'IAPKIT_BASE_URL not configured for Local (IAPKit) verification', + ); + } + + return baseUrl; +} + export function showNativeAlert(title: string, message?: string): void { const shouldSuppressAlerts = Boolean( (globalThis as {RN_IAP_SUPPRESS_NATIVE_ALERTS?: boolean}) @@ -37,13 +53,18 @@ export function createIapkitVerificationPayload( apiKey: string, baseUrl?: string | null, ): IapkitVerificationPayload { + const trimmedApiKey = apiKey.trim(); + if (!trimmedApiKey) { + throw new Error('IAPKIT_API_KEY not configured'); + } + const purchaseStore = ( (purchase as Purchase & {store?: string | null}).store ?? '' ).toLowerCase(); if (purchaseStore === 'amazon') { return withIapkitEndpoint( { - apiKey, + apiKey: trimmedApiKey, amazon: { receiptId: purchaseToken, sandbox: __DEV__, @@ -59,13 +80,13 @@ export function createIapkitVerificationPayload( return withIapkitEndpoint( isApplePurchase ? { - apiKey, + apiKey: trimmedApiKey, apple: { jws: purchaseToken, }, } : { - apiKey, + apiKey: trimmedApiKey, google: { purchaseToken, }, diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index e7dd31198..b85b1f834 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -452,6 +452,9 @@ class HybridRnIap: HybridRnIapSpec { } else if let plistApiKey = Bundle.main.object(forInfoDictionaryKey: "IAPKitAPIKey") as? String { iapkitDict["apiKey"] = plistApiKey } + if case .second(let baseUrl) = iapkit.baseUrl { + iapkitDict["baseUrl"] = baseUrl + } if case .second(let apple) = iapkit.apple { iapkitDict["apple"] = ["jws": apple.jws] } diff --git a/libraries/react-native-iap/src/__tests__/iapkit-base-url-bridge.test.js b/libraries/react-native-iap/src/__tests__/iapkit-base-url-bridge.test.js new file mode 100644 index 000000000..6d420c1ac --- /dev/null +++ b/libraries/react-native-iap/src/__tests__/iapkit-base-url-bridge.test.js @@ -0,0 +1,33 @@ +/* eslint-env jest, node */ + +const {readFileSync} = require('fs'); +const {resolve} = require('path'); + +const rootDir = resolve(__dirname, '../..'); + +function readSource(path) { + return readFileSync(resolve(rootDir, path), 'utf8'); +} + +describe('IAPKit baseUrl native bridge parity', () => { + it('declares baseUrl in the Nitro contract', () => { + const spec = readSource('src/specs/RnIap.nitro.ts'); + + expect(spec).toMatch( + /interface NitroVerifyPurchaseWithIapkitProps[\s\S]*?baseUrl\?: string \| null;/, + ); + }); + + it('forwards baseUrl through the iOS and Android native maps', () => { + const ios = readSource('ios/HybridRnIap.swift'); + const android = readSource( + 'android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt', + ); + + expect(ios).toContain('if case .second(let baseUrl) = iapkit.baseUrl'); + expect(ios).toContain('iapkitDict["baseUrl"] = baseUrl'); + expect(android).toContain( + 'iapkit.baseUrl.unwrapString()?.let { iapkitMap["baseUrl"] = it }', + ); + }); +}); diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 5834c215c..5fbad7066 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -1878,6 +1878,7 @@ describe('Public API (src/index.ts)', () => { provider: 'iapkit', iapkit: { apiKey: 'test-api-key', + baseUrl: 'http://127.0.0.1:4174', environment: 'sandbox', apple: { jws: 'test-jws-token', @@ -1889,6 +1890,7 @@ describe('Public API (src/index.ts)', () => { provider: 'iapkit', iapkit: { apiKey: 'test-api-key', + baseUrl: 'http://127.0.0.1:4174', environment: 'sandbox', apple: { jws: 'test-jws-token', @@ -2441,10 +2443,9 @@ describe('Public API (src/index.ts)', () => { describe('showBillingProgramInformationDialogAndroid', () => { it('should show Billing Choice information dialog with default program', async () => { (Platform as any).OS = 'android'; - const result = - await IAP.showBillingProgramInformationDialogAndroid({ - externalTransactionToken: 'choice-token-123', - }); + const result = await IAP.showBillingProgramInformationDialogAndroid({ + externalTransactionToken: 'choice-token-123', + }); expect( mockIap.showBillingProgramInformationDialogAndroid, @@ -2453,9 +2454,7 @@ describe('Public API (src/index.ts)', () => { externalTransactionToken: 'choice-token-123', }); expect(result.responseCode).toBe(0); - expect(result.subResponseCode).toBe( - 'no-applicable-sub-response-code', - ); + expect(result.subResponseCode).toBe('no-applicable-sub-response-code'); }); it('should throw on non-Android', async () => { 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 ffa034733..9bf7bd6fc 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -1200,9 +1200,23 @@ describe('Amazon Vega adapter', () => { } }); - it('supports custom IAPKit base URLs for Vega verification', async () => { + it.each([ + ['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'], + [ + 'https://[2001:db8::1]:65535///', + 'https://[2001:db8::1]:65535/v1/purchase/verify', + ], + ])('supports custom IAPKit base URL %s', async (baseUrl, expectedUrl) => { const service = createService(); const originalFetch = globalThis.fetch; + const originalUrl = globalThis.URL; + class KeplerUrl { + get protocol(): never { + throw new Error('URL.protocol is not implemented on Kepler'); + } + } const fetchMock = jest.fn( async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json({ @@ -1212,6 +1226,7 @@ describe('Amazon Vega adapter', () => { }), ) as unknown as jest.MockedFunction; globalThis.fetch = fetchMock; + globalThis.URL = KeplerUrl as unknown as typeof URL; try { const module = createVegaIapModule(service); @@ -1220,20 +1235,72 @@ describe('Amazon Vega adapter', () => { provider: 'iapkit', iapkit: { apiKey: 'kit-key', - baseUrl: 'http://localhost:3100/', + baseUrl, amazon: { userId: 'amazon-user', receiptId: 'receipt-vega-1', }, }, - } as Parameters[0] & { - iapkit: {baseUrl: string}; }); - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:3100/v1/purchase/verify', - expect.any(Object), - ); + expect(fetchMock).toHaveBeenCalledWith(expectedUrl, expect.any(Object)); + } finally { + globalThis.fetch = originalFetch; + globalThis.URL = originalUrl; + } + }); + + it.each([ + 'ftp://localhost:3100', + 'http://user:pass@localhost:3100', + 'http://localhost:3100/path', + 'http://localhost:3100?debug=1', + 'http://localhost:3100\\path', + 'http://localhost:0', + 'http://localhost:99999', + 'http://[]:3100', + 'http://[garbage]:3100', + 'http://[:::]:3100', + 'http://[deadbeef]:3100', + 'http://[1::2::3]:3100', + 'http://[1:2:3:4:5:6:7:8:9]:3100', + 'http://[::ffff:999.1.1.1]:3100', + 'http://[::1', + 'http://::1:3100', + 'http://127.00.0.1:3100', + 'http://0x7f.0.0.1:3100', + 'http://0x7f000001:3100', + 'http://0x7f.0.0.1.:3100', + 'http://example.123:3100', + 'http://example.0x7f:3100', + 'http://[::ffff:192.168.001.1]:3100', + 'http://999.999.999.999:3100', + 'http://%:3100', + ])('rejects non-origin IAPKit base URL %s', async (baseUrl) => { + const service = createService(); + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn() as unknown as jest.MockedFunction; + globalThis.fetch = fetchMock; + + try { + const module = createVegaIapModule(service); + + await expect( + module.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + baseUrl, + amazon: { + userId: 'amazon-user', + receiptId: 'receipt-vega-1', + }, + }, + }), + ).rejects.toMatchObject({ + code: ErrorCode.DeveloperError, + message: 'IAPKit baseUrl must be a valid HTTP(S) origin', + }); + expect(fetchMock).not.toHaveBeenCalled(); } finally { globalThis.fetch = originalFetch; } diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index 1e12c6abe..e24efc621 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -459,6 +459,12 @@ export interface NitroVerifyPurchaseWithIapkitProps { apiKey?: string | null; amazon?: NitroVerifyPurchaseWithIapkitAmazonProps | null; apple?: NitroVerifyPurchaseWithIapkitAppleProps | null; + /** + * Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + * HTTP(S) origin for a self-hosted or local IAPKit server. The apiKey must + * come from the same IAPKit/Convex deployment. + */ + baseUrl?: string | null; google?: NitroVerifyPurchaseWithIapkitGoogleProps | null; } diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 1ec00503c..76b2df063 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1978,6 +1978,13 @@ export interface RequestVerifyPurchaseWithIapkitProps { apiKey?: (string | null); /** Apple App Store verification parameters. */ apple?: (RequestVerifyPurchaseWithIapkitAppleProps | null); + /** + * Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + * Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + * Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + * The apiKey must be issued by the same IAPKit/Convex deployment as this server. + */ + baseUrl?: (string | null); /** Google Play Store verification parameters. */ google?: (RequestVerifyPurchaseWithIapkitGoogleProps | null); } diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index 8a7b5145d..ce0e71fe8 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -150,6 +150,129 @@ function createVegaError( return error; } +function isValidIpv4Address(address: string): boolean { + const octets = address.split('.'); + return ( + octets.length === 4 && + octets.every( + (octet) => + /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255, + ) + ); +} + +function isValidIpv6Address(address: string): boolean { + let ipv6Part = address; + let ipv4GroupCount = 0; + + if (address.includes('.')) { + const lastColon = address.lastIndexOf(':'); + if (lastColon < 0 || !isValidIpv4Address(address.slice(lastColon + 1))) { + return false; + } + const ipv6Prefix = address.slice(0, lastColon); + ipv6Part = ipv6Prefix.endsWith(':') ? `${ipv6Prefix}:` : ipv6Prefix; + ipv4GroupCount = 2; + } + + if ( + !ipv6Part.includes(':') || + !/^[0-9a-f:]+$/i.test(ipv6Part) || + ipv6Part.includes(':::') + ) { + return false; + } + + const compressionIndex = ipv6Part.indexOf('::'); + const hasCompression = compressionIndex >= 0; + if ( + (hasCompression && ipv6Part.indexOf('::', compressionIndex + 2) >= 0) || + (!hasCompression && (ipv6Part.startsWith(':') || ipv6Part.endsWith(':'))) + ) { + return false; + } + + const sections = hasCompression ? ipv6Part.split('::') : [ipv6Part]; + const groups: string[] = []; + for (const section of sections) { + if (section.length > 0) groups.push(...section.split(':')); + } + if (!groups.every((group) => /^[0-9a-f]{1,4}$/i.test(group))) { + return false; + } + + const groupCount = groups.length + ipv4GroupCount; + return hasCompression ? groupCount < 8 : groupCount === 8; +} + +function isValidHostname(hostname: string): boolean { + if (/^[0-9.]+$/.test(hostname)) { + return isValidIpv4Address(hostname); + } + + const normalizedHostname = hostname.endsWith('.') + ? hostname.slice(0, -1) + : hostname; + if (normalizedHostname.length === 0 || normalizedHostname.length > 253) { + return false; + } + const hostnameLabels = normalizedHostname.split('.'); + const lastLabel = hostnameLabels[hostnameLabels.length - 1]!; + if (/^(?:[0-9]+|0x[0-9a-f]+)$/i.test(lastLabel)) { + return false; + } + + return normalizedHostname + .split('.') + .every( + (label) => + label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label), + ); +} + +function getIapkitVerifyUrl(baseUrl?: string | null): string { + const requestedBaseUrl = + typeof baseUrl === 'string' && baseUrl.trim().length > 0 + ? baseUrl.trim() + : IAPKIT_DEFAULT_BASE_URL; + const normalizedBaseUrl = requestedBaseUrl.replace(/\/+$/, ''); + // Kepler's URL polyfill throws for standard getters such as protocol, + // host, and pathname. Parse the small origin-only contract directly. + const originMatch = /^(https?):\/\/([^/?#@\s\\]+)$/i.exec(normalizedBaseUrl); + if (!originMatch) { + throw createVegaError( + ErrorCode.DeveloperError, + 'IAPKit baseUrl must be a valid HTTP(S) origin', + ); + } + + const authority = originMatch[2]!; + const isBracketedIpv6 = authority.startsWith('['); + const authorityMatch = isBracketedIpv6 + ? /^\[([^\]]+)\](?::([0-9]+))?$/.exec(authority) + : /^([^:]+)(?::([0-9]+))?$/.exec(authority); + const host = authorityMatch?.[1]; + const requestedPort = authorityMatch?.[2]; + const portNumber = requestedPort ? Number(requestedPort) : null; + const hasValidHost = + typeof host === 'string' && + (isBracketedIpv6 ? isValidIpv6Address(host) : isValidHostname(host)); + const hasValidPort = + portNumber === null || + (Number.isInteger(portNumber) && portNumber >= 1 && portNumber <= 65535); + if (!authorityMatch || !hasValidHost || !hasValidPort) { + throw createVegaError( + ErrorCode.DeveloperError, + 'IAPKit baseUrl must be a valid HTTP(S) origin', + ); + } + + const scheme = originMatch[1]!.toLowerCase(); + const serializedHost = isBracketedIpv6 ? `[${host!}]` : host!; + const port = requestedPort ? `:${requestedPort}` : ''; + return `${scheme}://${serializedHost}${port}${IAPKIT_VERIFY_PATH}`; +} + function parseVegaErrorPayload(error: unknown): Record { if (!(error instanceof Error)) return {}; const vegaError = error as VegaError; @@ -1023,27 +1146,6 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { const verifyWithIapkit = async ( params: NitroVerifyPurchaseWithProviderProps, ): Promise => { - type IapkitEndpointOptions = NonNullable< - NitroVerifyPurchaseWithProviderProps['iapkit'] - > & { - baseUrl?: string | null; - }; - - function iapkitVerifyUrl( - iapkit: NitroVerifyPurchaseWithProviderProps['iapkit'], - ): string { - const endpointOptions = iapkit as - | IapkitEndpointOptions - | null - | undefined; - const baseUrl = - typeof endpointOptions?.baseUrl === 'string' && - endpointOptions.baseUrl.trim().length > 0 - ? endpointOptions.baseUrl.trim() - : IAPKIT_DEFAULT_BASE_URL; - return `${baseUrl.replace(/\/+$/, '')}${IAPKIT_VERIFY_PATH}`; - } - function normalizeIapkitState(state: unknown): IapkitPurchaseState { const normalized = typeof state === 'string' @@ -1198,6 +1300,7 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { const apiKey = typeof iapkit?.apiKey === 'string' ? iapkit.apiKey.trim() : ''; + const verificationUrl = getIapkitVerifyUrl(iapkit?.baseUrl); let response: Response; try { const controller = new AbortController(); @@ -1205,7 +1308,7 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { () => controller.abort(), IAPKIT_VERIFY_TIMEOUT_MS, ); - response = await fetch(iapkitVerifyUrl(iapkit), { + response = await fetch(verificationUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/openiap-versions.json b/openiap-versions.json index afa5c8dd0..430eaa760 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,5 +1,5 @@ { - "spec": "2.3.0", + "spec": "2.3.1", "google": "2.3.1", "apple": "2.3.0" } diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 2ef29a775..63992ccd4 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -2332,6 +2332,11 @@ public struct RequestVerifyPurchaseWithIapkitProps: Codable { public var apiKey: String? /// Apple App Store verification parameters. public var apple: RequestVerifyPurchaseWithIapkitAppleProps? + /// Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + /// Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + /// Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + /// The apiKey must be issued by the same IAPKit/Convex deployment as this server. + public var baseUrl: String? /// Google Play Store verification parameters. public var google: RequestVerifyPurchaseWithIapkitGoogleProps? @@ -2339,11 +2344,13 @@ public struct RequestVerifyPurchaseWithIapkitProps: Codable { amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = nil, apiKey: String? = nil, apple: RequestVerifyPurchaseWithIapkitAppleProps? = nil, + baseUrl: String? = nil, google: RequestVerifyPurchaseWithIapkitGoogleProps? = nil ) { self.amazon = amazon self.apiKey = apiKey self.apple = apple + self.baseUrl = baseUrl self.google = google } } diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index 3a72193c4..a04e92da9 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -497,6 +497,30 @@ import StoreKit apiKey: String?, jws: String?, completion: @escaping ([String: Any]?, Error?) -> Void + ) { + verifyPurchaseWithProviderObjC( + provider: provider, + apiKey: apiKey, + baseUrl: nil, + jws: jws, + completion: completion + ) + } + + /// Verify purchase with external provider using a custom IAPKit server. + /// - Parameters: + /// - provider: The provider name (currently only "iapkit" is supported) + /// - apiKey: Optional API key for the provider + /// - baseUrl: Optional IAPKit server base URL; defaults to the hosted service + /// - jws: JWS token from StoreKit 2 purchase (for Apple verification) + /// - completion: Callback with verification result dictionary or error + @objc(verifyPurchaseWithProviderObjCWithProvider:apiKey:baseUrl:jws:completion:) + func verifyPurchaseWithProviderObjC( + provider: String, + apiKey: String?, + baseUrl: String?, + jws: String?, + completion: @escaping ([String: Any]?, Error?) -> Void ) { Task { do { @@ -512,6 +536,7 @@ import StoreKit let iapkitProps = RequestVerifyPurchaseWithIapkitProps( apiKey: apiKey, apple: appleProps, + baseUrl: baseUrl, google: nil ) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 4a957330b..a384c1abb 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -20,6 +20,41 @@ import AppKit public final class OpenIapModule: NSObject, OpenIapModuleProtocol { public static let shared = OpenIapModule() + static func iapkitVerificationURL(baseUrl: String?) throws -> URL { + let defaultBaseUrl = "https://kit.openiap.dev" + let trimmedBaseUrl = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var normalizedBaseUrl = trimmedBaseUrl.isEmpty ? defaultBaseUrl : trimmedBaseUrl + + while normalizedBaseUrl.hasSuffix("/") { + normalizedBaseUrl.removeLast() + } + + guard let components = URLComponents(string: normalizedBaseUrl) else { + throw PurchaseError.make( + code: .developerError, + message: "IAPKit baseUrl must be a valid HTTP(S) origin" + ) + } + let hasValidPort = components.port.map { (1...65_535).contains($0) } ?? true + guard let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + components.host?.isEmpty == false, + components.user == nil, + components.password == nil, + components.path.isEmpty, + components.query == nil, + components.fragment == nil, + hasValidPort, + let url = URL(string: "\(normalizedBaseUrl)/v1/purchase/verify") else { + throw PurchaseError.make( + code: .developerError, + message: "IAPKit baseUrl must be a valid HTTP(S) origin" + ) + } + + return url + } + /// Objective-C accessor for [OpenIapModule.shared]. Exists so the .NET MAUI /// binding (`OpenIap.Maui.Bindings.iOS`) can surface the singleton via /// `[OpenIapModule sharedInstance]`; Swift's static stored properties @@ -823,7 +858,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } func verifyPurchaseWithIapkit(props: RequestVerifyPurchaseWithIapkitProps) async throws -> RequestVerifyPurchaseWithIapkitResult { - let url = URL(string: "https://kit.openiap.dev/v1/purchase/verify")! + let url = try Self.iapkitVerificationURL(baseUrl: props.baseUrl) let payload = try buildIapkitPayload(props: props) let store = payload.store diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift index fd2d523c2..d5aaf33cf 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -4,6 +4,62 @@ import XCTest @available(iOS 15.0, macOS 14.0, *) final class VerifyPurchaseWithProviderTests: XCTestCase { + func testIapkitVerificationURLUsesHostedDefaultForNilOrBlankBaseUrl() throws { + let expectedUrl = "https://kit.openiap.dev/v1/purchase/verify" + + XCTAssertEqual(expectedUrl, try OpenIapModule.iapkitVerificationURL(baseUrl: nil).absoluteString) + XCTAssertEqual(expectedUrl, try OpenIapModule.iapkitVerificationURL(baseUrl: " \n\t ").absoluteString) + } + + func testIapkitVerificationURLTrimsOverrideAndTrailingSlashes() throws { + let url = try OpenIapModule.iapkitVerificationURL( + baseUrl: " http://127.0.0.1:4174///\n" + ) + + XCTAssertEqual("http://127.0.0.1:4174/v1/purchase/verify", url.absoluteString) + } + + func testIapkitVerificationURLRejectsMalformedOverride() { + let invalidBaseUrls = [ + "localhost:4174", + "https://user:password@kit.openiap.dev", + "https://kit.openiap.dev/prefix", + "https://kit.openiap.dev?environment=local", + "https://kit.openiap.dev#fragment", + "http://127.0.0.1:0", + "http://127.0.0.1:65536", + "http://127.0.0.1:not-a-port", + ] + + for invalidBaseUrl in invalidBaseUrls { + XCTAssertThrowsError( + try OpenIapModule.iapkitVerificationURL(baseUrl: invalidBaseUrl), + "Expected \(invalidBaseUrl) to be rejected" + ) { error in + guard let purchaseError = error as? PurchaseError else { + return XCTFail("Expected PurchaseError, received \(type(of: error))") + } + XCTAssertEqual(.developerError, purchaseError.code) + XCTAssertEqual( + "IAPKit baseUrl must be a valid HTTP(S) origin", + purchaseError.message + ) + } + } + } + + func testObjCBridgeKeepsLegacySelectorAndExposesCustomBaseUrlSelector() { + let legacySelector = NSSelectorFromString( + "verifyPurchaseWithProviderObjCWithProvider:apiKey:jws:completion:" + ) + let customBaseUrlSelector = NSSelectorFromString( + "verifyPurchaseWithProviderObjCWithProvider:apiKey:baseUrl:jws:completion:" + ) + + XCTAssertTrue(OpenIapModule.shared.responds(to: legacySelector)) + XCTAssertTrue(OpenIapModule.shared.responds(to: customBaseUrlSelector)) + } + @MainActor func testStoreReturnsIapkitResult() async throws { let iapkitResult = RequestVerifyPurchaseWithIapkitResult( diff --git a/packages/docs/openiap-versions.json b/packages/docs/openiap-versions.json index afa5c8dd0..430eaa760 100644 --- a/packages/docs/openiap-versions.json +++ b/packages/docs/openiap-versions.json @@ -1,5 +1,5 @@ { - "spec": "2.3.0", + "spec": "2.3.1", "google": "2.3.1", "apple": "2.3.0" } diff --git a/packages/docs/package.json b/packages/docs/package.json index dbbb7b7f5..df1ce813e 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,7 +1,7 @@ { "name": "@hyodotdev/openiap-docs", "private": true, - "version": "2.3.0", + "version": "2.3.1", "type": "module", "scripts": { "dev": "bunx vite", diff --git a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-props.tsx b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-props.tsx index 9abec37bf..638523788 100644 --- a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-props.tsx +++ b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-props.tsx @@ -112,6 +112,22 @@ function VerifyPurchaseWithProviderProps() { ). + + + baseUrl + + + string? + + + Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / + openiap-google 2.4.0. IAPKit server origin. Defaults to{' '} + https://kit.openiap.dev; set it to a reachable + HTTP(S) origin for self-hosted or local IAPKit verification. The{' '} + apiKey must be issued by the same IAPKit/Convex + deployment that this origin uses. + + apple diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 2c2df499f..b088940d0 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -22,6 +22,17 @@ interface Note { element: React.ReactNode; } +const localIapkitPlannedReleases = [ + 'openiap-apple 2.4.0', + 'openiap-google 2.4.0', + 'react-native-iap 15.5.0', + 'expo-iap 4.5.0', + 'flutter_inapp_purchase 9.5.0', + 'godot-iap 2.5.0', + 'kmp-iap 2.5.0', + 'OpenIap.Maui 1.3.0', +] as const; + const purchaseSafetyReleases = [ ['openiap-apple 2.3.0', '2.3.0'], ['openiap-google 2.3.1', 'google-2.3.1'], @@ -37,6 +48,168 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // July 14, 2026 - Local and self-hosted IAPKit receipt verification + { + id: 'local-self-hosted-iapkit-verification-2026-07-14', + date: new Date('2026-07-14'), + element: ( +
+ + July 14, 2026 - Local and self-hosted IAPKit receipt verification + + +

+ Prepares OpenIAP Spec 2.3.1 and coordinated native and framework + releases that let verifyPurchaseWithProvider target a + self-hosted or device-reachable local IAPKit origin. Existing + callers continue to use https://kit.openiap.dev, while + malformed non-origin values fail as developer errors. The + implementation and physical-device receipt vertical are covered by{' '} + + PR #225 + + . +

+ +
+ Shared spec and native packages +
+
    +
  • + OpenIAP Spec 2.3.1 - adds the optional{' '} + + RequestVerifyPurchaseWithIapkitProps.baseUrl + {' '} + field. It accepts an HTTP(S) origin, keeps the hosted endpoint as + the default, and requires the API key to come from the same + IAPKit/Convex deployment. +
  • +
  • + openiap-apple 2.4.0 - forwards the custom origin + through Swift and Objective-C verification paths, validates it + before networking, and preserves the existing Objective-C + selector. +
  • +
  • + openiap-google 2.4.0 - routes IAPKit verification + through a validated custom origin for Google and Amazon receipt + payloads while leaving the hosted default unchanged. +
  • +
+ +
Framework libraries
+
    +
  • + react-native-iap 15.5.0 - forwards the custom + origin through Nitro on iOS and Android and validates hostname, + IPv4, IPv6, and port forms in Vega/Kepler runtimes. +
  • +
  • + expo-iap 4.5.0 - adds typed custom-origin + forwarding and Vega validation while keeping local and hosted + verification modes distinct. +
  • +
  • + flutter_inapp_purchase 9.5.0 - carries{' '} + baseUrl through Dart and the Android, iOS, and macOS + platform channels. +
  • +
  • + godot-iap 2.5.0 - normalizes the custom origin in + legacy payloads and refreshes Android and Apple native artifacts + with fail-closed native-load checks. +
  • +
  • + kmp-iap 2.5.0 - forwards the custom origin + through both Android and iOS bridges. +
  • +
  • + OpenIap.Maui 1.3.0 - exposes the generated CLR + property and consumes the coordinated native packages. +
  • +
+ +
+ Examples and local IAPKit validation +
+
    +
  • + The Martie React Native and Expo examples present{' '} + Local (Device), Local (IAPKit),{' '} + IAPKit, and None (Skip) in that order. + Local IAPKit uses a device-reachable origin; hosted IAPKit omits + the override. +
  • +
  • + The compiled-server smoke test fails closed on port ownership and + exercises POST /v1/purchase/verify; the E2E guide + separates that smoke check from a live store-receipt proof. +
  • +
  • + A physical iPhone Expo sandbox purchase completed through the + compiled local IAPKit server and the Martie Dev Convex deployment, + including transaction finish. The Android selector and ordering + were verified on a physical device without another purchase. +
  • +
+ +
+
Planned Package Releases
+
    + {localIapkitPlannedReleases.map((release) => ( +
  • {release}
  • + ))} +
+
+
+ ), + }, + // July 12, 2026 - Purchase safety and lifecycle hardening { id: 'purchase-safety-lifecycle-release-2026-07-12', 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 029313865..f0c73f688 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 @@ -5558,6 +5558,13 @@ public data class RequestVerifyPurchaseWithIapkitProps( * Apple App Store verification parameters. */ val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null, + /** + * Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + * Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + * Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + * The apiKey must be issued by the same IAPKit/Convex deployment as this server. + */ + val baseUrl: String? = null, /** * Google Play Store verification parameters. */ @@ -5569,6 +5576,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( amazon = (json["amazon"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(it) }, apiKey = json["apiKey"] as? String, apple = (json["apple"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAppleProps.fromJson(it) }, + baseUrl = json["baseUrl"] as? String, google = (json["google"] as? Map)?.let { RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(it) }, ) } @@ -5578,6 +5586,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( "amazon" to amazon?.toJson(), "apiKey" to apiKey, "apple" to apple?.toJson(), + "baseUrl" to baseUrl, "google" to google?.toJson(), ) } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt index ba3935177..c1c3be501 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 @@ -16,11 +16,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.IOException import java.net.HttpURLConnection +import java.net.URI +import java.net.URISyntaxException import java.net.URL import java.net.URLEncoder import java.util.Locale private const val DEFAULT_IAPKIT_ENDPOINT = "https://kit.openiap.dev/v1/purchase/verify" +private const val IAPKIT_VERIFY_PATH = "/v1/purchase/verify" private val gson = Gson() private fun openConnection(url: String): HttpURLConnection { @@ -173,7 +176,38 @@ suspend fun verifyPurchaseWithIapkit( fun malformedIapkitResponse(): OpenIapError.PurchaseVerificationFailed = OpenIapError.PurchaseVerificationFailed("IAPKit returned malformed response") - val endpoint = DEFAULT_IAPKIT_ENDPOINT + fun resolveIapkitEndpoint(): String { + val requestedBaseUrl = props.baseUrl?.trim() + if (requestedBaseUrl.isNullOrEmpty()) { + return DEFAULT_IAPKIT_ENDPOINT + } + + val normalizedBaseUrl = requestedBaseUrl.trimEnd('/') + val origin = try { + URI(normalizedBaseUrl) + } catch (_: URISyntaxException) { + throw OpenIapError.DeveloperError( + "IAPKit baseUrl must be a valid HTTP(S) origin" + ) + } + val scheme = origin.scheme?.lowercase(Locale.ROOT) + val hasValidPort = origin.port == -1 || origin.port in 1..65535 + val isValidOrigin = + (scheme == "http" || scheme == "https") && + !origin.host.isNullOrBlank() && + origin.rawUserInfo == null && + origin.rawQuery == null && + origin.rawFragment == null && + origin.path.isNullOrEmpty() && + hasValidPort + if (!isValidOrigin) { + throw OpenIapError.DeveloperError( + "IAPKit baseUrl must be a valid HTTP(S) origin" + ) + } + + return "$normalizedBaseUrl$IAPKIT_VERIFY_PATH" + } val hasApple = props.apple != null val hasGoogle = props.google != null @@ -265,6 +299,7 @@ suspend fun verifyPurchaseWithIapkit( else -> throw IllegalArgumentException("IAPKit verification on Android does not support ${store.rawValue}") } + val endpoint = resolveIapkitEndpoint() val connection = connectionFactory(endpoint).apply { requestMethod = "POST" doOutput = true 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 ddba7c265..9ee702a1b 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 @@ -182,9 +182,92 @@ class PurchaseVerificationValidatorTest { amazon = null ) - verifyPurchaseWithIapkit(props, "TEST") { _ -> + var requestedEndpoint: String? = null + verifyPurchaseWithIapkit(props, "TEST") { endpoint -> + requestedEndpoint = endpoint FakeHttpURLConnection(200, """{"store":"google","isValid":true,"state":"ENTITLED"}""") } + + assertEquals("https://kit.openiap.dev/v1/purchase/verify", requestedEndpoint) + } + + @Test + fun `verifyPurchaseWithIapkit uses default endpoint for blank base url`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = null, + google = RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken = "token-abc" + ), + amazon = null, + baseUrl = " \n " + ) + + var requestedEndpoint: String? = null + verifyPurchaseWithIapkit(props, "TEST") { endpoint -> + requestedEndpoint = endpoint + FakeHttpURLConnection(200, """{"store":"google","isValid":true,"state":"ENTITLED"}""") + } + + assertEquals("https://kit.openiap.dev/v1/purchase/verify", requestedEndpoint) + } + + @Test + fun `verifyPurchaseWithIapkit normalizes custom base url`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = null, + google = RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken = "token-abc" + ), + amazon = null, + baseUrl = " http://10.0.2.2:4174/// " + ) + + var requestedEndpoint: String? = null + verifyPurchaseWithIapkit(props, "TEST") { endpoint -> + requestedEndpoint = endpoint + FakeHttpURLConnection(200, """{"store":"google","isValid":true,"state":"ENTITLED"}""") + } + + assertEquals("http://10.0.2.2:4174/v1/purchase/verify", requestedEndpoint) + } + + @Test + fun `verifyPurchaseWithIapkit rejects malformed base url`() = runTest { + val baseProps = RequestVerifyPurchaseWithIapkitProps( + apiKey = null, + apple = null, + google = RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken = "token-abc" + ), + amazon = null + ) + val invalidBaseUrls = listOf( + "kit.openiap.dev/not-an-origin", + "ftp://kit.openiap.dev", + "https://user:password@kit.openiap.dev", + "https://kit.openiap.dev/prefix", + "https://kit.openiap.dev?environment=local", + "https://kit.openiap.dev#fragment", + "http://127.0.0.1:0", + "http://127.0.0.1:65536", + "http://127.0.0.1:not-a-port" + ) + + for (invalidBaseUrl in invalidBaseUrls) { + try { + verifyPurchaseWithIapkit( + baseProps.copy(baseUrl = invalidBaseUrl), + "TEST" + ) { _ -> + throw AssertionError("Connection should not be created for a malformed base URL") + } + throw AssertionError("Expected DeveloperError for malformed base URL: $invalidBaseUrl") + } catch (error: OpenIapError.DeveloperError) { + assertTrue(error.debugMessage?.contains("valid HTTP(S) origin") == true) + } + } } @Test diff --git a/packages/gql/package.json b/packages/gql/package.json index aefdb74e1..dbd2c3978 100644 --- a/packages/gql/package.json +++ b/packages/gql/package.json @@ -1,6 +1,6 @@ { "name": "@hyodotdev/openiap-gql", - "version": "2.3.0", + "version": "2.3.1", "type": "module", "main": "src/generated/types.ts", "exports": { diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index 9ce1179aa..c09558eb2 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -4581,6 +4581,12 @@ public sealed record RequestVerifyPurchaseWithIapkitProps /// API key used for the Authorization header (Bearer {apiKey}). [JsonPropertyName("apiKey")] public string? ApiKey { get; init; } + /// Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + /// Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + /// Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + /// The apiKey must be issued by the same IAPKit/Convex deployment as this server. + [JsonPropertyName("baseUrl")] + public string? BaseUrl { get; init; } /// Apple App Store verification parameters. [JsonPropertyName("apple")] public RequestVerifyPurchaseWithIapkitAppleProps? Apple { get; init; } diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 007378dd0..d8a7b0958 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -5681,6 +5681,13 @@ public data class RequestVerifyPurchaseWithIapkitProps( * Apple App Store verification parameters. */ val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null, + /** + * Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + * Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + * Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + * The apiKey must be issued by the same IAPKit/Convex deployment as this server. + */ + val baseUrl: String? = null, /** * Google Play Store verification parameters. */ @@ -5692,6 +5699,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( amazon = (json["amazon"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(it) }, apiKey = json["apiKey"] as? String, apple = (json["apple"] as? Map)?.let { RequestVerifyPurchaseWithIapkitAppleProps.fromJson(it) }, + baseUrl = json["baseUrl"] as? String, google = (json["google"] as? Map)?.let { RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(it) }, ) } @@ -5701,6 +5709,7 @@ public data class RequestVerifyPurchaseWithIapkitProps( "amazon" to amazon?.toJson(), "apiKey" to apiKey, "apple" to apple?.toJson(), + "baseUrl" to baseUrl, "google" to google?.toJson(), ) } diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 2ef29a775..63992ccd4 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -2332,6 +2332,11 @@ public struct RequestVerifyPurchaseWithIapkitProps: Codable { public var apiKey: String? /// Apple App Store verification parameters. public var apple: RequestVerifyPurchaseWithIapkitAppleProps? + /// Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + /// Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + /// Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + /// The apiKey must be issued by the same IAPKit/Convex deployment as this server. + public var baseUrl: String? /// Google Play Store verification parameters. public var google: RequestVerifyPurchaseWithIapkitGoogleProps? @@ -2339,11 +2344,13 @@ public struct RequestVerifyPurchaseWithIapkitProps: Codable { amazon: RequestVerifyPurchaseWithIapkitAmazonProps? = nil, apiKey: String? = nil, apple: RequestVerifyPurchaseWithIapkitAppleProps? = nil, + baseUrl: String? = nil, google: RequestVerifyPurchaseWithIapkitGoogleProps? = nil ) { self.amazon = amazon self.apiKey = apiKey self.apple = apple + self.baseUrl = baseUrl self.google = google } } diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index df6f95736..9b3fae0db 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -5476,6 +5476,7 @@ class RequestVerifyPurchaseWithIapkitProps { this.amazon, this.apiKey, this.apple, + this.baseUrl, this.google, }); @@ -5485,6 +5486,11 @@ class RequestVerifyPurchaseWithIapkitProps { final String? apiKey; /// Apple App Store verification parameters. final RequestVerifyPurchaseWithIapkitAppleProps? apple; + /// Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + /// Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + /// Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + /// The apiKey must be issued by the same IAPKit/Convex deployment as this server. + final String? baseUrl; /// Google Play Store verification parameters. final RequestVerifyPurchaseWithIapkitGoogleProps? google; @@ -5493,6 +5499,7 @@ class RequestVerifyPurchaseWithIapkitProps { amazon: json['amazon'] != null ? RequestVerifyPurchaseWithIapkitAmazonProps.fromJson(json['amazon'] as Map) : null, apiKey: json['apiKey'] as String?, apple: json['apple'] != null ? RequestVerifyPurchaseWithIapkitAppleProps.fromJson(json['apple'] as Map) : null, + baseUrl: json['baseUrl'] as String?, google: json['google'] != null ? RequestVerifyPurchaseWithIapkitGoogleProps.fromJson(json['google'] as Map) : null, ); } @@ -5502,6 +5509,7 @@ class RequestVerifyPurchaseWithIapkitProps { 'amazon': amazon?.toJson(), 'apiKey': apiKey, 'apple': apple?.toJson(), + 'baseUrl': baseUrl, 'google': google?.toJson(), }; } diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 3d6681169..248d9ca35 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -5139,6 +5139,8 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null + ## Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + var base_url: Variant = null ## Apple App Store verification parameters. var apple: RequestVerifyPurchaseWithIapkitAppleProps ## Google Play Store verification parameters. @@ -5150,6 +5152,8 @@ class RequestVerifyPurchaseWithIapkitProps: var obj = RequestVerifyPurchaseWithIapkitProps.new() if data.has("apiKey") and data["apiKey"] != null: obj.api_key = data["apiKey"] + if data.has("baseUrl") and data["baseUrl"] != null: + obj.base_url = data["baseUrl"] if data.has("apple") and data["apple"] != null: if data["apple"] is Dictionary: obj.apple = RequestVerifyPurchaseWithIapkitAppleProps.from_dict(data["apple"]) @@ -5171,6 +5175,8 @@ class RequestVerifyPurchaseWithIapkitProps: var dict = {} if api_key != null: dict["apiKey"] = api_key + if base_url != null: + dict["baseUrl"] = base_url if apple != null: if apple.has_method("to_dict"): dict["apple"] = apple.to_dict() diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 1ec00503c..76b2df063 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1978,6 +1978,13 @@ export interface RequestVerifyPurchaseWithIapkitProps { apiKey?: (string | null); /** Apple App Store verification parameters. */ apple?: (RequestVerifyPurchaseWithIapkitAppleProps | null); + /** + * Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + * Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + * Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + * The apiKey must be issued by the same IAPKit/Convex deployment as this server. + */ + baseUrl?: (string | null); /** Google Play Store verification parameters. */ google?: (RequestVerifyPurchaseWithIapkitGoogleProps | null); } diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index 8ea864c64..1961499df 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -350,6 +350,13 @@ input RequestVerifyPurchaseWithIapkitProps { """ apiKey: String """ + Available in OpenIAP Spec 2.3.1 / openiap-apple 2.4.0 / openiap-google 2.4.0. + Base URL for the IAPKit server. Defaults to https://kit.openiap.dev. + Set this to a reachable HTTP(S) origin when self-hosting or testing a local IAPKit server. + The apiKey must be issued by the same IAPKit/Convex deployment as this server. + """ + baseUrl: String + """ Apple App Store verification parameters. """ apple: RequestVerifyPurchaseWithIapkitAppleProps diff --git a/packages/kit/CONVENTION.md b/packages/kit/CONVENTION.md index 4cb74536f..e7aca2764 100644 --- a/packages/kit/CONVENTION.md +++ b/packages/kit/CONVENTION.md @@ -161,9 +161,11 @@ checkouts. If you really need to bypass, fix the underlying issue rather than passing `--no-verify`. `smoke:server` (`scripts/smoke-server.sh`) compiles the Bun binary, -boots it on port 3100, and probes `/health`, `/`, `/v1`, `/api/v1` — catches -startup regressions (missing env, bind conflicts, missing -`dist/index.html`). +boots it on port 3100, confirms that the spawned process owns the listener, and +probes `/health`, the SPA/API entry points, static 404 behavior, and a malformed +`POST /v1/purchase/verify` request that must return 400 without contacting a +store. This catches startup regressions (missing env, bind conflicts, missing +`dist/index.html`) without accepting responses from an older process. ## Long-running operations diff --git a/packages/kit/README.md b/packages/kit/README.md index 5fe7ae3d5..117efaac6 100644 --- a/packages/kit/README.md +++ b/packages/kit/README.md @@ -220,9 +220,12 @@ checkouts. Don't bypass with `--no-verify` — fix the underlying issue. `smoke:server` compiles the Bun binary via `build:all` and runs [`scripts/smoke-server.sh`](scripts/smoke-server.sh), which boots the -server on port 3100, polls `/health` until ready, and probes `/`, -`/v1`, `/api/v1`, `/health` — catches startup regressions (missing env, -bind conflicts, missing `dist/index.html`). Same script runs in CI. +server on port 3100, confirms that the spawned process owns the listener, and +probes `/`, `/v1`, `/api/v1`, `/health`, static 404 behavior, and a malformed +`POST /v1/purchase/verify` request that must return 400 without contacting a +store. This catches startup regressions (missing env, bind conflicts, missing +`dist/index.html`) without mistaking an older process for the binary under +test. The same script runs in CI. To run ad-hoc: diff --git a/packages/kit/scripts/smoke-server.sh b/packages/kit/scripts/smoke-server.sh index dac173af8..754ce7b13 100755 --- a/packages/kit/scripts/smoke-server.sh +++ b/packages/kit/scripts/smoke-server.sh @@ -37,6 +37,7 @@ LOG_FILE="$(mktemp /tmp/openiap-kit-smoke.XXXXXX.log)" # Run the binary in the background with placeholder env. CONVEX_URL="https://placeholder-build-1.convex.cloud" \ +VITE_KIT_CONVEX_URL="https://placeholder-build-1.convex.cloud" \ STATIC_ROOT="$DIST" \ PORT="$PORT" \ "$BINARY" > "$LOG_FILE" 2>&1 & @@ -50,23 +51,31 @@ cleanup() { } trap cleanup EXIT -# Poll until the server is listening or we give up. `kill -0` bails out -# fast when the child crashed during boot (bind conflict, missing env, -# import failure) instead of burning the full ~5s timeout before curl -# surfaces the same conclusion. +# Wait for this child to report that it owns the listener before probing HTTP. +# A health-first poll can accidentally hit an older process on the same port and +# report success even though the binary under test exited with EADDRINUSE. +ready=0 for _ in $(seq 1 20); do - if curl -sS -f -o /dev/null "http://localhost:${PORT}/health"; then - break - fi if ! kill -0 "$PID" 2>/dev/null; then - echo "smoke: server process exited before /health responded" >&2 + echo "smoke: server process exited before owning port $PORT" >&2 echo "---- server log ----" >&2 cat "$LOG_FILE" >&2 || true exit 1 fi + if grep -Fq "IAPKit server listening on :${PORT}" "$LOG_FILE"; then + ready=1 + break + fi sleep 0.25 done +if [[ "$ready" -ne 1 ]]; then + echo "smoke: server did not confirm ownership of port $PORT" >&2 + echo "---- server log ----" >&2 + cat "$LOG_FILE" >&2 || true + exit 1 +fi + fail=0 probe() { local path="$1" @@ -81,6 +90,24 @@ probe() { fi } +probe_json_post() { + local path="$1" + local expected="$2" + local code + code="$(curl -sS -o /dev/null -w "%{http_code}" \ + -X POST \ + -H "Authorization: Bearer openiap-kit_smoke-test-key" \ + -H "Content-Type: application/json" \ + --data '{}' \ + "http://localhost:${PORT}${path}")" + if [[ "$code" != "$expected" ]]; then + echo "smoke: POST $path expected $expected, got $code" >&2 + fail=1 + else + echo "smoke: POST $path → $code ✓" + fi +} + # Core surface: liveness probe must return 200 and the SPA fallback # must serve index.html for an unknown path. probe "/health" "200" @@ -90,6 +117,10 @@ probe "/api/v1" "200" probe "/intu/project/intu/apikeys" "200" probe "/assets/missing-build-asset.js" "404" probe "/missing-static-doc.json" "404" +# Exercise the compiled receipt route without reaching Convex or a store. The +# well-formed Bearer header passes auth-shape middleware, then the empty JSON +# object is rejected by the request schema with 400. +probe_json_post "/v1/purchase/verify" "400" if [[ "$fail" -ne 0 ]]; then echo "---- server log ----" >&2 diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 14118329f..8a5ff9d87 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -5161,6 +5161,27 @@ function checkFrameworkDependencyHygiene() { ], "root version sync must update MAUI Android dependency versions from packages/google", ); + expectIncludes( + "scripts/sync-versions.sh", + ["./libraries/godot-iap/scripts/sync-versions.sh"], + "root version sync must update the Godot Android dependency pin", + ); + expectIncludes( + ".github/workflows/release-google.yml", + ["libraries/godot-iap/addons/godot-iap/android/GodotIap.gdap"], + "Google release workflow must commit the synced Godot Android dependency pin", + ); + const googleReleaseWorkflow = read(".github/workflows/release-google.yml"); + const godotGdapStageCount = ( + googleReleaseWorkflow.match( + /git add libraries\/godot-iap\/addons\/godot-iap\/android\/GodotIap\.gdap/g, + ) ?? [] + ).length; + if (godotGdapStageCount < 2) { + fail( + "Google release workflow must stage the Godot Android dependency pin before commit and after rebase conflict recovery", + ); + } expectIncludes( "libraries/maui-iap/src/Directory.Build.props", [ diff --git a/scripts/sync-versions.sh b/scripts/sync-versions.sh index 930fa13f5..ea6d59903 100755 --- a/scripts/sync-versions.sh +++ b/scripts/sync-versions.sh @@ -334,6 +334,10 @@ echo "" echo "📦 Syncing MAUI Android dependency versions..." sync_maui_android_versions +echo "" +echo "📦 Syncing Godot Android dependency versions..." +./libraries/godot-iap/scripts/sync-versions.sh + # Sync generated types from packages/gql to libraries echo "" echo "📦 Syncing generated types..."