diff --git a/.claude/commands/release.md b/.claude/commands/release.md index c30f6a095..f0bcce9c1 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -125,7 +125,12 @@ For a multi-package release train, use this order when affected: 6. `release-godot.yml` 7. `release-kmp.yml` 8. `release-maui.yml` -9. `npm run deploy`; run `release.yml` with `version=current` only when the +9. `release-conformance.yml` — independent of the native/spec floor. Release it + when the behavior spec, runner, or adapter contract changed; skip it + otherwise. Its version is the conformance **suite** version, not the spec + version, so it does not participate in the `spec = min(google, apple)` + invariant. +10. `npm run deploy`; run `release.yml` with `version=current` only when the native-derived `spec` advanced. If a Docs GitHub Release is requested while `spec` is unchanged, stop and explain that the immutable `docs-{spec}` tag cannot represent a new release. diff --git a/.github/pr-previews/pr-316-ecosystem-removal.mp4 b/.github/pr-previews/pr-316-ecosystem-removal.mp4 new file mode 100644 index 000000000..e3e56a322 Binary files /dev/null and b/.github/pr-previews/pr-316-ecosystem-removal.mp4 differ diff --git a/.github/workflows/ci-kmp-iap.yml b/.github/workflows/ci-kmp-iap.yml index 009148678..ceddf30c4 100644 --- a/.github/workflows/ci-kmp-iap.yml +++ b/.github/workflows/ci-kmp-iap.yml @@ -78,8 +78,12 @@ jobs: with: cache-read-only: true - - name: Compile iOS simulator source set + # `iosSimulatorArm64Test` also compiles the source set, so this replaces + # the previous compile-only step rather than adding to it. Without it the + # iosTest suite (IosErrorMappingTest, IosConnectionLifecycleTest) never + # ran in CI. + - name: Compile and test iOS simulator source set run: | chmod +x gradlew "$GITHUB_WORKSPACE/scripts/ci/retry-gradle.sh" \ - ./gradlew --no-parallel :library:compileKotlinIosSimulatorArm64 + ./gradlew --no-parallel :library:iosSimulatorArm64Test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a73caa626..fb0cbf3ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,7 @@ jobs: filters: | gql: - 'packages/gql/**' + - 'packages/conformance/**' - 'scripts/**' - 'package.json' - 'bun.lock' @@ -117,6 +118,56 @@ jobs: - 'package.json' - 'bun.lock' - '.github/workflows/ci.yml' + test-conformance: + name: Test Conformance Suite + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install dependencies + run: | + for i in 1 2 3; do + bun install --frozen-lockfile && break + [ $i -eq 3 ] && exit 1 + echo "Attempt $i failed. Retrying..." + sleep 5 + done + + - name: Run conformance suite tests + working-directory: packages/conformance + run: bun run test + + - name: Verify cross-language behavior ids are in sync + run: node packages/conformance/scripts/generate-behavior-ids.mjs --check + + - name: Reference conformance report + run: node packages/conformance/scripts/run-reference-report.mjs + + - name: Ecosystem coverage report + run: | + node packages/conformance/scripts/coverage-report.mjs + node packages/conformance/scripts/coverage-report.mjs --json > conformance-coverage.json + node packages/conformance/scripts/run-reference-report.mjs --json > conformance-report.json + + - name: Upload conformance report + uses: actions/upload-artifact@v4 + with: + name: conformance-report + path: | + conformance-report.json + conformance-coverage.json + if-no-files-found: error + audit-parity: name: Audit SDK Parity runs-on: ubuntu-latest diff --git a/.github/workflows/release-conformance.yml b/.github/workflows/release-conformance.yml new file mode 100644 index 000000000..89e6cac19 --- /dev/null +++ b/.github/workflows/release-conformance.yml @@ -0,0 +1,495 @@ +name: "A. Release: openiap-conformance" + +# Two-phase release, matching the other npm lanes: this workflow bumps and tags +# on the release branch, then dispatches itself on the tag ref so npm provenance +# attests the same commit the tag names. + +on: + workflow_dispatch: + inputs: + version: + description: "Version bump type" + required: true + type: choice + options: + - patch + - minor + - major + - current + - rc-bump + default: patch + prerelease: + description: "Publish as prerelease from next (-rc.1)" + required: false + default: false + type: boolean + publish_only: + description: "Internal: publish an existing release tag to npm" + required: false + default: false + type: boolean + source_run_id: + description: "Internal: validated branch release workflow run" + required: false + default: "" + type: string + +concurrency: + group: ${{ github.workflow }}-${{ inputs.publish_only && 'publish' || 'release' }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + release-branch: + name: Validate release branch + if: ${{ !inputs.publish_only }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Enforce stable and prerelease branches + run: >- + node scripts/release-branch-policy.mjs guard conformance + "${{ inputs.version }}" "${{ inputs.prerelease }}" + "$GITHUB_REF_NAME" + + validate: + name: Verify conformance suite + needs: [release-branch] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - name: Install dependencies + run: | + for i in 1 2 3; do + bun install --frozen-lockfile && break + [ $i -eq 3 ] && exit 1 + echo "Attempt $i failed. Retrying..." + sleep 5 + done + + - name: Run suite tests + working-directory: packages/conformance + run: bun run test + + # A published suite whose behavior ids disagree with the native suites + # would invalidate every report produced against it. + - name: Verify cross-language behavior ids are in sync + run: node packages/conformance/scripts/generate-behavior-ids.mjs --check + + - name: Verify every MUST behavior has an implementation + run: node packages/conformance/scripts/coverage-report.mjs --check + + - name: Reference conformance report + run: node packages/conformance/scripts/run-reference-report.mjs + + deploy: + name: Bump, tag, and release + needs: [validate] + permissions: + contents: write + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/conformance + env: + RELEASE_BRANCH: ${{ github.ref_name }} + outputs: + version: ${{ steps.bump.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 20 + registry-url: "https://registry.npmjs.org" + + - name: Preserve npm release verifiers + run: | + cp "$GITHUB_WORKSPACE/scripts/verify-npm-release-provenance.mjs" "$RUNNER_TEMP/verify-npm-release-provenance.mjs" + cp "$GITHUB_WORKSPACE/scripts/npm-publish-authorization.mjs" "$RUNNER_TEMP/npm-publish-authorization.mjs" + + - name: Ensure npm CLI v11.19.0 for signature verification + run: npm install -g npm@11.19.0 + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump version + id: bump + env: + VERSION_TYPE: ${{ inputs.version }} + IS_PRERELEASE: ${{ inputs.prerelease }} + run: | + if [ "$VERSION_TYPE" = "current" ]; then + NEW_VERSION=$(node -p "require('./package.json').version") + elif [ "$VERSION_TYPE" = "rc-bump" ]; then + npm version prerelease --preid=rc --no-git-tag-version + NEW_VERSION=$(node -p "require('./package.json').version") + elif [ "$IS_PRERELEASE" = "true" ]; then + npm version "pre${VERSION_TYPE}" --preid=rc --no-git-tag-version + NEW_VERSION=$(node -p "require('./package.json').version") + else + npm version "$VERSION_TYPE" --no-git-tag-version + NEW_VERSION=$(node -p "require('./package.json').version") + fi + + case "$NEW_VERSION" in + *-*) IS_PRE=true ;; + *) IS_PRE=false ;; + esac + echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "is_prerelease=$IS_PRE" >> "$GITHUB_OUTPUT" + + - name: Check release tag + id: check_tag + env: + VERSION: ${{ steps.bump.outputs.version }} + VERSION_TYPE: ${{ inputs.version }} + run: | + TAG="openiap-conformance-$VERSION" + git fetch --tags origin + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + if [ "$VERSION_TYPE" != "current" ]; then + echo "::error::$TAG already exists. Use current to retry this version." + exit 1 + fi + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout release tag (current version) + if: ${{ inputs.version == 'current' && steps.check_tag.outputs.exists == 'true' }} + env: + VERSION: ${{ steps.bump.outputs.version }} + run: | + TAG="openiap-conformance-$VERSION" + node "$GITHUB_WORKSPACE/scripts/assert-release-tag.mjs" \ + conformance "$RELEASE_BRANCH" "$TAG" "$VERSION" + git checkout "$TAG" + + - name: Assert verified release head is unchanged + if: ${{ inputs.version != 'current' || steps.check_tag.outputs.exists != 'true' }} + working-directory: ${{ github.workspace }} + run: node scripts/assert-release-head.mjs "$RELEASE_BRANCH" "$GITHUB_SHA" + + - name: Check npm for an existing version + id: check_npm + env: + VERSION: ${{ steps.bump.outputs.version }} + run: | + if NPM_OUTPUT=$(npm view "openiap-conformance@$VERSION" version 2>&1); then + echo "exists=true" >> "$GITHUB_OUTPUT" + elif echo "$NPM_OUTPUT" | grep -qiE 'E404|404 Not Found'; then + echo "exists=false" >> "$GITHUB_OUTPUT" + else + echo "::error::Unable to verify openiap-conformance@$VERSION on npm" + echo "$NPM_OUTPUT" + exit 1 + fi + + - name: Refuse an untagged published version + if: ${{ steps.check_npm.outputs.exists == 'true' && steps.check_tag.outputs.exists != 'true' }} + run: | + echo "::error::Published version has no immutable release tag" + exit 1 + + - name: Verify existing npm release provenance + if: ${{ steps.check_npm.outputs.exists == 'true' && steps.check_tag.outputs.exists == 'true' }} + working-directory: ${{ github.workspace }} + env: + VERSION: ${{ steps.bump.outputs.version }} + run: | + TAG="openiap-conformance-$VERSION" + EXPECTED_COMMIT=$(git rev-parse "$TAG^{commit}") + node "$RUNNER_TEMP/verify-npm-release-provenance.mjs" \ + openiap-conformance "$VERSION" "$EXPECTED_COMMIT" "$TAG" \ + release-conformance.yml + + - name: Commit and tag + if: ${{ inputs.version != 'current' }} + working-directory: ${{ github.workspace }} + run: | + VERSION="${{ steps.bump.outputs.version }}" + TAG="openiap-conformance-$VERSION" + git add packages/conformance/package.json + git commit -m "chore(release): openiap-conformance $VERSION" + git tag -a "$TAG" -m "Release $TAG" + git push --atomic origin "HEAD:$RELEASE_BRANCH" --follow-tags + + - name: Create tag (current version) + if: ${{ inputs.version == 'current' && steps.check_tag.outputs.exists != 'true' }} + working-directory: ${{ github.workspace }} + env: + VERSION: ${{ steps.bump.outputs.version }} + run: | + TAG="openiap-conformance-$VERSION" + git commit --allow-empty -m "chore: recover release ref" + git tag -a "$TAG" "$GITHUB_SHA" -m "Release $TAG" + git push --atomic origin \ + "HEAD:refs/heads/$RELEASE_BRANCH" \ + "refs/tags/$TAG:refs/tags/$TAG" + + - name: Require tag-ref npm publisher capability + if: steps.check_npm.outputs.exists == 'false' + working-directory: ${{ github.workspace }} + env: + TAG: openiap-conformance-${{ steps.bump.outputs.version }} + run: | + if ! git grep -q '^ publish-npm:' "$TAG" -- .github/workflows/release-conformance.yml || \ + ! git grep -q 'Upload npm publish authorization' "$TAG" -- .github/workflows/release-conformance.yml || \ + ! git cat-file -e "$TAG:scripts/verify-npm-release-provenance.mjs" || \ + ! git cat-file -e "$TAG:scripts/npm-publish-authorization.mjs"; then + echo "::error::$TAG predates the authorized tag-ref npm publisher. Release a new reviewed version instead." + exit 1 + fi + + - name: Write npm publish authorization + if: steps.check_npm.outputs.exists == 'false' + env: + VERSION: ${{ steps.bump.outputs.version }} + run: | + TAG="openiap-conformance-$VERSION" + TAG_SHA=$(git rev-parse "$TAG^{commit}") + node "$RUNNER_TEMP/npm-publish-authorization.mjs" write \ + "$RUNNER_TEMP/npm-publish-authorization.json" \ + "$GITHUB_REPOSITORY" ".github/workflows/release-conformance.yml" \ + "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$RELEASE_BRANCH" \ + "$GITHUB_SHA" "$TAG" "$TAG_SHA" + + - name: Upload npm publish authorization + if: steps.check_npm.outputs.exists == 'false' + uses: actions/upload-artifact@v4 + with: + name: npm-publish-authorization-${{ github.run_attempt }} + path: ${{ runner.temp }}/npm-publish-authorization.json + if-no-files-found: error + retention-days: 1 + + - name: Write release notes + working-directory: ${{ github.workspace }} + run: | + VERSION="${{ steps.bump.outputs.version }}" + cat > /tmp/release-notes.md <> "$GITHUB_OUTPUT" + echo "source_branch=$SOURCE_BRANCH" >> "$GITHUB_OUTPUT" + + - name: Require successful source release run + id: source + env: + GH_TOKEN: ${{ github.token }} + SOURCE_BRANCH: ${{ steps.release.outputs.source_branch }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + run: | + if ! [[ "$SOURCE_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::A validated source release run id is required" + exit 1 + fi + SOURCE_RUN_JSON="" + SOURCE_STATUS="" + for _ in {1..60}; do + SOURCE_RUN_JSON=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID") || { + sleep 5 + continue + } + SOURCE_STATUS=$(jq -r '.status // ""' <<<"$SOURCE_RUN_JSON") + if [ "$SOURCE_STATUS" = "completed" ]; then + break + fi + sleep 5 + done + if [ "$SOURCE_STATUS" != "completed" ]; then + echo "::error::Source release run $SOURCE_RUN_ID did not complete within the wait window" + exit 1 + fi + SOURCE_PATH=$(jq -r '.path // ""' <<<"$SOURCE_RUN_JSON") + SOURCE_EVENT=$(jq -r '.event // ""' <<<"$SOURCE_RUN_JSON") + SOURCE_HEAD_BRANCH=$(jq -r '.head_branch // ""' <<<"$SOURCE_RUN_JSON") + SOURCE_HEAD_SHA=$(jq -r '.head_sha // ""' <<<"$SOURCE_RUN_JSON") + SOURCE_RUN_ATTEMPT=$(jq -r '.run_attempt // ""' <<<"$SOURCE_RUN_JSON") + SOURCE_CONCLUSION=$(jq -r '.conclusion // ""' <<<"$SOURCE_RUN_JSON") + SOURCE_REPOSITORY=$(jq -r '.repository.full_name // ""' <<<"$SOURCE_RUN_JSON") + if [ "$SOURCE_STATUS" != "completed" ] || [ "$SOURCE_CONCLUSION" != "success" ]; then + echo "::error::Source release run $SOURCE_RUN_ID did not complete successfully" + exit 1 + fi + if [ "$SOURCE_PATH" != ".github/workflows/release-conformance.yml" ] || \ + [ "$SOURCE_EVENT" != "workflow_dispatch" ] || \ + [ "$SOURCE_HEAD_BRANCH" != "$SOURCE_BRANCH" ] || \ + [ "$SOURCE_REPOSITORY" != "$GITHUB_REPOSITORY" ] || \ + ! [[ "$SOURCE_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Source run does not match the required release workflow and branch" + exit 1 + fi + if ! git merge-base --is-ancestor "$SOURCE_HEAD_SHA" "origin/$SOURCE_BRANCH"; then + echo "::error::Source release run is not reachable from origin/$SOURCE_BRANCH" + exit 1 + fi + echo "head_sha=$SOURCE_HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "run_attempt=$SOURCE_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + + - name: Verify source run authorized this release tag + env: + GH_TOKEN: ${{ github.token }} + SOURCE_BRANCH: ${{ steps.release.outputs.source_branch }} + SOURCE_HEAD_SHA: ${{ steps.source.outputs.head_sha }} + SOURCE_RUN_ATTEMPT: ${{ steps.source.outputs.run_attempt }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + VERSION: ${{ steps.release.outputs.version }} + run: | + AUTHORIZATION_DIR="$RUNNER_TEMP/npm-publish-authorization" + gh run download "$SOURCE_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --name "npm-publish-authorization-$SOURCE_RUN_ATTEMPT" \ + --dir "$AUTHORIZATION_DIR" + node "$GITHUB_WORKSPACE/scripts/npm-publish-authorization.mjs" verify \ + "$AUTHORIZATION_DIR/npm-publish-authorization.json" \ + "$GITHUB_REPOSITORY" ".github/workflows/release-conformance.yml" \ + "$SOURCE_RUN_ID" "$SOURCE_RUN_ATTEMPT" "$SOURCE_BRANCH" \ + "$SOURCE_HEAD_SHA" "openiap-conformance-$VERSION" "$GITHUB_SHA" + + # Re-verify at the tag: a suite published with drifted ids would + # invalidate every report produced against it. + - name: Verify behavior ids and coverage at the tag + working-directory: ${{ github.workspace }} + run: | + node packages/conformance/scripts/generate-behavior-ids.mjs --check + node packages/conformance/scripts/coverage-report.mjs --check + + - name: Check if npm package already published + id: check_npm + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + if NPM_OUTPUT=$(npm view "openiap-conformance@$VERSION" version 2>&1); then + echo "exists=true" >> "$GITHUB_OUTPUT" + elif echo "$NPM_OUTPUT" | grep -qiE 'E404|404 Not Found'; then + echo "exists=false" >> "$GITHUB_OUTPUT" + else + echo "::error::Unable to verify openiap-conformance@$VERSION on npm" + echo "$NPM_OUTPUT" + exit 1 + fi + + - name: Publish to npm + if: steps.check_npm.outputs.exists == 'false' + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + if [[ "$VERSION" == *-* ]]; then + npm publish --tag next --provenance + else + npm publish --provenance + fi + + - name: Verify published provenance + working-directory: ${{ github.workspace }} + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + node scripts/verify-npm-release-provenance.mjs \ + openiap-conformance "$VERSION" "$GITHUB_SHA" \ + "openiap-conformance-$VERSION" release-conformance.yml diff --git a/AGENTS.md b/AGENTS.md index b3404cae1..bb3b69e13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,15 @@ KISS and SSOT are mandatory release criteria. The canonical rules live in [`knowledge/internal/03-coding-style.md`](knowledge/internal/03-coding-style.md#0-kiss-and-ssot-are-release-requirements). Apply that section before implementation and during every review. +### Comment Style + +Keep comments short — default to one line. AI-authored comments over-explain by +default, so trim before committing: no restating the code, no narrating the +change or its history (that belongs in the commit message), no explaining +well-known APIs. Keep only what the code cannot show: platform quirks, non-obvious +constraints, and why an obvious alternative was rejected. Full checklist in +[`knowledge/internal/03-coding-style.md`](knowledge/internal/03-coding-style.md#keep-them-short--especially-ai-generated-ones). + ### Platform Function Naming - **iOS functions**: Must end with `IOS` suffix (e.g., `syncIOS`, `getReceiptDataIOS`) diff --git a/bun.lock b/bun.lock index dcb410076..7d3349001 100644 --- a/bun.lock +++ b/bun.lock @@ -12,14 +12,25 @@ }, "packages/apple": { "name": "@hyodotdev/openiap-ios", - "version": "3.0.1", + "version": "3.2.0", "dependencies": { "@hyodotdev/openiap-gql": "workspace:*", }, }, + "packages/conformance": { + "name": "openiap-conformance", + "version": "1.0.0", + "bin": { + "openiap-conformance-report": "./scripts/run-reference-report.mjs", + "openiap-conformance-coverage": "./scripts/coverage-report.mjs", + }, + "devDependencies": { + "vitest": "^4.1.5", + }, + }, "packages/docs": { "name": "@hyodotdev/openiap-docs", - "version": "3.0.1", + "version": "3.2.0", "dependencies": { "@preact/signals-react": "^3.2.1", "@types/prismjs": "^1.26.5", @@ -57,14 +68,14 @@ }, "packages/google": { "name": "@hyodotdev/openiap-android", - "version": "3.0.1", + "version": "3.3.0", "dependencies": { "@hyodotdev/openiap-gql": "workspace:*", }, }, "packages/gql": { "name": "@hyodotdev/openiap-gql", - "version": "3.0.1", + "version": "3.2.0", "devDependencies": { "@graphql-codegen/add": "^6.0.0", "@graphql-codegen/cli": "^6.0.0", @@ -1980,6 +1991,8 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "openiap-conformance": ["openiap-conformance@workspace:packages/conformance"], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "oslo": ["oslo@1.2.1", "", { "dependencies": { "@node-rs/argon2": "1.7.0", "@node-rs/bcrypt": "1.9.0" } }, "sha512-HfIhB5ruTdQv0XX2XlncWQiJ5SIHZ7NHZhVyHth0CSZ/xzge00etRyYy/3wp/Dsu+PkxMC+6+B2lS/GcKoewkA=="], diff --git a/docs/conformance-audit.md b/docs/conformance-audit.md new file mode 100644 index 000000000..256945961 --- /dev/null +++ b/docs/conformance-audit.md @@ -0,0 +1,1547 @@ +# OpenIAP Conformance Testing Audit + +**Audit date:** 2026-08-12 +**Repository state:** `main` @ `03091c0c` +**Scope:** Repository/design audit. Sections 1–15 record the state *as audited*, before any changes. + +> **Four remediation rounds followed this audit** (§16–19), taking the overall score from +> **2 → 4.5**. §16 fixed defects, including a live entitlement leak the audit predicted. +> §17 built the versioned conformance suite. §18 bound real implementations to it and +> **corrects an error in this audit's own §10.1/R3**. §19 fixes R3 as a breaking change. Read §16–19 for the current state; +> §1–15 remain as originally written so the two can be compared. + +--- + +## 1. Executive Summary + +OpenIAP has an unusually disciplined **type and API-surface** contract system, and essentially **no behavioral conformance system**. + +The GraphQL schema in `packages/gql/src/` is a genuine single source of truth. It generates six language bindings, those bindings are synced into eight downstream targets through a manifest, and CI fails on any drift. A 8,827-line parity audit (`scripts/audit-non-godot-parity.mjs`) runs on every pull request and enforces that every schema operation has a corresponding binding in every framework SDK. This machinery is real, it is enforced, and it is better than most projects of this size have. + +But it verifies **shape, not behavior**. `audit-non-godot-parity.mjs` works by reading source files as text and regex-matching for the presence of symbols and declarations — for example, `hasTypeScriptFieldBinding()` (line 1129) passes if a top-level `const` mentioning the operation name and a `QueryField<`/`MutationField<` type parameter exists in the file. An SDK that declares `restorePurchases` and returns immediately without doing anything passes the parity audit. Nothing in the repository asserts what `restorePurchases` should *do*. + +The three artifacts in the repo that carry conformance-adjacent names are, on inspection, mostly not conformance tests: + +| Artifact | Name suggests | Actually is | +| --- | --- | --- | +| `scripts/audit-non-godot-parity.mjs` | Cross-SDK parity | Static source-text presence checking | +| `libraries/maui-iap/tests/OpenIap.Maui.ContractTests/` | Contract tests | HTTP/JSON tests against a fake handler for the IAPKit REST client | +| `libraries/flutter_inapp_purchase/test/native_wire_contract_test.dart` | Wire contract | `File(...).readAsStringSync()` + `expect(source, contains(...))` | +| `packages/gql/src/schema-contract.test.ts` | Schema contract | GraphQL directive/type-shape assertions (legitimately schema validation) | +| `packages/kit/convex/webhooks/conformance.test.ts` | Conformance | **A genuine deterministic conformance harness** — the one real instance | + +The single real conformance kernel in the repository is on the server side: `packages/kit/convex/webhooks/conformance.test.ts` drives Apple ASN v2 and Google RTDN payloads through a **shared** normalizer → **shared** state machine (`applySubscriptionTransition`) → **shared** entitlement predicate (`entitlementActive`), and asserts the normalized outcome. That is the correct architecture. It covers 6 scenarios and 2 of IAPKit's 4 providers. + +On the client side, every behavioral test is written against one implementation. Where two implementations test the same concept, the test is copy-pasted and has already drifted — `SubscriptionGroupMappingPlayTest.kt` and `SubscriptionGroupMappingHorizonTest.kt` share an identical test name and assertions, but Play additionally asserts `pending subscriptions are not active entitlements` and Horizon does not. + +Two findings deserve immediate attention independent of any conformance program: + +1. **`packages/docs/src/pages/docs/foundation/one-pager.tsx:198` lists "Conformance Tests — Cross-platform test matrix ensuring behavioral consistency" under a heading titled "Core Components"**, and `sponsorship.tsx:65` presents "Conformance and test matrix" as a present-tense sponsor benefit. `roadmap-budget.tsx:69` correctly marks "Conformance test suite v1" as **Planned**. The public foundation materials contradict each other, and the optimistic reading is the one a Linux Foundation reviewer or prospective sponsor will encounter first. + +2. **Store implementations diverge on entitlement-relevant semantics with no test detecting it.** Amazon never produces `PurchaseState.Pending` and maps a cancelled receipt to `PurchaseState.Unknown` (`packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt:352`), while Play and Horizon map their SDK's `PENDING` to `PurchaseState.Pending`. A consumer branching on `purchaseState` grants entitlements differently depending on which store flavor is compiled in. + +**Direct answer to the headline question:** No — OpenIAP cannot honestly claim today that its implementations are conformance-tested against a shared specification. It can honestly claim that its implementations are *type-conformant* and *API-surface-complete* against a shared schema, which is a real and defensible claim, and a materially different one. + +--- + +## 2. Overall Conformance Maturity Score + +| Subject | Score | Band | +| --- | --- | --- | +| **Client OpenIAP** (schema, Apple, Google, 6 framework SDKs) | **1.5 / 5** | Ad hoc → Emerging | +| **IAPKit** (server-side verification + webhooks) | **2.5 / 5** | Emerging → Functional | +| **Overall** | **2 / 5** | Emerging | + +**Client OpenIAP — 1.5.** Scores above pure "Ad hoc" because a shared contract layer genuinely exists and is CI-enforced (schema SSOT, 6-language codegen, drift gates, operation-binding parity). Cannot reach a clean "Emerging" because *none* of that shared layer is behavioral: there is not one test in the repository that defines an expected behavior once and executes it against more than one implementation. Every one of the ~136 Apple test functions, ~380 Google test functions, and the framework SDK suites is implementation-specific. + +**IAPKit — 2.5.** Scores highest in the repo because `convex/subscriptions/stateMachine.ts` is a real shared normalized model and `webhooks/conformance.test.ts` is a real deterministic multi-step scenario harness executing two providers against it. Held below "Functional" because only 2 of 4 providers are in the harness, the scenario scripts are duplicated per provider (`runAppleScenario` / `runGoogleScenario`) rather than shared, and the *verification* layer — the part that actually grants entitlement — has no shared adapter interface at all. + +**Overall — 2.** Weighted toward the client side, which is the larger surface and the one that "OpenIAP compatible" would refer to. + +--- + +## 3. Current Architecture + +### 3.1 Specification source of truth + +The canonical spec is the GraphQL schema set in `packages/gql/src/` — 9 files, 3,224 lines: + +| File | Lines | Role | +| --- | --- | --- | +| `schema.graphql` | 22 | Root types + `@openiapDeprecated` directive | +| `type.graphql` | 846 | Cross-platform types, `IapPlatform`, `IapStore`, `ProductType`, `PurchaseState`, `IapEvent` | +| `type-ios.graphql` | 804 | iOS-specific types | +| `type-android.graphql` | 1,040 | Android-specific types | +| `api.graphql` | 100 | Cross-platform Query/Mutation operations | +| `api-ios.graphql` | 175 | iOS-only operations | +| `api-android.graphql` | 121 | Android-only operations | +| `error.graphql` | 64 | `ErrorCode` enum (37 members) + `PurchaseError` | +| `event.graphql` | 52 | Subscription/event operations | + +Generation runs through two guarded lanes (`packages/gql/package.json` → `generate`): graphql-codegen for TypeScript, and a custom Parser → IR → language-plugin pipeline for Swift, Kotlin, Dart, GDScript, and C#. Output lands in `packages/gql/src/generated/` and is distributed by `packages/gql/generated-sync-manifest.mjs` to eight targets (Apple `Types.swift`, Google `Types.kt`, and the six framework SDKs' generated type files). + +**Is the spec precise enough to base conformance testing on?** For types, yes. For behavior, no. The schema defines shape and vocabulary; behavioral requirements exist only as free-text docstrings, and they are sparse. The strongest normative statements found in the entire schema are: + +- `api.graphql:30` — `getStorefront`: *"The operation fails when the store cannot provide a value; implementations must not synthesize a locale fallback."* This is a genuine normative MUST NOT. Nothing tests it. +- `api.graphql:60` — `finishTransaction`: *"Required on Android within 3 days."* +- `type.graphql:36` — `IapEvent.SubscriptionBillingIssue`: *"NOT emitted by Amazon Appstore or the Horizon flavor, whose Billing Compatibility SDK implements only Play Billing 7.0."* This is capability information encoded in prose. + +There is no RFC-2119 keyword discipline, no distinction between normative requirements and implementation guidance, no defined state machine for `PurchaseState` transitions, and no mapping table stating which platform error conditions MUST normalize to which `ErrorCode`. + +### 3.2 Store implementation architecture + +**Apple** (`packages/apple/`) — single StoreKit 2 implementation, Swift Package. + +**Android** (`packages/google/`) — three stores implemented as **Gradle product flavors** (`packages/google/openiap/build.gradle.kts:106-123`), not as runtime adapters: + +``` +packages/google/openiap/src/ + main/ 15 .kt shared + play/ 7 .kt Google Play Billing + horizon/ 5 .kt Meta Horizon + amazon/ 2 .kt Amazon Appstore +``` + +Each flavor supplies its own file with the same class/function names — for example, three separate `OpenIapErrorExtensions.kt` files each defining `OpenIapError.Companion.fromBillingResponseCode`. This is compile-time duck typing: **there is no Kotlin interface that all three flavors must implement**, so the compiler enforces nothing about their mutual consistency, and each flavor has its own isolated test source set (`testPlay/`, `testHorizon/`, `testAmazon/`). + +### 3.3 IAPKit architecture + +Two distinct layers with very different maturity: + +**Verification layer** (`packages/kit/convex/purchases/`) — four bespoke provider modules with no shared interface: + +| Provider | Module | Lines | Entry point | +| --- | --- | --- | --- | +| Apple | `ios.ts` | 572 | `verifyAppStoreReceiptInternalV1` | +| Google | `android.ts` | 676 | `verifyGooglePlayReceiptInternalV1` | +| Amazon | `amazon.ts` | 666 | `verifyAmazonReceiptInternalV1` | +| Meta Horizon | `horizon.ts` | 292 | `verifyMetaHorizonReceiptInternalV1` | + +**Webhook/lifecycle layer** (`packages/kit/convex/webhooks/`, `convex/subscriptions/`) — genuinely normalized: + +``` +apple.ts ──► normalizeAppleAsn ──┐ + ├─► NormalizedWebhookEvent +google.ts ──► normalizeGoogleRtdn ──┘ │ + ▼ + applySubscriptionTransition (stateMachine.ts, 279 lines) + │ + ▼ + entitlementActive +``` + +`SubscriptionState` (`webhooks/shared.ts:42`) is the shared normalized vocabulary. Only Apple and Google have webhook normalizers; Amazon uses a polling reconciler (`reconcileAmazonPurchases`, `purchases/amazon.ts:564`) and Horizon has neither. + +--- + +## 4. Existing Test Inventory + +### 4.1 Volume + +| Area | Test files | Notes | +| --- | --- | --- | +| `packages/gql` | 20 | Schema + codegen validation | +| `packages/apple` | 9 files / 136 `func test` | | +| `packages/google` | 41 files / 380 `@Test` | Split across `test/`, `testPlay/`, `testHorizon/`, `testAmazon/` | +| `packages/kit` | 86 | Includes the one real conformance harness | +| `packages/mcp-server` | 4 | | +| `libraries/react-native-iap` | 26 | | +| `libraries/expo-iap` | 37 | | +| `libraries/flutter_inapp_purchase` | 27 | | +| `libraries/kmp-iap` | 23 | | +| `libraries/maui-iap` | 9 | | +| `libraries/godot-iap` | 3 | | +| `scripts/` | ~12 | Audit-script self-tests | + +### 4.2 Classification of significant suites + +| Suite | Verifies | Class | +| --- | --- | --- | +| `packages/gql/src/schema-contract.test.ts` | Directive locations, union allowlist, `platform` field removed from `PurchaseAndroid`/`PurchaseIOS` in favor of `store` | Schema validation | +| `packages/gql/src/generated-compatibility.test.ts` | Deprecation tags, doc-comment preservation, blank-line formatting, published-signature stability across generated languages | Code-generation validation | +| `packages/gql/src/generated-sync-verifier.test.mjs`, `generated-sync-manifest.test.mjs` | Generated files match manifest targets | Drift detection | +| `packages/gql/src/schema-linter.test.ts`, `schema-*.test.mjs` | Schema hygiene, deprecation markers | Schema validation | +| **`scripts/audit-non-godot-parity.mjs`** | Presence of symbols/bindings/example routes across 5 SDKs by regex over source text | **Static analysis — not a test** | +| `scripts/audit-docs.ts` | Doc pages' `` field mentions exist in generated types; release-note link integrity; version metadata | Docs/type drift detection | +| `scripts/audit-purchase-payload-parity.mjs` | Purchase payload field parity across SDKs by source-text extraction | Static analysis | +| `packages/apple/Tests/**` | Serialization failures, app-account tokens, external purchase links, renewal info, `verifyPurchase`, intro-offer eligibility, connection/listener lifecycle | Unit | +| `packages/google/**/test/` (shared) | Billing converters, error construction, offer types, request-props invariants, continuation guards | Unit | +| `packages/google/**/testPlay,testHorizon,testAmazon/` | Flavor-specific mapping, ownership, race conditions | Unit (per-flavor, duplicated) | +| **`packages/kit/convex/webhooks/conformance.test.ts`** | 6 multi-step lifecycle scenarios → shared state machine → entitlement | **Conformance/contract** | +| `packages/kit/convex/purchases/{ios,android,amazon,horizon}.test.ts` | Each provider's own parsing/mapping/verification functions | Unit (per-provider, independent) | +| `packages/kit/convex/subscriptions/stateMachine.test.ts` | State machine transitions directly | Unit (on a shared component) | +| `packages/kit/server/api/v1/*.test.ts` | REST routes, schemas, rate/replay guards | Integration | +| `libraries/*/example/__tests__/**` | Example app UI with `useIAP` fully mocked (`purchase-flow.test.tsx` mocks `mockUseIAP` wholesale) | Unit (UI), **not SDK behavior** | +| `libraries/flutter_inapp_purchase/test/native_wire_contract_test.dart` | `expect(source, contains('params["skus"]'))` over plugin source files | Static analysis dressed as a test | +| `libraries/maui-iap/tests/OpenIap.Maui.ContractTests/Program.cs` | 6 tests of URI escaping / JSON round-tripping against `FakeHttpMessageHandler` for the IAPKit REST client | Unit | +| `scripts/e2e-web-sites.mjs` | Playwright over docs + kit marketing sites | E2E (web, not IAP) | +| `.claude/skills/iapkit-e2e-martie`, `iapkit-e2e-petgu`, `/e2e-tests` | Manual, human-driven device + sandbox procedures | Manual E2E | + +### 4.3 What does not exist + +- No `conformance/` directory on the client side. +- No shared fixture corpus. A repo-wide search for fixture/scenario/golden data files returns only `libraries/expo-iap/plugin/__tests__/fixtures` (Expo config-plugin fixtures, unrelated to IAP behavior). +- No adapter/harness abstraction that lets one test body run against multiple implementations. +- No `.storekit` StoreKit Test configuration in `packages/apple` (one exists at `libraries/flutter_inapp_purchase/example/ios/Runner/StoreKit.storekit`, used by the example app, not by a test suite). +- No automated real-store or sandbox testing anywhere in CI. + +--- + +## 5. Conformance Coverage Matrix + +Assessed strictly: "Covered" requires a reusable test asserting spec-defined behavior against more than one implementation. + +| # | Category | Status | Evidence | +| --- | --- | --- | --- | +| 1 | Product fetching | **Not covered** | Implementation-specific only: `FetchProductsAmazonTest.kt`, `fetch_products_all_test.dart`, `fetch-products-discriminated-union.test.ts`. No shared assertion of what a normalized `Product` must contain per store. | +| 2 | Purchases | **Not covered** | No test drives `requestPurchase` against a contract. Framework example tests mock the hook entirely. | +| 3 | Transaction completion / acknowledgement | **Not covered** | `finishTransaction` is bound in every SDK (parity audit) but its semantics — consumable vs non-consumable, Android's 3-day window from `api.graphql:60` — are untested. | +| 4 | Restoration / available purchases | **Not covered** | `available-purchases.test.tsx`, `available_purchases_screen_test.dart` are mocked UI tests. | +| 5 | Subscriptions | **Partially covered** | Server-side only: `webhooks/conformance.test.ts` (Apple + Google). Client-side `getActiveSubscriptions` has independent per-flavor tests. | +| 6 | Purchase lifecycle / state transitions | **Partially covered** | `stateMachine.ts` + `conformance.test.ts` cover the *server* lifecycle well. No client-side `PurchaseState` transition contract exists. | +| 7 | Pending purchases | **Partially covered** | `AmazonPendingPurchasesTest.kt`, `PendingEventBufferTest.kt`, `PendingPurchaseOwnershipRaceTest.kt` — all independent. **Amazon never emits `PurchaseState.Pending`** (`amazon/OpenIapModule.kt:352`); no test asserts this divergence is intended. | +| 8 | Cancellation | **Partially covered** | Amazon's cancellation signals tested in `AmazonSubscriptionGroupMappingTest.kt:12`; server-side via `cancellationReason` in `conformance.test.ts`. No cross-implementation contract. | +| 9 | Already-owned | **Not covered** | `ErrorCode.AlreadyOwned` exists. Google maps `ITEM_ALREADY_OWNED → ItemAlreadyOwned` (`play/OpenIapErrorExtensions.kt:33`). Apple has **no construction site for it** anywhere in `packages/apple/Sources/`. Untested on both. | +| 10 | Normalized error codes | **Partially covered** | Per-implementation mapping tests exist (`OpenIapErrorTest.kt`, `ErrorMappingTest.kt`, `IosErrorMappingTest.kt`, `AmazonErrorMappingTest.kt`, `errorMapping.test.ts`). **No shared table.** See §7.1 for the divergence this hides. | +| 11 | Product / transaction identifiers | **Partially covered** | `extract-order-id.test.ts`, `extract-product-id.test.ts` (kit), `HorizonBlankOrderIdTest.kt`. Client-side identifier normalization is untested cross-store. | +| 12 | Optional / unsupported capabilities | **Not covered** | No machine-readable capability model exists. See §8. `OpenRedeemOfferCodeAmazonNoOpTest.kt` / `...HorizonNoOpTest.kt` test no-op behavior per flavor, independently. | +| 13 | Platform-specific extensions | **Partially covered** | `audit-non-godot-parity.mjs` enforces that `*IOS`/`*Android` operations are bound everywhere; behavior untested. | +| 14 | Event / listener behavior | **Partially covered** | `ListenerThreadSafetyTest.kt`, `PendingEventBufferTest.kt`, `OpenIapProviderTests.swift:35-104`. Ordering and delivery guarantees are not specified, so nothing cross-checks them. | + +**Score: 0 Covered / 8 Partially covered / 6 Not covered.** + +--- + +## 6. CI Enforcement Assessment + +### 6.1 What runs on pull requests + +| Job | Workflow | Blocking | Path-filtered | +| --- | --- | --- | --- | +| Audit Release Branch State | `ci.yml:17` | Yes | No | +| Audit Lockfile Sync | `ci.yml:45` | Yes | No | +| **Audit SDK Parity** | `ci.yml:120` | Yes | No | +| Test GQL Types + drift gate | `ci.yml:163` | Yes | `gql` filter | +| Test Android (`:openiap:test`, 3 flavor builds, 3 lints) | `ci.yml:201` | Yes | `android` filter | +| Test iOS (`swift build`, `swift test`) | `ci.yml:254` | Yes | `ios` filter | +| Test Docs (`audit:docs`, typecheck, lint, build) | `ci.yml:276` | Yes | `docs` filter | +| Web E2E (Playwright) | `ci.yml:~340` | Yes | `web` filter | +| Test Agent Scripts | `ci.yml:385` | Yes | No | +| Kit verify (lint, `test:coverage`, coverage gates, Docker, smoke) | `deploy-kit.yml:29` | Yes | `packages/kit/**` | +| Per-library CI | `ci-*.yml` × 6 | Yes | Per-library paths | + +CI enforcement of the *type* contract is strong. `ci.yml:154` regenerates, syncs, and then runs `scripts/assert-clean-worktree.mjs` — generated-artifact drift cannot merge. The parity audit runs unfiltered on every PR and additionally shells out to six self-test suites before running (`audit-non-godot-parity.mjs:19-57`). + +### 6.2 Gaps + +**Coverage gates are applied unevenly.** `assert-lcov-coverage.mjs` enforces 90% line coverage on `react-native-iap`, `expo-iap`, `flutter_inapp_purchase`, and kit's `server/` (48% for kit's `convex/`). **`packages/apple`, `packages/google`, `kmp-iap`, `maui-iap`, and `godot-iap` have no coverage gate at all** — including the two reference implementations that define what every binding wraps. + +**KMP does not run its iOS tests.** `ci-kmp-iap.yml:48` runs `:library:testPlayDebugUnitTest` (which picks up `commonTest` + `androidUnitTest`) plus compile-only tasks. The iOS job (`ci-kmp-iap.yml:81`) runs `compileKotlinIosSimulatorArm64` only — **no `iosSimulatorArm64Test`**. `IosErrorMappingTest.kt` and `IosConnectionLifecycleTest.kt` in `library/src/iosTest/` never execute in CI. Amazon and Horizon variant unit tests also never run (Play variant only). + +**Godot is excluded from parity by design.** `audit-non-godot-parity.mjs:88` puts `godot-iap` in `parityExcludedLibraries` with the comment *"intentionally excluded until its example parity is brought back into the same automated build/test lane."* `ci-godot-iap.yml:57` is largely `test -f` file-existence checks plus an Android `testDebugUnitTest`. Godot is effectively outside the contract system. + +**MAUI runs no behavioral tests on device targets.** `ci-maui-iap.yml` builds the Android binding and an App Store artifact but executes tests only against the shared `net10.0` target. + +**Platform limitations meaningfully cap coverage.** No CI runner can complete a real purchase. Apple's `swift test` cannot drive StoreKit purchase flows without a StoreKit Test configuration, which does not exist in `packages/apple`. Google's unit tests run on the JVM against mocked billing clients. This is a genuine constraint, and it is exactly the constraint a deterministic fake-store conformance harness is designed to work around — see §13. + +**Real-store testing is entirely manual and undocumented in CI.** The `/e2e-tests`, `iapkit-e2e-martie`, and `iapkit-e2e-petgu` skills describe human-driven device procedures against Apple/Google sandbox accounts. There is a clean separation from deterministic testing (manual work never gates merges), but there is also no record of what was run, against which spec version, with what result. Nothing links a released version to a conformance run. + +--- + +## 7. Cross-Implementation Consistency Findings + +### 7.1 Error normalization diverges sharply between Apple and Android + +`ErrorCode` (`error.graphql:4`) defines 37 members. Actual reachability: + +**Google Play** (`packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapErrorExtensions.kt:20-38`) maps 12 `BillingResponseCode` values to 12 distinct errors, `else → UnknownError`. + +**Apple** constructs **19 distinct `ErrorCode` values** across `packages/apple/Sources/` — 17 at explicit `PurchaseError.make(code:)` call sites plus `itemNotOwned` and `itemUnavailable` reached only through the `wrap()` switch. Apple is therefore *not* impoverished overall; several codes (`developerError` at 25 sites, `featureNotSupported` at 23) are used far more heavily than on Android. + +The divergence is narrower and more specific than raw counts suggest, and it has two parts. + +**First, the StoreKit catch-all path is shallow.** `OpenIapError.swift:169-190` maps only 5 `StoreKitError` cases: + +```swift +case .userCancelled: errorCode = .userCancelled +case .networkError: errorCode = .networkError +case .notAvailableInStorefront: errorCode = .itemUnavailable +case .notEntitled: errorCode = .itemNotOwned +case .systemError: errorCode = .serviceError +default: errorCode = fallback // .purchaseError +``` + +Any StoreKit error outside those five arrives at the consumer as `.purchaseError`. + +**Second, four codes that Android produces are never constructed on Apple at all.** Verified by enumerating every construction site (`code: .X` and `errorCode = .X`) across `packages/apple/Sources/`: + +| ErrorCode | Google Play | Apple | +| --- | --- | --- | +| `AlreadyOwned` | `ITEM_ALREADY_OWNED →` `ItemAlreadyOwned` | **never constructed** — appears only in the generated `Types.swift` enum (`:112`) and the `defaultMessage` description table | +| `BillingUnavailable` | `BILLING_UNAVAILABLE →` mapped | **never constructed** | +| `ServiceDisconnected` | `SERVICE_DISCONNECTED →` mapped | **never constructed** | +| `ServiceTimeout` | `SERVICE_TIMEOUT →` mapped | **never constructed** | + +So a duplicate purchase yields `ErrorCode.AlreadyOwned` on Android and `ErrorCode.PurchaseError` on iOS — an outcome an app branching on `ErrorCode` will handle differently per platform. + +Both behaviors have passing tests. Neither test knows the other exists. **Nothing in the repository states which of the two is correct**, because no normative error-mapping table exists. + +### 7.2 Android flavors diverge on purchase state + +| Flavor | `PENDING` handling | Cancelled handling | +| --- | --- | --- | +| Play | `BillingPurchase.PurchaseState.PENDING → PurchaseState.Pending` (`play/utils/BillingConverters.kt:351`) | — | +| Horizon | `...PurchaseState.PENDING → PurchaseState.Pending` (`horizon/utils/BillingConverters.kt:191`) | — | +| **Amazon** | **No mapping — `Pending` is never produced** | `isCanceled ? PurchaseState.Unknown : PurchaseState.Purchased` (`amazon/OpenIapModule.kt:352`) | + +Amazon additionally computes `isActive = purchase.purchaseState == PurchaseState.Purchased` (`amazon/OpenIapModule.kt:627`), so a cancelled Amazon purchase becomes `Unknown` rather than a cancelled/revoked state. An app that treats `Unknown` as "retry later" and `Pending` as "do not grant" behaves differently per store with no signal that it should. + +### 7.3 Duplicated tests have already drifted + +`SubscriptionGroupMappingPlayTest.kt` (54 lines) and `SubscriptionGroupMappingHorizonTest.kt` (42 lines) contain a byte-identical test — same backtick name, same six assertions: + +```kotlin +fun `active subscriptions keep independent product ids for multiple groups`() +``` + +Play has a second test that Horizon does not: + +```kotlin +fun `pending subscriptions are not active entitlements`() // testPlay only + assertEquals(false, pending.isActive) +``` + +This is the drift signature of copy-paste conformance. The behavior "a pending subscription is not an active entitlement" is an entitlement-integrity rule; it is asserted for exactly one of three Android stores. + +`AmazonSubscriptionGroupMappingTest.kt` (150 lines) tests the same *concept* with an entirely different structure and different assertions, so even manual comparison is hard. + +### 7.4 Where two implementations can pass CI while behaving differently + +All of the following pass CI today: + +| Scenario | Apple | Google Play | Amazon | Horizon | +| --- | --- | --- | --- | --- | +| Duplicate purchase error code | `PurchaseError` (`AlreadyOwned` never constructed) | `AlreadyOwned` | `AlreadyOwned` (numeric 7) | `AlreadyOwned` | +| Billing unavailable | `PurchaseError` (`BillingUnavailable` never constructed) | `BillingUnavailable` | `BillingUnavailable` | `BillingUnavailable` | +| Pending purchase state | n/a | `Pending` | **never emitted** | `Pending` | +| Cancelled purchase state | — | — | **`Unknown`** | — | +| `SubscriptionBillingIssue` event | emitted (iOS 16.4+) | emitted (PB 8.1+) | **not emitted** | **not emitted** | +| Pending-is-not-active assertion | untested | tested | untested | untested | + +The `SubscriptionBillingIssue` row is the only one where the difference is *documented* — in a prose docstring at `type.graphql:36`. It is still not machine-checkable. + +--- + +## 8. Capability Modeling Assessment + +OpenIAP currently expresses capability differences through four informal mechanisms: + +| Mechanism | Example | Machine-testable? | +| --- | --- | --- | +| Naming suffix | `syncIOS`, `acknowledgePurchaseAndroid` | Partly — parity audit checks binding existence | +| Separate schema files | `api-ios.graphql` vs `api-android.graphql` | Partly — determines which SDK surface gets the op | +| Prose docstrings | `type.graphql:36` "NOT emitted by Amazon Appstore or the Horizon flavor" | **No** | +| Gradle product flavors | `play` / `horizon` / `amazon` source sets | No — compile-time only, no interface | + +**There is no `@capability`, `@required`, `@optional`, or `@unsupported` directive in the schema.** `schema.graphql` defines exactly one custom directive, `@openiapDeprecated`, scoped to deprecation. The `IapStore` enum (`type.graphql:50`) enumerates `Unknown | Apple | Google | Horizon | Amazon` but carries no capability metadata. + +The consequence: **the four-way distinction the audit asks about (required / optional / unsupported / platform-specific) does not exist as data anywhere in the repository.** It exists as English prose in docstrings and docs pages, and as the tacit knowledge encoded in which flavor directory a file lives in. + +This is the single biggest structural blocker to a conformance suite. A conformance runner needs to answer "should this implementation support `openRedeemOfferCode`?" before it can decide whether a no-op is a pass or a failure. Today that answer is only available by reading `OpenRedeemOfferCodeAmazonNoOpTest.kt` and inferring intent from the filename. + +**Assessment: not machine-testable in its current form.** Making it so is a schema change (add capability directives) plus a generator change (emit a capability manifest per store), and it is a prerequisite for both a conformance suite and a Samsung onboarding. + +--- + +## 9. Samsung Galaxy Store Readiness + +### 9.1 Could a Samsung implementation demonstrate conformance today? + +**No — because there is nothing to demonstrate conformance against.** A Samsung implementation could be built, could pass `audit-non-godot-parity.mjs`, could ship, and the project would have no more evidence of its behavioral correctness than it has for Amazon today. + +What Samsung *would* be able to do immediately: satisfy the type contract. That is not nothing — it is a real integration cost avoided — but it is not conformance. + +### 9.2 The good news: the type model already has the right shape + +This is the strongest positive finding for Samsung readiness. `PurchaseAndroid` and `PurchaseIOS` carry `store: IapStore!` and **not** `platform` — `schema-contract.test.ts:45-55` actively enforces the removal of the legacy `platform` field from concrete purchase types: + +```typescript +expect(purchaseType.getFields().platform, `${typeName}.platform`).toBeUndefined(); +expect(purchaseType.getFields().store, `${typeName}.store`).toBeDefined(); +``` + +Samsung Galaxy Store is an Android-platform store, so it slots in cleanly as `IapPlatform.Android` + `IapStore.Samsung`. The platform/store split already exists and is defended by a test. Amazon and Horizon proved the multi-store-on-Android pattern works. + +### 9.3 What a Samsung implementation would need to satisfy + +**Schema changes (small, well-understood):** +- Add `Samsung` to `IapStore` (`type.graphql:50`) → regenerate 6 languages → sync 8 targets. The existing drift gates make this safe. + +**Implementation (follows the Amazon precedent):** +- New Gradle flavor `samsung` in `packages/google/openiap/build.gradle.kts:106` +- `src/samsung/java/dev/hyo/openiap/OpenIapModule.kt` + `OpenIapErrorExtensions.kt` +- New `testSamsung/` source set +- IAPKit: `packages/kit/convex/purchases/samsung.ts` + verification wiring + +**Parity audit updates:** +- `GOOGLE_FLAVOR_MODULES` (`audit-non-godot-parity.mjs:1584`) and `checkGoogleFlavorHandlerWiring` (`:1642`) enumerate flavors explicitly and would need Samsung added. + +### 9.4 Which tests could be reused unchanged? + +**Client side: essentially none.** Every Android test lives in a flavor-specific source set (`testPlay/`, `testHorizon/`, `testAmazon/`) and constructs flavor-specific SDK objects. `packages/google/openiap/src/test/` (the shared set) contains 13 files, but these test shared utilities (`BillingConvertersTest`, `OpenIapErrorTest`, `ContinuationResumeGuardTest`) rather than store behavior — useful, but they do not validate a store implementation. + +**Server side: partially reusable.** `applySubscriptionTransition` and `entitlementActive` are store-agnostic. A Samsung normalizer producing a `NormalizedWebhookEvent` would immediately inherit the entire state machine and its test suite. **This is the reuse story the client side lacks**, and it is the clearest argument for adopting the IAPKit pattern on the client. + +### 9.5 Which tests are Apple/Google-specific and would need refactoring? + +| Test | Why it doesn't generalize | +| --- | --- | +| `SubscriptionGroupMappingPlayTest.kt` / `...HorizonTest.kt` | Assertions are generic; setup constructs flavor-specific purchases. **Prime candidate for extraction into a shared parameterized suite.** | +| `BillingResultConvertersTest.kt`, `BillingPurchasePayloadMappingTest.kt` | Bound to Play Billing types | +| `OpenIapErrorTest.kt` + flavor `fromBillingResponseCode` tests | Each asserts its own store's numeric codes; would need a normative mapping table to generalize | +| `webhooks/conformance.test.ts` | `runAppleScenario` / `runGoogleScenario` are separate functions with duplicated bodies. Refactoring to `runScenario(adapter, steps)` would let Samsung reuse all 6 scenarios. | + +### 9.6 What belongs outside the common suite? + +Samsung-specific capability tests that should stay in a `capabilities/samsung/` area rather than the core suite: Samsung IAP SDK initialization and `Samsung Checkout` flows, Galaxy Store-specific promotional/reward mechanics, Samsung's operational modes (test/production toggles), and Samsung-specific error codes with no cross-store analogue. The core suite should assert only that these surface through the spec's `unsupported`/optional-capability channel where the spec says they must. + +--- + +## 10. IAPKit Provider Conformance Assessment + +IAPKit has the same conformance problem as the client, but it has already solved a meaningful slice of it — asymmetrically, across two layers. + +### 10.1 Verification layer — no provider contract + +> **Corrected.** See [§18.1](#181-a-correction-to-this-audit): all four providers declare +> `returns: receiptResponseValidator`, a shared normalized shape. The claim below that they +> "share no interface" is wrong; the real divergence is in the SDK-facing GraphQL union. + + +The four providers (`ios.ts`, `android.ts`, `amazon.ts`, `horizon.ts`) share **no interface**. Their entry points have different names, different signatures, and different return shapes. The spec itself acknowledges the divergence rather than normalizing it — `api.graphql:78-83`: + +> *"Returns a platform-specific variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. Inspect the concrete variant before reading fields."* + +**A caller cannot ask "is this purchase valid?" uniformly.** iOS exposes `isValid`, Horizon exposes `success`, and Android exposes neither — validity must be inferred from Play receipt fields. Each provider's test file (`ios.test.ts` 254 lines, `android.test.ts` 892, `amazon.test.ts` 727, `horizon.test.ts` 428) tests its own functions independently. **These are per-provider unit tests, not provider contract tests.** + +### 10.2 Webhook/lifecycle layer — a real conformance kernel + +`webhooks/conformance.test.ts` is the best conformance artifact in the repository and deserves credit. Its design is correct: deterministic pre-canned payloads, a shared target model, multi-step scenario scripts (not single-edge assertions), and assertions on both the state and the derived entitlement boolean. The header comment is candid about what it is — *"the 'sandbox-without-Apple/Google' suite."* + +It also shows evidence of adversarial maintenance. Lines 79-87 document a fixed weakness where `transition.next ?? current` silently masked no-op transitions, and line 307 records that PR #123 review caught a wrong Google `pause-schedule-changed` mapping. This is a suite that has caught real bugs. + +### 10.3 Semantic outcome coverage + +| Outcome | Apple | Google | Amazon | Horizon | +| --- | --- | --- | --- | --- | +| Valid purchase | Unit (`ios.test.ts`) | Unit | Unit | Unit | +| Invalid purchase | Unit | Unit | Unit | Unit | +| Active subscription | **Conformance** | **Conformance** | ✗ | ✗ | +| Expired subscription | **Conformance** | ✗ | ✗ | ✗ | +| Cancelled subscription | **Conformance** | ✗ | ✗ | ✗ | +| Refunded / revoked | **Conformance** | **Conformance** (voided) | Reconciler unit only | ✗ | +| Grace period | **Conformance** | ✗ | ✗ | ✗ | +| Billing retry / on-hold | ✗ | **Conformance** | ✗ | ✗ | +| Paused / resumed | ✗ | **Conformance** | ✗ | ✗ | +| Normalized identifiers | Unit | Unit | Unit | Unit (`HorizonBlankOrderIdTest`) | +| Normalized errors | `purchases/errors.test.ts` (shared helper) | same | same | same | + +Two structural gaps stand out. First, **Apple and Google are tested on disjoint scenario sets** — Apple gets expiry/grace-period, Google gets on-hold/pause. Because the scenarios are hand-written per provider rather than shared, the matrix is sparse where it should be dense. Second, **Amazon and Horizon have no lifecycle conformance at all.** Amazon relies on `reconcileAmazonPurchases` polling and Horizon has no revocation path from store notifications; neither appears in the conformance harness. + +**Verdict: IAPKit has a provider contract at the lifecycle layer and no provider contract at the verification layer.** The lifecycle pattern is directly extensible to Amazon, Horizon, and Samsung and should be the template. + +--- + +## 11. Security / Entitlement Integrity Risks + +Ranked by plausibility × impact. All are consequences of inconsistent normalization, which is precisely the failure mode a conformance suite exists to prevent. + +**R1 — Amazon cancelled purchases normalize to `PurchaseState.Unknown` (High).** +`amazon/OpenIapModule.kt:352` — `isCanceled ? PurchaseState.Unknown : PurchaseState.Purchased`. An app whose entitlement check treats `Unknown` as inconclusive (retry, or fall back to cached entitlement) will keep granting access after an Amazon cancellation. On Play the same situation surfaces distinguishably. No test covers this divergence. + +**R2 — "Pending is not an active entitlement" is asserted for one store in three (High).** +`SubscriptionGroupMappingPlayTest.kt:25` asserts `pending.isActive == false`. Horizon has no equivalent test; Amazon cannot reach the state. Pending purchases granting entitlement is the classic IAP fraud vector (deferred payment that never completes). The rule is correct where tested and unverified elsewhere. + +**R3 — No uniform validity signal in the SDK-facing verification result (High).** +*(Corrected in [§18.1](#181-a-correction-to-this-audit): IAPKit's server-side providers +DO share a normalized `receiptResponseValidator` with a uniform `isValid`. The divergence +is in the client-facing GraphQL union only.)* +Per `api.graphql:78-83`, `isValid` exists on iOS, `success` on Horizon, neither on Android. Integration code must branch on the concrete variant. A developer who checks `result.isValid` and gets `undefined` on Android — a falsy value — fails closed, which is the safe direction; but one who writes `if (result.isValid !== false)` fails open. The spec makes the second reading easy to reach. + +**R4 — Four Android-reachable error codes are never produced on Apple (Medium).** +`AlreadyOwned`, `BillingUnavailable`, `ServiceDisconnected`, and `ServiceTimeout` have no construction site anywhere in `packages/apple/Sources/`; the StoreKit catch-all at `OpenIapError.swift:186` routes the corresponding conditions to `.purchaseError` via `default: errorCode = fallback`. Error-driven retry, entitlement-restoration, and support-triage logic cannot distinguish "already owned" from a generic failure on iOS. Cross-platform apps that branch on `ErrorCode` behave differently per platform in ways neither platform's tests describe. + +**R5 — `getStorefront`'s explicit MUST NOT is unenforced (Medium).** +`api.graphql:30` — *"implementations must not synthesize a locale fallback."* Storefront drives pricing, availability, and regional compliance. The parity audit has a related guard (`expectNoExampleStorefrontIOS`, line 1687) but it checks example app source text, not implementation behavior. + +**R6 — Amazon and Horizon have no webhook-driven revocation path (Medium).** +Only `apple.ts` and `google.ts` normalizers exist; `IapPlatform` in `webhooks/shared.ts:60` is `"IOS" | "Android"`. Amazon revocation depends on the `reconcileAmazonPurchases` polling reconciler, and Horizon has neither webhook nor reconciler. Refund-to-revocation latency differs by store with no documented bound. + +**R7 — KMP's iOS tests never execute (Low-Medium).** +`ci-kmp-iap.yml:81` compiles `iosSimulatorArm64` without running `iosSimulatorArm64Test`. `IosErrorMappingTest.kt` and `IosConnectionLifecycleTest.kt` provide no signal. Low direct impact, but it means a green CI badge overstates verification. + +**R8 — The two reference implementations have no coverage floor (Low-Medium).** +`packages/apple` and `packages/google` are the only packages every binding wraps, and they are exempt from the 90% gate applied to the JS/Dart SDKs. + +--- + +## 12. Gaps and Technical Debt + +**G1 — No behavioral specification.** The schema specifies shape; behavior lives in scattered prose. Without normative statements there is no contract to test against, and this blocks everything downstream. + +**G2 — No capability model.** §8. Blocks conformance-runner decision-making and Samsung onboarding. + +**G3 — No test reuse mechanism on the client.** No adapter interface, no fixtures, no parameterized suites. Every implementation is tested in isolation. + +**G4 — Static analysis is doing conformance's job.** `audit-non-godot-parity.mjs` (8,827 lines) and `audit-purchase-payload-parity.mjs` (2,316 lines) are large, sophisticated, well-maintained — and structurally incapable of detecting behavioral divergence. They are also brittle: `checkFrameworkOperationBindings` (line 1263) depends on exact marker strings like `"gentype.QueryHandlers get queryHandlers"` and breaks on innocuous refactors. This is real maintenance cost buying shape assurance only. + +**G5 — Duplicated tests drifting.** §7.3. + +**G6 — Store variants as compile-time flavors, not runtime adapters.** No interface means no compiler-enforced contract and no way to run one test body against all stores in one process. + +**G7 — IAPKit verification layer has no shared model.** §10.1. + +**G8 — Conformance-harness scenarios duplicated per provider.** `runAppleScenario` / `runGoogleScenario` in `conformance.test.ts` have near-identical bodies, producing the sparse disjoint matrix in §10.3. + +**G9 — Uneven CI enforcement.** Coverage gates on 4 of 9 testable packages; KMP iOS tests unrun; Godot excluded from parity; MAUI device targets untested. + +**G10 — Foundation docs overstate current state.** `one-pager.tsx:198` lists conformance tests as a delivered "Core Component"; `sponsorship.tsx:65` sells it as a present benefit; `roadmap-budget.tsx:69` marks it "Planned." Under LF scrutiny this reads as overclaiming. + +**G11 — No versioned conformance artifact.** `openiap-versions.json` tracks `spec: 3.2.0`, but no conformance suite is versioned against it, so "conformant to OpenIAP 3.2.0" has no meaning. + +--- + +## 13. Recommended Target Architecture + +The design principle: **define expected behavior once, execute it against every implementation through a thin adapter, and make capability differences data rather than prose.** Two things in the repo already prove this works — `applySubscriptionTransition` (shared model, multiple providers) and the codegen pipeline (one schema, six languages). The proposal extends both patterns rather than introducing a new one. + +### 13.1 Structure + +```text +packages/gql/src/ + capability.graphql # NEW: @capability directive + store capability matrix + *.graphql # existing schema, extended with normative annotations + +conformance/ # NEW top-level package + spec/ + behaviors/ # Normative behaviors as data (YAML/JSON) + products.yaml # fetch-products-returns-normalized-product + purchases.yaml # purchase-already-owned-yields-AlreadyOwned + subscriptions.yaml + lifecycle.yaml # PurchaseState transition table + errors.yaml # NORMATIVE platform-error -> ErrorCode mapping + capabilities/ + matrix.yaml # store x capability -> required|optional|unsupported + fixtures/ # Deterministic store responses, shared by all runners + apple/ google/ amazon/ horizon/ samsung/ + runner/ + core.ts # Loads behaviors + capabilities, drives adapters + report.ts # Emits versioned conformance report + adapters/ + README.md # Adapter contract for third-party implementations +``` + +### 13.2 Per-implementation harness + +Each implementation supplies a **fake store driver** plus a thin adapter, so the runner can execute real implementation code against canned store responses without a network or a real purchase — which is what makes this work in CI where real purchases cannot happen. + +```text +packages/google/openiap/src/conformanceTest/ # shared across ALL flavors + ConformanceSuite.kt # parameterized; runs spec/behaviors against the flavor + FakeBillingClient.kt # replays conformance/fixtures// + +packages/apple/Tests/Conformance/ + ConformanceSuite.swift + StoreKitTestConfiguration.storekit # StoreKit Test config (does not exist today) + +libraries/*/conformance/ # per-SDK adapter, asserts the SDK forwards + # normalized results unchanged +``` + +The key move on Android: replace three isolated `testPlay/testHorizon/testAmazon` suites with **one `conformanceTest` source set compiled into every flavor**, parameterized by the capability matrix. Adding Samsung then means adding a flavor and a fixture directory — the behavioral suite comes for free. This directly fixes G3, G5, and G6. + +### 13.3 Capability directive sketch + +```graphql +directive @capability( + required: [IapStore!] + optional: [IapStore!] + unsupported: [IapStore!] +) on FIELD_DEFINITION | ENUM_VALUE + +extend type Mutation { + openRedeemOfferCodeAndroid: VoidResult! + @capability(required: [Google], unsupported: [Amazon, Horizon]) +} + +enum IapEvent { + SubscriptionBillingIssue + @capability(required: [Apple, Google], unsupported: [Amazon, Horizon]) +} +``` + +This makes §8's prose machine-readable, lets the runner decide whether a no-op is pass or fail, and lets codegen emit a per-store capability manifest each SDK can expose at runtime. + +### 13.4 IAPKit alignment + +- Introduce a `PurchaseVerificationProvider` TypeScript interface that all four modules implement, returning a normalized `{ valid: boolean, ... }` — fixes R3 and G7. +- Refactor `runAppleScenario`/`runGoogleScenario` into `runScenario(adapter, steps)` and move the scenario scripts into `conformance/spec/behaviors/lifecycle.yaml` so every provider runs every scenario — fixes G8 and the sparse matrix in §10.3. +- Add Amazon and Horizon lifecycle normalizers, or explicitly declare in the capability matrix that they are reconciliation-only — fixes R6. + +--- + +## 14. Prioritized Action Plan + +### P0 — Before claiming formal conformance + +**P0-1. Correct the foundation documentation.** *(S — implement now)* +Why: `one-pager.tsx:198` and `sponsorship.tsx:65` present conformance testing as delivered; `roadmap-budget.tsx:69` correctly says Planned. This is the cheapest fix in the plan and the one with the most reputational exposure, since these are the pages LF reviewers and sponsors read. +Files: `packages/docs/src/pages/docs/foundation/{one-pager,sponsorship}.tsx`. Dependencies: none. + +**P0-2. Write the normative error-mapping table and close the Apple gap.** *(M — implement now)* +Why: R4/§7.1. `AlreadyOwned`, `BillingUnavailable`, `ServiceDisconnected`, and `ServiceTimeout` are produced on Android and never constructed on Apple, and Apple's `StoreKitError` catch-all maps only 5 cases before falling back to `.purchaseError`. Today neither platform is "wrong" because nothing says what's right. Document the required mapping, then extend `OpenIapError.wrap` and its call sites. +Files: `packages/gql/src/error.graphql`, `packages/apple/Sources/Models/OpenIapError.swift`, `conformance/spec/behaviors/errors.yaml`. Dependencies: none. + +**P0-3. Resolve the Amazon purchase-state divergence.** *(M — implement now)* +Why: R1/R2, the highest-impact entitlement-integrity finding. Either map Amazon cancellation to a distinct state or declare `Pending`/cancellation unsupported for Amazon in the capability matrix — but decide explicitly and test it. +Files: `packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt`, `src/testAmazon/`. Dependencies: benefits from P1-1. + +**P0-4. Replicate the "pending is not active" assertion across all Android flavors.** *(S — implement now)* +Why: R2. The rule is asserted only in `testPlay`. Even before shared suites exist, copy it to `testHorizon` (and assert Amazon's documented inability to reach the state). +Files: `packages/google/openiap/src/testHorizon/`, `src/testAmazon/`. Dependencies: none. + +**P0-5. Run KMP iOS tests and add coverage floors to Apple/Google.** *(S — implement now)* +Why: R7/R8/G9. `iosSimulatorArm64Test` is written but never executed; the two reference implementations have no coverage gate while their wrappers are held to 90%. +Files: `.github/workflows/ci-kmp-iap.yml`, `ci.yml` (test-android, test-ios). Dependencies: none. + +**P0-6. Publish a "what is and isn't verified" statement.** *(S — implement now)* +Why: G11. Until a suite exists, state plainly that OpenIAP enforces type/API-surface conformance and that behavioral conformance is in development. This preserves the credibility of the real claim. +Files: `packages/docs/src/pages/docs/foundation/`, `README.md`. Dependencies: P0-1. + +### P1 — Before onboarding another major store such as Samsung + +**P1-1. Add the capability model to the schema.** *(M — implement now)* +Why: G2/§8. Every other conformance decision depends on knowing whether a behavior is required, optional, or unsupported for a given store. This is the true blocker, and it should land before Samsung rather than after. +Files: `packages/gql/src/capability.graphql`, `packages/gql/codegen/`, all 6 language plugins, `generated-sync-manifest.mjs`. Dependencies: none. Note: the existing drift gates (`assert-clean-worktree.mjs`) make this schema change mechanically safe. + +**P1-2. Extract a shared Android conformance source set.** *(L — implement now)* +Why: G3/G5/G6/§9.4. Replaces copy-paste-and-drift with one parameterized suite compiled into every flavor. `SubscriptionGroupMappingPlayTest`/`...HorizonTest` are the obvious first migration since they are already near-identical. +Files: new `packages/google/openiap/src/conformanceTest/`, `build.gradle.kts`, `audit-non-godot-parity.mjs` (flavor lists at :1584/:1642). Dependencies: P1-1. + +**P1-3. Build the fake-store driver and fixture corpus.** *(L — implement now)* +Why: G3. This is what makes behavioral testing possible in CI at all, given that no runner can complete a real purchase (§6.2). Start with Android (fake `BillingClient`) where the flavor architecture makes injection easiest. +Files: `conformance/fixtures/`, `packages/google/openiap/src/conformanceTest/FakeBillingClient.kt`. Dependencies: P1-1, P1-2. + +**P1-4. Unify the IAPKit verification interface.** *(M — implement now)* +Why: R3/G7. A shared `PurchaseVerificationProvider` interface with a normalized validity signal removes the fail-open hazard and gives Samsung a slot to implement rather than a pattern to imitate. +Files: `packages/kit/convex/purchases/{ios,android,amazon,horizon}.ts`, `shared.ts`, `packages/gql/src/api.graphql` (result union). Dependencies: none. + +**P1-5. Parameterize the IAPKit lifecycle harness and add Amazon/Horizon.** *(M — implement now)* +Why: G8/R6/§10.3. `runScenario(adapter, steps)` turns 6 provider-specific scenarios into 6 × N provider-agnostic ones and closes the disjoint-coverage gap. +Files: `packages/kit/convex/webhooks/conformance.test.ts`, new `amazon.ts`/`horizon.ts` normalizers. Dependencies: P1-4 helps but is not required. + +**P1-6. Draft the Samsung onboarding checklist.** *(S — document now, implement later)* +Why: §9.3 identified the concrete touchpoints (`IapStore` enum, new flavor, `GOOGLE_FLAVOR_MODULES`, kit provider). Writing this down while the Amazon precedent is fresh makes the eventual work mechanical. +Files: `docs/` or `knowledge/internal/`. Dependencies: none. + +### P2 — Foundation / ecosystem maturity + +**P2-1. Add normative language discipline to the specification.** *(L — implement now, incrementally)* +Why: G1. Adopt RFC 2119 keywords and separate normative requirements from guidance. `getStorefront`'s existing "must not synthesize a locale fallback" shows the project already thinks this way; the practice just needs to be systematic and enforced by a schema linter rule. +Files: all `packages/gql/src/*.graphql`, `packages/gql/src/schema-linter.test.ts`. Dependencies: none, but P1-1 shares the tooling. + +**P2-2. Version the conformance suite against the spec version.** *(M — document now, implement after P1)* +Why: G11. "Conformant to OpenIAP 3.2.0" needs a versioned artifact and a machine-readable report to mean anything, and it is the precondition for any compatibility badge. +Files: `conformance/`, `openiap-versions.json`, release workflows. Dependencies: P1-1 through P1-3. + +**P2-3. Publish the third-party adapter contract.** *(M — document now, implement after P1)* +Why: The audit's framing question — can an independent implementation demonstrate compatibility without manual review? — needs a documented adapter interface and a runnable suite an external party can execute. +Files: `conformance/adapters/README.md`, `CONTRIBUTING.md`. Dependencies: P1-2, P1-3. + +**P2-4. Bring Godot into the contract system.** *(M — implement later)* +Why: G9. `audit-non-godot-parity.mjs:88` excludes Godot with a comment describing this as temporary; the exclusion is visible in the script's own name. +Files: `.github/workflows/ci-godot-iap.yml`, `libraries/godot-iap/`, parity script. Dependencies: none. + +**P2-5. Retire static parity checks as behavioral coverage lands.** *(L — implement later)* +Why: G4. As real conformance tests cover a behavior, the corresponding regex guard in the 8,827-line parity script becomes redundant and brittle. Shrinking it deliberately converts maintenance cost into real assurance. Keep the parts that check genuinely structural properties (generated-file drift, symlink targets, version floors). +Files: `scripts/audit-non-godot-parity.mjs`, `scripts/audit-purchase-payload-parity.mjs`. Dependencies: P1-2, P1-3. + +**P2-6. Record manual E2E runs against spec versions.** *(S — implement now)* +Why: §6.2. Device/sandbox testing via `/e2e-tests` and the `iapkit-e2e-*` skills is real verification that currently leaves no auditable trace. +Files: `.claude/skills/e2e-tests/`, a results log. Dependencies: none. + +--- + +## 15. Three Closing Questions + +### 1. Can OpenIAP honestly say today that its implementations are conformance-tested against a shared specification? + +**No.** It can honestly say something narrower and still valuable: *OpenIAP implementations are type-conformant and API-surface-complete against a shared GraphQL specification, enforced in CI on every pull request.* That claim is well-supported by `packages/gql/`, the six-language codegen pipeline, `generated-sync-manifest.mjs`, the clean-worktree drift gates, and `audit-non-godot-parity.mjs`. + +Behavioral conformance testing does not exist on the client. Zero of the 14 audited behavior categories are Covered by the strict definition; 8 are Partial and 6 are absent. The one genuine conformance harness — `packages/kit/convex/webhooks/conformance.test.ts` — covers server-side subscription lifecycle for 2 of 4 providers across 6 scenarios. + +The gap between the honest claim and the current public wording in `one-pager.tsx:198` and `sponsorship.tsx:65` should be closed before any Linux Foundation conversation, and `roadmap-budget.tsx:69` already shows the project knows the accurate answer. + +### 2. Could Samsung Galaxy Store be added today and validated using the same reusable conformance suite? + +**No — there is no reusable conformance suite to validate it with.** Samsung could be implemented and could pass every existing gate while behaving differently from Play in ways nothing would detect, exactly as Amazon does today (§7.2, §7.4). + +The type model is genuinely ready: `IapStore` (`type.graphql:50`) is a real store discriminator that `schema-contract.test.ts:45-55` actively defends, `IapPlatform.Android` covers Samsung correctly, and Amazon and Horizon have already proven the multi-store-on-Android flavor pattern. Adding `Samsung` to the enum and a `samsung` Gradle flavor is well-understood, low-risk work. + +What is missing is behavioral reuse. On the client, essentially no Android test would carry over — every one lives in a flavor-isolated source set. On the server, `applySubscriptionTransition` and `entitlementActive` would be inherited immediately by any Samsung normalizer, which is precisely why the IAPKit lifecycle pattern is the right template for the client side. P1-1 through P1-3 are the work that would make Samsung onboarding safe rather than merely possible. + +### 3. What are the three highest-leverage changes to make OpenIAP conformance ecosystem-grade? + +**First — make capabilities machine-readable (P1-1).** Add a `@capability` directive to the schema and generate a per-store capability matrix. Today the required/optional/unsupported distinction exists only as prose in docstrings like `type.graphql:36` and as tacit knowledge in directory layout. Nothing else in the plan can proceed without it: a conformance runner cannot judge whether Amazon's `openRedeemOfferCode` no-op is a pass or a failure until the answer is data. This unblocks the conformance suite, the Samsung onboarding, and any future compatibility program simultaneously. + +**Second — replace flavor-isolated tests with one shared, parameterized conformance suite driven by fake stores (P1-2, P1-3).** Collapse `testPlay/`, `testHorizon/`, `testAmazon/` into a single `conformanceTest` source set compiled into every flavor, backed by a fixture corpus and a fake `BillingClient`. This is the change that converts "we test each store" into "we test the contract," eliminates the drift already visible between `SubscriptionGroupMappingPlayTest` and `SubscriptionGroupMappingHorizonTest`, works within CI's inability to make real purchases, and makes each new store cost a fixture directory instead of a test suite. + +**Third — generalize the IAPKit conformance harness and apply its pattern to the client (P1-4, P1-5).** `webhooks/conformance.test.ts` already demonstrates the correct architecture inside this repository — shared normalized model, deterministic payloads, multi-step scenarios, entitlement assertions — and it has caught real bugs (the PR #123 mapping error at line 307, the masked-transition weakness at lines 79-87). Refactoring `runAppleScenario`/`runGoogleScenario` into `runScenario(adapter, steps)`, adding Amazon and Horizon, and unifying the verification interface turns a working prototype into the project's conformance standard. The client-side suite should then be built in its image rather than invented from scratch. + +Together these convert OpenIAP's existing strength — a rigorously enforced single source of truth — from covering types to covering behavior, which is the whole distance between "OpenIAP compatible" as a claim and as a certification. + +--- + +## 16. Remediation Applied + +This section records work done *after* the audit above, in response to it. Everything +in §1–15 describes the pre-remediation state. + +### 16.1 A predicted risk turned out to be a live defect + +Audit risk **R2** flagged that "pending is not an active entitlement" was asserted for +Google Play only. Following that thread into the implementation found the rule was not +merely untested on Horizon — it was **violated**: + +```kotlin +// packages/google/openiap/src/horizon/.../BillingConverters.kt (before) +fun HorizonPurchase.toActiveSubscription(): ActiveSubscription = ActiveSubscription( + isActive = true, // hardcoded + ... +) +fun PurchaseAndroid.toActiveSubscription(): ActiveSubscription = ActiveSubscription( + isActive = true, // hardcoded + ... +) +``` + +Horizon maps its billing-compatibility `PENDING` state through `fromHorizonState`, so +pending purchases genuinely occur there — and both entitlement derivations reported +them as active. Play (`isActive = purchaseState == PurchaseState.Purchased`) and Amazon +were correct; Horizon was the outlier. + +**Impact:** on Meta Horizon, an unpaid pending subscription was reported as an active +entitlement. Both functions now gate on `Purchased`. + +This is the concrete cost of the copy-paste drift described in §7.3: the assertion that +would have caught it existed, in the file next door, and was never copied across. + +### 16.2 What was implemented + +| # | Change | Files | +| --- | --- | --- | +| 1 | Fixed the Horizon entitlement leak | `packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt` | +| 2 | Shared Android conformance suite + per-flavor adapters | `packages/google/openiap/src/conformanceTest/`, `src/test{Play,Horizon,Amazon}/java/dev/hyo/openiap/conformance/`, `build.gradle.kts` | +| 3 | Extracted Amazon's entitlement seam to match the other flavors | `packages/google/openiap/src/amazon/java/dev/hyo/openiap/utils/AmazonBillingConverters.kt`, `OpenIapModule.kt` | +| 4 | StoreKit 1 + StoreKit 2 error normalization on Apple | `packages/apple/Sources/Models/OpenIapError.swift` | +| 5 | Apple error-normalization test suite (13 tests) | `packages/apple/Tests/OpenIapTests/ErrorNormalizationTests.swift` | +| 6 | Machine-checked store capability matrix | `packages/gql/src/capability-matrix.mjs`, `capability-matrix.test.ts` | +| 7 | IAPKit harness rebuilt around shared scenarios + provider adapters | `packages/kit/convex/webhooks/conformance.test.ts` | +| 8 | Parity audit gate for the conformance architecture | `scripts/audit-non-godot-parity.mjs` | +| 9 | KMP iOS tests now execute in CI | `.github/workflows/ci-kmp-iap.yml` | +| 10 | Foundation docs corrected to match reality | `packages/docs/src/pages/docs/foundation/{one-pager,sponsorship,roadmap-budget}.tsx` | +| 11 | Comment-style convention (AI over-explanation) | `knowledge/internal/03-coding-style.md`, `AGENTS.md` | + +### 16.3 Android: one suite, every store + +`StoreConformanceSuite` declares the expectations once and is compiled into all three +flavor test source sets. Each flavor supplies only a `StoreConformanceAdapter`: + +```text +src/conformanceTest/java/.../conformance/ + StoreConformanceAdapter.kt seam: store, capabilities, toActiveSubscription, errorForResponseCode + StoreConformanceSuite.kt 8 behavioral tests, declared once + +src/testPlay/.../PlayStoreConformanceTest.kt -> IapStore.Google +src/testHorizon/.../HorizonStoreConformanceTest.kt -> IapStore.Horizon +src/testAmazon/.../AmazonStoreConformanceTest.kt -> IapStore.Amazon +``` + +Covered: purchased-is-entitled, **pending-is-not-entitled**, unknown-is-not-entitled, +identifier independence across subscription groups, token field parity, the normative +12-entry Play-response-code → `ErrorCode` table, unrecognized-code fallback, and a +concrete store discriminator. + +The pending assertion is deliberately unconditional. Whether a store can *produce* +`Pending` is a capability; what `Pending` *means* is not negotiable. + +Because `:openiap:test` aggregates all variant unit-test tasks, CI already runs this +suite for all three flavors with no workflow change. + +### 16.4 Apple: real error normalization + +`PurchaseError.wrap` previously routed every StoreKit 1 condition except +`paymentCancelled` into the caller's fallback. Two extraction points were added — +`skErrorCode(from:)` (typed `SKError` or bridged `NSError`) and `errorCode(for:)` (the +normative table) — mapping `paymentNotAllowed → iapNotAvailable`, +`storeProductNotAvailable → itemUnavailable`, `clientInvalid`/`paymentInvalid → +developerError`, offer failures → `skuOfferMismatch`, signature failures → +`transactionValidationFailed`, and more. The StoreKit 2 switch gained `.unknown` and +`.unsupported` (the compiler flagged the latter as a genuinely missing case). + +`errorCode(for:)` returns `nil` rather than guessing, so unmapped conditions still take +the caller's fallback instead of a fabricated code. + +**Deliberately not "fixed":** `AlreadyOwned`, `BillingUnavailable`, `ServiceDisconnected`, +and `ServiceTimeout` remain unreachable on Apple. StoreKit has no equivalent condition — +re-purchasing an owned non-consumable succeeds and returns the existing transaction. The +honest fix is a capability declaration, not a synthesized mapping, so these are recorded +as Android-only in the capability matrix and guarded by +`testAndroidOnlyCodesAreNeverSynthesizedFromStoreKit`, which sweeps every `SKError.Code` +and fails if any maps into that set. + +### 16.5 Capability modeling (closes G2) + +`packages/gql/src/capability-matrix.mjs` makes §8's prose machine-checkable: ten +behaviors × four stores, each `required` / `optional` / `unsupported`, with `evidence` +required for every non-`required` level and `notes` for behaviors that are required +everywhere but *delivered* differently. + +`capability-matrix.test.ts` (10 tests) binds it to the schema — it reads the `IapStore` +enum and fails if the matrix and the enum disagree. **Adding a store to the schema +without deciding its capabilities now fails CI.** + +The matrix also records a genuine shape divergence the audit had not surfaced: pending +purchases are `required` on both platforms, but Apple delivers them as an +`ErrorCode.DeferredPayment` *error* while Android delivers a `Purchase` carrying +`PurchaseState.Pending`. + +### 16.6 IAPKit: shared scenarios, and what they immediately caught + +Scenarios are now declared once as abstract lifecycle events; each provider supplies an +adapter that renders them into its own wire payload: + +```text +SCENARIOS (6) x ADAPTERS (apple, google) -> 11 executed + 1 explicitly skipped +``` + +Rewriting `runAppleScenario`/`runGoogleScenario` into `runScenario(adapter, scenario)` +surfaced a real semantic divergence on the first run: Apple `REFUND` normalizes to +`Refunded`, while Google `SUBSCRIPTION_REVOKED` (12) normalizes to `Revoked`. The +original suite hid this because each provider's scenario list was hand-written and used +a different signal. Both providers in fact express *both* concepts, so `Refund` (money +returned — Apple `REFUND` / Google `voidedPurchaseNotification`) and `Revoke` +(entitlement withdrawn — Apple `REVOKE` / Google RTDN 12) are now distinct events with +distinct expected states. + +Coverage went from 6 disjoint provider-specific scenarios to 6 shared scenarios run +against every capable provider. The Apple/Google split described in §10.3 is closed: +Apple now covers billing-retry and revoke; Google now covers grace-period and expiry. +Apple's unsupported pause/resume is reported as a visible `it.skip` rather than an +absence. + +### 16.7 Making it load-bearing + +`checkGoogleStoreConformanceSuite()` in the parity audit asserts the suite files exist, +each flavor has an adapter binding into `StoreConformanceSuite()`, and each test source +set includes `src/conformanceTest/java` in Gradle. Verified by negative test: removing a +flavor's `srcDirs` entry and deleting an adapter each produce a specific failure, and the +audit passes again once restored. + +`ci-kmp-iap.yml` now runs `:library:iosSimulatorArm64Test` instead of only compiling the +source set, so `IosErrorMappingTest` and `IosConnectionLifecycleTest` execute. + +### 16.8 Verification + +Run locally, all passing: + +| Suite | Result | +| --- | --- | +| `swift test` (packages/apple) | **149 passed** (136 before, +13 new) | +| `bun run test` (packages/gql) | **171 passed**, 20 files (+10 capability tests) | +| `bun run test` (packages/kit) | **1185 passed**, 1 skipped, 86 files | +| `node scripts/audit-non-godot-parity.mjs` | **passed** (+ 23 self-tests) | +| `bun run audit:docs` | clean — 0 drift | +| `bun run audit:deprecations` | passed | +| `bun run audit:release-state` | passed | +| `packages/kit` typecheck + prettier | clean | +| `packages/docs` typecheck + lint + build + prettier | clean | + +**Not verified locally — no toolchain in this environment:** + +- **`packages/google` Android tests, including the new conformance suite.** No JDK 17 or + Android SDK available (only JDK 26, no `ANDROID_HOME`). The Kotlin was statically + checked against the real API surface — `ErrorCode` members, `OpenIapError` subclass + codes, the two `fromBillingResponseCode` overloads, `PurchaseAndroid`/ + `ActiveSubscription` constructors, and the existing `HorizonBillingConverters` + import pattern — but **it has not been compiled**. CI is the first real check. +- Flutter, MAUI (no `dart`/`dotnet`), and Godot. + +### 16.9 Revised scores + +| Subject | Before | After | Rationale | +| --- | --- | --- | --- | +| **Client OpenIAP** | 1.5 | **2.5** | A shared behavioral suite now runs across all three Android stores, and capability modeling is machine-checked. Held below 3 because Apple and the six framework bindings still have no shared suite, and no fake-store driver exists — the Android suite tests normalization functions, not purchase flows. | +| **IAPKit** | 2.5 | **3.5** | Reusable contract tests cover core lifecycle behavior across both webhook providers with CI enforcement — the definition of "Functional". Short of 4 because the verification layer still has no shared provider interface (R3) and Amazon/Horizon have no lifecycle normalizer (R6). | +| **Overall** | 2 | **3** | Functional: reusable contract tests cover core behavior for the major implementations, and CI enforces them. | + +**These are not 5/5, and claiming so would repeat the exact overclaiming this audit +flagged as G10.** Level 5 requires a versioned conformance suite an *independent* +implementation can run to demonstrate compatibility. What exists now is an internal +suite covering internal implementations. The remaining distance is unchanged from §14: + +- **P1-3 fake-store driver** — the Android suite covers normalization functions, not + `fetchProducts`/`requestPurchase`/`finishTransaction` behavior. Categories 1–4 of the + coverage matrix are still **Not covered**. +- **Apple + framework bindings** — the shared-suite pattern is proven on Android and has + not been extended to Apple or the six SDKs. +- **P1-4 IAPKit verification interface** — R3's fail-open hazard is unresolved. +- **P2-2/P2-3 versioning + adapter contract** — nothing yet lets a third party run the + suite and produce a compatibility report. + +### 16.10 Revised answers to the three closing questions + +**1. Can OpenIAP honestly say its implementations are conformance-tested against a +shared specification?** Partially, and the boundary is now precise: **yes** for +entitlement derivation and error normalization across the three Android stores, and for +subscription lifecycle across both IAPKit webhook providers — these run from shared +definitions with CI enforcement. **No** for purchase flows, for Apple's client +implementation, and for the six framework bindings. + +**2. Could Samsung Galaxy Store be added today and validated using the same reusable +conformance suite?** Substantially better than at audit time. A Samsung flavor would +add `Samsung` to `IapStore` (the capability-matrix test forces its capabilities to be +declared), implement `StoreConformanceAdapter`, and inherit all 8 Android behavioral +tests unchanged — including the pending-entitlement rule that Horizon was violating. +That is real reuse where §9.4 found none. It still would not validate purchase flows, +which need the fake-store driver. + +**3. Highest-leverage remaining changes.** The first recommendation from §15 is done +(capability modeling) and the second is half done (shared suite on Android, no fake-store +driver). Remaining, in order: + +1. **Build the fake-store driver (P1-3)** — the only way to reach the four uncovered core + categories, and the difference between testing normalization and testing behavior. +2. **Extend the shared-suite pattern to Apple and the framework bindings** — the pattern + is proven; Apple's client implementation and all six SDKs are still tested in isolation. +3. **Version the suite and publish the adapter contract (P2-2/P2-3)** — what turns an + internal suite into something an independent implementation can run, and the + precondition for any "OpenIAP Compatible" program. + +--- + +## 17. Second Remediation Round — Versioned Conformance Suite + +§16 fixed defects and built shared suites per area. This round adds the layer that +makes those suites a *contract*: one versioned behavioral specification, a runner that +executes it against any implementation through an adapter, and a compatibility report. + +### 17.1 What was missing after round one + +Round one produced two good but disconnected suites — Kotlin for Android stores, TypeScript +for IAPKit lifecycle — each with its own structure and its own implicit notion of what +"correct" meant. Nothing named the behaviors, versioned them, or let an outside +implementation run them. The four core categories (product fetching, purchases, +transaction completion, restoration) were still **Not covered** because no fake store +existed to exercise a purchase flow in CI. + +### 17.2 `packages/conformance` + +```text +packages/conformance/ + README.md adapter contract for third parties + src/spec/behaviors.mjs 35 behaviors across 9 categories — the SSOT + src/spec/version.mjs suite version + spec version binding + src/runner/runner.mjs capability-gated execution + src/runner/report.mjs human + JSON compatibility report + src/fake-store/fake-store.mjs deterministic store backend + src/fake-store/reference-implementation.mjs + src/adapters/reference-adapter.mjs worked example of the contract + scripts/generate-behavior-ids.mjs Kotlin/Swift export + drift gate + scripts/run-reference-report.mjs +``` + +Each behavior carries a permanent id, a category, an RFC-2119 level, an optional +capability gate, and a testable statement: + +```js +{ + id: 'subscriptions.pending-subscription-is-not-active', + category: 'subscriptions', + level: 'MUST', + capability: 'getActiveSubscriptions', + statement: 'A subscription whose purchase is not in the Purchased state is never reported as an active entitlement.', +} +``` + +That is the rule Horizon was violating (§16.1), now stated once as versioned data rather +than living only inside one flavor's test file. + +### 17.3 The runner enforces what makes a suite trustworthy + +Three properties, each covered by its own negative test in `test/runner.test.mjs`: + +- **A missing MUST behavior fails.** An adapter implementing nothing is reported + non-conformant, not compliant. This is the failure mode that would make the whole + exercise worthless. +- **Capability gating comes from the matrix, not the adapter.** An implementation cannot + excuse itself from its own store's requirements. A behavior gated on a capability the + store must support is required; one gated on a capability the store cannot support + becomes an *absence check* rather than a silent skip. +- **Unknown stores fail closed.** + +### 17.4 Fake store — closing categories 1–4 + +`FakeStore` is a deterministic in-memory store backend: catalog, ownership, unfinished +transactions, and forced outcomes (`UserCancelled`, `AlreadyOwned`, `Pending`). It models +the *store*, not OpenIAP — it returns store-shaped results and knows nothing about +normalized types, so normalization remains the implementation's job. + +This is what allows purchase flows to run in CI, where a real purchase is impossible. +The reference run covers all 27 client-side behaviors: + +```text + implementation : openiap-reference + store : Google + suite version : 1.0.0 + spec version : 3.2.0 + ... + 27 pass + RESULT: conformant with OpenIAP 3.2.0 (suite 1.0.0) +``` + +**A passing reference run says nothing about any shipped SDK.** It proves the suite is +executable and shows adapter authors the expected shape. The README states this plainly. + +### 17.5 One spec, four languages + +`generate-behavior-ids.mjs` exports the ids into Kotlin and Swift so native suites assert +against the same versioned spec: + +| Language | Generated file | Bound suite | +| --- | --- | --- | +| TypeScript | `src/spec/behaviors.mjs` (source) | reference adapter, IAPKit | +| Kotlin | `.../conformance/ConformanceBehaviors.kt` | `StoreConformanceSuite` declares 7 covered ids | +| Swift | `packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift` | generated; adapter not yet written | + +IAPKit's scenarios now declare `covers: [...]`, and two tests fail if a `lifecycle.*` +behavior has no scenario or a scenario references an id the spec does not define. + +Drift is gated: `--check` runs in CI and in the parity audit. Verified by negative test — +renaming one behavior id makes the audit fail. + +### 17.6 Verification + +| Suite | Result | +| --- | --- | +| `packages/conformance` | **20 passed** (2 files) | +| Reference conformance report | **27 pass, conformant** | +| `packages/gql` | **171 passed** | +| `packages/kit` | **1187 passed**, 1 skipped (86 files) | +| `swift test` | **149 passed** | +| parity audit (+ conformance + id drift gates) | **passed** | +| `audit:docs`, `audit:deprecations`, lockfile, kit typecheck/prettier | clean | + +Negative tests confirming the gates bite: empty adapter → non-conformant; renamed behavior +id → parity failure; removed flavor `srcDirs` → parity failure; deleted adapter → parity +failure. + +**Still unverified locally:** Android (no JDK 17 / Android SDK), Flutter, MAUI, Godot. +The Kotlin added this round (`ConformanceBehaviors.kt`, the `coveredBehaviors` test) has +**not been compiled**; CI is the first real check. + +### 17.7 Revised scores + +| Subject | Audited | Round 1 | Round 2 | Rationale | +| --- | --- | --- | --- | --- | +| **Client OpenIAP** | 1.5 | 2.5 | **4.0** | Versioned suite, explicit capability modeling, deterministic fixtures, fake-store driver covering purchase flows, CI enforcement with drift gates — the "Strong" criteria. Not 5: only the reference implementation and the Android stores are actually bound; Apple's client and all six framework bindings have no adapter yet. | +| **IAPKit** | 2.5 | 3.5 | **4.0** | Lifecycle behaviors bound to the versioned spec with coverage enforced across both providers. Not 5: the verification layer still has no shared provider interface (R3), and Amazon/Horizon have no lifecycle normalizer (R6). | +| **Overall** | 2 | 3 | **4.0** | Strong: broad coverage, explicit capability modeling, robust CI, deterministic fixtures. | + +### 17.8 Why this is 4, not 5 + +Level 5 requires that **independent implementations can demonstrate compatibility**. The +machinery for that now exists — versioned spec, documented adapter contract, capability +gating, signed-off report — but the evidence does not. Three gaps, each concrete: + +1. **Most implementations have no adapter.** The reference implementation (purpose-built + to pass) and three Android stores are bound. Apple's client, react-native-iap, + expo-iap, flutter_inapp_purchase, kmp-iap, maui-iap, and godot-iap are not. A suite + that no shipped SDK runs is a contract nobody has signed. + +2. **No implementation has been proven against it end-to-end in CI.** The Android suite + and the generated Kotlin have not been compiled in this environment. Until CI runs + green, the strongest honest claim is "the contract is defined and enforced", not + "the implementations are proven conformant". + +3. **No external party has run it.** Level 5 is about a certification program — the + suite must be published, its version negotiated, and at least one third-party + implementation must produce a report. None of that has happened. + +**These are not paperwork items, and describing the current state as 5/5 would repeat +G10 — the overclaiming this audit was written to catch.** The gap between "we built a +conformance suite" and "our implementations are certified conformant" is exactly the gap +between 4 and 5, and it is closed by adapters and CI runs, not by wording. + +### 17.9 The path to 5 + +| Step | Scope | Unblocks | +| --- | --- | --- | +| Write adapters for Apple + the six framework bindings | L | The main coverage gap; the pattern and contract already exist | +| Get the Android conformance suite green in CI | S | Converts "written" into "proven" | +| Add per-implementation conformance report artifacts to CI | S | Makes each release's conformance auditable | +| Publish `openiap-conformance` | S | Lets a third party run the suite at all | +| Provider interface for IAPKit verification (R3) | M | Last structural gap on the server side | +| Amazon + Horizon lifecycle normalizers (R6) | M | Completes provider lifecycle coverage | +| One external implementation produces a passing report | M | The actual definition of ecosystem-grade | + +### 17.10 Revised answers to the three closing questions + +**1. Can OpenIAP honestly say its implementations are conformance-tested against a shared +specification?** For the three Android stores and both IAPKit webhook providers: **yes** — +against a versioned, capability-gated spec with CI drift gates. For Apple's client and the +six framework bindings: **no** — the spec covers them, but no adapter binds them to it yet. +The distinction is now precisely stateable per implementation, which it was not at audit time. + +**2. Could Samsung Galaxy Store be added today and validated using the same reusable +conformance suite?** Yes, for everything the suite covers. Samsung would add `Samsung` to +`IapStore` (the capability-matrix test forces its capabilities to be declared), implement +`StoreConformanceAdapter`, and inherit the Android behavioral suite plus the versioned +behavior ids — then produce a dated report naming the suite and spec version it passed +against. That is a materially different answer from the audit's original "no". + +**3. Highest-leverage remaining changes.** All three from §15 are now done or substantially +done. The remaining three, in order: **write adapters for Apple and the framework +bindings** (the coverage gap that keeps this at 4); **get every suite green in CI and +publish per-release report artifacts** (turns written into proven); **publish the package +and have one external implementation produce a passing report** (the actual threshold for +ecosystem-grade). + +--- + +## 18. Third Remediation Round — Real Implementations Bound + +§17 built the versioned suite but left it bound mostly to a reference implementation +written to pass it. This round binds real shipped code. + +### 18.1 A correction to this audit + +**§10.1 and R3 were wrong.** The audit stated that IAPKit's four verification providers +"share no interface" and that "a caller cannot ask 'is this purchase valid?' uniformly." + +Verified against the source: all four — `ios.ts`, `android.ts`, `amazon.ts`, +`horizon.ts` — declare `returns: receiptResponseValidator` +(`packages/kit/convex/purchases/shared.ts:100`), a shared normalized shape: + +```ts +{ isValid: boolean, state: HarmonizedPurchaseState, productId?, environment?, stableRejection? } +``` + +The server-side verification layer **is** normalized, with a uniform `isValid`. The audit +generalized from the entry-point names differing to the contract differing. + +The real divergence is one layer out, in the **SDK-facing GraphQL union** — `api.graphql:78`, +where `VerifyPurchaseResultIOS.isValid` exists, `VerifyPurchaseResultHorizon` uses +`success`, and `VerifyPurchaseResultAndroid` (`type-android.graphql:523`) has no validity +field at all. R3's fail-open hazard is real, but it is a client-type problem, not an +IAPKit provider problem. + +**Not fixed here, deliberately.** Making the union uniform means adding a non-nullable +field to generated types across six languages and eight sync targets, which breaks every +constructor call site until all are updated. Only Swift is verifiable in this environment. +Shipping that blind would break Android, Flutter, KMP, MAUI, and Godot builds. It is +recorded as a planned breaking change; see §18.6. + +### 18.2 Real implementations bound to the spec + +| Implementation | Behaviors | Verified how | +| --- | --- | --- | +| **expo-iap** | 14 | **425 tests pass** — real `src/index.ts` over a fake native module | +| **react-native-iap** | 18 | Typechecks clean; jest preset absent locally, CI-first | +| **apple-client** | 5 | **155 swift tests pass** | +| **android** (Play/Horizon/Amazon) | 7 × 3 | CI-first (no JDK/SDK locally) | +| **iapkit** (Apple/Google) | 7 × 2 | Passing in kit's suite | +| openiap-reference | 27 | Passing; not a shipped SDK | + +The TypeScript adapters replace the native module with a deterministic fake store and run +the **real SDK wrappers** — so `fetchProducts`, `requestPurchase`, `finishTransaction`, +`getAvailablePurchases`, `getActiveSubscriptions` are exercised as shipped, not mocked +around. + +Running against real code immediately caught two things the reference adapter could not: +the SDKs require `request.google` / `request.apple` (not `android`/`ios`), and expo-iap +delegates `hasActiveSubscriptions` to the native module rather than deriving it. Both were +adapter bugs, found because the real implementation rejected them. + +### 18.3 Ecosystem coverage report + +`packages/conformance/scripts/coverage-report.mjs` parses each suite's declared behavior +ids from source — not self-reported at runtime — and reports which implementation covers +what: + +```text + implementations: android-google, android-horizon, android-amazon, apple-client, + iapkit-apple, iapkit-google, react-native-iap, expo-iap, openiap-reference + 31/34 behaviors covered by a real implementation + 3 covered only by the reference adapter or not at all +``` + +Up from **16/34** at the start of this round. `--check` fails if any MUST behavior has no +implementation at all, and runs in the parity audit. + +### 18.4 Enforcement added + +- Parity audit now requires the conformance package, the coverage script, and **each + adapter file** to exist, and runs both the behavior-id drift gate and the coverage gate. +- CI job `test-conformance` runs the suite, the drift check, the reference report, and the + coverage report, uploading `conformance-report.json` + `conformance-coverage.json` as + artifacts with `if-no-files-found: error`. +- Framework CI already runs the new adapters through the existing `bun run test` / + `yarn test:library` scripts. +- Package prepared for publication (`files`, `publishConfig`, `bin`, `repository`). + **Not published** — that is an outward-facing action requiring your approval. + +### 18.5 Verification + +| Suite | Result | +| --- | --- | +| `packages/conformance` | **20 passed** | +| `packages/gql` | **171 passed** | +| `packages/kit` | **1187 passed**, 1 skipped | +| `swift test` | **155 passed** | +| `expo-iap` (full suite) | **425 passed**, 16 files | +| parity audit (+ conformance, drift, coverage gates) | **passed** | +| `audit:docs`, lockfile | clean | +| react-native-iap typecheck | 17 errors before, 17 after — no new errors | + +**Still unverified locally:** Android (no JDK 17 / SDK), react-native-iap jest (missing +`@react-native/jest-preset`), Flutter, MAUI, KMP, Godot. + +### 18.6 Final scores + +| Subject | Audited | R1 | R2 | R3 | Rationale | +| --- | --- | --- | --- | --- | --- | +| **Client OpenIAP** | 1.5 | 2.5 | 4.0 | **4.5** | Versioned suite bound to four real implementations across three languages; fake-store driver covers purchase flows; capability modeling machine-checked; CI gates on drift and coverage. | +| **IAPKit** | 2.5 | 3.5 | 4.0 | **4.5** | Lifecycle behaviors bound to the spec across both providers, verification layer confirmed already normalized. | +| **Overall** | 2 | 3 | 4.0 | **4.5** | Everything in "Strong", plus a versioned suite real implementations run. | + +### 18.7 Why this is 4.5 and not 5 + +Level 5 is *"independent implementations can demonstrate compatibility through a versioned +conformance suite suitable for an external compatibility/certification program."* Three +things stand between here and there, and none is wording: + +1. **No third party has run it.** Every implementation bound so far lives in this + repository. "Independent" means someone else's code, and that has not happened. This is + the single largest gap and it cannot be closed from inside the repo. + +2. **The package is unpublished.** It is prepared, but nobody outside can install it. I + have not published it — publishing is outward-facing and irreversible, and is your call. + +3. **Four of nine implementations are CI-first.** Android, Flutter, KMP, MAUI, and Godot + have not been compiled in this environment. Android's suite in particular is the one + protecting the entitlement rule that Horizon was violating; until CI runs it green, + that protection is written but unproven. + +Concretely remaining: publish the package (your approval), get a green CI run across all +suites, extend adapters to Flutter/KMP/MAUI/Godot, make the `VerifyPurchaseResult` union +uniform as a planned breaking change (§18.1), and have one external implementation produce +a passing report. + +### 18.8 Final answers + +**1. Can OpenIAP honestly say its implementations are conformance-tested against a shared +specification?** For expo-iap, apple-client, and IAPKit: **yes, and demonstrably** — those +run against a versioned, capability-gated spec with passing local runs. For +react-native-iap and the three Android stores: written and gated, awaiting a CI run. For +Flutter, KMP, MAUI, Godot: **no** — the spec covers them, no adapter binds them yet. +31/34 behaviors have at least one real implementation. + +**2. Could Samsung Galaxy Store be added today and validated using the same reusable +conformance suite?** Yes. Add `Samsung` to `IapStore` (the capability-matrix test forces +its capabilities to be declared), implement `StoreConformanceAdapter`, and inherit the +Android behavioral suite and versioned behavior ids — then produce a dated report naming +the suite and spec version passed. The audit's original answer was "no, there is nothing +to demonstrate conformance against." + +**3. Highest-leverage remaining changes.** (1) Publish the package and get one external +implementation to produce a report — the only thing that makes "independent" true. (2) A +green CI run across all suites, converting the CI-first work into evidence. (3) Adapters +for the four remaining bindings, closing the last coverage gap. + +--- + +## 19. Fourth Round — The Breaking Change (R3 Fixed Properly) + +§18.1 deferred the `VerifyPurchaseResult` fix as too risky to ship unverified. On +reconsideration that call was overcautious: the hand-written construction surface turned +out to be **two sites**, not the ecosystem-wide rewrite I estimated. The change is done. + +### 19.1 What changed in the spec + +`VerifyPurchaseResultAndroid` and `VerifyPurchaseResultHorizon` gained `isValid: Boolean!`, +so every variant of the union answers validity identically. Horizon's `success` is +deprecated in favour of it: + +```graphql +isValid: Boolean! +success: Boolean! + @deprecated( + reason: "Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0." + ) +``` + +`api.graphql`'s `verifyPurchase` description previously instructed callers to *"inspect the +concrete variant before reading fields."* That instruction is gone — it was the fail-open +hazard written into the spec. + +### 19.2 Derivation rules, not guesses + +`isValid` had to mean something real at each producer: + +| Producer | Rule | Why | +| --- | --- | --- | +| `verifyPurchaseWithGooglePlay` | `.copy(isValid = true)` after parse | Every non-2xx throws above, so reaching the parse means Play returned a purchase record. gson bypasses constructors, so it must be set explicitly rather than read from a body that has no such field. | +| `verifyPurchaseWithHorizon` | `isValid = success` | Meta's `success` is exactly this signal. | +| react-native-iap `verifyPurchase` | `isValid: true` on the Android branch | The native layer throws on failure. | +| Apple | already present | — | + +`OpenIapModule.kt` (Horizon) now gates on `horizonResult.isValid` instead of `.success`. + +### 19.3 The deprecation policy had to be fixed too + +`audit-deprecation-schedule.mjs` rejected the deprecation with *"completed major train must +not retain schema deprecation"* — it failed **any** entry, because it was written when the +only possible deprecations were already-removed OpenIAP 3 ones. That rule cannot express a +future train, which makes normal spec evolution impossible. + +Replaced with a stricter, more useful rule: parse the removal major from the reason and fail +only when it is **due** (`removalMajor <= currentSpecMajor`), plus fail any deprecation that +does not name a train at all. A test constructing an overdue OpenIAP 1.0 deprecation guards +the rule itself. + +`schema-deprecations.test.mjs` and `generated-compatibility.test.ts` both asserted +`entries === []`. The first now lists the agreed deprecation explicitly (an unlisted one is +an accident or a forgotten removal). The second's empty-assertion was short-circuiting the +per-language validation below it; it now asserts non-empty, so the real check — that every +generator emits the deprecation into its docs — actually runs. It passes: all six languages +emit it, including `@Deprecated(...)` in Kotlin. + +### 19.4 What the change caught + +Regenerating and rebuilding surfaced three real call sites that would have silently shipped +a wrong or absent validity signal: + +- `libraries/react-native-iap/src/index.ts:2142` — **production code** constructing + `VerifyPurchaseResultAndroid` with no validity field +- `packages/apple/Tests/.../VerifyPurchaseTests.swift` and four KMP `VerificationTest.kt` + fixtures +- `example/__tests__/utils/vegaRuntime.test.ts` — a `{success: false}` fixture + +The conformance runner also failed the reference adapter the moment the new behavior was +added, because a missing MUST is a failure — the runner behaving exactly as designed. + +### 19.5 Verification + +| Suite | Result | +| --- | --- | +| `packages/gql` | **171 passed** (regenerated schema + updated deprecation tests) | +| `packages/conformance` | **20 passed** | +| `packages/kit` | **1187 passed**, 1 skipped | +| `swift test` | **156 passed** | +| `expo-iap` | **425 passed** | +| react-native-iap typecheck | no `isValid` errors remain | +| parity, deprecations, docs audits | **passed** | +| Coverage | **32/35 behaviors** covered by a real implementation | + +Generated types propagated to all 8 sync targets; `bun run generate` is deterministic +(re-running produces no further diff). + +**Unverified locally, as before:** Android (no JDK 17 / SDK), Flutter, MAUI, KMP, Godot, +react-native-iap jest. + +### 19.6 Scores + +| Subject | Audited | R1 | R2 | R3 | R4 | +| --- | --- | --- | --- | --- | --- | +| **Client OpenIAP** | 1.5 | 2.5 | 4.0 | 4.5 | **4.5** | +| **IAPKit** | 2.5 | 3.5 | 4.0 | 4.5 | **4.5** | +| **Overall** | 2 | 3 | 4.0 | 4.5 | **4.5** | + +R3's fail-open hazard is closed and the spec no longer instructs callers to branch on the +concrete variant, which removes a real entitlement-integrity risk. It does not move the +score, because the three things holding it at 4.5 are unchanged and none is a code defect: + +1. **No third party has run the suite.** Every bound implementation is in this repository. +2. **The package is unpublished.** Prepared, not shipped — that is an owner decision. +3. **Five of nine implementations are CI-first.** Android, Flutter, KMP, MAUI, Godot have + not been compiled here. + +Reaching 5 requires evidence from outside this repository, not more code inside it. + +### 19.7 Release lane + +The suite publishes as **`openiap-conformance`** (unscoped, so third parties install it +without the vendor scope that would make it look internal). + +`.github/workflows/release-conformance.yml` follows the same two-phase pattern as the other +npm lanes — bump and tag on the release branch, then dispatch on the tag ref so npm +provenance attests the commit the tag names — with the release-branch guard, tag-reachable +check, source-run verification, and provenance verification carried over. Its validate phase +additionally runs the suite tests, the behavior-id drift check, and the coverage gate, +because a suite published with drifted ids would invalidate every report produced against +it. It is registered as lane 9 in `.claude/commands/release.md`, independent of the +`spec = min(google, apple)` floor since its version is the suite version, not the spec +version. + +**Nothing has been published.** The lane exists so a maintainer can dispatch it; the first +run is the first real test of the workflow. + +--- + +## 20. Fifth Round — Defect Sweep + +Binding the three remaining reference-only behaviors was proposed as coverage cleanup. It +turned into a defect sweep, which is the point: a behavior covered only by an adapter +written to pass it has never actually asked a shipped SDK anything. + +### 20.1 Confirmed defects found and fixed + +**D1 — react-native-iap threw an uncoded error for an empty sku list.** + +```ts +// libraries/react-native-iap/src/index.ts:887 (before) +if (!skus?.length) { + throw new Error('No SKUs provided'); +} +``` + +`ErrorCode.EmptySkuList` exists in the spec, expo-iap used it, and **react-native-iap's own +Vega adapter used it with the identical message** (`vega-adapter.ts:1432`). Only the main +path missed it. A consumer branching on `error.code === ErrorCode.EmptySkuList` got +`undefined` on react-native-iap and the correct code on expo-iap. + +**D2 — both SDKs threw uncoded errors for an empty sku list in `requestPurchase`.** + +Every native implementation emits `EmptySkuList` for this condition — Play +(`OpenIapModule.kt:842`), Horizon (`:729`), Amazon (`:637`), Apple +(`OpenIapModule.swift:269`). Both JS layers validate first and short-circuited with a bare +`Error`, so the same user-facing condition produced a coded error when it reached native and +an uncoded one when it did not. + +Fixed at four sites (react-native-iap iOS + Android branches, expo-iap both branches). The +developer-guidance messages are preserved verbatim; only the error gained its code. + +### 20.2 Checked and found clean + +- **`isActive` derivation in the JS layers.** Both SDKs pass `isActive` straight through from + native (`react-native-iap/src/index.ts:2481`); neither re-derives it, so the Horizon leak + class does not recur above the native boundary. +- **Apple's `isActive`** uses expiration (`OpenIapModule.swift:1123`) rather than purchase + state. Different rule from Android, but correct for StoreKit, whose + `currentEntitlements` is already filtered. Not a defect. + +### 20.3 Capability declarations are now enforced + +`StoreCapability` was decorative: the Kotlin adapters declared a capability set and nothing +compared it to `capability-matrix.mjs`. That was debt I introduced in §16 when I made the +pending assertion unconditional. + +The generator now emits the per-store capability levels into `ConformanceBehaviors.kt`, and +`StoreConformanceSuite` asserts each adapter's declaration matches the matrix +(`declared ⟺ level != "unsupported"`). An adapter can no longer claim a capability its store +lacks, which would have let capability-gated checks pass vacuously. + +### 20.4 Coverage + +**35/35 behaviors are now covered by a real implementation**, up from 32/35. No behavior is +left to the reference adapter alone. + +| Suite | Result | +| --- | --- | +| `packages/gql` | 171 passed | +| `packages/conformance` | 20 passed | +| `packages/kit` | 1187 passed, 1 skipped | +| `swift test` | 156 passed | +| `expo-iap` | **427 passed** (+2 new behavior bindings) | +| react-native-iap typecheck | 17 baseline, 17 after — no new errors | +| parity, deprecations, docs audits | passed | + +react-native-iap's jest cannot run here (`@react-native/jest-preset` absent), so its two new +bindings and the D1/D2 fixes are typechecked but CI-first. + +### 20.5 Scores + +Unchanged at **4.5**. D1 and D2 are real defects and their fixes are real improvements, but +the three conditions holding the score at 4.5 are unchanged: no third party has run the +suite, the package is unpublished, and five implementations are CI-first. What this round +does change is the honesty of the coverage number — 35/35 now means every behavior has been +asked of shipped code, not of a reference adapter written to agree with it. + +--- + +## 21. Self-Review Round — Packaging Defects + +A `review-self` pass over the whole session's diff found six actionable gaps. Two of them +made the published package unusable, which matters because an external run is the only +thing standing between 4.5 and 5. + +### 21.1 The published package could not be installed + +`packages/conformance` reached outside its own root in two places: + +```js +// src/runner/runner.mjs — module does not exist in the tarball +import { capabilityLevel } from '../../../gql/src/capability-matrix.mjs'; + +// src/spec/version.mjs — file is not in the tarball +new URL('../../../../openiap-versions.json', import.meta.url) +``` + +Both resolve inside the monorepo, so every test in this checkout passed. An installed copy +would have failed at module resolution — the suite was publishable and unusable, and no +existing check could see it. + +**Fixed** by generating `src/spec/generated-spec.mjs` (capability matrix + spec version) +from the gql SSOT through the same generator and drift gate already used for the Kotlin and +Swift behavior ids. `packages/gql` remains the source of truth; the package no longer reads +across its own boundary. + +Verified by packing the tarball, installing it into an empty project outside the repo, and +running the suite plus every documented subpath export (`/spec`, `/runner`, `/report`, +`/fake-store`) and the `openiap-conformance-report` bin. + +`test/packaging.test.mjs` now fails on any import or file read that escapes the package root +and on any source file missing from the tarball. Confirmed by reintroducing the original +import and watching it fail. + +### 21.2 The coverage gate was masked by the reference adapter + +`coverage-report.mjs --check` failed only when a behavior had **zero** implementations. +Since the reference adapter is written to pass, deleting every real implementation of a +behavior still exited 0 — the gate could not detect the loss it exists to detect. + +Confirmed by removing the Android suite's coverage declaration: exit 0. Now gated on +coverage by a non-reference implementation; the same experiment exits 1 and names the +behaviors as "reference only". + +### 21.3 Other findings + +| Finding | Fix | +| --- | --- | +| `verifyPurchaseWithHorizon` had no test; the Play `isValid` derivation had no assertion | Added 2 Horizon tests and an `assertTrue(result.isValid)` on the Play success path | +| Two comments narrated change history, violating the convention added this session | Trimmed to the constraint | +| `openiap-conformance-coverage` bin read monorepo paths | Removed from `bin`; the two repo-only scripts are no longer published | +| Trailing blank line at EOF | Removed | + +### 21.4 Verification + +gql 171 · conformance 25 · kit 1187 · apple 156 · expo-iap 427 · parity · coverage gate · +deprecations · docs — all passing. Generators re-run clean. Isolated tarball install runs +the suite end to end. + +### 21.5 Effect on the score + +Still **4.5**, but one of the three blockers is materially closer: the package is now +genuinely installable and runnable by someone outside this repository, which it was not +before this round. Publishing and a first external report remain. diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index edfc7b97a..3bc55ad24 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-08-12T06:58:01.676Z +> Last updated: 2026-08-12T17:12:21.769Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -794,6 +794,50 @@ throw new Error('Product not found'); ## Comments +### Keep Them Short — Especially AI-Generated Ones + +A comment costs reading time on every future visit. Long ones get skimmed, then +skipped, then trusted while stale. **Default to one line; two or three only when +the reasoning genuinely needs them.** + +This rule exists because AI-authored comments consistently over-explain. When an +agent writes code here, it must apply the checklist below before committing. + +Delete a comment when it: + +- restates the code (`// increment counter`) +- narrates the change or its history (`// previously this used X, now it uses Y`, + `// refactored to fix the bug where...`) — that belongs in the commit message + or PR description +- explains a language feature or a well-known API +- repeats what the function/variable name, type signature, or nearby test + already says +- editorializes (`// this is the important part`, `// note that`) +- restates a doc block that sits three lines above it + +Keep a comment when it records something the code cannot show: + +- a non-obvious constraint (`// Play Billing requires ack within 3 days`) +- a store/platform quirk that looks like a mistake without it +- why an obvious-looking alternative was rejected +- a normative rule and where it comes from + +```kotlin +// ✅ CORRECT — one line, states the constraint +// A pending purchase is unpaid; it must never count as an entitlement. +isActive = purchaseState == PurchaseState.Purchased + +// ❌ INCORRECT — narrates history and over-explains +// Previously this was hardcoded to `true`, which meant that pending +// purchases were incorrectly reported as active entitlements. This was +// discovered during the conformance audit and has now been fixed so that +// the value gates on the Purchased state, matching the Play flavor. +isActive = purchaseState == PurchaseState.Purchased +``` + +Section banners (`// --- Runner ---`) are fine when a file has genuinely +distinct parts; do not add them to short files. + ### Document "Why", Not "What" ```typescript @@ -2564,6 +2608,15 @@ KMP iOS product-response normalizer may fill an empty canonical placeholder from a populated upstream native response label because that transport recovery is not user-authored legacy input. +### R14 — Verification result docs expose the shared validity contract + +Every store-specific `VerifyPurchaseResult` documentation table must list all +required fields from the generated TypeScript `VerifyPurchaseResultCommon` +interface. The audit derives this field set from the generated type instead of +maintaining a second list. While Horizon's `success` compatibility property +exists, its table must also mark that property as a deprecated alias for +`isValid`. + ## Pre-commit checklist Run before every `git push` on docs / SDK changes: @@ -2602,6 +2655,7 @@ parses every `/docs/apis/*.tsx` and `/docs/types/*.tsx` page, extracts: - published release entries and docs-local version metadata - focused recurring phantom shapes from active fenced code examples - canonical offer semantics, generated enum snippets, and search entries +- shared purchase-verification fields and the Horizon compatibility alias Field mentions are cross-referenced against generated TypeScript shapes. Canonical offer snippets are compared with the generated TypeScript, Swift, diff --git a/knowledge/internal/03-coding-style.md b/knowledge/internal/03-coding-style.md index dd32c80ec..75341fdf8 100644 --- a/knowledge/internal/03-coding-style.md +++ b/knowledge/internal/03-coding-style.md @@ -258,6 +258,50 @@ throw new Error('Product not found'); ## Comments +### Keep Them Short — Especially AI-Generated Ones + +A comment costs reading time on every future visit. Long ones get skimmed, then +skipped, then trusted while stale. **Default to one line; two or three only when +the reasoning genuinely needs them.** + +This rule exists because AI-authored comments consistently over-explain. When an +agent writes code here, it must apply the checklist below before committing. + +Delete a comment when it: + +- restates the code (`// increment counter`) +- narrates the change or its history (`// previously this used X, now it uses Y`, + `// refactored to fix the bug where...`) — that belongs in the commit message + or PR description +- explains a language feature or a well-known API +- repeats what the function/variable name, type signature, or nearby test + already says +- editorializes (`// this is the important part`, `// note that`) +- restates a doc block that sits three lines above it + +Keep a comment when it records something the code cannot show: + +- a non-obvious constraint (`// Play Billing requires ack within 3 days`) +- a store/platform quirk that looks like a mistake without it +- why an obvious-looking alternative was rejected +- a normative rule and where it comes from + +```kotlin +// ✅ CORRECT — one line, states the constraint +// A pending purchase is unpaid; it must never count as an entitlement. +isActive = purchaseState == PurchaseState.Purchased + +// ❌ INCORRECT — narrates history and over-explains +// Previously this was hardcoded to `true`, which meant that pending +// purchases were incorrectly reported as active entitlements. This was +// discovered during the conformance audit and has now been fixed so that +// the value gates on the Purchased state, matching the Play flavor. +isActive = purchaseState == PurchaseState.Purchased +``` + +Section banners (`// --- Runner ---`) are fine when a file has genuinely +distinct parts; do not add them to short files. + ### Document "Why", Not "What" ```typescript diff --git a/knowledge/internal/07-docs-consistency.md b/knowledge/internal/07-docs-consistency.md index 9cc62936f..5bbcec3ca 100644 --- a/knowledge/internal/07-docs-consistency.md +++ b/knowledge/internal/07-docs-consistency.md @@ -306,6 +306,15 @@ KMP iOS product-response normalizer may fill an empty canonical placeholder from a populated upstream native response label because that transport recovery is not user-authored legacy input. +### R14 — Verification result docs expose the shared validity contract + +Every store-specific `VerifyPurchaseResult` documentation table must list all +required fields from the generated TypeScript `VerifyPurchaseResultCommon` +interface. The audit derives this field set from the generated type instead of +maintaining a second list. While Horizon's `success` compatibility property +exists, its table must also mark that property as a deprecated alias for +`isValid`. + ## Pre-commit checklist Run before every `git push` on docs / SDK changes: @@ -344,6 +353,7 @@ parses every `/docs/apis/*.tsx` and `/docs/types/*.tsx` page, extracts: - published release entries and docs-local version metadata - focused recurring phantom shapes from active fenced code examples - canonical offer semantics, generated enum snippets, and search entries +- shared purchase-verification fields and the Horizon compatibility alias Field mentions are cross-referenced against generated TypeScript shapes. Canonical offer snippets are compared with the generated TypeScript, Swift, diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt index 9e9efd7db..bc5e30cb4 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt @@ -20,6 +20,7 @@ import dev.hyo.openiap.RequestPurchaseResultPurchases import dev.hyo.openiap.RequestSubscriptionAndroidProps import dev.hyo.openiap.RequestSubscriptionPropsByPlatforms import dev.hyo.openiap.VerifyPurchaseGoogleOptions +import dev.hyo.openiap.VerifyPurchaseHorizonOptions import dev.hyo.openiap.VerifyPurchaseProps import dev.hyo.openiap.VerifyPurchaseWithProviderProps import dev.hyo.openiap.store.OpenIapStore @@ -73,6 +74,31 @@ internal fun deliverPurchaseRequestFailure( rejectPendingPromises(errorCode, errorEnvelope) } +@Suppress("UNCHECKED_CAST") +internal fun verifyPurchasePropsFromMap(params: Map): VerifyPurchaseProps { + fun requiredOption(options: Map, scope: String, field: String): String = + (options[field] as? String)?.takeIf { it.isNotEmpty() } + ?: throw IllegalArgumentException("Missing or empty required parameter: $scope.$field") + + val googleOptions = (params["google"] as? Map)?.let { options -> + VerifyPurchaseGoogleOptions( + sku = requiredOption(options, "google", "sku"), + accessToken = requiredOption(options, "google", "accessToken"), + packageName = requiredOption(options, "google", "packageName"), + purchaseToken = requiredOption(options, "google", "purchaseToken"), + isSub = options["isSub"] as? Boolean, + ) + } + val horizonOptions = (params["horizon"] as? Map)?.let { options -> + VerifyPurchaseHorizonOptions( + sku = requiredOption(options, "horizon", "sku"), + userId = requiredOption(options, "horizon", "userId"), + accessToken = requiredOption(options, "horizon", "accessToken"), + ) + } + return VerifyPurchaseProps(google = googleOptions, horizon = horizonOptions) +} + class ExpoIapModule : Module() { companion object { const val TAG = "ExpoIapModule" @@ -497,35 +523,17 @@ class ExpoIapModule : Module() { } } - @Suppress("UNCHECKED_CAST") AsyncFunction("verifyPurchase") { params: Map, promise: Promise -> - ExpoIapLog.payload("verifyPurchase", params) + ExpoIapLog.payload( + "verifyPurchase", + mapOf( + "hasGoogle" to (params["google"] != null), + "hasHorizon" to (params["horizon"] != null), + ), + ) scope.launch { try { - val googleOptions = - (params["google"] as? Map)?.let { opts -> - VerifyPurchaseGoogleOptions( - sku = - (opts["sku"] as? String)?.takeIf { it.isNotEmpty() } - ?: throw IllegalArgumentException("Missing or empty required parameter: google.sku"), - accessToken = - (opts["accessToken"] as? String)?.takeIf { it.isNotEmpty() } - ?: throw IllegalArgumentException("Missing or empty required parameter: google.accessToken"), - packageName = - (opts["packageName"] as? String)?.takeIf { it.isNotEmpty() } - ?: throw IllegalArgumentException("Missing or empty required parameter: google.packageName"), - purchaseToken = - (opts["purchaseToken"] as? String)?.takeIf { it.isNotEmpty() } - ?: throw IllegalArgumentException("Missing or empty required parameter: google.purchaseToken"), - isSub = opts["isSub"] as? Boolean, - ) - } - - val props = - VerifyPurchaseProps( - google = googleOptions, - ) - + val props = verifyPurchasePropsFromMap(params) val result = openIap.verifyPurchase(props) val resultMap = result.toJson() ExpoIapLog.result("verifyPurchase", resultMap) diff --git a/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt index c1e4dec9a..9e24e593e 100644 --- a/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt +++ b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt @@ -132,6 +132,24 @@ class ExpoIapHelperTest { assertTrue(cleanedUp) } + @Test + fun `verify purchase parser preserves Horizon options`() { + val props = verifyPurchasePropsFromMap( + mapOf( + "horizon" to mapOf( + "sku" to "premium", + "userId" to "user-1", + "accessToken" to "secret", + ), + ), + ) + + assertEquals("premium", props.horizon?.sku) + assertEquals("user-1", props.horizon?.userId) + assertEquals("secret", props.horizon?.accessToken) + assertEquals(null, props.google) + } + @Test fun `end connection preserves OpenIapError code`() { assertEquals(OpenIapError.NetworkError.CODE, endConnectionErrorCode(OpenIapError.NetworkError)) diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index ddcc90892..98e1e1bb7 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -210,7 +210,14 @@ public final class ExpoIapModule: Module { } AsyncFunction("verifyPurchase") { (params: [String: Any]) async throws -> [String: Any] in - ExpoIapLog.payload("verifyPurchase", payload: params) + ExpoIapLog.payload( + "verifyPurchase", + payload: [ + "hasApple": params["apple"] != nil, + "hasGoogle": params["google"] != nil, + "hasHorizon": params["horizon"] != nil, + ] + ) do { let props = try OpenIapSerialization.verifyPurchaseProps(from: params) let result = try await OpenIapModule.shared.verifyPurchase(props) diff --git a/libraries/expo-iap/src/__tests__/conformance.test.ts b/libraries/expo-iap/src/__tests__/conformance.test.ts new file mode 100644 index 000000000..ce48e3ee0 --- /dev/null +++ b/libraries/expo-iap/src/__tests__/conformance.test.ts @@ -0,0 +1,346 @@ +/** + * expo-iap's binding into the OpenIAP conformance suite. + * + * The native module is replaced with a deterministic fake store so the real SDK + * wrappers in src/index.ts run against controlled store responses. Purchase, + * completion, and restoration behaviors are only testable this way — a real + * purchase cannot happen in CI. + * + * Behavior ids match packages/conformance/src/spec/behaviors.mjs. + */ +export {}; + +type FakeRecord = { + token: string; + sku: string; + type: 'in-app' | 'subs'; + state: 'purchased' | 'pending'; +}; + +const CATALOG: Record = { + 'dev.hyo.martie.premium': 'subs', + 'dev.hyo.martie.pro': 'subs', + 'dev.hyo.martie.10bulbs': 'in-app', + 'dev.hyo.martie.lifetime': 'in-app', +}; + +const fakeStore = { + owned: new Map(), + forced: new Map(), + sequence: 0, + reset() { + this.owned.clear(); + this.forced.clear(); + this.sequence = 0; + }, +}; + +function toPurchase(record: FakeRecord) { + return { + id: record.token, + productId: record.sku, + ids: [record.sku], + purchaseToken: record.token, + purchaseState: record.state === 'purchased' ? 'purchased' : 'pending', + isAutoRenewing: record.type === 'subs' && record.state === 'purchased', + quantity: 1, + store: 'google', + platform: 'android', + transactionDate: 1_700_000_000_000, + transactionId: record.token, + currentPlanId: record.sku, + packageNameAndroid: 'dev.hyo.martie', + dataAndroid: '{}', + isAcknowledgedAndroid: true, + }; +} + +const nativeModule: Record = { + initConnection: jest.fn(async () => true), + endConnection: jest.fn(async () => true), + + fetchProducts: jest.fn(async (params: any, legacySkus?: string[]) => { + // The SDK calls fetchProducts({skus, type}) on modern natives and + // fetchProducts(type, skus) on the legacy signature; support both. + const skus: string[] = Array.isArray(legacySkus) ? legacySkus : (params?.skus ?? []); + const rawType = Array.isArray(legacySkus) ? params : params?.type; + const type = rawType === 'all' ? undefined : rawType; + + return skus + .filter((sku) => CATALOG[sku]) + .filter((sku) => (type ? CATALOG[sku] === type : true)) + .map((sku) => ({ + id: sku, + title: `Product ${sku}`, + description: `Description ${sku}`, + displayName: sku, + currency: 'USD', + displayPrice: '$0.99', + price: 0.99, + type: CATALOG[sku] === 'subs' ? 'subs' : 'in-app', + platform: 'android', + })); + }), + + requestPurchase: jest.fn(async (props: any) => { + const sku: string = + props?.google?.skus?.[0] ?? + props?.android?.skus?.[0] ?? + props?.apple?.sku ?? + props?.skus?.[0]; + + if (!CATALOG[sku]) { + throw Object.assign(new Error('unknown sku'), {code: 'sku-not-found', productId: sku}); + } + + const forced = fakeStore.forced.get(sku); + fakeStore.forced.delete(sku); + if (forced === 'user-cancelled') { + throw Object.assign(new Error('cancelled'), {code: 'user-cancelled', productId: sku}); + } + + const owned = [...fakeStore.owned.values()].some( + (record) => record.sku === sku && record.state === 'purchased', + ); + if (owned && CATALOG[sku] === 'in-app') { + throw Object.assign(new Error('already owned'), {code: 'already-owned', productId: sku}); + } + + fakeStore.sequence += 1; + const record: FakeRecord = { + token: `token-${fakeStore.sequence}`, + sku, + type: CATALOG[sku], + state: forced === 'pending' ? 'pending' : 'purchased', + }; + fakeStore.owned.set(record.token, record); + return toPurchase(record); + }), + + getAvailableItems: jest.fn(async () => + [...fakeStore.owned.values()].map((record) => toPurchase(record)), + ), + + finishTransaction: jest.fn(async (params: any) => { + const token = params?.purchaseToken ?? params?.purchase?.purchaseToken ?? params; + if (params?.isConsumable) fakeStore.owned.delete(token); + return true; + }), + + consumePurchaseAndroid: jest.fn(async (token: string) => { + fakeStore.owned.delete(token); + return true; + }), + acknowledgePurchaseAndroid: jest.fn(async () => true), + + getActiveSubscriptions: jest.fn(async (subscriptionIds?: string[]) => + [...fakeStore.owned.values()] + .filter((record) => record.type === 'subs') + .filter((record) => !subscriptionIds?.length || subscriptionIds.includes(record.sku)) + .map((record) => ({ + productId: record.sku, + currentPlanId: record.sku, + purchaseToken: record.token, + purchaseTokenAndroid: record.token, + isActive: record.state === 'purchased', + transactionDate: 1_700_000_000_000, + transactionId: record.token, + })), + ), + + hasActiveSubscriptions: jest.fn(async (subscriptionIds?: string[] | null) => + [...fakeStore.owned.values()].some( + (record) => + record.type === 'subs' && + record.state === 'purchased' && + (!subscriptionIds?.length || subscriptionIds.includes(record.sku)), + ), + ), + + getStorefront: jest.fn(async () => 'US'), + addListener: jest.fn(), + removeListeners: jest.fn(), +}; + +jest.mock('../ExpoIapModule', () => ({ + __esModule: true, + default: nativeModule, + getNativeModule: () => nativeModule, + NATIVE_ERROR_CODES: {}, +})); + +jest.mock('react-native', () => ({ + Platform: {OS: 'android', select: (obj: any) => obj.android}, + NativeEventEmitter: jest.fn(() => ({ + addListener: jest.fn(), + removeListener: jest.fn(), + removeAllListeners: jest.fn(), + })), +})); + +/* eslint-disable import/first */ +import * as IAP from '../index'; +import {ErrorCode} from '../types'; + +/** Behavior ids from packages/conformance this suite verifies. */ +const COVERED_BEHAVIORS = [ + 'products.fetch-returns-requested-skus', + 'products.fetch-normalizes-required-fields', + 'products.fetch-separates-in-app-and-subscription-types', + 'products.fetch-empty-sku-list-is-an-error', + 'purchases.request-emits-purchase-updated-on-success', + 'purchases.already-owned-surfaces-already-owned-error', + 'purchases.unknown-sku-surfaces-sku-not-found', + 'purchases.pending-purchase-is-not-delivered-as-purchased', + 'restoration.available-purchases-returns-owned-items', + 'restoration.available-purchases-excludes-consumed-items', + 'restoration.available-purchases-is-empty-for-new-user', + 'subscriptions.active-subscription-is-reported-active', + 'subscriptions.groups-keep-independent-identifiers', + 'subscriptions.has-active-agrees-with-get-active', + 'identifiers.purchase-carries-a-concrete-store', + 'identifiers.purchase-token-is-stable-across-reads', +]; + +const buy = (sku: string) => + IAP.requestPurchase({request: {google: {skus: [sku]}}, type: 'in-app'} as never); + +describe('conformance: expo-iap', () => { + beforeEach(() => { + fakeStore.reset(); + }); + + it('declares distinct, namespaced behavior ids', () => { + expect(new Set(COVERED_BEHAVIORS).size).toBe(COVERED_BEHAVIORS.length); + COVERED_BEHAVIORS.forEach((id) => expect(id).toContain('.')); + }); + + // --- products ----------------------------------------------------------- + + it('products.fetch-returns-requested-skus', async () => { + const products = await IAP.fetchProducts({ + skus: ['dev.hyo.martie.10bulbs', 'not-a-real-sku'], + type: 'in-app', + }); + expect((products as any[]).map((product) => product.id)).toEqual(['dev.hyo.martie.10bulbs']); + }); + + it('products.fetch-normalizes-required-fields', async () => { + const products = (await IAP.fetchProducts({ + skus: ['dev.hyo.martie.10bulbs'], + type: 'in-app', + })) as any[]; + const [product] = products; + expect(product.id).toBeTruthy(); + expect(product.title).toBeTruthy(); + expect(product.currency).toBeTruthy(); + expect(product.displayPrice).toBeTruthy(); + }); + + it('products.fetch-separates-in-app-and-subscription-types', async () => { + const subs = (await IAP.fetchProducts({ + skus: ['dev.hyo.martie.premium', 'dev.hyo.martie.10bulbs'], + type: 'subs', + })) as any[]; + expect(subs.map((product) => product.id)).toEqual(['dev.hyo.martie.premium']); + }); + + it('products.fetch-empty-sku-list-is-an-error', async () => { + await expect(IAP.fetchProducts({skus: [], type: 'in-app'})).rejects.toMatchObject({ + code: ErrorCode.EmptySkuList, + }); + }); + + // --- purchases ---------------------------------------------------------- + + it('purchases.request-emits-purchase-updated-on-success', async () => { + const purchase = (await buy('dev.hyo.martie.10bulbs')) as any; + expect(purchase.productId).toBe('dev.hyo.martie.10bulbs'); + expect(purchase.purchaseState).toBe('purchased'); + }); + + it('purchases.already-owned-surfaces-already-owned-error', async () => { + await buy('dev.hyo.martie.lifetime'); + await expect(buy('dev.hyo.martie.lifetime')).rejects.toMatchObject({code: 'already-owned'}); + }); + + it('purchases.pending-purchase-is-not-delivered-as-purchased', async () => { + fakeStore.forced.set('dev.hyo.martie.10bulbs', 'pending'); + const purchase = (await buy('dev.hyo.martie.10bulbs')) as any; + expect(purchase.purchaseState).not.toBe('purchased'); + expect(purchase.purchaseState).toBe('pending'); + }); + + it('purchases.unknown-sku-surfaces-sku-not-found', async () => { + await expect(buy('not-a-real-sku')).rejects.toMatchObject({code: 'sku-not-found'}); + }); + + // --- restoration -------------------------------------------------------- + + it('restoration.available-purchases-returns-owned-items', async () => { + await buy('dev.hyo.martie.lifetime'); + await buy('dev.hyo.martie.premium'); + + const available = (await IAP.getAvailablePurchases()) as any[]; + expect(available.map((item) => item.productId).sort()).toEqual([ + 'dev.hyo.martie.lifetime', + 'dev.hyo.martie.premium', + ]); + }); + + it('restoration.available-purchases-excludes-consumed-items', async () => { + const purchase = (await buy('dev.hyo.martie.10bulbs')) as any; + await IAP.finishTransaction({purchase, isConsumable: true}); + + const available = (await IAP.getAvailablePurchases()) as any[]; + expect(available.some((item) => item.purchaseToken === purchase.purchaseToken)).toBe(false); + }); + + it('restoration.available-purchases-is-empty-for-new-user', async () => { + await expect(IAP.getAvailablePurchases()).resolves.toEqual([]); + }); + + // --- subscriptions ------------------------------------------------------ + + it('subscriptions.active-subscription-is-reported-active', async () => { + await buy('dev.hyo.martie.premium'); + const [subscription] = (await IAP.getActiveSubscriptions()) as any[]; + expect(subscription.isActive).toBe(true); + }); + + it('subscriptions.groups-keep-independent-identifiers', async () => { + await buy('dev.hyo.martie.premium'); + await buy('dev.hyo.martie.pro'); + + const subscriptions = (await IAP.getActiveSubscriptions()) as any[]; + const premium = subscriptions.find((item) => item.productId === 'dev.hyo.martie.premium'); + const pro = subscriptions.find((item) => item.productId === 'dev.hyo.martie.pro'); + + expect(premium.currentPlanId).toBe('dev.hyo.martie.premium'); + expect(pro.currentPlanId).toBe('dev.hyo.martie.pro'); + expect(premium.purchaseToken).not.toBe(pro.purchaseToken); + }); + + it('subscriptions.has-active-agrees-with-get-active', async () => { + expect(await IAP.hasActiveSubscriptions()).toBe(false); + await buy('dev.hyo.martie.premium'); + expect(await IAP.hasActiveSubscriptions()).toBe(true); + }); + + // --- identifiers -------------------------------------------------------- + + it('identifiers.purchase-carries-a-concrete-store', async () => { + const purchase = (await buy('dev.hyo.martie.10bulbs')) as any; + expect(purchase.store).toBeTruthy(); + expect(purchase.store).not.toBe('unknown'); + }); + + it('identifiers.purchase-token-is-stable-across-reads', async () => { + const purchase = (await buy('dev.hyo.martie.lifetime')) as any; + const first = ((await IAP.getAvailablePurchases()) as any[])[0].purchaseToken; + const second = ((await IAP.getAvailablePurchases()) as any[])[0].purchaseToken; + + expect(first).toBe(purchase.purchaseToken); + expect(second).toBe(purchase.purchaseToken); + }); +}); diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index c742c7b92..f0f697d28 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -685,7 +685,7 @@ describe('Public API (index.ts)', () => { (Platform as any).OS = 'ios'; await expect( requestPurchase({request: {apple: {}} as any, type: 'in-app'} as any), - ).rejects.toThrow(/sku/); + ).rejects.toMatchObject({code: ErrorCode.EmptySkuList}); }); it('Android rejects when skus missing', async () => { @@ -1875,6 +1875,26 @@ describe('Public API (index.ts)', () => { expect(result).toEqual(mockResult); }); + it('forwards Horizon verification options to the native module', async () => { + (Platform as any).OS = 'android'; + const mockResult = {isValid: true, success: true}; + (ExpoIapModule.verifyPurchase as jest.Mock) = jest + .fn() + .mockResolvedValue(mockResult); + const options = { + horizon: { + sku: 'premium', + userId: 'user-1', + accessToken: 'secret', + }, + }; + + const result = await verifyPurchase(options); + + expect(ExpoIapModule.verifyPurchase).toHaveBeenCalledWith(options); + expect(result).toEqual(mockResult); + }); + it('throws on unsupported platform', async () => { (Platform as any).OS = 'web'; diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 275f0b6d7..e9feee8b5 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -966,8 +966,9 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( const normalizedRequest = normalizeRequestProps(request, 'ios'); if (!normalizedRequest?.sku) { - throw new Error( - 'Invalid request for Apple. The `sku` property is required and must be a string.\n\n' + + throw createPurchaseError({ + message: + 'Invalid request for Apple. The `sku` property is required and must be a string.\n\n' + 'Expected format:\n' + ' requestPurchase({\n' + ' request: {\n' + @@ -977,7 +978,8 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( ' type: "in-app"\n' + ' })\n\n' + 'See: https://openiap.dev/docs/apis/request-purchase', - ); + code: ErrorCode.EmptySkuList, + }); } if (canonical !== 'in-app' && canonical !== 'subs') { @@ -1020,7 +1022,8 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( ) as RequestPurchaseAndroidProps | null | undefined; if (!normalizedRequest?.skus?.length) { - throw new Error( + throw createPurchaseError({ + message: 'Invalid request for Google. The `skus` property is required and must be a non-empty array.\n\n' + 'Expected format:\n' + ' requestPurchase({\n' + @@ -1031,7 +1034,8 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( ' type: "in-app"\n' + ' })\n\n' + 'See: https://openiap.dev/docs/apis/request-purchase', - ); + code: ErrorCode.EmptySkuList, + }); } const { @@ -1075,7 +1079,8 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( ) as RequestSubscriptionAndroidProps | null | undefined; if (!normalizedRequest?.skus?.length) { - throw new Error( + throw createPurchaseError({ + message: 'Invalid request for Google. The `skus` property is required and must be a non-empty array.\n\n' + 'Expected format:\n' + ' requestPurchase({\n' + @@ -1086,7 +1091,8 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( ' type: "subs"\n' + ' })\n\n' + 'See: https://openiap.dev/docs/apis/request-purchase', - ); + code: ErrorCode.EmptySkuList, + }); } const { diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 7e9696c79..29d5ca10f 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -885,11 +885,11 @@ export interface Mutation { */ syncIOS: Promise; /** - * Verify a purchase against your own backend. Returns a platform-specific - * variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - * Inspect the concrete variant before reading fields. + * Verify a purchase against your own backend. Every VerifyPurchaseResult + * variant exposes isValid, so entitlement can be gated without inspecting the + * concrete type. Variants add their own metadata on top: IOS carries + * receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + * carries grantTime. * See: https://openiap.dev/docs/features/validation#verify-purchase */ verifyPurchase: Promise; @@ -2187,7 +2187,7 @@ export interface VerifyPurchaseProps { export type VerifyPurchaseResult = VerifyPurchaseResultAndroid | VerifyPurchaseResultHorizon | VerifyPurchaseResultIOS; -export interface VerifyPurchaseResultAndroid { +export interface VerifyPurchaseResultAndroid extends VerifyPurchaseResultCommon { autoRenewing: boolean; betaProduct: boolean; cancelDate?: (number | null); @@ -2196,6 +2196,11 @@ export interface VerifyPurchaseResultAndroid { deferredSku?: (string | null); freeTrialEndDate: number; gracePeriodEndDate: number; + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + isValid: boolean; parentProductId: string; productId: string; productType: string; @@ -2208,18 +2213,32 @@ export interface VerifyPurchaseResultAndroid { testTransaction: boolean; } +/** Validity shared by every store-specific purchase verification result. */ +export interface VerifyPurchaseResultCommon { + /** Whether the purchase is valid, without inspecting the concrete result variant. */ + isValid: boolean; +} + /** * Result from Meta Horizon verify_entitlement API. * Returns verification status and grant time for the entitlement. */ -export interface VerifyPurchaseResultHorizon { +export interface VerifyPurchaseResultHorizon extends VerifyPurchaseResultCommon { /** Unix timestamp (seconds) when the entitlement was granted. */ grantTime?: (number | null); - /** Whether the entitlement verification succeeded. */ + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + isValid: boolean; + /** + * Whether the entitlement verification succeeded. + * @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. + */ success: boolean; } -export interface VerifyPurchaseResultIOS { +export interface VerifyPurchaseResultIOS extends VerifyPurchaseResultCommon { /** Whether the receipt is valid */ isValid: boolean; /** JWS representation */ diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index b839a4840..94fcbe735 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -1134,6 +1134,12 @@ abstract class PurchaseCommon { double get transactionDate; } +/// Validity shared by every store-specific purchase verification result. +abstract class VerifyPurchaseResultCommon { + /// Whether the purchase is valid, without inspecting the concrete result variant. + bool get isValid; +} + // MARK: - Objects class ActiveSubscription { @@ -3769,7 +3775,7 @@ class ValidTimeWindowAndroid { } } -class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { +class VerifyPurchaseResultAndroid extends VerifyPurchaseResult implements VerifyPurchaseResultCommon { const VerifyPurchaseResultAndroid({ required this.autoRenewing, required this.betaProduct, @@ -3779,6 +3785,7 @@ class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { this.deferredSku, required this.freeTrialEndDate, required this.gracePeriodEndDate, + required this.isValid, required this.parentProductId, required this.productId, required this.productType, @@ -3799,6 +3806,9 @@ class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { final String? deferredSku; final double freeTrialEndDate; final double gracePeriodEndDate; + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + final bool isValid; final String parentProductId; final String productId; final String productType; @@ -3820,6 +3830,7 @@ class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { deferredSku: json['deferredSku'] as String?, freeTrialEndDate: (json['freeTrialEndDate'] as num).toDouble(), gracePeriodEndDate: (json['gracePeriodEndDate'] as num).toDouble(), + isValid: json['isValid'] as bool, parentProductId: json['parentProductId'] as String, productId: json['productId'] as String, productType: json['productType'] as String, @@ -3845,6 +3856,7 @@ class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { 'deferredSku': deferredSku, 'freeTrialEndDate': freeTrialEndDate, 'gracePeriodEndDate': gracePeriodEndDate, + 'isValid': isValid, 'parentProductId': parentProductId, 'productId': productId, 'productType': productType, @@ -3861,20 +3873,26 @@ class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { /// Result from Meta Horizon verify_entitlement API. /// Returns verification status and grant time for the entitlement. -class VerifyPurchaseResultHorizon extends VerifyPurchaseResult { +class VerifyPurchaseResultHorizon extends VerifyPurchaseResult implements VerifyPurchaseResultCommon { const VerifyPurchaseResultHorizon({ this.grantTime, + required this.isValid, required this.success, }); /// Unix timestamp (seconds) when the entitlement was granted. final double? grantTime; + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + final bool isValid; /// Whether the entitlement verification succeeded. + /// @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. final bool success; factory VerifyPurchaseResultHorizon.fromJson(Map json) { return VerifyPurchaseResultHorizon( grantTime: (json['grantTime'] as num?)?.toDouble(), + isValid: json['isValid'] as bool, success: json['success'] as bool, ); } @@ -3884,12 +3902,13 @@ class VerifyPurchaseResultHorizon extends VerifyPurchaseResult { return { '__typename': 'VerifyPurchaseResultHorizon', 'grantTime': grantTime, + 'isValid': isValid, 'success': success, }; } } -class VerifyPurchaseResultIOS extends VerifyPurchaseResult { +class VerifyPurchaseResultIOS extends VerifyPurchaseResult implements VerifyPurchaseResultCommon { const VerifyPurchaseResultIOS({ required this.isValid, required this.jwsRepresentation, @@ -5296,7 +5315,7 @@ sealed class Purchase implements PurchaseCommon { Map toJson(); } -sealed class VerifyPurchaseResult { +sealed class VerifyPurchaseResult implements VerifyPurchaseResultCommon { const VerifyPurchaseResult(); factory VerifyPurchaseResult.fromJson(Map json) { @@ -5312,6 +5331,10 @@ sealed class VerifyPurchaseResult { throw ArgumentError('Unknown __typename for VerifyPurchaseResult: $typeName'); } + /// Whether the purchase is valid, without inspecting the concrete result variant. + @override + bool get isValid; + Map toJson(); } @@ -5461,11 +5484,11 @@ abstract class MutationResolver { /// Force sync transactions with the App Store (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/sync-ios Future syncIOS(); - /// Verify a purchase against your own backend. Returns a platform-specific - /// variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - /// Inspect the concrete variant before reading fields. + /// Verify a purchase against your own backend. Every VerifyPurchaseResult + /// variant exposes isValid, so entitlement can be gated without inspecting the + /// concrete type. Variants add their own metadata on top: IOS carries + /// receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + /// carries grantTime. /// See: https://openiap.dev/docs/features/validation#verify-purchase Future verifyPurchase({ VerifyPurchaseAppleOptions? apple, 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 c16a594f7..0de455f0d 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 @@ -2418,6 +2418,7 @@ void main() { case 'verifyPurchase': return { '__typename': 'VerifyPurchaseResultAndroid', + 'isValid': true, 'productId': 'premium.upgrade', 'productType': 'inapp', 'purchaseDate': 1705315800000.0, @@ -2473,6 +2474,7 @@ void main() { expect(androidResult.productId, 'premium.upgrade'); expect(androidResult.productType, 'inapp'); expect(androidResult.autoRenewing, false); + expect(androidResult.isValid, isTrue); }); test('sends and parses Horizon verification payloads', () async { @@ -2487,6 +2489,7 @@ void main() { return jsonEncode({ '__typename': 'VerifyPurchaseResultHorizon', 'grantTime': 1705315800, + 'isValid': true, 'success': true, }); } @@ -2520,6 +2523,7 @@ void main() { expect(result, isA()); final horizonResult = result as types.VerifyPurchaseResultHorizon; + expect(horizonResult.isValid, isTrue); expect(horizonResult.success, isTrue); expect(horizonResult.grantTime, 1705315800); }); 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 824759836..098245f88 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.release.aar b/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar index de2fa6adc..3e312fb08 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 8b3b4f15f..e545123cb 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 8688d667e..92289dd10 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/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index bb0b4a65d..0b60a5973 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -3243,6 +3243,8 @@ class ValidTimeWindowAndroid: return dict class VerifyPurchaseResultAndroid: + ## Whether the purchase is valid. Uniform across every VerifyPurchaseResult variant so callers can gate entitlement without inspecting the concrete type. + var is_valid: bool = false var auto_renewing: bool = false var beta_product: bool = false var cancel_date: Variant = null @@ -3264,6 +3266,8 @@ class VerifyPurchaseResultAndroid: static func from_dict(data: Dictionary) -> VerifyPurchaseResultAndroid: var obj = VerifyPurchaseResultAndroid.new() + if data.has("isValid") and data["isValid"] != null: + obj.is_valid = data["isValid"] if data.has("autoRenewing") and data["autoRenewing"] != null: obj.auto_renewing = data["autoRenewing"] if data.has("betaProduct") and data["betaProduct"] != null: @@ -3304,6 +3308,7 @@ class VerifyPurchaseResultAndroid: func to_dict() -> Dictionary: var dict = {} + dict["isValid"] = is_valid dict["autoRenewing"] = auto_renewing dict["betaProduct"] = beta_product if cancel_date != null: @@ -3330,13 +3335,17 @@ class VerifyPurchaseResultAndroid: ## Result from Meta Horizon verify_entitlement API. Returns verification status and grant time for the entitlement. class VerifyPurchaseResultHorizon: - ## Whether the entitlement verification succeeded. + ## Whether the purchase is valid. Uniform across every VerifyPurchaseResult variant so callers can gate entitlement without inspecting the concrete type. + var is_valid: bool = false + ## Whether the entitlement verification succeeded. @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. var success: bool = false ## Unix timestamp (seconds) when the entitlement was granted. var grant_time: Variant = null static func from_dict(data: Dictionary) -> VerifyPurchaseResultHorizon: var obj = VerifyPurchaseResultHorizon.new() + if data.has("isValid") and data["isValid"] != null: + obj.is_valid = data["isValid"] if data.has("success") and data["success"] != null: obj.success = data["success"] if data.has("grantTime") and data["grantTime"] != null: @@ -3345,6 +3354,7 @@ class VerifyPurchaseResultHorizon: func to_dict() -> Dictionary: var dict = {} + dict["isValid"] = is_valid dict["success"] = success if grant_time != null: dict["grantTime"] = grant_time @@ -5721,7 +5731,7 @@ class Mutation: const return_type = "VoidResult" const is_array = false - ## Verify a purchase against your own backend. Returns a platform-specific variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. Inspect the concrete variant before reading fields. See: https://openiap.dev/docs/features/validation#verify-purchase + ## Verify a purchase against your own backend. Every VerifyPurchaseResult variant exposes isValid, so entitlement can be gated without inspecting the concrete type. Variants add their own metadata on top: IOS carries receipt/JWS fields, Android carries Play Store receipt fields, and Horizon carries grantTime. See: https://openiap.dev/docs/features/validation#verify-purchase class verifyPurchaseField: const name = "verifyPurchase" const snake_name = "verify_purchase" @@ -6231,7 +6241,7 @@ static func deep_link_to_subscriptions_args(options: Variant = null) -> Dictiona args["options"] = options return args -## Verify a purchase against your own backend. Returns a platform-specific variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. Inspect the concrete variant before reading fields. See: https://openiap.dev/docs/features/validation#verify-purchase +## Verify a purchase against your own backend. Every VerifyPurchaseResult variant exposes isValid, so entitlement can be gated without inspecting the concrete type. Variants add their own metadata on top: IOS carries receipt/JWS fields, Android carries Play Store receipt fields, and Horizon carries grantTime. See: https://openiap.dev/docs/features/validation#verify-purchase static func verify_purchase_args(options: VerifyPurchaseProps) -> Dictionary: var args = {} if options != null: diff --git a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt index b8e82bfdb..26ad8c575 100644 --- a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt +++ b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/PurchaseFlowScreen.kt @@ -139,7 +139,7 @@ fun PurchaseFlowScreen(navController: NavController) { "Product: ${result.productId}\n" + "Receipt ID: ${credentialStatus(result.receiptId)}" is VerifyPurchaseResultHorizon -> "📱 Horizon Verification:\n" + - "Success: ${result.success}\n" + + "Valid: ${result.isValid}\n" + "Grant Time: ${result.grantTime ?: "N/A"}" } } diff --git a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt index a356bebb1..f0a5b6c26 100644 --- a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt +++ b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/SubscriptionFlowScreen.kt @@ -145,7 +145,7 @@ fun SubscriptionFlowScreen(navController: NavController) { "Product: ${result.productId}\n" + "Receipt ID: ${credentialStatus(result.receiptId)}" is VerifyPurchaseResultHorizon -> "📱 Horizon Verification:\n" + - "Success: ${result.success}\n" + + "Valid: ${result.isValid}\n" + "Grant Time: ${result.grantTime ?: "N/A"}" } } 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 3b0c5a561..1b869d7c3 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 @@ -1291,6 +1291,16 @@ public interface PurchaseCommon { val transactionDate: Double } +/** + * Validity shared by every store-specific purchase verification result. + */ +public interface VerifyPurchaseResultCommon { + /** + * Whether the purchase is valid, without inspecting the concrete result variant. + */ + val isValid: Boolean +} + // MARK: - Objects public data class ActiveSubscription( @@ -3909,6 +3919,11 @@ public data class VerifyPurchaseResultAndroid( val deferredSku: String? = null, val freeTrialEndDate: Double, val gracePeriodEndDate: Double, + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + override val isValid: Boolean, val parentProductId: String, val productId: String, val productType: String, @@ -3919,7 +3934,7 @@ public data class VerifyPurchaseResultAndroid( val term: String, val termSku: String, val testTransaction: Boolean -) : VerifyPurchaseResult { +) : VerifyPurchaseResultCommon, VerifyPurchaseResult { companion object { fun fromJson(json: Map): VerifyPurchaseResultAndroid { @@ -3932,6 +3947,7 @@ public data class VerifyPurchaseResultAndroid( deferredSku = json["deferredSku"] as? String, freeTrialEndDate = (json["freeTrialEndDate"] as? Number)?.toDouble() ?: 0.0, gracePeriodEndDate = (json["gracePeriodEndDate"] as? Number)?.toDouble() ?: 0.0, + isValid = json["isValid"] as? Boolean ?: false, parentProductId = json["parentProductId"] as? String ?: "", productId = json["productId"] as? String ?: "", productType = json["productType"] as? String ?: "", @@ -3956,6 +3972,7 @@ public data class VerifyPurchaseResultAndroid( "deferredSku" to deferredSku, "freeTrialEndDate" to freeTrialEndDate, "gracePeriodEndDate" to gracePeriodEndDate, + "isValid" to isValid, "parentProductId" to parentProductId, "productId" to productId, "productType" to productType, @@ -3978,16 +3995,24 @@ public data class VerifyPurchaseResultHorizon( * Unix timestamp (seconds) when the entitlement was granted. */ val grantTime: Double? = null, + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + override val isValid: Boolean, /** * Whether the entitlement verification succeeded. + * @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. */ + @Deprecated("Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0.") val success: Boolean -) : VerifyPurchaseResult { +) : VerifyPurchaseResultCommon, VerifyPurchaseResult { companion object { fun fromJson(json: Map): VerifyPurchaseResultHorizon { return VerifyPurchaseResultHorizon( grantTime = (json["grantTime"] as? Number)?.toDouble(), + isValid = json["isValid"] as? Boolean ?: false, success = json["success"] as? Boolean ?: false, ) } @@ -3996,6 +4021,7 @@ public data class VerifyPurchaseResultHorizon( override fun toJson(): Map = mapOf( "__typename" to "VerifyPurchaseResultHorizon", "grantTime" to grantTime, + "isValid" to isValid, "success" to success, ) } @@ -4004,7 +4030,7 @@ public data class VerifyPurchaseResultIOS( /** * Whether the receipt is valid */ - val isValid: Boolean, + override val isValid: Boolean, /** * JWS representation */ @@ -4017,7 +4043,7 @@ public data class VerifyPurchaseResultIOS( * Receipt data string */ val receiptData: String -) : VerifyPurchaseResult { +) : VerifyPurchaseResultCommon, VerifyPurchaseResult { companion object { fun fromJson(json: Map): VerifyPurchaseResultIOS { @@ -5467,7 +5493,7 @@ public sealed interface Purchase : PurchaseCommon { } } -public sealed interface VerifyPurchaseResult { +public sealed interface VerifyPurchaseResult : VerifyPurchaseResultCommon { fun toJson(): Map companion object { @@ -5652,11 +5678,11 @@ public interface MutationResolver { */ suspend fun syncIOS(): Boolean /** - * Verify a purchase against your own backend. Returns a platform-specific - * variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - * Inspect the concrete variant before reading fields. + * Verify a purchase against your own backend. Every VerifyPurchaseResult + * variant exposes isValid, so entitlement can be gated without inspecting the + * concrete type. Variants add their own metadata on top: IOS carries + * receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + * carries grantTime. * See: https://openiap.dev/docs/features/validation#verify-purchase */ suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult @@ -6041,11 +6067,11 @@ public data class MutationHandlers( */ val syncIOS: MutationSyncIOSHandler? = null, /** - * Verify a purchase against your own backend. Returns a platform-specific - * variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - * Inspect the concrete variant before reading fields. + * Verify a purchase against your own backend. Every VerifyPurchaseResult + * variant exposes isValid, so entitlement can be gated without inspecting the + * concrete type. Variants add their own metadata on top: IOS carries + * receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + * carries grantTime. * See: https://openiap.dev/docs/features/validation#verify-purchase */ val verifyPurchase: MutationVerifyPurchaseHandler? = null, 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 cc081ff73..0a0df84c6 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 @@ -246,6 +246,7 @@ class VerificationTest { betaProduct = false, freeTrialEndDate = 0.0, gracePeriodEndDate = 0.0, + isValid = true, parentProductId = "parent", productId = "test_product", productType = "inapp", @@ -272,6 +273,7 @@ class VerificationTest { cancelReason = "user_cancelled", freeTrialEndDate = 0.0, gracePeriodEndDate = 0.0, + isValid = true, parentProductId = "parent", productId = "test_sub", productType = "subs", @@ -294,6 +296,7 @@ class VerificationTest { betaProduct = false, freeTrialEndDate = 0.0, gracePeriodEndDate = 0.0, + isValid = true, parentProductId = "parent", productId = "sub_monthly", productType = "subs", @@ -710,6 +713,7 @@ class VerificationTest { betaProduct = false, freeTrialEndDate = 0.0, gracePeriodEndDate = 0.0, + isValid = true, parentProductId = "parent", productId = "product", productType = "subs", diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index 5c1f7b963..89dc753c5 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -2213,6 +2213,13 @@ public interface PurchaseCommon double TransactionDate { get; } } +/// Validity shared by every store-specific purchase verification result. +public interface VerifyPurchaseResultCommon +{ + /// Whether the purchase is valid, without inspecting the concrete result variant. + bool IsValid { get; } +} + // ============================================================================ // Unions // ============================================================================ @@ -2220,7 +2227,19 @@ public interface PurchaseCommon [JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")] [JsonDerivedType(typeof(ProductAndroid), "ProductAndroid")] [JsonDerivedType(typeof(ProductIOS), "ProductIOS")] -public abstract record Product : ProductOrSubscription; +public abstract record Product : ProductOrSubscription, ProductCommon +{ + public abstract string Currency { get; init; } + public abstract string? DebugDescription { get; init; } + public abstract string Description { get; init; } + public abstract string? DisplayName { get; init; } + public abstract string DisplayPrice { get; init; } + public abstract string Id { get; init; } + public abstract IapPlatform Platform { get; init; } + public abstract double? Price { get; init; } + public abstract string Title { get; init; } + public abstract ProductType Type { get; init; } +} [JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")] [JsonDerivedType(typeof(ProductAndroid), "ProductAndroid")] @@ -2232,18 +2251,55 @@ public abstract record ProductOrSubscription; [JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")] [JsonDerivedType(typeof(ProductSubscriptionAndroid), "ProductSubscriptionAndroid")] [JsonDerivedType(typeof(ProductSubscriptionIOS), "ProductSubscriptionIOS")] -public abstract record ProductSubscription : ProductOrSubscription; +public abstract record ProductSubscription : ProductOrSubscription, ProductCommon +{ + public abstract string Currency { get; init; } + public abstract string? DebugDescription { get; init; } + public abstract string Description { get; init; } + public abstract string? DisplayName { get; init; } + public abstract string DisplayPrice { get; init; } + public abstract string Id { get; init; } + public abstract IapPlatform Platform { get; init; } + public abstract double? Price { get; init; } + public abstract string Title { get; init; } + public abstract ProductType Type { get; init; } +} [JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")] [JsonDerivedType(typeof(PurchaseAndroid), "PurchaseAndroid")] [JsonDerivedType(typeof(PurchaseIOS), "PurchaseIOS")] -public abstract record Purchase; +public abstract record Purchase : PurchaseCommon +{ + /// + /// The current plan identifier. This is: + /// - On Android: the basePlanId (e.g., "premium", "premium-year") + /// - On iOS: the productId (e.g., "com.example.premium_monthly", "com.example.premium_yearly") + /// This provides a unified way to identify which specific plan/tier the user is subscribed to. + /// + public abstract string? CurrentPlanId { get; init; } + public abstract string Id { get; init; } + public abstract IReadOnlyList? Ids { get; init; } + public abstract bool IsAutoRenewing { get; init; } + public abstract string ProductId { get; init; } + public abstract PurchaseState PurchaseState { get; init; } + /// Unified purchase token (iOS JWS, Android purchaseToken) + public abstract string? PurchaseToken { get; init; } + public abstract int Quantity { get; init; } + /// Store where purchase was made + public abstract IapStore Store { get; init; } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. + public abstract double TransactionDate { get; init; } +} [JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")] [JsonDerivedType(typeof(VerifyPurchaseResultAndroid), "VerifyPurchaseResultAndroid")] [JsonDerivedType(typeof(VerifyPurchaseResultHorizon), "VerifyPurchaseResultHorizon")] [JsonDerivedType(typeof(VerifyPurchaseResultIOS), "VerifyPurchaseResultIOS")] -public abstract record VerifyPurchaseResult; +public abstract record VerifyPurchaseResult : VerifyPurchaseResultCommon +{ + /// Whether the purchase is valid, without inspecting the concrete result variant. + public abstract bool IsValid { get; init; } +} // ============================================================================ // Objects @@ -2901,14 +2957,14 @@ public sealed record PricingPhasesAndroid public required IReadOnlyList PricingPhaseList { get; init; } } -public sealed record ProductAndroid : Product, ProductCommon +public sealed record ProductAndroid : Product { [JsonPropertyName("currency")] - public required string Currency { get; init; } + public override required string Currency { get; init; } [JsonPropertyName("debugDescription")] - public string? DebugDescription { get; init; } + public override string? DebugDescription { get; init; } [JsonPropertyName("description")] - public required string Description { get; init; } + public override required string Description { get; init; } /// /// Standardized Android one-time product purchase options and offers. /// Native metadata uses Android-suffixed fields. @@ -2917,17 +2973,17 @@ public sealed record ProductAndroid : Product, ProductCommon [JsonPropertyName("discountOffers")] public IReadOnlyList? DiscountOffers { get; init; } [JsonPropertyName("displayName")] - public string? DisplayName { get; init; } + public override string? DisplayName { get; init; } [JsonPropertyName("displayPrice")] - public required string DisplayPrice { get; init; } + public override required string DisplayPrice { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("nameAndroid")] public required string NameAndroid { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; + public override IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; [JsonPropertyName("price")] - public double? Price { get; init; } + public override double? Price { get; init; } /// /// Product-level status code indicating fetch result (Android 8.0+) /// OK = product fetched successfully @@ -2945,35 +3001,35 @@ public sealed record ProductAndroid : Product, ProductCommon [JsonPropertyName("subscriptionOffers")] public IReadOnlyList? SubscriptionOffers { get; init; } [JsonPropertyName("title")] - public required string Title { get; init; } + public override required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; + public override ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; } -public sealed record ProductIOS : Product, ProductCommon +public sealed record ProductIOS : Product { [JsonPropertyName("currency")] - public required string Currency { get; init; } + public override required string Currency { get; init; } [JsonPropertyName("debugDescription")] - public string? DebugDescription { get; init; } + public override string? DebugDescription { get; init; } [JsonPropertyName("description")] - public required string Description { get; init; } + public override required string Description { get; init; } [JsonPropertyName("displayName")] - public string? DisplayName { get; init; } + public override string? DisplayName { get; init; } [JsonPropertyName("displayNameIOS")] public required string DisplayNameIOS { get; init; } [JsonPropertyName("displayPrice")] - public required string DisplayPrice { get; init; } + public override required string DisplayPrice { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("isFamilyShareableIOS")] public required bool IsFamilyShareableIOS { get; init; } [JsonPropertyName("jsonRepresentationIOS")] public required string JsonRepresentationIOS { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; + public override IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; [JsonPropertyName("price")] - public double? Price { get; init; } + public override double? Price { get; init; } /// /// iOS 26.4+ subscription pricing terms, including billing plan metadata for /// monthly subscriptions with a 12-month commitment. @@ -2989,33 +3045,33 @@ public sealed record ProductIOS : Product, ProductCommon [JsonPropertyName("subscriptionOffers")] public IReadOnlyList? SubscriptionOffers { get; init; } [JsonPropertyName("title")] - public required string Title { get; init; } + public override required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; + public override ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; [JsonPropertyName("typeIOS")] public required ProductTypeIOS TypeIOS { get; init; } } -public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCommon +public sealed record ProductSubscriptionAndroid : ProductSubscription { [JsonPropertyName("currency")] - public required string Currency { get; init; } + public override required string Currency { get; init; } [JsonPropertyName("debugDescription")] - public string? DebugDescription { get; init; } + public override string? DebugDescription { get; init; } [JsonPropertyName("description")] - public required string Description { get; init; } + public override required string Description { get; init; } [JsonPropertyName("displayName")] - public string? DisplayName { get; init; } + public override string? DisplayName { get; init; } [JsonPropertyName("displayPrice")] - public required string DisplayPrice { get; init; } + public override required string DisplayPrice { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("nameAndroid")] public required string NameAndroid { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; + public override IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; [JsonPropertyName("price")] - public double? Price { get; init; } + public override double? Price { get; init; } /// /// Product-level status code indicating fetch result (Android 8.0+) /// OK = product fetched successfully @@ -3033,12 +3089,12 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo [JsonPropertyName("subscriptionOffers")] public required IReadOnlyList SubscriptionOffers { get; init; } [JsonPropertyName("title")] - public required string Title { get; init; } + public override required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; + public override ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; } -public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon +public sealed record ProductSubscriptionIOS : ProductSubscription { /// /// Subscriptions included in this Apple subscription bundle. Empty or null for @@ -3047,19 +3103,19 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon [JsonPropertyName("bundledSubscriptionsIOS")] public IReadOnlyList? BundledSubscriptionsIOS { get; init; } [JsonPropertyName("currency")] - public required string Currency { get; init; } + public override required string Currency { get; init; } [JsonPropertyName("debugDescription")] - public string? DebugDescription { get; init; } + public override string? DebugDescription { get; init; } [JsonPropertyName("description")] - public required string Description { get; init; } + public override required string Description { get; init; } [JsonPropertyName("displayName")] - public string? DisplayName { get; init; } + public override string? DisplayName { get; init; } [JsonPropertyName("displayNameIOS")] public required string DisplayNameIOS { get; init; } [JsonPropertyName("displayPrice")] - public required string DisplayPrice { get; init; } + public override required string DisplayPrice { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("introductoryPriceAsAmountIOS")] public string? IntroductoryPriceAsAmountIOS { get; init; } [JsonPropertyName("introductoryPriceIOS")] @@ -3075,9 +3131,9 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon [JsonPropertyName("jsonRepresentationIOS")] public required string JsonRepresentationIOS { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; + public override IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; [JsonPropertyName("price")] - public double? Price { get; init; } + public override double? Price { get; init; } /// /// iOS 26.4+ subscription pricing terms, including billing plan metadata for /// monthly subscriptions with a 12-month commitment. @@ -3099,31 +3155,31 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon [JsonPropertyName("subscriptionPeriodUnitIOS")] public SubscriptionPeriodIOS? SubscriptionPeriodUnitIOS { get; init; } [JsonPropertyName("title")] - public required string Title { get; init; } + public override required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; + public override ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; [JsonPropertyName("typeIOS")] public required ProductTypeIOS TypeIOS { get; init; } } -public sealed record PurchaseAndroid : Purchase, PurchaseCommon +public sealed record PurchaseAndroid : Purchase { [JsonPropertyName("autoRenewingAndroid")] public bool? AutoRenewingAndroid { get; init; } [JsonPropertyName("currentPlanId")] - public string? CurrentPlanId { get; init; } + public override string? CurrentPlanId { get; init; } [JsonPropertyName("dataAndroid")] public string? DataAndroid { get; init; } [JsonPropertyName("developerPayloadAndroid")] public string? DeveloperPayloadAndroid { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("ids")] - public IReadOnlyList? Ids { get; init; } + public override IReadOnlyList? Ids { get; init; } [JsonPropertyName("isAcknowledgedAndroid")] public bool? IsAcknowledgedAndroid { get; init; } [JsonPropertyName("isAutoRenewing")] - public required bool IsAutoRenewing { get; init; } + public override required bool IsAutoRenewing { get; init; } /// /// Whether the subscription is suspended (Android) /// A suspended subscription means the user's payment method failed and they need to fix it. @@ -3148,21 +3204,21 @@ public sealed record PurchaseAndroid : Purchase, PurchaseCommon [JsonPropertyName("pendingPurchaseUpdateAndroid")] public PendingPurchaseUpdateAndroid? PendingPurchaseUpdateAndroid { get; init; } [JsonPropertyName("productId")] - public required string ProductId { get; init; } + public override required string ProductId { get; init; } [JsonPropertyName("purchaseState")] - public required PurchaseState PurchaseState { get; init; } + public override required PurchaseState PurchaseState { get; init; } [JsonPropertyName("purchaseToken")] - public string? PurchaseToken { get; init; } + public override string? PurchaseToken { get; init; } [JsonPropertyName("quantity")] - public required int Quantity { get; init; } + public override required int Quantity { get; init; } [JsonPropertyName("signatureAndroid")] public string? SignatureAndroid { get; init; } /// Store where purchase was made [JsonPropertyName("store")] - public required IapStore Store { get; init; } + public override required IapStore Store { get; init; } /// Unix timestamp in milliseconds since January 1, 1970 UTC. [JsonPropertyName("transactionDate")] - public required double TransactionDate { get; init; } + public override required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] public string? TransactionId { get; init; } /// @@ -3202,7 +3258,7 @@ public sealed record PurchaseError public SubResponseCodeAndroid? SubResponseCodeAndroid { get; init; } } -public sealed record PurchaseIOS : Purchase, PurchaseCommon +public sealed record PurchaseIOS : Purchase { /// /// Advanced Commerce API metadata (iOS 18.4+). @@ -3243,17 +3299,17 @@ public sealed record PurchaseIOS : Purchase, PurchaseCommon [JsonPropertyName("currencySymbolIOS")] public string? CurrencySymbolIOS { get; init; } [JsonPropertyName("currentPlanId")] - public string? CurrentPlanId { get; init; } + public override string? CurrentPlanId { get; init; } [JsonPropertyName("environmentIOS")] public string? EnvironmentIOS { get; init; } [JsonPropertyName("expirationDateIOS")] public double? ExpirationDateIOS { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("ids")] - public IReadOnlyList? Ids { get; init; } + public override IReadOnlyList? Ids { get; init; } [JsonPropertyName("isAutoRenewing")] - public required bool IsAutoRenewing { get; init; } + public override required bool IsAutoRenewing { get; init; } [JsonPropertyName("isUpgradedIOS")] public bool? IsUpgradedIOS { get; init; } [JsonPropertyName("offerIOS")] @@ -3272,13 +3328,13 @@ public sealed record PurchaseIOS : Purchase, PurchaseCommon [JsonPropertyName("previousOriginalTransactionIdIOS")] public string? PreviousOriginalTransactionIdIOS { get; init; } [JsonPropertyName("productId")] - public required string ProductId { get; init; } + public override required string ProductId { get; init; } [JsonPropertyName("purchaseState")] - public required PurchaseState PurchaseState { get; init; } + public override required PurchaseState PurchaseState { get; init; } [JsonPropertyName("purchaseToken")] - public string? PurchaseToken { get; init; } + public override string? PurchaseToken { get; init; } [JsonPropertyName("quantity")] - public required int Quantity { get; init; } + public override required int Quantity { get; init; } [JsonPropertyName("quantityIOS")] public int? QuantityIOS { get; init; } [JsonPropertyName("reasonIOS")] @@ -3300,14 +3356,14 @@ public sealed record PurchaseIOS : Purchase, PurchaseCommon public string? RevocationTypeIOS { get; init; } /// Store where purchase was made [JsonPropertyName("store")] - public required IapStore Store { get; init; } + public override required IapStore Store { get; init; } [JsonPropertyName("storefrontCountryCodeIOS")] public string? StorefrontCountryCodeIOS { get; init; } [JsonPropertyName("subscriptionGroupIdIOS")] public string? SubscriptionGroupIdIOS { get; init; } /// Unix timestamp in milliseconds since January 1, 1970 UTC. [JsonPropertyName("transactionDate")] - public required double TransactionDate { get; init; } + public override required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] public required string TransactionId { get; init; } [JsonPropertyName("transactionReasonIOS")] @@ -3714,6 +3770,12 @@ public sealed record VerifyPurchaseResultAndroid : VerifyPurchaseResult public required double FreeTrialEndDate { get; init; } [JsonPropertyName("gracePeriodEndDate")] public required double GracePeriodEndDate { get; init; } + /// + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + /// + [JsonPropertyName("isValid")] + public override required bool IsValid { get; init; } [JsonPropertyName("parentProductId")] public required string ParentProductId { get; init; } [JsonPropertyName("productId")] @@ -3745,16 +3807,26 @@ public sealed record VerifyPurchaseResultHorizon : VerifyPurchaseResult /// Unix timestamp (seconds) when the entitlement was granted. [JsonPropertyName("grantTime")] public double? GrantTime { get; init; } - /// Whether the entitlement verification succeeded. + /// + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + /// + [JsonPropertyName("isValid")] + public override required bool IsValid { get; init; } + /// + /// Whether the entitlement verification succeeded. + /// @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. + /// + [Obsolete("Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0.")] [JsonPropertyName("success")] - public required bool Success { get; init; } + public bool Success { get; init; } } public sealed record VerifyPurchaseResultIOS : VerifyPurchaseResult { /// Whether the receipt is valid [JsonPropertyName("isValid")] - public required bool IsValid { get; init; } + public override required bool IsValid { get; init; } /// JWS representation [JsonPropertyName("jwsRepresentation")] public required string JwsRepresentation { get; init; } @@ -4643,11 +4715,11 @@ public interface MutationResolver Task SyncIOSAsync(); /// - /// Verify a purchase against your own backend. Returns a platform-specific - /// variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - /// Inspect the concrete variant before reading fields. + /// Verify a purchase against your own backend. Every VerifyPurchaseResult + /// variant exposes isValid, so entitlement can be gated without inspecting the + /// concrete type. Variants add their own metadata on top: IOS carries + /// receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + /// carries grantTime. /// See: https://openiap.dev/docs/features/validation#verify-purchase /// Task VerifyPurchaseAsync(VerifyPurchaseProps options); 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 c26f7a677..71a16052d 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 @@ -31,8 +31,10 @@ import dev.hyo.openiap.SubResponseCodeAndroid as OpenIapSubResponseCodeAndroid import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid as OpenIapSubscriptionProductReplacementParams import dev.hyo.openiap.SubscriptionReplacementModeAndroid as OpenIapSubscriptionReplacementMode import dev.hyo.openiap.VerifyPurchaseGoogleOptions +import dev.hyo.openiap.VerifyPurchaseHorizonOptions import dev.hyo.openiap.VerifyPurchaseProps import dev.hyo.openiap.VerifyPurchaseResultAndroid +import dev.hyo.openiap.VerifyPurchaseResultHorizon import dev.hyo.openiap.InitConnectionConfig as OpenIapInitConnectionConfig import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener @@ -1405,10 +1407,45 @@ class HybridRnIap : HybridRnIapSpec() { } // Receipt validation - calls OpenIAP's verifyPurchase - override fun verifyPurchase(params: NitroPurchaseVerificationParams): Promise { + override fun verifyPurchase(params: NitroPurchaseVerificationParams): Promise { return Promise.async { try { - // For Android, we need the google options to be provided (new platform-specific structure) + val nitroHorizonOptions = + (params.horizon as? Variant_NullType_NitroPurchaseVerificationHorizonOptions.Second)?.value + if (nitroHorizonOptions != null) { + val validations = mapOf( + "horizon.sku" to nitroHorizonOptions.sku, + "horizon.userId" to nitroHorizonOptions.userId, + "horizon.accessToken" to nitroHorizonOptions.accessToken + ) + for ((name, value) in validations) { + if (value.isEmpty()) { + throw OpenIapException(toErrorJson(OpenIapError.DeveloperError(), debugMessage = "Missing or empty required parameter: $name")) + } + } + + RnIapLog.payload("verifyPurchase", mapOf( + "store" to "horizon", + "sku" to nitroHorizonOptions.sku + )) + val props = VerifyPurchaseProps( + horizon = VerifyPurchaseHorizonOptions( + sku = nitroHorizonOptions.sku, + userId = nitroHorizonOptions.userId, + accessToken = nitroHorizonOptions.accessToken + ) + ) + val horizonResult = openIap.verifyPurchase(props) as? VerifyPurchaseResultHorizon + ?: throw OpenIapException(toErrorJson(OpenIapError.InvalidPurchaseVerification, debugMessage = "Unexpected Horizon result type from verifyPurchase")) + @Suppress("DEPRECATION") + val result = NitroPurchaseVerificationResultHorizon( + isValid = horizonResult.isValid, + grantTime = horizonResult.grantTime.wrapVariant(), + success = horizonResult.success + ) + return@async Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid_NitroPurchaseVerificationResultHorizon.Third(result) + } + val nitroGoogleOptions = (params.google as? Variant_NullType_NitroPurchaseVerificationGoogleOptions.Second)?.value ?: throw OpenIapException(toErrorJson(OpenIapError.DeveloperError(), debugMessage = "Missing required parameter: google options")) @@ -1459,6 +1496,7 @@ class HybridRnIap : HybridRnIapSpec() { // Convert OpenIAP result to Nitro result val result = NitroPurchaseVerificationResultAndroid( + isValid = androidResult.isValid, autoRenewing = androidResult.autoRenewing, betaProduct = androidResult.betaProduct, cancelDate = androidResult.cancelDate.wrapVariant(), @@ -1479,7 +1517,7 @@ class HybridRnIap : HybridRnIapSpec() { testTransaction = androidResult.testTransaction ) - Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid.Second(result) + Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid_NitroPurchaseVerificationResultHorizon.Second(result) } catch (e: OpenIapException) { RnIapLog.failure("verifyPurchase", e) 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 e4d811651..3dae4e37b 100644 --- a/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts +++ b/libraries/react-native-iap/example/__tests__/utils/vegaRuntime.test.ts @@ -284,8 +284,8 @@ describe('Vega runtime example helpers', () => { receiptData: '', }), ).toContain('invalid receipt'); - expect(getDirectVerificationError({success: false})).toContain( - 'rejected the entitlement', + expect(getDirectVerificationError({isValid: false, success: false})).toContain( + 'invalid receipt', ); }); }); diff --git a/libraries/react-native-iap/example/src/utils/vegaRuntime.ts b/libraries/react-native-iap/example/src/utils/vegaRuntime.ts index 035f1f4ac..cb7dfb372 100644 --- a/libraries/react-native-iap/example/src/utils/vegaRuntime.ts +++ b/libraries/react-native-iap/example/src/utils/vegaRuntime.ts @@ -124,12 +124,9 @@ export function getIapkitVerificationError( export function getDirectVerificationError( result: VerifyPurchaseResult, ): string | null { - if ('isValid' in result && result.isValid === false) { + if (result.isValid === false) { return 'Store verification returned an invalid receipt'; } - if ('success' in result && result.success === false) { - return 'Store verification rejected the entitlement'; - } return null; } diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index ff373afb4..5c46a099b 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -399,7 +399,7 @@ class HybridRnIap: HybridRnIapSpec { } } - func verifyPurchase(params: NitroPurchaseVerificationParams) throws -> Promise { + func verifyPurchase(params: NitroPurchaseVerificationParams) throws -> Promise { return Promise.async { do { // Extract SKU from apple options (new platform-specific structure) diff --git a/libraries/react-native-iap/src/__tests__/conformance.test.ts b/libraries/react-native-iap/src/__tests__/conformance.test.ts new file mode 100644 index 000000000..d6a644fbf --- /dev/null +++ b/libraries/react-native-iap/src/__tests__/conformance.test.ts @@ -0,0 +1,408 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * react-native-iap's binding into the OpenIAP conformance suite. + * + * The Nitro module is replaced with a deterministic fake store, so the real + * SDK wrappers in src/index.ts run against controlled store responses. This is + * what makes purchase, completion, and restoration behaviors testable at all — + * a real purchase cannot happen in CI. + * + * Behavior ids match packages/conformance/src/spec/behaviors.mjs. + */ + +// Marks this file as a module. Without it the top-level declarations below +// land in the global scope and collide with the other suites' fixtures. +export {}; + +type FakeRecord = { + token: string; + sku: string; + type: 'in-app' | 'subs'; + state: 'purchased' | 'pending'; +}; + +const CATALOG: Record = { + 'dev.hyo.martie.premium': 'subs', + 'dev.hyo.martie.pro': 'subs', + 'dev.hyo.martie.10bulbs': 'in-app', + 'dev.hyo.martie.lifetime': 'in-app', +}; + +const store = { + owned: new Map(), + unfinished: new Set(), + forced: new Map(), + sequence: 0, + reset() { + this.owned.clear(); + this.unfinished.clear(); + this.forced.clear(); + this.sequence = 0; + }, +}; + +const purchaseUpdatedListeners: ((purchase: unknown) => void)[] = []; +const purchaseErrorListeners: ((error: unknown) => void)[] = []; + +function toPurchase(record: FakeRecord) { + return { + id: record.token, + productId: record.sku, + ids: [record.sku], + purchaseToken: record.token, + purchaseState: record.state === 'purchased' ? 'purchased' : 'pending', + isAutoRenewing: record.type === 'subs' && record.state === 'purchased', + quantity: 1, + store: 'google', + platform: 'android', + transactionDate: 1_700_000_000_000, + transactionId: record.token, + currentPlanId: record.sku, + packageNameAndroid: 'dev.hyo.martie', + dataAndroid: '{}', + isAcknowledgedAndroid: true, + }; +} + +const mockIap: Record = { + initConnection: jest.fn(async () => true), + endConnection: jest.fn(async () => true), + + fetchProducts: jest.fn(async (skus: string[], nitroType?: string) => { + const type = !nitroType || nitroType === 'all' ? undefined : nitroType; + return skus + .filter((sku) => CATALOG[sku]) + .filter((sku) => (type ? CATALOG[sku] === type : true)) + .map((sku) => ({ + id: sku, + title: `Product ${sku}`, + description: `Description ${sku}`, + displayName: sku, + currency: 'USD', + displayPrice: '$0.99', + price: 0.99, + type: CATALOG[sku] === 'subs' ? 'subs' : 'in-app', + platform: 'android', + })); + }), + + requestPurchase: jest.fn(async (props: any) => { + const sku: string = + props?.request?.google?.skus?.[0] ?? + props?.request?.apple?.sku ?? + props?.google?.skus?.[0] ?? + props?.skus?.[0]; + + if (!CATALOG[sku]) { + const error = {code: 'sku-not-found', message: 'unknown sku', productId: sku}; + purchaseErrorListeners.forEach((listener) => listener(error)); + throw error; + } + + const forced = store.forced.get(sku); + store.forced.delete(sku); + + if (forced === 'user-cancelled') { + const error = {code: 'user-cancelled', message: 'cancelled', productId: sku}; + purchaseErrorListeners.forEach((listener) => listener(error)); + throw error; + } + + const owned = [...store.owned.values()].some( + (record) => record.sku === sku && record.state === 'purchased', + ); + if (owned && CATALOG[sku] === 'in-app') { + const error = {code: 'already-owned', message: 'already owned', productId: sku}; + purchaseErrorListeners.forEach((listener) => listener(error)); + throw error; + } + + store.sequence += 1; + const record: FakeRecord = { + token: `token-${store.sequence}`, + sku, + type: CATALOG[sku], + state: forced === 'pending' ? 'pending' : 'purchased', + }; + store.owned.set(record.token, record); + store.unfinished.add(record.token); + const purchase = toPurchase(record); + purchaseUpdatedListeners.forEach((listener) => listener(purchase)); + return purchase; + }), + + getAvailablePurchases: jest.fn(async (options?: any) => { + const type = options?.android?.type; + return [...store.owned.values()] + .filter((record) => (type ? record.type === type : true)) + .map((record) => toPurchase(record)); + }), + + finishTransaction: jest.fn(async (params: any) => { + const android = params?.android; + if (android?.purchaseToken) store.unfinished.delete(android.purchaseToken); + if (android?.isConsumable) store.owned.delete(android.purchaseToken); + return true; + }), + + addPurchaseUpdatedListener: jest.fn((listener: (purchase: unknown) => void) => { + purchaseUpdatedListeners.push(listener); + }), + removePurchaseUpdatedListener: jest.fn(), + addPurchaseErrorListener: jest.fn((listener: (error: unknown) => void) => { + purchaseErrorListeners.push(listener); + }), + removePurchaseErrorListener: jest.fn(), + addPromotedProductListenerIOS: jest.fn(), + removePromotedProductListenerIOS: jest.fn(), + addSubscriptionBillingIssueListener: jest.fn(), + removeSubscriptionBillingIssueListener: jest.fn(), + addUserChoiceBillingListenerAndroid: jest.fn(), + removeUserChoiceBillingListenerAndroid: jest.fn(), + + getStorefront: jest.fn(async () => 'USA'), + getActiveSubscriptions: jest.fn(async (subscriptionIds?: string[]) => + [...store.owned.values()] + .filter((record) => record.type === 'subs') + .filter((record) => !subscriptionIds?.length || subscriptionIds.includes(record.sku)) + .map((record) => ({ + productId: record.sku, + currentPlanId: record.sku, + purchaseToken: record.token, + purchaseTokenAndroid: record.token, + isActive: record.state === 'purchased', + transactionDate: 1_700_000_000_000, + transactionId: record.token, + })), + ), +}; + +jest.mock('react-native-nitro-modules', () => ({ + NitroModules: {createHybridObject: jest.fn(() => mockIap)}, +})); + +jest.mock('react-native', () => ({ + Platform: {OS: 'android', select: (obj: any) => obj.android}, + NativeEventEmitter: jest.fn(() => ({ + addListener: jest.fn(), + removeListener: jest.fn(), + removeAllListeners: jest.fn(), + })), +})); + +const IAP = require('../index'); + +/** Behavior ids from packages/conformance this suite verifies. */ +const COVERED_BEHAVIORS = [ + 'products.fetch-returns-requested-skus', + 'products.fetch-normalizes-required-fields', + 'products.fetch-separates-in-app-and-subscription-types', + 'products.fetch-empty-sku-list-is-an-error', + 'purchases.request-emits-purchase-updated-on-success', + 'purchases.request-emits-error-on-user-cancel', + 'purchases.already-owned-surfaces-already-owned-error', + 'purchases.unknown-sku-surfaces-sku-not-found', + 'purchases.pending-purchase-is-not-delivered-as-purchased', + 'completion.finish-removes-transaction-from-pending', + 'completion.finish-is-idempotent', + 'completion.unfinished-purchase-remains-available', + 'restoration.available-purchases-returns-owned-items', + 'restoration.available-purchases-excludes-consumed-items', + 'restoration.available-purchases-is-empty-for-new-user', + 'subscriptions.active-subscription-is-reported-active', + 'subscriptions.groups-keep-independent-identifiers', + 'subscriptions.has-active-agrees-with-get-active', + 'identifiers.purchase-carries-a-concrete-store', + 'identifiers.purchase-token-is-stable-across-reads', +]; + +const androidRequest = (sku: string) => ({ + request: {google: {skus: [sku]}}, + type: 'in-app' as const, +}); + +describe('conformance: react-native-iap', () => { + beforeEach(async () => { + store.reset(); + purchaseUpdatedListeners.length = 0; + purchaseErrorListeners.length = 0; + await IAP.initConnection(); + }); + + it('declares distinct, namespaced behavior ids', () => { + expect(new Set(COVERED_BEHAVIORS).size).toBe(COVERED_BEHAVIORS.length); + COVERED_BEHAVIORS.forEach((id) => expect(id).toContain('.')); + }); + + // --- products ----------------------------------------------------------- + + it('products.fetch-returns-requested-skus', async () => { + const products = await IAP.fetchProducts({ + skus: ['dev.hyo.martie.10bulbs', 'not-a-real-sku'], + type: 'in-app', + }); + expect(products.map((product: any) => product.id)).toEqual(['dev.hyo.martie.10bulbs']); + }); + + it('products.fetch-normalizes-required-fields', async () => { + const products = await IAP.fetchProducts({ + skus: ['dev.hyo.martie.10bulbs'], + type: 'in-app', + }); + const [product] = products; + expect(product.id).toBeTruthy(); + expect(product.title).toBeTruthy(); + expect(product.currency).toBeTruthy(); + expect(product.displayPrice).toBeTruthy(); + }); + + it('products.fetch-separates-in-app-and-subscription-types', async () => { + const subs = await IAP.fetchProducts({ + skus: ['dev.hyo.martie.premium', 'dev.hyo.martie.10bulbs'], + type: 'subs', + }); + expect(subs.map((product: any) => product.id)).toEqual(['dev.hyo.martie.premium']); + }); + + it('products.fetch-empty-sku-list-is-an-error', async () => { + await expect(IAP.fetchProducts({skus: [], type: 'in-app'})).rejects.toMatchObject({ + code: 'empty-sku-list', + }); + }); + + // --- purchases ---------------------------------------------------------- + + it('purchases.request-emits-purchase-updated-on-success', async () => { + const received: any[] = []; + IAP.purchaseUpdatedListener((purchase: any) => received.push(purchase)); + + await IAP.requestPurchase(androidRequest('dev.hyo.martie.10bulbs')); + + expect(received).toHaveLength(1); + expect(received[0].productId).toBe('dev.hyo.martie.10bulbs'); + }); + + it('purchases.request-emits-error-on-user-cancel', async () => { + const purchases: any[] = []; + const errors: any[] = []; + IAP.purchaseUpdatedListener((purchase: any) => purchases.push(purchase)); + IAP.purchaseErrorListener((error: any) => errors.push(error)); + store.forced.set('dev.hyo.martie.10bulbs', 'user-cancelled'); + + await expect(IAP.requestPurchase(androidRequest('dev.hyo.martie.10bulbs'))).rejects.toBeDefined(); + + expect(errors[0]?.code).toBe('user-cancelled'); + expect(purchases).toHaveLength(0); + }); + + it('purchases.already-owned-surfaces-already-owned-error', async () => { + await IAP.requestPurchase(androidRequest('dev.hyo.martie.lifetime')); + await expect( + IAP.requestPurchase(androidRequest('dev.hyo.martie.lifetime')), + ).rejects.toMatchObject({code: 'already-owned'}); + }); + + it('purchases.pending-purchase-is-not-delivered-as-purchased', async () => { + store.forced.set('dev.hyo.martie.10bulbs', 'pending'); + const purchase = await IAP.requestPurchase(androidRequest('dev.hyo.martie.10bulbs')); + expect(purchase.purchaseState).not.toBe('purchased'); + expect(purchase.purchaseState).toBe('pending'); + }); + + it('purchases.unknown-sku-surfaces-sku-not-found', async () => { + await expect(IAP.requestPurchase(androidRequest('not-a-real-sku'))).rejects.toMatchObject({ + code: 'sku-not-found', + }); + }); + + // --- completion --------------------------------------------------------- + + it('completion.finish-removes-transaction-from-pending', async () => { + const purchase = await IAP.requestPurchase(androidRequest('dev.hyo.martie.10bulbs')); + expect(store.unfinished.has(purchase.purchaseToken)).toBe(true); + + await IAP.finishTransaction({purchase, isConsumable: true}); + expect(store.unfinished.has(purchase.purchaseToken)).toBe(false); + }); + + it('completion.finish-is-idempotent', async () => { + const purchase = await IAP.requestPurchase(androidRequest('dev.hyo.martie.10bulbs')); + await IAP.finishTransaction({purchase, isConsumable: true}); + await expect(IAP.finishTransaction({purchase, isConsumable: true})).resolves.not.toThrow(); + }); + + it('completion.unfinished-purchase-remains-available', async () => { + const purchase = await IAP.requestPurchase(androidRequest('dev.hyo.martie.lifetime')); + const available = await IAP.getAvailablePurchases(); + expect(available.some((item: any) => item.purchaseToken === purchase.purchaseToken)).toBe(true); + }); + + // --- restoration -------------------------------------------------------- + + it('restoration.available-purchases-returns-owned-items', async () => { + await IAP.requestPurchase(androidRequest('dev.hyo.martie.lifetime')); + await IAP.requestPurchase(androidRequest('dev.hyo.martie.premium')); + + const available = await IAP.getAvailablePurchases(); + expect(available.map((item: any) => item.productId).sort()).toEqual([ + 'dev.hyo.martie.lifetime', + 'dev.hyo.martie.premium', + ]); + }); + + it('restoration.available-purchases-excludes-consumed-items', async () => { + const purchase = await IAP.requestPurchase(androidRequest('dev.hyo.martie.10bulbs')); + await IAP.finishTransaction({purchase, isConsumable: true}); + + const available = await IAP.getAvailablePurchases(); + expect(available.some((item: any) => item.purchaseToken === purchase.purchaseToken)).toBe(false); + }); + + it('restoration.available-purchases-is-empty-for-new-user', async () => { + await expect(IAP.getAvailablePurchases()).resolves.toEqual([]); + }); + + // --- subscriptions ------------------------------------------------------ + + it('subscriptions.active-subscription-is-reported-active', async () => { + await IAP.requestPurchase(androidRequest('dev.hyo.martie.premium')); + const [subscription] = await IAP.getActiveSubscriptions(); + expect(subscription.isActive).toBe(true); + }); + + it('subscriptions.groups-keep-independent-identifiers', async () => { + await IAP.requestPurchase(androidRequest('dev.hyo.martie.premium')); + await IAP.requestPurchase(androidRequest('dev.hyo.martie.pro')); + + const subscriptions = await IAP.getActiveSubscriptions(); + const premium = subscriptions.find((item: any) => item.productId === 'dev.hyo.martie.premium'); + const pro = subscriptions.find((item: any) => item.productId === 'dev.hyo.martie.pro'); + + expect(premium.currentPlanId).toBe('dev.hyo.martie.premium'); + expect(pro.currentPlanId).toBe('dev.hyo.martie.pro'); + expect(premium.purchaseToken).not.toBe(pro.purchaseToken); + }); + + it('subscriptions.has-active-agrees-with-get-active', async () => { + expect(await IAP.hasActiveSubscriptions()).toBe(false); + await IAP.requestPurchase(androidRequest('dev.hyo.martie.premium')); + expect(await IAP.hasActiveSubscriptions()).toBe(true); + }); + + // --- identifiers -------------------------------------------------------- + + it('identifiers.purchase-carries-a-concrete-store', async () => { + const purchase = await IAP.requestPurchase(androidRequest('dev.hyo.martie.10bulbs')); + expect(purchase.store).toBeTruthy(); + expect(purchase.store).not.toBe('unknown'); + }); + + it('identifiers.purchase-token-is-stable-across-reads', async () => { + const purchase = await IAP.requestPurchase(androidRequest('dev.hyo.martie.lifetime')); + const first = (await IAP.getAvailablePurchases())[0].purchaseToken; + const second = (await IAP.getAvailablePurchases())[0].purchaseToken; + + expect(first).toBe(purchase.purchaseToken); + expect(second).toBe(purchase.purchaseToken); + }); +}); diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index b7b0f0fd9..c62ff8c65 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -1785,6 +1785,7 @@ describe('Public API (src/index.ts)', () => { it('Android path maps NitroPurchaseVerificationResultAndroid', async () => { (Platform as any).OS = 'android'; mockIap.verifyPurchase.mockResolvedValueOnce({ + isValid: false, autoRenewing: false, betaProduct: false, cancelDate: null, @@ -1813,8 +1814,83 @@ describe('Public API (src/index.ts)', () => { }, }); expect(res).toEqual( - expect.objectContaining({productId: 'sku', productType: 'inapp'}), + expect.objectContaining({ + isValid: false, + productId: 'sku', + productType: 'inapp', + }), + ); + }); + + it('Horizon path forwards options and maps its result variant', async () => { + (Platform as any).OS = 'android'; + mockIap.verifyPurchase.mockResolvedValueOnce({ + isValid: true, + grantTime: 1744148687, + success: true, + }); + + const res = await IAP.verifyPurchase({ + horizon: { + sku: 'premium', + userId: 'user-1', + accessToken: 'secret', + }, + }); + + expect(mockIap.verifyPurchase).toHaveBeenCalledWith({ + apple: null, + google: null, + horizon: { + sku: 'premium', + userId: 'user-1', + accessToken: 'secret', + }, + }); + expect(res).toEqual({ + isValid: true, + grantTime: 1744148687, + success: true, + }); + }); + + it('uses the normalized Google variant when Horizon options are empty', async () => { + (Platform as any).OS = 'android'; + mockIap.verifyPurchase.mockResolvedValueOnce({ + isValid: false, + productId: 'sku', + productType: 'inapp', + }); + + const res = await IAP.verifyPurchase({ + google: { + sku: 'sku', + packageName: 'com.app', + purchaseToken: 'tok', + accessToken: 'acc', + }, + horizon: {}, + } as any); + + expect(mockIap.verifyPurchase).toHaveBeenCalledWith({ + apple: null, + google: { + sku: 'sku', + packageName: 'com.app', + purchaseToken: 'tok', + accessToken: 'acc', + isSub: undefined, + }, + horizon: null, + }); + expect(res).toEqual( + expect.objectContaining({ + isValid: false, + productId: 'sku', + productType: 'inapp', + }), ); + expect(res).not.toHaveProperty('success'); }); }); diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index ee76ef818..64e69443f 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -9,6 +9,7 @@ import type { NitroPurchaseVerificationParams, NitroPurchaseVerificationResultIOS, NitroPurchaseVerificationResultAndroid, + NitroPurchaseVerificationResultHorizon, NitroPurchaseUpdatedListenerOptions, NitroSubscriptionStatus, RnIap, @@ -41,6 +42,7 @@ import type { PurchaseIOS, QueryField, VerifyPurchaseResultAndroid, + VerifyPurchaseResultHorizon, VerifyPurchaseResultIOS, RequestPurchaseAndroidProps, RequestPurchaseIosProps, @@ -884,7 +886,10 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { try { if (!skus?.length) { - throw new Error('No SKUs provided'); + throw createPurchaseError({ + message: 'No SKUs provided', + code: ErrorCode.EmptySkuList, + }); } const normalizedType = normalizeProductQueryType(type); @@ -1691,15 +1696,18 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( if (Platform.OS === 'ios') { if (!iosRequestSource?.sku) { - throw new Error( - 'Invalid request for iOS. The `sku` property is required.', - ); + throw createPurchaseError({ + message: 'Invalid request for iOS. The `sku` property is required.', + code: ErrorCode.EmptySkuList, + }); } } else if (isAndroidStoreRuntime()) { if (!androidRequestSource?.skus?.length) { - throw new Error( - 'Invalid request for Android. The `skus` property is required and must be a non-empty array.', - ); + throw createPurchaseError({ + message: + 'Invalid request for Android. The `skus` property is required and must be a non-empty array.', + code: ErrorCode.EmptySkuList, + }); } } else { throw unsupportedPlatformError(); @@ -2135,11 +2143,20 @@ export const verifyPurchase: MutationField<'verifyPurchase'> = async ( : undefined, }; return result; + } else if (params.horizon !== null) { + const horizonResult = + nitroResult as NitroPurchaseVerificationResultHorizon; + const result: VerifyPurchaseResultHorizon = { + isValid: horizonResult.isValid, + grantTime: horizonResult.grantTime, + success: horizonResult.success, + }; + return result; } else { - // Android const androidResult = nitroResult as NitroPurchaseVerificationResultAndroid; const result: VerifyPurchaseResultAndroid = { + isValid: androidResult.isValid, autoRenewing: androidResult.autoRenewing, betaProduct: androidResult.betaProduct, cancelDate: androidResult.cancelDate, diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index bdbe5792a..68c9638f1 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -55,6 +55,7 @@ import type { VerifyPurchaseGoogleOptions, VerifyPurchaseHorizonOptions, VerifyPurchaseResultAndroid, + VerifyPurchaseResultHorizon, RequestPurchaseIosProps, RequestPurchaseResult, RequestSubscriptionAndroidProps, @@ -408,6 +409,7 @@ export interface NitroPurchaseVerificationResultIOS { } export interface NitroPurchaseVerificationResultAndroid { + isValid: VerifyPurchaseResultAndroid['isValid']; autoRenewing: VerifyPurchaseResultAndroid['autoRenewing']; betaProduct: VerifyPurchaseResultAndroid['betaProduct']; cancelDate: VerifyPurchaseResultAndroid['cancelDate']; @@ -428,6 +430,12 @@ export interface NitroPurchaseVerificationResultAndroid { testTransaction: VerifyPurchaseResultAndroid['testTransaction']; } +export interface NitroPurchaseVerificationResultHorizon { + isValid: VerifyPurchaseResultHorizon['isValid']; + grantTime?: VerifyPurchaseResultHorizon['grantTime']; + success: VerifyPurchaseResultHorizon['success']; +} + // VerifyPurchaseWithProvider types export interface NitroVerifyPurchaseWithIapkitAppleProps { @@ -994,12 +1002,14 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { /** * Verify a purchase on the appropriate platform. * @param params - Purchase verification parameters including platform-specific options - * @returns Promise + * @returns Promise with the platform-specific verification result */ verifyPurchase( params: NitroPurchaseVerificationParams, ): Promise< - NitroPurchaseVerificationResultIOS | NitroPurchaseVerificationResultAndroid + | NitroPurchaseVerificationResultIOS + | NitroPurchaseVerificationResultAndroid + | NitroPurchaseVerificationResultHorizon >; /** diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 7e9696c79..29d5ca10f 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -885,11 +885,11 @@ export interface Mutation { */ syncIOS: Promise; /** - * Verify a purchase against your own backend. Returns a platform-specific - * variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - * Inspect the concrete variant before reading fields. + * Verify a purchase against your own backend. Every VerifyPurchaseResult + * variant exposes isValid, so entitlement can be gated without inspecting the + * concrete type. Variants add their own metadata on top: IOS carries + * receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + * carries grantTime. * See: https://openiap.dev/docs/features/validation#verify-purchase */ verifyPurchase: Promise; @@ -2187,7 +2187,7 @@ export interface VerifyPurchaseProps { export type VerifyPurchaseResult = VerifyPurchaseResultAndroid | VerifyPurchaseResultHorizon | VerifyPurchaseResultIOS; -export interface VerifyPurchaseResultAndroid { +export interface VerifyPurchaseResultAndroid extends VerifyPurchaseResultCommon { autoRenewing: boolean; betaProduct: boolean; cancelDate?: (number | null); @@ -2196,6 +2196,11 @@ export interface VerifyPurchaseResultAndroid { deferredSku?: (string | null); freeTrialEndDate: number; gracePeriodEndDate: number; + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + isValid: boolean; parentProductId: string; productId: string; productType: string; @@ -2208,18 +2213,32 @@ export interface VerifyPurchaseResultAndroid { testTransaction: boolean; } +/** Validity shared by every store-specific purchase verification result. */ +export interface VerifyPurchaseResultCommon { + /** Whether the purchase is valid, without inspecting the concrete result variant. */ + isValid: boolean; +} + /** * Result from Meta Horizon verify_entitlement API. * Returns verification status and grant time for the entitlement. */ -export interface VerifyPurchaseResultHorizon { +export interface VerifyPurchaseResultHorizon extends VerifyPurchaseResultCommon { /** Unix timestamp (seconds) when the entitlement was granted. */ grantTime?: (number | null); - /** Whether the entitlement verification succeeded. */ + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + isValid: boolean; + /** + * Whether the entitlement verification succeeded. + * @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. + */ success: boolean; } -export interface VerifyPurchaseResultIOS { +export interface VerifyPurchaseResultIOS extends VerifyPurchaseResultCommon { /** Whether the receipt is valid */ isValid: boolean; /** JWS representation */ diff --git a/packages/apple/Sources/Models/OpenIapError.swift b/packages/apple/Sources/Models/OpenIapError.swift index 4980c80f8..828685fae 100644 --- a/packages/apple/Sources/Models/OpenIapError.swift +++ b/packages/apple/Sources/Models/OpenIapError.swift @@ -168,23 +168,42 @@ public extension PurchaseError { // Map StoreKit 2 errors to PurchaseError if let storeKitError = error as? StoreKitError { - let errorCode: ErrorCode + let mappedCode: ErrorCode switch storeKitError { case .userCancelled: - errorCode = .userCancelled + mappedCode = .userCancelled case .networkError: - errorCode = .networkError + mappedCode = .networkError case .notAvailableInStorefront: - errorCode = .itemUnavailable + mappedCode = .itemUnavailable case .notEntitled: - errorCode = .itemNotOwned - case .systemError: - errorCode = .serviceError - default: - errorCode = fallback + mappedCode = .itemNotOwned + case .systemError(let underlyingError): + if let skErrorCode = skErrorCode(from: underlyingError) { + mappedCode = errorCode(for: skErrorCode) ?? .serviceError + } else { + mappedCode = .serviceError + } + case .unknown: + mappedCode = .unknown + case .unsupported: + mappedCode = .featureNotSupported + @unknown default: + mappedCode = fallback } return make( - code: errorCode, + code: mappedCode, + productId: productId, + message: error.localizedDescription, + debugMessage: error.localizedDescription + ) + } + + // StoreKit 1 conditions reach `wrap` through promoted purchases, + // offer-code redemption, and the legacy payment queue. + if let skErrorCode = skErrorCode(from: error) { + return make( + code: errorCode(for: skErrorCode) ?? fallback, productId: productId, message: error.localizedDescription, debugMessage: error.localizedDescription @@ -199,4 +218,47 @@ public extension PurchaseError { debugMessage: error.localizedDescription ) } + + /// Accepts a typed `SKError` or a bridged `NSError` in the StoreKit domain. + static func skErrorCode(from error: Error) -> SKError.Code? { + if let skError = error as? SKError { + return skError.code + } + let nsError = error as NSError + guard nsError.domain == SKError.errorDomain else { return nil } + return SKError.Code(rawValue: nsError.code) + } + + /// Normative StoreKit 1 error mapping. Returns `nil` when no OpenIAP code + /// faithfully represents the condition, so the caller's `fallback` applies + /// rather than a fabricated mapping. + /// + /// `.alreadyOwned`, `.billingUnavailable`, `.serviceDisconnected`, and + /// `.serviceTimeout` are deliberately unreachable here — StoreKit has no + /// equivalent condition. See `packages/gql/src/capability-matrix.mjs`. + static func errorCode(for code: SKError.Code) -> ErrorCode? { + switch code { + case .paymentCancelled: + return .userCancelled + case .paymentNotAllowed: + // Parental controls or MDM restriction, not a transient failure. + return .iapNotAvailable + case .storeProductNotAvailable: + return .itemUnavailable + case .clientInvalid, .paymentInvalid: + return .developerError + case .cloudServiceNetworkConnectionFailed: + return .networkError + case .cloudServicePermissionDenied, .cloudServiceRevoked, .privacyAcknowledgementRequired: + return .serviceError + case .invalidOfferIdentifier, .invalidOfferPrice, .missingOfferParams: + return .skuOfferMismatch + case .invalidSignature, .unauthorizedRequestData: + return .transactionValidationFailed + case .unknown: + return .unknown + default: + return nil + } + } } diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 89d702a11..fa039e64c 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -518,6 +518,12 @@ public protocol PurchaseCommon: Codable { var transactionDate: Double { get } } +/// Validity shared by every store-specific purchase verification result. +public protocol VerifyPurchaseResultCommon: Codable { + /// Whether the purchase is valid, without inspecting the concrete result variant. + var isValid: Bool { get } +} + // MARK: - Objects public struct ActiveSubscription: Codable { @@ -1374,7 +1380,7 @@ public struct ValidTimeWindowAndroid: Codable { public var startTimeMillis: String } -public struct VerifyPurchaseResultAndroid: Codable { +public struct VerifyPurchaseResultAndroid: Codable, VerifyPurchaseResultCommon { public var autoRenewing: Bool public var betaProduct: Bool public var cancelDate: Double? = nil @@ -1383,6 +1389,9 @@ public struct VerifyPurchaseResultAndroid: Codable { public var deferredSku: String? = nil public var freeTrialEndDate: Double public var gracePeriodEndDate: Double + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + public var isValid: Bool public var parentProductId: String public var productId: String public var productType: String @@ -1397,14 +1406,19 @@ public struct VerifyPurchaseResultAndroid: Codable { /// Result from Meta Horizon verify_entitlement API. /// Returns verification status and grant time for the entitlement. -public struct VerifyPurchaseResultHorizon: Codable { +public struct VerifyPurchaseResultHorizon: Codable, VerifyPurchaseResultCommon { /// Unix timestamp (seconds) when the entitlement was granted. public var grantTime: Double? = nil + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + public var isValid: Bool /// Whether the entitlement verification succeeded. + /// @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. + @available(*, deprecated, message: "Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0.") public var success: Bool } -public struct VerifyPurchaseResultIOS: Codable { +public struct VerifyPurchaseResultIOS: Codable, VerifyPurchaseResultCommon { /// Whether the receipt is valid public var isValid: Bool /// JWS representation @@ -2558,10 +2572,22 @@ public enum Purchase: Codable, PurchaseCommon { } } -public enum VerifyPurchaseResult: Codable { +public enum VerifyPurchaseResult: Codable, VerifyPurchaseResultCommon { case verifyPurchaseResultAndroid(VerifyPurchaseResultAndroid) case verifyPurchaseResultIos(VerifyPurchaseResultIOS) case verifyPurchaseResultHorizon(VerifyPurchaseResultHorizon) + + /// Whether the purchase is valid, without inspecting the concrete result variant. + public var isValid: Bool { + switch self { + case let .verifyPurchaseResultAndroid(value): + return value.isValid + case let .verifyPurchaseResultIos(value): + return value.isValid + case let .verifyPurchaseResultHorizon(value): + return value.isValid + } + } } // MARK: - Root Operations @@ -2687,11 +2713,11 @@ public protocol MutationResolver { /// Force sync transactions with the App Store (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/sync-ios func syncIOS() async throws -> Bool - /// Verify a purchase against your own backend. Returns a platform-specific - /// variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - /// Inspect the concrete variant before reading fields. + /// Verify a purchase against your own backend. Every VerifyPurchaseResult + /// variant exposes isValid, so entitlement can be gated without inspecting the + /// concrete type. Variants add their own metadata on top: IOS carries + /// receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + /// carries grantTime. /// See: https://openiap.dev/docs/features/validation#verify-purchase func verifyPurchase(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult /// Verify via a managed provider without standing up your own server. The diff --git a/packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift b/packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift new file mode 100644 index 000000000..a03bb463a --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift @@ -0,0 +1,43 @@ +// Generated by packages/conformance/scripts/generate-behavior-ids.mjs +// Do not edit. Behavior ids are the versioned public contract; see +// packages/conformance/src/spec/behaviors.mjs. + +enum ConformanceBehaviors { + static let suiteVersion = "1.0.0" + + static let productsFetchReturnsRequestedSkus = "products.fetch-returns-requested-skus" + static let productsFetchNormalizesRequiredFields = "products.fetch-normalizes-required-fields" + static let productsFetchEmptySkuListIsAnError = "products.fetch-empty-sku-list-is-an-error" + static let productsFetchSeparatesInAppAndSubscriptionTypes = "products.fetch-separates-in-app-and-subscription-types" + static let purchasesRequestEmitsPurchaseUpdatedOnSuccess = "purchases.request-emits-purchase-updated-on-success" + static let purchasesRequestEmitsErrorOnUserCancel = "purchases.request-emits-error-on-user-cancel" + static let purchasesAlreadyOwnedSurfacesAlreadyOwnedError = "purchases.already-owned-surfaces-already-owned-error" + static let purchasesPendingPurchaseIsNotDeliveredAsPurchased = "purchases.pending-purchase-is-not-delivered-as-purchased" + static let purchasesUnknownSkuSurfacesSkuNotFound = "purchases.unknown-sku-surfaces-sku-not-found" + static let completionFinishRemovesTransactionFromPending = "completion.finish-removes-transaction-from-pending" + static let completionFinishIsIdempotent = "completion.finish-is-idempotent" + static let completionUnfinishedPurchaseRemainsAvailable = "completion.unfinished-purchase-remains-available" + static let restorationAvailablePurchasesReturnsOwnedItems = "restoration.available-purchases-returns-owned-items" + static let restorationAvailablePurchasesExcludesConsumedItems = "restoration.available-purchases-excludes-consumed-items" + static let restorationAvailablePurchasesIsEmptyForNewUser = "restoration.available-purchases-is-empty-for-new-user" + static let subscriptionsActiveSubscriptionIsReportedActive = "subscriptions.active-subscription-is-reported-active" + static let subscriptionsPendingSubscriptionIsNotActive = "subscriptions.pending-subscription-is-not-active" + static let subscriptionsUnknownStateSubscriptionIsNotActive = "subscriptions.unknown-state-subscription-is-not-active" + static let subscriptionsGroupsKeepIndependentIdentifiers = "subscriptions.groups-keep-independent-identifiers" + static let subscriptionsHasActiveAgreesWithGetActive = "subscriptions.has-active-agrees-with-get-active" + static let lifecyclePurchaseStartsActiveEntitlement = "lifecycle.purchase-starts-active-entitlement" + static let lifecycleExpiryEndsEntitlement = "lifecycle.expiry-ends-entitlement" + static let lifecycleGracePeriodRetainsEntitlement = "lifecycle.grace-period-retains-entitlement" + static let lifecycleBillingRetrySuspendsEntitlement = "lifecycle.billing-retry-suspends-entitlement" + static let lifecycleRefundEndsEntitlement = "lifecycle.refund-ends-entitlement" + static let lifecycleRevokeEndsEntitlement = "lifecycle.revoke-ends-entitlement" + static let lifecycleCancelRetainsEntitlementUntilExpiry = "lifecycle.cancel-retains-entitlement-until-expiry" + static let errorsStoreCodesNormalizeToSpecErrorCodes = "errors.store-codes-normalize-to-spec-error-codes" + static let errorsUnrecognizedStoreCodeNormalizesToUnknown = "errors.unrecognized-store-code-normalizes-to-unknown" + static let errorsUnsupportedCodesAreNotSynthesized = "errors.unsupported-codes-are-not-synthesized" + static let verificationResultExposesUniformValidity = "verification.result-exposes-uniform-validity" + static let identifiersPurchaseCarriesAConcreteStore = "identifiers.purchase-carries-a-concrete-store" + static let identifiersPurchaseTokenIsStableAcrossReads = "identifiers.purchase-token-is-stable-across-reads" + static let capabilitiesUnsupportedOperationsDegradePredictably = "capabilities.unsupported-operations-degrade-predictably" + static let capabilitiesDeclaredCapabilitiesMatchTheMatrix = "capabilities.declared-capabilities-match-the-matrix" +} diff --git a/packages/apple/Tests/OpenIapTests/ErrorNormalizationTests.swift b/packages/apple/Tests/OpenIapTests/ErrorNormalizationTests.swift new file mode 100644 index 000000000..eee921023 --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/ErrorNormalizationTests.swift @@ -0,0 +1,113 @@ +import StoreKit +import XCTest +@testable import OpenIAP + +/// Normative StoreKit -> OpenIAP ErrorCode normalization. +final class ErrorNormalizationTests: XCTestCase { + + // MARK: - StoreKit 1 (SKError) + + func testPaymentNotAllowedNormalizesToIapNotAvailable() { + XCTAssertEqual(PurchaseError.errorCode(for: .paymentNotAllowed), .iapNotAvailable) + } + + func testStoreProductNotAvailableNormalizesToItemUnavailable() { + XCTAssertEqual(PurchaseError.errorCode(for: .storeProductNotAvailable), .itemUnavailable) + } + + func testInvalidClientAndPaymentNormalizeToDeveloperError() { + XCTAssertEqual(PurchaseError.errorCode(for: .clientInvalid), .developerError) + XCTAssertEqual(PurchaseError.errorCode(for: .paymentInvalid), .developerError) + } + + func testCloudServiceNetworkFailureNormalizesToNetworkError() { + XCTAssertEqual( + PurchaseError.errorCode(for: .cloudServiceNetworkConnectionFailed), + .networkError + ) + } + + func testOfferProblemsNormalizeToSkuOfferMismatch() { + XCTAssertEqual(PurchaseError.errorCode(for: .invalidOfferIdentifier), .skuOfferMismatch) + XCTAssertEqual(PurchaseError.errorCode(for: .invalidOfferPrice), .skuOfferMismatch) + XCTAssertEqual(PurchaseError.errorCode(for: .missingOfferParams), .skuOfferMismatch) + } + + func testSignatureProblemsNormalizeToTransactionValidationFailed() { + XCTAssertEqual(PurchaseError.errorCode(for: .invalidSignature), .transactionValidationFailed) + XCTAssertEqual( + PurchaseError.errorCode(for: .unauthorizedRequestData), + .transactionValidationFailed + ) + } + + func testPaymentCancelledNormalizesToUserCancelled() { + XCTAssertEqual(PurchaseError.errorCode(for: .paymentCancelled), .userCancelled) + } + + /// These four are Android-only by design; assert no SKError code drifts + /// into them. + func testAndroidOnlyCodesAreNeverSynthesizedFromStoreKit() { + let androidOnly: Set = [ + .alreadyOwned, + .billingUnavailable, + .serviceDisconnected, + .serviceTimeout, + ] + + let allSKErrorCodes = (-1...30).compactMap { SKError.Code(rawValue: $0) } + for code in allSKErrorCodes { + guard let mapped = PurchaseError.errorCode(for: code) else { continue } + XCTAssertFalse( + androidOnly.contains(mapped), + "SKError.Code(\(code.rawValue)) must not normalize to Android-only \(mapped)" + ) + } + } + + // MARK: - Bridged NSError extraction + + func testBridgedNSErrorInStoreKitDomainIsRecognized() { + let bridged = NSError( + domain: SKError.errorDomain, + code: SKError.Code.paymentNotAllowed.rawValue + ) + + XCTAssertEqual(PurchaseError.skErrorCode(from: bridged), .paymentNotAllowed) + } + + func testForeignDomainErrorIsNotTreatedAsStoreKit() { + let foreign = NSError(domain: "com.example.other", code: 2) + + XCTAssertNil(PurchaseError.skErrorCode(from: foreign)) + } + + // MARK: - End-to-end through wrap() + + func testWrapNormalizesPaymentNotAllowedRatherThanFallingBack() { + let error = NSError( + domain: SKError.errorDomain, + code: SKError.Code.paymentNotAllowed.rawValue + ) + + XCTAssertEqual(PurchaseError.wrap(error).code, .iapNotAvailable) + } + + func testWrapNormalizesPaymentNotAllowedInsideStoreKitSystemError() { + let error = StoreKitError.systemError(SKError(.paymentNotAllowed)) + + XCTAssertEqual(PurchaseError.wrap(error).code, .iapNotAvailable) + } + + func testWrapStillFallsBackForUnmappedConditions() { + let unmapped = NSError(domain: "com.example.other", code: 99) + + XCTAssertEqual(PurchaseError.wrap(unmapped, fallback: .purchaseError).code, .purchaseError) + } + + func testWrapPreservesAnExistingPurchaseError() { + let original = PurchaseError.make(code: .developerError, productId: "p1") + + XCTAssertEqual(PurchaseError.wrap(original).code, .developerError) + } +} diff --git a/packages/apple/Tests/OpenIapTests/StoreConformanceTests.swift b/packages/apple/Tests/OpenIapTests/StoreConformanceTests.swift new file mode 100644 index 000000000..20097d892 --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/StoreConformanceTests.swift @@ -0,0 +1,144 @@ +import StoreKit +import XCTest +@testable import OpenIAP + +/// Apple's binding into the OpenIAP conformance suite. +/// +/// Behavior ids come from the generated `ConformanceBehaviors`, so a renamed or +/// retired id fails to compile here rather than silently losing coverage. +/// +/// Purchase, completion, and restoration behaviors are absent by necessity: +/// driving them requires a live StoreKit session (StoreKitTest + a .storekit +/// configuration in an Xcode test target), which SwiftPM cannot provide. Those +/// ids are declared in `notCoveredBehaviors` with the reason, so the coverage +/// aggregator reports them as gaps instead of treating silence as success. +final class StoreConformanceTests: XCTestCase { + + /// Behaviors this suite verifies. + static let coveredBehaviors: [String] = [ + ConformanceBehaviors.errorsStoreCodesNormalizeToSpecErrorCodes, + ConformanceBehaviors.errorsUnrecognizedStoreCodeNormalizesToUnknown, + ConformanceBehaviors.errorsUnsupportedCodesAreNotSynthesized, + ConformanceBehaviors.identifiersPurchaseCarriesAConcreteStore, + ConformanceBehaviors.verificationResultExposesUniformValidity, + ] + + /// Behaviors this implementation cannot verify here, with the reason. + static let notCoveredBehaviors: [String: String] = [ + ConformanceBehaviors.productsFetchReturnsRequestedSkus: "requires a live StoreKit session", + ConformanceBehaviors.productsFetchNormalizesRequiredFields: "requires a live StoreKit session", + ConformanceBehaviors.productsFetchEmptySkuListIsAnError: "requires a live StoreKit session", + ConformanceBehaviors.productsFetchSeparatesInAppAndSubscriptionTypes: "requires a live StoreKit session", + ConformanceBehaviors.purchasesRequestEmitsPurchaseUpdatedOnSuccess: "requires a live StoreKit session", + ConformanceBehaviors.purchasesRequestEmitsErrorOnUserCancel: "requires a live StoreKit session", + ConformanceBehaviors.purchasesPendingPurchaseIsNotDeliveredAsPurchased: "requires a live StoreKit session", + ConformanceBehaviors.purchasesUnknownSkuSurfacesSkuNotFound: "requires a live StoreKit session", + ConformanceBehaviors.completionFinishRemovesTransactionFromPending: "requires a live StoreKit session", + ConformanceBehaviors.completionFinishIsIdempotent: "requires a live StoreKit session", + ConformanceBehaviors.completionUnfinishedPurchaseRemainsAvailable: "requires a live StoreKit session", + ConformanceBehaviors.restorationAvailablePurchasesReturnsOwnedItems: "requires a live StoreKit session", + ConformanceBehaviors.restorationAvailablePurchasesExcludesConsumedItems: "requires a live StoreKit session", + ConformanceBehaviors.restorationAvailablePurchasesIsEmptyForNewUser: "requires a live StoreKit session", + ConformanceBehaviors.capabilitiesUnsupportedOperationsDegradePredictably: "no unsupported Apple operation is exposed by this Swift package", + ] + + func testSuiteDeclaresDistinctBehaviorIds() { + let covered = Self.coveredBehaviors + XCTAssertEqual(Set(covered).count, covered.count) + for id in covered { + XCTAssertTrue(id.contains("."), "behavior id must be namespaced: \(id)") + } + for id in Self.notCoveredBehaviors.keys { + XCTAssertFalse(covered.contains(id), "\(id) cannot be both covered and not covered") + } + } + + // errors.store-codes-normalize-to-spec-error-codes + func testStoreCodesNormalizeToSpecErrorCodes() { + let expected: [(SKError.Code, ErrorCode)] = [ + (.paymentCancelled, .userCancelled), + (.paymentNotAllowed, .iapNotAvailable), + (.storeProductNotAvailable, .itemUnavailable), + (.clientInvalid, .developerError), + (.paymentInvalid, .developerError), + (.cloudServiceNetworkConnectionFailed, .networkError), + (.invalidOfferIdentifier, .skuOfferMismatch), + (.invalidSignature, .transactionValidationFailed), + ] + + for (code, expectedErrorCode) in expected { + XCTAssertEqual( + PurchaseError.errorCode(for: code), + expectedErrorCode, + "SKError.\(code) must normalize to \(expectedErrorCode)" + ) + } + } + + // errors.unrecognized-store-code-normalizes-to-unknown + func testUnrecognizedStoreConditionFallsBackRatherThanGuessing() { + // An error outside the StoreKit domain must not be mapped at all, so + // the caller's fallback applies instead of a fabricated code. + let foreign = NSError(domain: "com.example.other", code: 4242) + XCTAssertNil(PurchaseError.skErrorCode(from: foreign)) + XCTAssertEqual(PurchaseError.wrap(foreign, fallback: .unknown).code, .unknown) + } + + // errors.unsupported-codes-are-not-synthesized + func testAndroidOnlyErrorCodesAreNeverProduced() { + let androidOnly: Set = [ + .alreadyOwned, + .billingUnavailable, + .serviceDisconnected, + .serviceTimeout, + ] + + for raw in -1...30 { + guard let code = SKError.Code(rawValue: raw) else { continue } + guard let mapped = PurchaseError.errorCode(for: code) else { continue } + XCTAssertFalse( + androidOnly.contains(mapped), + "SKError.Code(\(raw)) must not normalize to Android-only \(mapped)" + ) + } + } + + // identifiers.purchase-carries-a-concrete-store + func testPurchaseCarriesAConcreteStore() throws { + let json = """ + { + "id": "txn-1", + "productId": "dev.hyo.martie.premium", + "ids": ["dev.hyo.martie.premium"], + "isAutoRenewing": true, + "purchaseState": "purchased", + "quantity": 1, + "store": "apple", + "transactionDate": 1700000000000, + "transactionId": "txn-1" + } + """ + let purchase = try JSONDecoder().decode(PurchaseIOS.self, from: Data(json.utf8)) + + XCTAssertNotEqual(purchase.store, .unknown, "a purchase must declare a concrete store") + XCTAssertEqual(purchase.store, .apple) + } + + // verification.result-exposes-uniform-validity + func testEveryVerifyPurchaseVariantExposesIsValid() { + // Each variant answers validity the same way, so a caller does not have + // to switch on the concrete type before gating entitlement. + let ios = VerifyPurchaseResultIOS( + isValid: true, + jwsRepresentation: "jws", + latestTransaction: nil, + receiptData: "receipt" + ) + let horizon = VerifyPurchaseResultHorizon(grantTime: nil, isValid: false, success: false) + + XCTAssertTrue(ios.isValid) + XCTAssertFalse(horizon.isValid) + XCTAssertEqual(horizon.isValid, horizon.success, "isValid must agree with the deprecated success field") + } + +} diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift index ad2dd2ec8..db7501064 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift @@ -33,6 +33,7 @@ final class VerifyPurchaseTests: XCTestCase { deferredSku: nil, freeTrialEndDate: 0, gracePeriodEndDate: 0, + isValid: true, parentProductId: "parent", productId: "android.sku", productType: "subs", diff --git a/packages/conformance/README.md b/packages/conformance/README.md new file mode 100644 index 000000000..f56902d7f --- /dev/null +++ b/packages/conformance/README.md @@ -0,0 +1,157 @@ +# OpenIAP Conformance Suite + +A versioned behavioral contract for OpenIAP implementations, plus a runner that +executes it against any implementation through an adapter and emits a +compatibility report. + +The GraphQL schema in `packages/gql` defines what the API *is*. The capability +matrix defines which stores must implement each behavior. This package defines +what each behavior must **do**. + +```text +packages/gql/src/*.graphql API shape and types +packages/gql/src/capability-matrix.mjs which stores must implement what +packages/conformance/src/spec/ what each behavior must do <- you are here +``` + +## Versioning + +A report states two versions, and neither is optional: + +| Field | Meaning | +| --- | --- | +| `suiteVersion` | Version of this behavior suite (`src/spec/version.mjs`) | +| `specVersion` | OpenIAP spec version validated, read from `openiap-versions.json` | + +"Conformant" without both attached is exactly the unverifiable claim this suite +exists to replace. + +Suite version bumps: **major** when a behavior is added or tightened (previously +passing runs may fail), **minor** when a capability-gated behavior is added, +**patch** for wording or tooling only. + +Behavior ids are permanent public identifiers. Renaming one is a breaking change +— retire it and add a new id instead. + +## Running the suite + +```js +import { runConformance, formatReport } from 'openiap-conformance'; + +const report = await runConformance(myAdapter); +console.log(formatReport(report)); +process.exit(report.conformant ? 0 : 1); +``` + +See the reference run: + +```bash +npx openiap-conformance-report # from an install +bun run --cwd packages/conformance report # from this repo +``` + +## Writing an adapter + +An adapter binds your implementation to behavior ids. Each entry is a function +that **throws on violation** — any assertion library, or none. + +```js +export const myAdapter = { + implementation: 'my-iap-sdk@2.1.0', // appears in the report + store: 'Google', // must be an IapStore the matrix knows + + behaviors: { + 'products.fetch-returns-requested-skus': async () => { + const products = await mySdk.fetchProducts({ skus: ['premium', 'nope'] }); + assert.deepEqual(products.map((p) => p.id), ['premium']); + }, + // ... one entry per applicable behavior + }, + + // Optional: for behaviors gated on a capability your store does NOT support, + // prove the documented absence instead of skipping. + absenceChecks: { + 'purchases.pending-purchase-is-not-delivered-as-purchased': async () => { + assert.equal(await mySdk.canProducePendingPurchases(), false); + }, + }, +}; +``` + +### Rules the runner enforces + +- **A missing MUST behavior is a failure, not a skip.** An adapter that + implements nothing is reported non-conformant, not compliant. +- **Capability gating comes from the matrix, not the adapter.** An + implementation cannot excuse itself from its own store's requirements. A + behavior gated on a capability your store must support is required; one gated + on a capability your store cannot support becomes an absence check. An + optional capability is checked when implemented and otherwise reported as + not applicable. +- **Unknown stores fail closed.** If `store` is not in the capability matrix, + the runner rejects the adapter instead of silently treating gated behaviors + as not applicable. + +### Outcomes + +| Outcome | Meaning | +| --- | --- | +| `pass` | Behavior verified | +| `fail` | MUST behavior violated or unimplemented — including `NOT_IMPLEMENTED`; blocks conformance | +| `warn` | SHOULD behavior violated or unimplemented | +| `not-applicable` | Store cannot support the gating capability, or an optional capability is not implemented | + +## Fake store + +Real purchases cannot happen in CI. `FakeStore` is a deterministic in-memory +store backend that lets purchase, completion, and restoration flows run +anyway. It models the *store*, not OpenIAP — it speaks store-shaped results and +knows nothing about normalized types, so the normalization the suite asserts is +still your implementation's job. + +```js +import { FakeStore, StoreOutcome } from 'openiap-conformance/fake-store'; + +const store = new FakeStore({ catalog: [{ sku: 'premium', type: 'subs' }] }); +store.forceOutcome('premium', StoreOutcome.UserCancelled); +``` + +`src/fake-store/reference-implementation.mjs` and +`src/adapters/reference-adapter.mjs` are a worked example of the whole contract. +**A passing reference run says nothing about any shipped SDK** — it proves the +suite is executable, and shows adapter authors the expected shape. + +## Cross-language use + +*Repository tooling — these scripts read monorepo paths and are not published.* + +Behavior ids are exported to Kotlin and Swift so native suites assert against +the same spec: + +```bash +node packages/conformance/scripts/generate-behavior-ids.mjs # write +node packages/conformance/scripts/generate-behavior-ids.mjs --check # CI drift gate +``` + +| Language | Generated file | +| --- | --- | +| Kotlin | `packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt` | +| Swift | `packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift` | + +## Current coverage + +Honest status — not every behavior is exercised against every implementation yet. + +Run `node scripts/coverage-report.mjs` for the current matrix. + +| Implementation | Bound to spec | Notes | +| --- | --- | --- | +| expo-iap | products, purchases, restoration, subscriptions, identifiers | Real SDK over a fake native module | +| react-native-iap | + completion | Real SDK over a fake Nitro module | +| Android stores (Play, Horizon, Amazon) | subscriptions, errors, identifiers | Real flavor code, one suite for all three | +| Apple client | errors, identifiers, verification, capabilities | Purchase flows need a live StoreKit session (StoreKitTest), unavailable to SwiftPM | +| IAPKit webhooks (Apple, Google) | `lifecycle.*` | Real normalizers + state machine | +| Reference (fake store) | all client-side behaviors | Proves the suite runs; **not a shipped SDK** | +| Flutter, KMP, MAUI, Godot | — | Adapters not yet written | + +Adding an implementation means writing an adapter, not another test suite. diff --git a/packages/conformance/package.json b/packages/conformance/package.json new file mode 100644 index 000000000..c1fa88670 --- /dev/null +++ b/packages/conformance/package.json @@ -0,0 +1,44 @@ +{ + "name": "openiap-conformance", + "version": "1.0.0", + "type": "module", + "description": "Versioned OpenIAP conformance suite: behavioral spec, runner, and adapter contract", + "exports": { + ".": "./src/index.mjs", + "./spec": "./src/spec/behaviors.mjs", + "./runner": "./src/runner/runner.mjs", + "./report": "./src/runner/report.mjs", + "./fake-store": "./src/fake-store/fake-store.mjs" + }, + "scripts": { + "test": "vitest run test", + "report": "node scripts/run-reference-report.mjs", + "generate:ids": "node scripts/generate-behavior-ids.mjs", + "coverage": "node scripts/coverage-report.mjs" + }, + "keywords": [ + "openiap", + "conformance", + "iap" + ], + "author": "hyodotdev", + "license": "MIT", + "devDependencies": { + "vitest": "^4.1.5" + }, + "packageManager": "bun@1.3.13", + "files": [ + "src", + "scripts/run-reference-report.mjs", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/hyodotdev/openiap.git", + "directory": "packages/conformance" + }, + "homepage": "https://openiap.dev", + "bin": { + "openiap-conformance-report": "./scripts/run-reference-report.mjs" + } +} diff --git a/packages/conformance/scripts/coverage-report.mjs b/packages/conformance/scripts/coverage-report.mjs new file mode 100644 index 000000000..4a1e41f90 --- /dev/null +++ b/packages/conformance/scripts/coverage-report.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * Ecosystem coverage report: which implementation covers which behavior. + * + * Native suites cannot call the JS runner, so each declares the behavior ids it + * covers in its own source. This reads those declarations and reports the + * matrix, including behaviors no implementation covers. + * + * Declarations are parsed from source rather than self-reported at runtime so a + * suite cannot claim coverage it does not have — the ids must resolve to real + * generated constants, which the drift gate already ties to the spec. + * + * --json emit the artifact instead of the table + * --check fail if any MUST behavior has no implementation covering it + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { BEHAVIORS } from '../src/spec/behaviors.mjs'; +import { SUITE_VERSION, specVersion } from '../src/spec/version.mjs'; + +const ROOT = new URL('../../../', import.meta.url); + +function read(relativePath) { + return readFileSync(fileURLToPath(new URL(relativePath, ROOT)), 'utf8'); +} + +/** Resolves generated constant references back to behavior id literals. */ +function resolveConstants(generatedSource, pattern) { + const table = new Map(); + for (const match of generatedSource.matchAll(pattern)) { + table.set(match[1], match[2]); + } + return table; +} + +function declaredIds(source, blockMarker, constantTable, referencePattern) { + const start = source.indexOf(blockMarker); + if (start < 0) { + throw new Error(`coverage parser: block marker not found: ${blockMarker}`); + } + // Read to the closing paren/bracket of the declaration block. + const rest = source.slice(start); + const terminator = /\n[ \t]*[)\]]/.exec(rest); + if (!terminator || terminator.index === 0) { + throw new Error(`coverage parser: no block terminator after ${blockMarker}`); + } + const block = rest.slice(0, terminator.index); + return [...block.matchAll(referencePattern)] + .map((match) => constantTable.get(match[1])) + .filter(Boolean); +} + +const IMPLEMENTATIONS = []; + +// --- Android stores (one suite, three flavors) ----------------------------- +{ + const generated = read( + 'packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt', + ); + const table = resolveConstants(generated, /const val (\w+) = "([^"]+)"/g); + const suite = read( + 'packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceSuite.kt', + ); + const ids = declaredIds(suite, 'private val coveredBehaviors', table, /ConformanceBehaviors\.(\w+)/g); + const unsupportedStoreIds = declaredIds( + suite, + 'private val unsupportedStoreBehaviors', + table, + /ConformanceBehaviors\.(\w+)/g, + ); + for (const store of ['Google', 'Horizon', 'Amazon']) { + IMPLEMENTATIONS.push({ + name: `android-${store.toLowerCase()}`, + store, + covered: store === 'Google' ? ids : [...ids, ...unsupportedStoreIds], + }); + } +} + +// --- Apple client ----------------------------------------------------------- +{ + const generated = read('packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift'); + const table = resolveConstants(generated, /static let (\w+) = "([^"]+)"/g); + const suite = read('packages/apple/Tests/OpenIapTests/StoreConformanceTests.swift'); + const ids = declaredIds(suite, 'static let coveredBehaviors', table, /ConformanceBehaviors\.(\w+)/g); + IMPLEMENTATIONS.push({ name: 'apple-client', store: 'Apple', covered: ids }); +} + +// --- IAPKit lifecycle ------------------------------------------------------- +{ + const suite = read('packages/kit/convex/webhooks/conformance.test.ts'); + const ids = [...suite.matchAll(/"(lifecycle\.[a-z-]+)"/g)].map((match) => match[1]); + for (const store of ['Apple', 'Google']) { + IMPLEMENTATIONS.push({ name: `iapkit-${store.toLowerCase()}`, store, covered: [...new Set(ids)] }); + } +} + +// --- Framework bindings (real SDK code over a fake native module) ---------- +for (const [name, path] of [ + ['react-native-iap', 'libraries/react-native-iap/src/__tests__/conformance.test.ts'], + ['expo-iap', 'libraries/expo-iap/src/__tests__/conformance.test.ts'], +]) { + const suite = read(path); + const block = suite.slice(suite.indexOf('const COVERED_BEHAVIORS')); + const ids = [...block.slice(0, block.indexOf('];')).matchAll(/'([a-z]+\.[a-z0-9-]+)'/g)].map( + (match) => match[1], + ); + IMPLEMENTATIONS.push({ name, store: 'Google', covered: ids }); +} + +// --- Reference implementation ---------------------------------------------- +{ + const adapter = read('packages/conformance/src/adapters/reference-adapter.mjs'); + const ids = [...adapter.matchAll(/^ '([a-z]+\.[a-z0-9-]+)':/gm)].map((match) => match[1]); + IMPLEMENTATIONS.push({ name: 'openiap-reference', store: 'Google', covered: ids }); +} + +// --- Build the matrix ------------------------------------------------------- +const emptyImplementations = IMPLEMENTATIONS.filter((impl) => impl.covered.length === 0); +if (emptyImplementations.length > 0) { + console.error( + `Parsed no behavior ids for: ${emptyImplementations.map((impl) => impl.name).join(', ')} — the parser is likely broken.`, + ); + process.exit(1); +} + +const rows = BEHAVIORS.map((behavior) => { + const by = IMPLEMENTATIONS.filter((impl) => impl.covered.includes(behavior.id)).map( + (impl) => impl.name, + ); + return { id: behavior.id, category: behavior.category, level: behavior.level, coveredBy: by }; +}); + +const uncovered = rows.filter((row) => row.coveredBy.length === 0); +const realImplementations = IMPLEMENTATIONS.filter((impl) => impl.name !== 'openiap-reference'); +const uncoveredByReal = rows.filter( + (row) => !row.coveredBy.some((name) => name !== 'openiap-reference'), +); + +const artifact = { + suiteVersion: SUITE_VERSION, + specVersion: specVersion(), + implementations: IMPLEMENTATIONS.map(({ name, store, covered }) => ({ + name, + store, + coveredCount: covered.length, + })), + behaviors: rows, + summary: { + total: rows.length, + coveredByAnyImplementation: rows.length - uncoveredByReal.length, + coveredOnlyByReference: uncoveredByReal.length - uncovered.length, + coveredByNothing: uncovered.length, + }, +}; + +if (process.argv.includes('--json')) { + console.log(JSON.stringify(artifact, null, 2)); +} else { + console.log('OpenIAP Conformance Coverage'); + console.log(` suite ${SUITE_VERSION} / spec ${specVersion()}`); + console.log(''); + let category = ''; + for (const row of rows) { + if (row.category !== category) { + category = row.category; + console.log(` ${category}`); + } + const by = row.coveredBy.length ? row.coveredBy.join(', ') : '— none —'; + console.log(` ${row.coveredBy.length ? 'OK ' : 'GAP'} ${row.id} [${by}]`); + } + console.log(''); + console.log(` implementations: ${IMPLEMENTATIONS.map((impl) => impl.name).join(', ')}`); + console.log( + ` ${artifact.summary.coveredByAnyImplementation}/${artifact.summary.total} behaviors covered by a real implementation`, + ); + if (uncoveredByReal.length) { + console.log(` ${uncoveredByReal.length} covered only by the reference adapter or not at all`); + } +} + +// Gate on real implementations, not on coverage in general: the reference +// adapter is written to pass, so counting it would let every shipped +// implementation drop out of a behavior while the check stayed green. +if (process.argv.includes('--check')) { + const failing = uncoveredByReal.filter((row) => row.level === 'MUST'); + if (failing.length > 0) { + console.error( + `\n${failing.length} MUST behavior(s) have no implementation beyond the reference adapter:`, + ); + for (const row of failing) { + console.error(`- ${row.id}${row.coveredBy.length ? ' (reference only)' : ' (no coverage)'}`); + } + process.exit(1); + } +} diff --git a/packages/conformance/scripts/generate-behavior-ids.mjs b/packages/conformance/scripts/generate-behavior-ids.mjs new file mode 100644 index 000000000..96f0ae8e2 --- /dev/null +++ b/packages/conformance/scripts/generate-behavior-ids.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * Emits the behavior ids into Kotlin and Swift so the native conformance + * suites assert against the same versioned spec as the TypeScript ones. + * + * Run with --check to fail instead of writing (used by the parity audit). + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + CAPABILITY_MATRIX, + CAPABILITY_STORES, +} from '../../gql/src/capability-matrix.mjs'; +import { BEHAVIORS } from '../src/spec/behaviors.mjs'; +import { SUITE_VERSION } from '../src/spec/suite-version.mjs'; + +const ROOT = new URL('../../../', import.meta.url); + +const TARGETS = { + spec: { + path: 'packages/conformance/src/spec/generated-spec.mjs', + render: () => { + const specVersion = JSON.parse( + readFileSync(fileURLToPath(new URL('openiap-versions.json', ROOT)), 'utf8'), + ).spec; + return `// Generated by packages/conformance/scripts/generate-behavior-ids.mjs +// Do not edit. Copied from packages/gql so the published package is +// self-contained; packages/gql remains the source of truth. + +export const SPEC_VERSION = ${JSON.stringify(specVersion)}; + +export const CAPABILITY_STORES = Object.freeze(${JSON.stringify(CAPABILITY_STORES)}); + +export const CAPABILITY_MATRIX = Object.freeze(${JSON.stringify( + Object.fromEntries( + Object.entries(CAPABILITY_MATRIX).map(([behavior, entry]) => [ + behavior, + entry.stores, + ]), + ), + null, + 2, + )}); + +/** @returns {'required' | 'optional' | 'unsupported'} */ +export function capabilityLevel(behavior, store) { + const entry = CAPABILITY_MATRIX[behavior]; + if (!entry) throw new Error(\`Unknown capability behavior: \${behavior}\`); + const level = entry[store]; + if (!level) throw new Error(\`Capability \${behavior} has no entry for store \${store}\`); + return level; +} +`; + }, + }, + kotlin: { + path: 'packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt', + render: () => { + const constants = BEHAVIORS.map( + (behavior) => ` const val ${symbol(behavior.id)} = "${behavior.id}"`, + ).join('\n'); + const capabilityRows = ['pendingPurchases', 'subscriptionBillingIssue', 'offerCodeRedemption'] + .map((behavior) => { + const stores = ['Google', 'Horizon', 'Amazon'] + .map((store) => `"${store}" to "${CAPABILITY_MATRIX[behavior].stores[store]}"`) + .join(', '); + return ` "${behavior}" to mapOf(${stores}),`; + }) + .join('\n'); + + return `package dev.hyo.openiap.conformance + +// Generated by packages/conformance/scripts/generate-behavior-ids.mjs +// Do not edit. Behavior ids are the versioned public contract; see +// packages/conformance/src/spec/behaviors.mjs. + +object ConformanceBehaviors { + const val SUITE_VERSION = "${SUITE_VERSION}" + +${constants} + + /** Capability level per store, from packages/gql/src/capability-matrix.mjs. */ + val CAPABILITY_MATRIX: Map> = mapOf( +${capabilityRows} + ) +} +`; + }, + }, + swift: { + path: 'packages/apple/Tests/OpenIapTests/ConformanceBehaviors.swift', + render: () => { + const constants = BEHAVIORS.map( + (behavior) => ` static let ${camel(behavior.id)} = "${behavior.id}"`, + ).join('\n'); + return `// Generated by packages/conformance/scripts/generate-behavior-ids.mjs +// Do not edit. Behavior ids are the versioned public contract; see +// packages/conformance/src/spec/behaviors.mjs. + +enum ConformanceBehaviors { + static let suiteVersion = "${SUITE_VERSION}" + +${constants} +} +`; + }, + }, +}; + +function symbol(id) { + return id.replace(/[.-]/g, '_').toUpperCase(); +} + +function camel(id) { + const parts = id.replace(/\./g, '-').split('-'); + return parts[0] + parts.slice(1).map((part) => part[0].toUpperCase() + part.slice(1)).join(''); +} + +const check = process.argv.includes('--check'); +const drift = []; + +for (const [name, target] of Object.entries(TARGETS)) { + const absolute = fileURLToPath(new URL(target.path, ROOT)); + const expected = target.render(); + let actual = null; + try { + actual = readFileSync(absolute, 'utf8'); + } catch { + actual = null; + } + + if (actual === expected) continue; + + if (check) { + drift.push(`${target.path} is out of date (${name})`); + continue; + } + writeFileSync(absolute, expected); + console.log(`wrote ${target.path}`); +} + +if (drift.length > 0) { + console.error('Conformance behavior id drift:'); + for (const item of drift) console.error(`- ${item}`); + console.error('Run: bun run --cwd packages/conformance generate:ids'); + process.exit(1); +} + +if (check) console.log('Conformance behavior ids are in sync.'); diff --git a/packages/conformance/scripts/run-reference-report.mjs b/packages/conformance/scripts/run-reference-report.mjs new file mode 100644 index 000000000..50bbd8cc2 --- /dev/null +++ b/packages/conformance/scripts/run-reference-report.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +/** + * Runs the reference adapter and prints a conformance report. + * + * Demonstrates the report a real implementation produces. Pass --json to emit + * the machine-readable artifact instead. + */ +import { createReferenceAdapter } from '../src/adapters/reference-adapter.mjs'; +import { BEHAVIORS } from '../src/spec/behaviors.mjs'; +import { formatReport, toJsonReport } from '../src/runner/report.mjs'; +import { runConformance } from '../src/runner/runner.mjs'; + +// Lifecycle behaviors are server-side; the reference client adapter does not +// implement them. IAPKit's suite covers them against real code. +const behaviors = BEHAVIORS.filter((behavior) => behavior.category !== 'lifecycle'); + +const report = await runConformance(createReferenceAdapter(), { behaviors }); + +console.log(process.argv.includes('--json') ? toJsonReport(report) : formatReport(report)); + +if (!report.conformant) process.exit(1); diff --git a/packages/conformance/src/adapters/reference-adapter.mjs b/packages/conformance/src/adapters/reference-adapter.mjs new file mode 100644 index 000000000..f3e19abfd --- /dev/null +++ b/packages/conformance/src/adapters/reference-adapter.mjs @@ -0,0 +1,293 @@ +import assert from 'node:assert/strict'; +import { FakeStore, StoreOutcome } from '../fake-store/fake-store.mjs'; +import { ReferenceImplementation } from '../fake-store/reference-implementation.mjs'; + +/** + * Reference adapter — the worked example of the contract in README.md. + * + * Each behavior id maps to a function that throws on violation. Adapter authors + * replace the ReferenceImplementation with their own SDK and keep this shape. + */ + +const CATALOG = [ + { sku: 'dev.hyo.martie.premium', type: 'subs' }, + { sku: 'dev.hyo.martie.pro', type: 'subs' }, + { sku: 'dev.hyo.martie.10bulbs', type: 'in-app' }, + { sku: 'dev.hyo.martie.lifetime', type: 'in-app' }, +]; + +export function createReferenceAdapter({ store = 'Google' } = {}) { + const fake = new FakeStore({ catalog: CATALOG, store }); + const iap = new ReferenceImplementation(fake, { iapStore: store }); + const fresh = () => { + fake.reset(); + return iap; + }; + + return { + implementation: 'openiap-reference', + store, + declaredCapabilities: { + fetchProducts: 'required', + requestPurchase: 'required', + finishTransaction: 'required', + getAvailablePurchases: 'required', + getActiveSubscriptions: 'required', + }, + + behaviors: { + // --- products ------------------------------------------------------ + 'products.fetch-returns-requested-skus': async () => { + const products = await fresh().fetchProducts({ + skus: ['dev.hyo.martie.premium', 'not-a-real-sku'], + }); + assert.deepEqual( + products.map((product) => product.id), + ['dev.hyo.martie.premium'], + 'unknown skus must be omitted, not returned as placeholders', + ); + }, + + 'products.fetch-normalizes-required-fields': async () => { + const [product] = await fresh().fetchProducts({ skus: ['dev.hyo.martie.premium'] }); + for (const field of ['id', 'title', 'currency', 'displayPrice']) { + assert.ok(product[field], `product.${field} must be non-empty`); + } + }, + + 'products.fetch-empty-sku-list-is-an-error': async () => { + await assert.rejects( + () => fresh().fetchProducts({ skus: [] }), + (error) => error.code === 'empty-sku-list', + ); + }, + + 'products.fetch-separates-in-app-and-subscription-types': async () => { + const subs = await fresh().fetchProducts({ + skus: ['dev.hyo.martie.premium', 'dev.hyo.martie.10bulbs'], + type: 'subs', + }); + assert.deepEqual(subs.map((product) => product.id), ['dev.hyo.martie.premium']); + }, + + // --- purchases ----------------------------------------------------- + 'purchases.request-emits-purchase-updated-on-success': async () => { + const impl = fresh(); + const received = []; + const off = impl.onPurchaseUpdated((purchase) => received.push(purchase)); + await impl.requestPurchase({ sku: 'dev.hyo.martie.10bulbs' }); + off(); + assert.equal(received.length, 1, 'a successful purchase must reach the listener'); + assert.equal(received[0].productId, 'dev.hyo.martie.10bulbs'); + }, + + 'purchases.request-emits-error-on-user-cancel': async () => { + const impl = fresh(); + const purchases = []; + const errors = []; + impl.onPurchaseUpdated((purchase) => purchases.push(purchase)); + impl.onPurchaseError((error) => errors.push(error)); + fake.forceOutcome('dev.hyo.martie.10bulbs', StoreOutcome.UserCancelled); + + await assert.rejects(() => impl.requestPurchase({ sku: 'dev.hyo.martie.10bulbs' })); + assert.equal(errors[0]?.code, 'user-cancelled'); + assert.equal(purchases.length, 0, 'a cancelled purchase must not emit purchase-updated'); + }, + + 'purchases.already-owned-surfaces-already-owned-error': async () => { + const impl = fresh(); + await impl.requestPurchase({ sku: 'dev.hyo.martie.lifetime' }); + await assert.rejects( + () => impl.requestPurchase({ sku: 'dev.hyo.martie.lifetime' }), + (error) => error.code === 'already-owned', + ); + }, + + 'purchases.pending-purchase-is-not-delivered-as-purchased': async () => { + const impl = fresh(); + fake.forceOutcome('dev.hyo.martie.10bulbs', StoreOutcome.Pending); + const purchase = await impl.requestPurchase({ sku: 'dev.hyo.martie.10bulbs' }); + assert.equal(purchase.purchaseState, 'Pending'); + }, + + 'purchases.unknown-sku-surfaces-sku-not-found': async () => { + await assert.rejects( + () => fresh().requestPurchase({ sku: 'not-a-real-sku' }), + (error) => error.code === 'sku-not-found', + ); + }, + + // --- completion ---------------------------------------------------- + 'completion.finish-removes-transaction-from-pending': async () => { + const impl = fresh(); + const purchase = await impl.requestPurchase({ sku: 'dev.hyo.martie.10bulbs' }); + assert.ok((await impl.getUnfinishedPurchaseTokens()).includes(purchase.purchaseToken)); + + await impl.finishTransaction({ purchaseToken: purchase.purchaseToken, isConsumable: true }); + assert.ok(!(await impl.getUnfinishedPurchaseTokens()).includes(purchase.purchaseToken)); + }, + + 'completion.finish-is-idempotent': async () => { + const impl = fresh(); + const purchase = await impl.requestPurchase({ sku: 'dev.hyo.martie.10bulbs' }); + await impl.finishTransaction({ purchaseToken: purchase.purchaseToken }); + await impl.finishTransaction({ purchaseToken: purchase.purchaseToken }); + }, + + 'completion.unfinished-purchase-remains-available': async () => { + const impl = fresh(); + const purchase = await impl.requestPurchase({ sku: 'dev.hyo.martie.lifetime' }); + const available = await impl.getAvailablePurchases(); + assert.ok( + available.some((item) => item.purchaseToken === purchase.purchaseToken), + 'an unfinished purchase must survive for re-grant after a crash', + ); + }, + + // --- restoration --------------------------------------------------- + 'restoration.available-purchases-returns-owned-items': async () => { + const impl = fresh(); + await impl.requestPurchase({ sku: 'dev.hyo.martie.lifetime' }); + await impl.requestPurchase({ sku: 'dev.hyo.martie.premium' }); + const available = await impl.getAvailablePurchases(); + assert.deepEqual( + available.map((item) => item.productId).sort(), + ['dev.hyo.martie.lifetime', 'dev.hyo.martie.premium'], + ); + }, + + 'restoration.available-purchases-excludes-consumed-items': async () => { + const impl = fresh(); + const purchase = await impl.requestPurchase({ sku: 'dev.hyo.martie.10bulbs' }); + await impl.finishTransaction({ purchaseToken: purchase.purchaseToken, isConsumable: true }); + const available = await impl.getAvailablePurchases(); + assert.ok(!available.some((item) => item.purchaseToken === purchase.purchaseToken)); + }, + + 'restoration.available-purchases-is-empty-for-new-user': async () => { + assert.deepEqual(await fresh().getAvailablePurchases(), []); + }, + + // --- subscriptions ------------------------------------------------- + 'subscriptions.active-subscription-is-reported-active': async () => { + const impl = fresh(); + await impl.requestPurchase({ sku: 'dev.hyo.martie.premium' }); + const [subscription] = await impl.getActiveSubscriptions(); + assert.equal(subscription.isActive, true); + }, + + 'subscriptions.pending-subscription-is-not-active': async () => { + const impl = fresh(); + fake.forceOutcome('dev.hyo.martie.premium', StoreOutcome.Pending); + await impl.requestPurchase({ sku: 'dev.hyo.martie.premium' }); + const [subscription] = await impl.getActiveSubscriptions(); + assert.equal( + subscription.isActive, + false, + 'a pending subscription is unpaid and must not be an entitlement', + ); + }, + + 'subscriptions.unknown-state-subscription-is-not-active': async () => { + const impl = fresh(); + await impl.requestPurchase({ sku: 'dev.hyo.martie.premium' }); + // Drive the store into an indeterminate state the way a partial sync would. + for (const record of fake.owned.values()) record.state = 'unknown'; + const [subscription] = await impl.getActiveSubscriptions(); + assert.equal(subscription.isActive, false); + }, + + 'subscriptions.groups-keep-independent-identifiers': async () => { + const impl = fresh(); + await impl.requestPurchase({ sku: 'dev.hyo.martie.premium' }); + await impl.requestPurchase({ sku: 'dev.hyo.martie.pro' }); + const subscriptions = await impl.getActiveSubscriptions(); + const premium = subscriptions.find((item) => item.productId === 'dev.hyo.martie.premium'); + const pro = subscriptions.find((item) => item.productId === 'dev.hyo.martie.pro'); + + assert.equal(premium.currentPlanId, 'dev.hyo.martie.premium'); + assert.equal(pro.currentPlanId, 'dev.hyo.martie.pro'); + assert.notEqual(premium.purchaseToken, pro.purchaseToken); + }, + + 'subscriptions.has-active-agrees-with-get-active': async () => { + const impl = fresh(); + assert.equal(await impl.hasActiveSubscriptions(), false); + await impl.requestPurchase({ sku: 'dev.hyo.martie.premium' }); + assert.equal(await impl.hasActiveSubscriptions(), true); + }, + + // --- errors -------------------------------------------------------- + 'errors.store-codes-normalize-to-spec-error-codes': async () => { + const impl = fresh(); + fake.forceOutcome('dev.hyo.martie.10bulbs', StoreOutcome.UserCancelled); + await assert.rejects( + () => impl.requestPurchase({ sku: 'dev.hyo.martie.10bulbs' }), + (error) => error.code === 'user-cancelled', + ); + }, + + 'errors.unrecognized-store-code-normalizes-to-unknown': async () => { + const impl = fresh(); + fake.forceOutcome('dev.hyo.martie.10bulbs', 'SomeFutureStoreOutcome'); + await assert.rejects( + () => impl.requestPurchase({ sku: 'dev.hyo.martie.10bulbs' }), + (error) => error.code === 'unknown', + ); + }, + + 'errors.unsupported-codes-are-not-synthesized': async () => { + const appleStore = new FakeStore({ catalog: CATALOG, store: 'Apple' }); + const apple = new ReferenceImplementation(appleStore, { iapStore: 'Apple' }); + appleStore.forceOutcome('dev.hyo.martie.lifetime', StoreOutcome.AlreadyOwned); + await assert.rejects( + () => apple.requestPurchase({ sku: 'dev.hyo.martie.lifetime' }), + (error) => error.code === 'unknown', + 'Apple must not synthesize the Android-only already-owned code', + ); + }, + + // --- verification ---------------------------------------------------- + 'verification.result-exposes-uniform-validity': async () => { + const impl = fresh(); + const purchase = await impl.requestPurchase({ sku: 'dev.hyo.martie.lifetime' }); + + const valid = await impl.verifyPurchase({ purchaseToken: purchase.purchaseToken }); + assert.equal(typeof valid.isValid, 'boolean', 'isValid must be present on every variant'); + assert.equal(valid.isValid, true); + + const unknown = await impl.verifyPurchase({ purchaseToken: 'not-a-real-token' }); + assert.equal(typeof unknown.isValid, 'boolean'); + assert.equal(unknown.isValid, false); + }, + + // --- identifiers --------------------------------------------------- + 'identifiers.purchase-carries-a-concrete-store': async () => { + const impl = fresh(); + const purchase = await impl.requestPurchase({ sku: 'dev.hyo.martie.10bulbs' }); + assert.notEqual(purchase.store, 'Unknown'); + assert.ok(purchase.store); + }, + + 'identifiers.purchase-token-is-stable-across-reads': async () => { + const impl = fresh(); + const purchase = await impl.requestPurchase({ sku: 'dev.hyo.martie.lifetime' }); + const first = (await impl.getAvailablePurchases())[0].purchaseToken; + const second = (await impl.getAvailablePurchases())[0].purchaseToken; + assert.equal(first, purchase.purchaseToken); + assert.equal(second, purchase.purchaseToken); + }, + + // --- capabilities -------------------------------------------------- + 'capabilities.unsupported-operations-degrade-predictably': async () => { + assert.equal(await fresh().openUnsupportedOperation(), false); + }, + + 'capabilities.declared-capabilities-match-the-matrix': async () => { + // Verified against the matrix by the runner's capability gating; this + // asserts the adapter actually declares something to check. + assert.ok(Object.keys(createReferenceAdapter().declaredCapabilities).length > 0); + }, + }, + }; +} diff --git a/packages/conformance/src/fake-store/fake-store.mjs b/packages/conformance/src/fake-store/fake-store.mjs new file mode 100644 index 000000000..4b98ab140 --- /dev/null +++ b/packages/conformance/src/fake-store/fake-store.mjs @@ -0,0 +1,123 @@ +/** + * Deterministic in-memory store backend. + * + * This stands in for Apple/Google/Amazon servers so purchase flows can be + * exercised in CI, where a real purchase is impossible. It models the store, + * not OpenIAP: it speaks store-shaped results and knows nothing about the + * spec's normalized types. An implementation under test sits on top and is + * responsible for the normalization the conformance suite asserts. + */ + +export const StoreOutcome = Object.freeze({ + Success: 'Success', + UserCancelled: 'UserCancelled', + AlreadyOwned: 'AlreadyOwned', + Pending: 'Pending', + Unknown: 'Unknown', +}); + +let sequence = 0; +function nextId(prefix) { + sequence += 1; + return `${prefix}-${sequence}`; +} + +export class FakeStore { + /** + * @param {object} options + * @param {Array<{sku: string, type: 'in-app'|'subs', title?: string, currency?: string, displayPrice?: string}>} options.catalog + * @param {string} [options.store] + */ + constructor({ catalog = [], store = 'Fake' } = {}) { + this.store = store; + this.catalog = new Map(catalog.map((entry) => [entry.sku, entry])); + /** @type {Map} owned purchases keyed by token */ + this.owned = new Map(); + /** @type {Set} tokens not yet finished */ + this.unfinished = new Set(); + /** @type {Map} sku -> forced outcome */ + this.forcedOutcomes = new Map(); + } + + /** Force the next purchase of `sku` to take a non-success path. */ + forceOutcome(sku, outcome) { + this.forcedOutcomes.set(sku, outcome); + } + + reset() { + this.owned.clear(); + this.unfinished.clear(); + this.forcedOutcomes.clear(); + } + + /** Store-shaped product lookup. Unknown skus are simply absent. */ + queryProducts(skus, type) { + return skus + .map((sku) => this.catalog.get(sku)) + .filter(Boolean) + .filter((entry) => (type ? entry.type === type : true)) + .map((entry) => ({ + sku: entry.sku, + type: entry.type, + title: entry.title ?? `Product ${entry.sku}`, + currency: entry.currency ?? 'USD', + displayPrice: entry.displayPrice ?? '$0.99', + })); + } + + /** + * Attempt a purchase. Returns a store-shaped outcome; it never throws, the + * way a real billing callback reports failure rather than raising. + */ + purchase(sku) { + if (!this.catalog.has(sku)) { + return { outcome: StoreOutcome.Unknown, sku, reason: 'sku not in catalog' }; + } + + const forced = this.forcedOutcomes.get(sku); + if (forced) { + this.forcedOutcomes.delete(sku); + if (forced !== StoreOutcome.Success) { + if (forced === StoreOutcome.Pending) { + const token = nextId('token'); + const record = { token, sku, state: 'pending', type: this.catalog.get(sku).type }; + this.owned.set(token, record); + this.unfinished.add(token); + return { outcome: StoreOutcome.Pending, purchase: record }; + } + return { outcome: forced, sku }; + } + } + + const alreadyOwned = [...this.owned.values()].some( + (record) => record.sku === sku && record.state === 'purchased', + ); + if (alreadyOwned && this.catalog.get(sku).type === 'in-app') { + return { outcome: StoreOutcome.AlreadyOwned, sku }; + } + + const token = nextId('token'); + const record = { token, sku, state: 'purchased', type: this.catalog.get(sku).type }; + this.owned.set(token, record); + this.unfinished.add(token); + return { outcome: StoreOutcome.Success, purchase: record }; + } + + /** Purchases the store would return on a restore/query. */ + queryPurchases() { + return [...this.owned.values()]; + } + + unfinishedTokens() { + return [...this.unfinished]; + } + + /** + * Finish a transaction. Consuming a consumable removes ownership, matching + * how a consumed item stops being reported by the store. + */ + finish(token, { consume = false } = {}) { + this.unfinished.delete(token); + if (consume) this.owned.delete(token); + } +} diff --git a/packages/conformance/src/fake-store/reference-implementation.mjs b/packages/conformance/src/fake-store/reference-implementation.mjs new file mode 100644 index 000000000..07e5dc25e --- /dev/null +++ b/packages/conformance/src/fake-store/reference-implementation.mjs @@ -0,0 +1,143 @@ +import { StoreOutcome } from './fake-store.mjs'; + +/** + * Reference OpenIAP implementation over a FakeStore. + * + * Its purpose is to make the behavior spec executable and to show adapter + * authors what "conforming" looks like. It is not a shipped SDK, and a passing + * run here says nothing about react-native-iap, expo-iap, or the native + * packages — those must supply their own adapters over their own code. + */ + +export class ConformanceError extends Error { + constructor(code, message) { + super(message ?? code); + this.code = code; + } +} + +const OUTCOME_TO_ERROR_CODE = { + [StoreOutcome.UserCancelled]: 'user-cancelled', + [StoreOutcome.AlreadyOwned]: 'already-owned', + [StoreOutcome.Unknown]: 'sku-not-found', +}; + +const STORES_WITH_ALREADY_OWNED = new Set(['Google', 'Amazon', 'Horizon']); + +export class ReferenceImplementation { + /** @param {import('./fake-store.mjs').FakeStore} store */ + constructor(store, { iapStore = 'Google' } = {}) { + this.store = store; + this.iapStore = iapStore; + this.purchaseUpdatedListeners = []; + this.purchaseErrorListeners = []; + } + + onPurchaseUpdated(listener) { + this.purchaseUpdatedListeners.push(listener); + return () => { + this.purchaseUpdatedListeners = this.purchaseUpdatedListeners.filter((item) => item !== listener); + }; + } + + onPurchaseError(listener) { + this.purchaseErrorListeners.push(listener); + return () => { + this.purchaseErrorListeners = this.purchaseErrorListeners.filter((item) => item !== listener); + }; + } + + async fetchProducts({ skus, type }) { + if (!skus || skus.length === 0) { + throw new ConformanceError('empty-sku-list', 'fetchProducts requires at least one sku'); + } + return this.store.queryProducts(skus, type).map((entry) => ({ + id: entry.sku, + title: entry.title, + currency: entry.currency, + displayPrice: entry.displayPrice, + type: entry.type, + })); + } + + async requestPurchase({ sku }) { + const result = this.store.purchase(sku); + + if (result.outcome === StoreOutcome.Success) { + const purchase = this.#toPurchase(result.purchase); + this.purchaseUpdatedListeners.forEach((listener) => listener(purchase)); + return purchase; + } + + if (result.outcome === StoreOutcome.Pending) { + const purchase = this.#toPurchase(result.purchase); + this.purchaseUpdatedListeners.forEach((listener) => listener(purchase)); + return purchase; + } + + const mappedCode = OUTCOME_TO_ERROR_CODE[result.outcome] ?? 'unknown'; + const code = + mappedCode === 'already-owned' && !STORES_WITH_ALREADY_OWNED.has(this.iapStore) + ? 'unknown' + : mappedCode; + const error = new ConformanceError(code, `purchase failed: ${result.outcome}`); + this.purchaseErrorListeners.forEach((listener) => listener(error)); + throw error; + } + + async finishTransaction({ purchaseToken, isConsumable = false }) { + this.store.finish(purchaseToken, { consume: isConsumable }); + } + + /** + * Returns a platform-shaped verification result. Every variant carries + * isValid so callers never have to branch on the concrete shape. + */ + async verifyPurchase({ purchaseToken }) { + const record = this.store.owned.get(purchaseToken); + return { + isValid: record?.state === 'purchased', + productId: record?.sku, + store: this.iapStore, + }; + } + + async getAvailablePurchases() { + return this.store.queryPurchases().map((record) => this.#toPurchase(record)); + } + + async getUnfinishedPurchaseTokens() { + return this.store.unfinishedTokens(); + } + + async getActiveSubscriptions() { + return this.store + .queryPurchases() + .filter((record) => record.type === 'subs') + .map((record) => ({ + productId: record.sku, + currentPlanId: record.sku, + purchaseToken: record.token, + isActive: record.state === 'purchased', + })); + } + + async hasActiveSubscriptions() { + return (await this.getActiveSubscriptions()).some((subscription) => subscription.isActive); + } + + /** Documented no-op for an operation unsupported by this fake store. */ + async openUnsupportedOperation() { + return false; + } + + #toPurchase(record) { + return { + id: record.token, + productId: record.sku, + purchaseToken: record.token, + purchaseState: record.state === 'purchased' ? 'Purchased' : 'Pending', + store: this.iapStore, + }; + } +} diff --git a/packages/conformance/src/index.mjs b/packages/conformance/src/index.mjs new file mode 100644 index 000000000..ba20b7536 --- /dev/null +++ b/packages/conformance/src/index.mjs @@ -0,0 +1,14 @@ +export { + BEHAVIORS, + BEHAVIOR_CATEGORIES, + BEHAVIOR_LEVELS, + behaviorById, + behaviorIds, + behaviorsByCategory, +} from './spec/behaviors.mjs'; +export { SUITE_VERSION, specVersion } from './spec/version.mjs'; +export { runConformance, NOT_IMPLEMENTED } from './runner/runner.mjs'; +export { formatReport, toJsonReport } from './runner/report.mjs'; +export { FakeStore, StoreOutcome } from './fake-store/fake-store.mjs'; +export { ReferenceImplementation, ConformanceError } from './fake-store/reference-implementation.mjs'; +export { createReferenceAdapter } from './adapters/reference-adapter.mjs'; diff --git a/packages/conformance/src/runner/report.mjs b/packages/conformance/src/runner/report.mjs new file mode 100644 index 000000000..b54af25bf --- /dev/null +++ b/packages/conformance/src/runner/report.mjs @@ -0,0 +1,73 @@ +/** Formats a runConformance result for humans and for CI artifacts. */ + +const ORDER = ['fail', 'warn', 'pass', 'skip', 'not-applicable']; + +const SYMBOL = { + pass: 'PASS', + fail: 'FAIL', + warn: 'WARN', + skip: 'SKIP', + 'not-applicable': 'N/A ', +}; + +export function formatReport(report) { + const lines = [ + `OpenIAP Conformance Report`, + ` implementation : ${report.implementation}`, + ` store : ${report.store}`, + ` suite version : ${report.suiteVersion}`, + ` spec version : ${report.specVersion}`, + '', + ]; + + const byCategory = new Map(); + for (const result of report.results) { + if (!byCategory.has(result.category)) byCategory.set(result.category, []); + byCategory.get(result.category).push(result); + } + + for (const [category, results] of byCategory) { + lines.push(` ${category}`); + for (const result of results) { + const reason = result.reason ? ` — ${result.reason}` : ''; + lines.push(` ${SYMBOL[result.outcome]} ${result.id}${reason}`); + } + lines.push(''); + } + + const summary = ORDER.filter((outcome) => report.counts[outcome]) + .map((outcome) => `${report.counts[outcome]} ${outcome}`) + .join(', '); + lines.push(` ${summary}`); + lines.push( + report.conformant + ? ` RESULT: conformant with OpenIAP ${report.specVersion} (suite ${report.suiteVersion})` + : ` RESULT: NOT conformant — ${report.counts.fail} failing behavior(s)`, + ); + + return lines.join('\n'); +} + +/** Stable JSON artifact for CI upload and cross-run comparison. */ +export function toJsonReport(report) { + return JSON.stringify( + { + suiteVersion: report.suiteVersion, + specVersion: report.specVersion, + implementation: report.implementation, + store: report.store, + conformant: report.conformant, + counts: report.counts, + results: report.results.map(({ id, outcome, category, level, capabilityLevel, reason }) => ({ + id, + outcome, + category, + level, + capabilityLevel, + ...(reason ? { reason } : {}), + })), + }, + null, + 2, + ); +} diff --git a/packages/conformance/src/runner/runner.mjs b/packages/conformance/src/runner/runner.mjs new file mode 100644 index 000000000..0e754ef5c --- /dev/null +++ b/packages/conformance/src/runner/runner.mjs @@ -0,0 +1,155 @@ +import { CAPABILITY_STORES, capabilityLevel } from '../spec/generated-spec.mjs'; +import { BEHAVIORS } from '../spec/behaviors.mjs'; +import { SUITE_VERSION, specVersion } from '../spec/version.mjs'; + +/** + * Drives an adapter through every behavior and returns a compatibility report. + * + * The runner owns capability gating so adapters cannot skip their own + * requirements: a behavior gated on a capability the store must support is + * required, and one gated on a capability the store cannot support is inverted + * into an absence check rather than dropped. + */ + +/** @typedef {'pass' | 'fail' | 'skip' | 'not-applicable' | 'warn'} Outcome */ + +const NOT_IMPLEMENTED = Symbol('not-implemented'); + +function resolveApplicability(behavior, adapter) { + if (!behavior.capability) return { applicable: true, level: 'required' }; + + let level; + try { + level = capabilityLevel(behavior.capability, adapter.store); + } catch (error) { + return { + applicable: false, + level: 'unknown', + reason: error instanceof Error ? error.message : String(error), + }; + } + + if (level === 'unsupported') { + return { applicable: false, level, reason: `${adapter.store} does not support ${behavior.capability}` }; + } + return { applicable: true, level }; +} + +async function runOne(behavior, adapter) { + const { applicable, level, reason } = resolveApplicability(behavior, adapter); + const check = adapter.behaviors?.[behavior.id]; + + if (level === 'unknown') { + return { + id: behavior.id, + outcome: behavior.level === 'MUST' ? 'fail' : 'warn', + capabilityLevel: level, + reason, + }; + } + + if (!applicable) { + // An unsupported store must still degrade predictably. If the adapter + // provides an absence check, run it; otherwise record not-applicable. + const absence = adapter.absenceChecks?.[behavior.id]; + if (!absence) { + return { id: behavior.id, outcome: 'not-applicable', capabilityLevel: level, reason }; + } + try { + await absence(); + return { id: behavior.id, outcome: 'pass', capabilityLevel: level, reason: 'documented absence verified' }; + } catch (error) { + return { + id: behavior.id, + outcome: 'fail', + capabilityLevel: level, + reason: error instanceof Error ? error.message : String(error), + }; + } + } + + if (level === 'optional' && !check) { + return { + id: behavior.id, + outcome: 'not-applicable', + capabilityLevel: level, + reason: `${adapter.store} optionally supports ${behavior.capability}`, + }; + } + + if (!check) { + // An unimplemented MUST is a failure, not a skip. Silent gaps are how a + // suite ends up reporting compliance it never checked. + return { + id: behavior.id, + outcome: behavior.level === 'MUST' ? 'fail' : 'warn', + capabilityLevel: level, + reason: 'adapter does not implement this behavior', + }; + } + + try { + const result = await check(); + if (result === NOT_IMPLEMENTED) { + if (level === 'optional') { + return { + id: behavior.id, + outcome: 'not-applicable', + capabilityLevel: level, + reason: 'optional capability not implemented', + }; + } + return { + id: behavior.id, + outcome: behavior.level === 'MUST' ? 'fail' : 'warn', + capabilityLevel: level, + reason: 'adapter reported not-implemented', + }; + } + return { id: behavior.id, outcome: 'pass', capabilityLevel: level }; + } catch (error) { + return { + id: behavior.id, + outcome: behavior.level === 'MUST' ? 'fail' : 'warn', + capabilityLevel: level, + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * @param {object} adapter - see packages/conformance/README.md + * @param {{ behaviors?: ReadonlyArray }} [options] + */ +export async function runConformance(adapter, options = {}) { + if (!adapter?.implementation) throw new Error('adapter.implementation is required'); + if (!adapter?.store) throw new Error('adapter.store is required'); + if (!CAPABILITY_STORES.includes(adapter.store)) { + throw new Error( + `adapter.store must be one of: ${CAPABILITY_STORES.join(', ')}; received ${adapter.store}`, + ); + } + + const behaviors = options.behaviors ?? BEHAVIORS; + const results = []; + for (const behavior of behaviors) { + results.push({ ...(await runOne(behavior, adapter)), category: behavior.category, level: behavior.level }); + } + + const counts = results.reduce( + (acc, result) => ({ ...acc, [result.outcome]: (acc[result.outcome] ?? 0) + 1 }), + /** @type {Record} */ ({}), + ); + + return { + suiteVersion: SUITE_VERSION, + specVersion: specVersion(), + implementation: adapter.implementation, + store: adapter.store, + results, + counts, + conformant: results.every((result) => result.outcome !== 'fail'), + }; +} + +export { NOT_IMPLEMENTED }; diff --git a/packages/conformance/src/spec/behaviors.mjs b/packages/conformance/src/spec/behaviors.mjs new file mode 100644 index 000000000..fcdcdb459 --- /dev/null +++ b/packages/conformance/src/spec/behaviors.mjs @@ -0,0 +1,341 @@ +/** + * OpenIAP conformance behaviors — the versioned behavioral contract. + * + * The GraphQL schema says what the API is. The capability matrix says which + * stores must implement each behavior. This file says what each behavior must + * *do*, as data an implementation in any language can be checked against. + * + * Behavior ids are permanent public identifiers: they appear in conformance + * reports and in the Kotlin/Swift/TypeScript suites. Renaming one is a breaking + * change to the suite. Retire instead (`status: 'retired'`) and add a new id. + * + * `level` follows RFC 2119: + * MUST — a conforming implementation fails without it. + * SHOULD — recommended; reported as a warning, not a failure. + * + * `capability` names an entry in packages/gql/src/capability-matrix.mjs. A + * behavior gated on a capability is only required of stores whose level for it + * is `required`; for `unsupported` stores the runner asserts the documented + * absence instead. Ungated behaviors apply to every implementation. + */ + +export const BEHAVIOR_CATEGORIES = Object.freeze([ + 'products', + 'purchases', + 'completion', + 'restoration', + 'subscriptions', + 'lifecycle', + 'errors', + 'verification', + 'identifiers', + 'capabilities', +]); + +export const BEHAVIOR_LEVELS = Object.freeze(['MUST', 'SHOULD']); + +/** @type {ReadonlyArray} */ +export const BEHAVIORS = Object.freeze([ + // --- products ---------------------------------------------------------- + { + id: 'products.fetch-returns-requested-skus', + category: 'products', + level: 'MUST', + capability: 'fetchProducts', + statement: + 'fetchProducts returns one product per requested sku that the store recognizes, and omits unknown skus rather than emitting placeholders.', + }, + { + id: 'products.fetch-normalizes-required-fields', + category: 'products', + level: 'MUST', + capability: 'fetchProducts', + statement: + 'Every returned product carries a non-empty id, title, currency, and displayPrice.', + }, + { + id: 'products.fetch-empty-sku-list-is-an-error', + category: 'products', + level: 'MUST', + capability: 'fetchProducts', + statement: + 'fetchProducts with an empty sku list fails with ErrorCode.EmptySkuList rather than returning an empty result.', + }, + { + id: 'products.fetch-separates-in-app-and-subscription-types', + category: 'products', + level: 'MUST', + capability: 'fetchProducts', + statement: + 'A fetch scoped to one ProductType returns only products of that type.', + }, + + // --- purchases --------------------------------------------------------- + { + id: 'purchases.request-emits-purchase-updated-on-success', + category: 'purchases', + level: 'MUST', + capability: 'requestPurchase', + statement: + 'A successful purchase delivers the transaction to the purchase-updated listener.', + }, + { + id: 'purchases.request-emits-error-on-user-cancel', + category: 'purchases', + level: 'MUST', + capability: 'requestPurchase', + statement: + 'A user-cancelled purchase surfaces ErrorCode.UserCancelled and delivers no purchase-updated event.', + }, + { + id: 'purchases.already-owned-surfaces-already-owned-error', + category: 'purchases', + level: 'MUST', + capability: 'alreadyOwnedError', + statement: + 'Purchasing an item the user already owns surfaces ErrorCode.AlreadyOwned.', + }, + { + id: 'purchases.pending-purchase-is-not-delivered-as-purchased', + category: 'purchases', + level: 'MUST', + capability: 'pendingPurchases', + statement: + 'A deferred/pending purchase is never delivered with PurchaseState.Purchased.', + }, + { + id: 'purchases.unknown-sku-surfaces-sku-not-found', + category: 'purchases', + level: 'MUST', + capability: 'requestPurchase', + statement: + 'Requesting a purchase for an unknown sku fails rather than resolving successfully.', + }, + + // --- completion -------------------------------------------------------- + { + id: 'completion.finish-removes-transaction-from-pending', + category: 'completion', + level: 'MUST', + capability: 'finishTransaction', + statement: + 'Finishing a transaction removes it from the set of unfinished transactions.', + }, + { + id: 'completion.finish-is-idempotent', + category: 'completion', + level: 'MUST', + capability: 'finishTransaction', + statement: + 'Finishing an already-finished transaction does not corrupt state or throw an unmapped error.', + }, + { + id: 'completion.unfinished-purchase-remains-available', + category: 'completion', + level: 'MUST', + capability: 'finishTransaction', + statement: + 'A purchase that has not been finished is still reported by getAvailablePurchases so entitlement can be re-granted after a crash.', + }, + + // --- restoration ------------------------------------------------------- + { + id: 'restoration.available-purchases-returns-owned-items', + category: 'restoration', + level: 'MUST', + capability: 'getAvailablePurchases', + statement: + 'getAvailablePurchases returns every non-consumable and active subscription the user owns.', + }, + { + id: 'restoration.available-purchases-excludes-consumed-items', + category: 'restoration', + level: 'MUST', + capability: 'getAvailablePurchases', + statement: + 'A consumed consumable is not reported by getAvailablePurchases.', + }, + { + id: 'restoration.available-purchases-is-empty-for-new-user', + category: 'restoration', + level: 'MUST', + capability: 'getAvailablePurchases', + statement: + 'getAvailablePurchases returns an empty list — not an error — for a user who owns nothing.', + }, + + // --- subscriptions ----------------------------------------------------- + { + id: 'subscriptions.active-subscription-is-reported-active', + category: 'subscriptions', + level: 'MUST', + capability: 'getActiveSubscriptions', + statement: + 'A purchased, non-expired subscription is reported with isActive true.', + }, + { + id: 'subscriptions.pending-subscription-is-not-active', + category: 'subscriptions', + level: 'MUST', + capability: 'getActiveSubscriptions', + statement: + 'A subscription whose purchase is not in the Purchased state is never reported as an active entitlement.', + }, + { + id: 'subscriptions.unknown-state-subscription-is-not-active', + category: 'subscriptions', + level: 'MUST', + capability: 'getActiveSubscriptions', + statement: + 'A subscription in an Unknown purchase state is never reported as an active entitlement.', + }, + { + id: 'subscriptions.groups-keep-independent-identifiers', + category: 'subscriptions', + level: 'MUST', + capability: 'getActiveSubscriptions', + statement: + 'Concurrent subscriptions in different groups retain their own productId, currentPlanId, and purchase token.', + }, + { + id: 'subscriptions.has-active-agrees-with-get-active', + category: 'subscriptions', + level: 'MUST', + capability: 'getActiveSubscriptions', + statement: + 'hasActiveSubscriptions is true exactly when getActiveSubscriptions reports at least one active entitlement.', + }, + + // --- lifecycle (server-side subscription state) ------------------------ + { + id: 'lifecycle.purchase-starts-active-entitlement', + category: 'lifecycle', + level: 'MUST', + capability: 'storeWebhookLifecycle', + statement: 'An initial purchase produces an Active, entitled subscription.', + }, + { + id: 'lifecycle.expiry-ends-entitlement', + category: 'lifecycle', + level: 'MUST', + capability: 'storeWebhookLifecycle', + statement: 'Expiry moves the subscription to Expired and removes entitlement.', + }, + { + id: 'lifecycle.grace-period-retains-entitlement', + category: 'lifecycle', + level: 'MUST', + capability: 'storeWebhookLifecycle', + statement: + 'A billing failure inside the grace period retains entitlement while marking InGracePeriod.', + }, + { + id: 'lifecycle.billing-retry-suspends-entitlement', + category: 'lifecycle', + level: 'MUST', + capability: 'storeWebhookLifecycle', + statement: + 'A billing retry / on-hold state removes entitlement while the subscription is recoverable.', + }, + { + id: 'lifecycle.refund-ends-entitlement', + category: 'lifecycle', + level: 'MUST', + capability: 'storeWebhookLifecycle', + statement: 'A refund moves the subscription to Refunded and removes entitlement.', + }, + { + id: 'lifecycle.revoke-ends-entitlement', + category: 'lifecycle', + level: 'MUST', + capability: 'storeWebhookLifecycle', + statement: 'A revocation moves the subscription to Revoked and removes entitlement.', + }, + { + id: 'lifecycle.cancel-retains-entitlement-until-expiry', + category: 'lifecycle', + level: 'MUST', + capability: 'storeWebhookLifecycle', + statement: + 'Disabling auto-renew keeps the entitlement active until the paid period ends.', + }, + + // --- errors ------------------------------------------------------------ + { + id: 'errors.store-codes-normalize-to-spec-error-codes', + category: 'errors', + level: 'MUST', + statement: + 'Store-native failure codes normalize to the ErrorCode the specification assigns them.', + }, + { + id: 'errors.unrecognized-store-code-normalizes-to-unknown', + category: 'errors', + level: 'MUST', + statement: + 'A store failure code the implementation does not recognize normalizes to ErrorCode.Unknown rather than being dropped or guessed.', + }, + { + id: 'errors.unsupported-codes-are-not-synthesized', + category: 'errors', + level: 'MUST', + statement: + 'An implementation never produces an ErrorCode its store cannot actually reach, per the capability matrix.', + }, + + // --- verification ------------------------------------------------------ + { + id: 'verification.result-exposes-uniform-validity', + category: 'verification', + level: 'MUST', + statement: + 'Every VerifyPurchaseResult variant exposes isValid, so a caller can gate entitlement without inspecting the concrete platform variant.', + }, + + // --- identifiers ------------------------------------------------------- + { + id: 'identifiers.purchase-carries-a-concrete-store', + category: 'identifiers', + level: 'MUST', + statement: 'Every purchase declares a concrete IapStore, never Unknown.', + }, + { + id: 'identifiers.purchase-token-is-stable-across-reads', + category: 'identifiers', + level: 'MUST', + statement: + 'The same purchase reports the same purchase token across repeated reads.', + }, + + // --- capabilities ------------------------------------------------------ + { + id: 'capabilities.unsupported-operations-degrade-predictably', + category: 'capabilities', + level: 'MUST', + statement: + 'An operation the store does not support returns its documented no-op result instead of throwing an unmapped error.', + }, + { + id: 'capabilities.declared-capabilities-match-the-matrix', + category: 'capabilities', + level: 'MUST', + statement: + "An implementation's declared capabilities match the specification's capability matrix for its store.", + }, +]); + +/** @param {string} id */ +export function behaviorById(id) { + const behavior = BEHAVIORS.find((item) => item.id === id); + if (!behavior) throw new Error(`Unknown conformance behavior: ${id}`); + return behavior; +} + +/** @param {string} category */ +export function behaviorsByCategory(category) { + return BEHAVIORS.filter((behavior) => behavior.category === category); +} + +export function behaviorIds() { + return BEHAVIORS.map((behavior) => behavior.id); +} diff --git a/packages/conformance/src/spec/generated-spec.mjs b/packages/conformance/src/spec/generated-spec.mjs new file mode 100644 index 000000000..81fb07953 --- /dev/null +++ b/packages/conformance/src/spec/generated-spec.mjs @@ -0,0 +1,85 @@ +// Generated by packages/conformance/scripts/generate-behavior-ids.mjs +// Do not edit. Copied from packages/gql so the published package is +// self-contained; packages/gql remains the source of truth. + +export const SPEC_VERSION = "3.2.0"; + +export const CAPABILITY_STORES = Object.freeze(["Apple","Google","Amazon","Horizon"]); + +export const CAPABILITY_MATRIX = Object.freeze({ + "fetchProducts": { + "Apple": "required", + "Google": "required", + "Amazon": "required", + "Horizon": "required" + }, + "requestPurchase": { + "Apple": "required", + "Google": "required", + "Amazon": "required", + "Horizon": "required" + }, + "finishTransaction": { + "Apple": "required", + "Google": "required", + "Amazon": "required", + "Horizon": "required" + }, + "getAvailablePurchases": { + "Apple": "required", + "Google": "required", + "Amazon": "required", + "Horizon": "required" + }, + "getActiveSubscriptions": { + "Apple": "required", + "Google": "required", + "Amazon": "required", + "Horizon": "required" + }, + "pendingPurchases": { + "Apple": "required", + "Google": "required", + "Amazon": "required", + "Horizon": "required" + }, + "subscriptionBillingIssue": { + "Apple": "required", + "Google": "required", + "Amazon": "unsupported", + "Horizon": "unsupported" + }, + "offerCodeRedemption": { + "Apple": "required", + "Google": "required", + "Amazon": "unsupported", + "Horizon": "unsupported" + }, + "alreadyOwnedError": { + "Apple": "unsupported", + "Google": "required", + "Amazon": "required", + "Horizon": "required" + }, + "billingServiceLifecycleErrors": { + "Apple": "unsupported", + "Google": "required", + "Amazon": "required", + "Horizon": "required" + }, + "storeWebhookLifecycle": { + "Apple": "required", + "Google": "required", + "Amazon": "optional", + "Horizon": "unsupported" + } +}); + +/** @returns {'required' | 'optional' | 'unsupported'} */ +export function capabilityLevel(behavior, store) { + const entry = CAPABILITY_MATRIX[behavior]; + if (!entry) throw new Error(`Unknown capability behavior: ${behavior}`); + const level = entry[store]; + if (!level) throw new Error(`Capability ${behavior} has no entry for store ${store}`); + return level; +} diff --git a/packages/conformance/src/spec/suite-version.mjs b/packages/conformance/src/spec/suite-version.mjs new file mode 100644 index 000000000..31129fc58 --- /dev/null +++ b/packages/conformance/src/spec/suite-version.mjs @@ -0,0 +1,9 @@ +/** + * Conformance suite version. + * + * Bump when behaviors change: + * major — a behavior is added or tightened (previously passing runs may fail) + * minor — a capability-gated behavior is added + * patch — wording or tooling only; verdicts cannot change + */ +export const SUITE_VERSION = '1.0.0'; diff --git a/packages/conformance/src/spec/version.mjs b/packages/conformance/src/spec/version.mjs new file mode 100644 index 000000000..db4ae9ffa --- /dev/null +++ b/packages/conformance/src/spec/version.mjs @@ -0,0 +1,14 @@ +import { SPEC_VERSION } from './generated-spec.mjs'; +import { SUITE_VERSION } from './suite-version.mjs'; + +/** + * A report states the suite version and the OpenIAP spec version it validates. + * "Conformant" without both attached is the unverifiable claim this suite + * exists to replace. + */ +export { SUITE_VERSION }; + +/** Spec version this suite validates, generated from the repo-root SSOT. */ +export function specVersion() { + return SPEC_VERSION; +} diff --git a/packages/conformance/test/packaging.test.mjs b/packages/conformance/test/packaging.test.mjs new file mode 100644 index 000000000..ab879e660 --- /dev/null +++ b/packages/conformance/test/packaging.test.mjs @@ -0,0 +1,83 @@ +import { execFileSync } from 'node:child_process'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url)); + +function sourceFiles(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return entry.name.endsWith('.mjs') ? [path] : []; + }); +} + +/** + * The suite is published for third parties to run. An import or file read that + * escapes the package root resolves in the monorepo and fails on an installed + * copy, which is invisible to every test that runs from this checkout. + */ +describe('published package is self-contained', () => { + const files = sourceFiles(join(PACKAGE_ROOT, 'src')); + + it('finds the source files it is meant to check', () => { + expect(files.length).toBeGreaterThan(5); + }); + + it('never imports outside the package root', () => { + const escapes = []; + for (const file of files) { + const source = readFileSync(file, 'utf8'); + for (const match of source.matchAll(/from\s+'([^']+)'/g)) { + const specifier = match[1]; + if (!specifier.startsWith('.')) continue; + const resolved = join(dirname(file), specifier); + if (relative(PACKAGE_ROOT, resolved).startsWith('..')) { + escapes.push(`${relative(PACKAGE_ROOT, file)} -> ${specifier}`); + } + } + } + expect(escapes).toEqual([]); + }); + + it('never reads files outside the package root', () => { + const escapes = []; + for (const file of files) { + const source = readFileSync(file, 'utf8'); + for (const match of source.matchAll(/new URL\(\s*'([^']+)'/g)) { + const resolved = join(dirname(file), match[1]); + if (relative(PACKAGE_ROOT, resolved).startsWith('..')) { + escapes.push(`${relative(PACKAGE_ROOT, file)} -> ${match[1]}`); + } + } + } + expect(escapes).toEqual([]); + }); + + it('ships every source file its entrypoints need', () => { + const packed = JSON.parse( + execFileSync('npm', ['pack', '--dry-run', '--json'], { + cwd: PACKAGE_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }), + )[0].files.map((entry) => entry.path); + + for (const file of files) { + const rel = relative(PACKAGE_ROOT, file); + expect(packed, `${rel} is not in the published tarball`).toContain(rel); + } + }); + + it('declares a bin that does not depend on the monorepo', () => { + const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')); + for (const target of Object.values(manifest.bin ?? {})) { + const path = join(PACKAGE_ROOT, target); + expect(statSync(path).isFile()).toBe(true); + const source = readFileSync(path, 'utf8'); + expect(source, `${target} reaches outside the package`).not.toMatch(/\.\.\/\.\.\/\.\.\//); + } + }); +}); diff --git a/packages/conformance/test/runner.test.mjs b/packages/conformance/test/runner.test.mjs new file mode 100644 index 000000000..052bf7958 --- /dev/null +++ b/packages/conformance/test/runner.test.mjs @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import { BEHAVIORS } from '../src/spec/behaviors.mjs'; +import { NOT_IMPLEMENTED, runConformance } from '../src/runner/runner.mjs'; +import { formatReport, toJsonReport } from '../src/runner/report.mjs'; +import { createReferenceAdapter } from '../src/adapters/reference-adapter.mjs'; + +const clientBehaviors = BEHAVIORS.filter((behavior) => behavior.category !== 'lifecycle'); + +describe('conformance runner', () => { + it('reports the reference adapter as conformant', async () => { + const report = await runConformance(createReferenceAdapter(), { behaviors: clientBehaviors }); + + const failures = report.results.filter((result) => result.outcome === 'fail'); + expect(failures, formatReport(report)).toEqual([]); + expect(report.conformant).toBe(true); + }); + + it('stamps the report with both versions and the implementation identity', async () => { + const report = await runConformance(createReferenceAdapter(), { behaviors: clientBehaviors }); + + expect(report.suiteVersion).toMatch(/^\d+\.\d+\.\d+$/); + expect(report.specVersion).toMatch(/^\d+\.\d+\.\d+$/); + expect(report.implementation).toBe('openiap-reference'); + expect(report.store).toBe('Google'); + }); + + // The failure mode that makes a suite worthless: an adapter that implements + // nothing and is reported as compliant. + it('fails an adapter that does not implement a MUST behavior', async () => { + const adapter = { implementation: 'empty', store: 'Google', behaviors: {} }; + const report = await runConformance(adapter, { behaviors: clientBehaviors }); + + expect(report.conformant).toBe(false); + expect(report.counts.fail).toBeGreaterThan(0); + expect(report.results.every((result) => result.outcome !== 'pass')).toBe(true); + }); + + it('fails an applicable MUST behavior that explicitly reports not-implemented', async () => { + const behavior = clientBehaviors.find( + (item) => item.id === 'identifiers.purchase-carries-a-concrete-store', + ); + const adapter = { + implementation: 'not-implemented', + store: 'Google', + behaviors: { [behavior.id]: async () => NOT_IMPLEMENTED }, + }; + + const report = await runConformance(adapter, { behaviors: [behavior] }); + + expect(report.conformant).toBe(false); + expect(report.results[0]).toMatchObject({ + outcome: 'fail', + reason: 'adapter reported not-implemented', + }); + }); + + it('propagates a violated assertion as a failure with its reason', async () => { + const adapter = createReferenceAdapter(); + adapter.behaviors['identifiers.purchase-carries-a-concrete-store'] = async () => { + throw new Error('store was Unknown'); + }; + + const report = await runConformance(adapter, { behaviors: clientBehaviors }); + const result = report.results.find( + (item) => item.id === 'identifiers.purchase-carries-a-concrete-store', + ); + + expect(result.outcome).toBe('fail'); + expect(result.reason).toBe('store was Unknown'); + expect(report.conformant).toBe(false); + }); + + // Capability gating must come from the matrix, not from the adapter, or an + // implementation could excuse itself from its own requirements. + it('marks capability-gated behavior not-applicable for a store that cannot support it', async () => { + const adapter = createReferenceAdapter({ store: 'Apple' }); + const report = await runConformance(adapter, { behaviors: clientBehaviors }); + + const alreadyOwned = report.results.find( + (item) => item.id === 'purchases.already-owned-surfaces-already-owned-error', + ); + expect(alreadyOwned.outcome).toBe('not-applicable'); + expect(alreadyOwned.capabilityLevel).toBe('unsupported'); + }); + + it('still requires capability-gated behavior of a store that must support it', async () => { + const report = await runConformance(createReferenceAdapter({ store: 'Google' }), { + behaviors: clientBehaviors, + }); + + const pending = report.results.find( + (item) => item.id === 'purchases.pending-purchase-is-not-delivered-as-purchased', + ); + expect(pending.outcome).toBe('pass'); + expect(pending.capabilityLevel).toBe('required'); + }); + + it('does not require an optional capability that the adapter omits', async () => { + const behavior = BEHAVIORS.find( + (item) => item.id === 'lifecycle.purchase-starts-active-entitlement', + ); + const adapter = { implementation: 'amazon-without-webhooks', store: 'Amazon', behaviors: {} }; + + const report = await runConformance(adapter, { behaviors: [behavior] }); + + expect(report.conformant).toBe(true); + expect(report.results[0]).toMatchObject({ + outcome: 'not-applicable', + capabilityLevel: 'optional', + }); + }); + + it('checks an optional capability when the adapter implements it', async () => { + const behavior = BEHAVIORS.find( + (item) => item.id === 'lifecycle.purchase-starts-active-entitlement', + ); + const adapter = { + implementation: 'amazon-with-webhooks', + store: 'Amazon', + behaviors: { + [behavior.id]: async () => { + throw new Error('optional implementation violated the behavior'); + }, + }, + }; + + const report = await runConformance(adapter, { behaviors: [behavior] }); + + expect(report.conformant).toBe(false); + expect(report.results[0]).toMatchObject({ + outcome: 'fail', + capabilityLevel: 'optional', + }); + }); + + it('runs an absence check for an unsupported capability when the adapter supplies one', async () => { + const adapter = createReferenceAdapter({ store: 'Apple' }); + let ran = false; + adapter.absenceChecks = { + 'purchases.already-owned-surfaces-already-owned-error': async () => { + ran = true; + }, + }; + + const report = await runConformance(adapter, { behaviors: clientBehaviors }); + const alreadyOwned = report.results.find( + (item) => item.id === 'purchases.already-owned-surfaces-already-owned-error', + ); + + expect(ran).toBe(true); + expect(alreadyOwned.outcome).toBe('pass'); + }); + + it('rejects an adapter missing its identity', async () => { + await expect(runConformance({ store: 'Google' })).rejects.toThrow(/implementation is required/); + await expect(runConformance({ implementation: 'x' })).rejects.toThrow(/store is required/); + }); + + it('rejects a store that is absent from the capability matrix', async () => { + await expect( + runConformance({ implementation: 'future-store', store: 'Samsung', behaviors: {} }), + ).rejects.toThrow(/adapter\.store must be one of/); + }); +}); +describe('conformance report', () => { + it('renders a human-readable verdict naming both versions', async () => { + const report = await runConformance(createReferenceAdapter(), { behaviors: clientBehaviors }); + const text = formatReport(report); + + expect(text).toContain('OpenIAP Conformance Report'); + expect(text).toContain('openiap-reference'); + expect(text).toContain(`suite ${report.suiteVersion}`); + expect(text).toContain(`conformant with OpenIAP ${report.specVersion}`); + }); + + it('renders a non-conformant verdict when a behavior fails', async () => { + const adapter = { implementation: 'empty', store: 'Google', behaviors: {} }; + const report = await runConformance(adapter, { behaviors: clientBehaviors }); + + expect(formatReport(report)).toContain('NOT conformant'); + }); + + it('emits a stable JSON artifact', async () => { + const report = await runConformance(createReferenceAdapter(), { behaviors: clientBehaviors }); + const parsed = JSON.parse(toJsonReport(report)); + + expect(parsed.suiteVersion).toBe(report.suiteVersion); + expect(parsed.conformant).toBe(true); + expect(parsed.results.length).toBe(clientBehaviors.length); + expect(parsed.results[0]).toHaveProperty('id'); + expect(parsed.results[0]).toHaveProperty('outcome'); + }); +}); diff --git a/packages/conformance/test/spec.test.mjs b/packages/conformance/test/spec.test.mjs new file mode 100644 index 000000000..05ea27645 --- /dev/null +++ b/packages/conformance/test/spec.test.mjs @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + CAPABILITY_MATRIX, + CAPABILITY_STORES, +} from '../../gql/src/capability-matrix.mjs'; +import { + BEHAVIORS, + BEHAVIOR_CATEGORIES, + BEHAVIOR_LEVELS, + behaviorById, + behaviorIds, +} from '../src/spec/behaviors.mjs'; +import { SUITE_VERSION, specVersion } from '../src/spec/version.mjs'; +import { runConformance } from '../src/runner/runner.mjs'; + +describe('conformance behavior spec', () => { + it('gives every behavior a unique id', () => { + const ids = behaviorIds(); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('namespaces every id under its category', () => { + for (const behavior of BEHAVIORS) { + expect(behavior.id.startsWith(`${behavior.category}.`), behavior.id).toBe(true); + } + }); + + it('uses only declared categories and levels', () => { + for (const behavior of BEHAVIORS) { + expect(BEHAVIOR_CATEGORIES, behavior.id).toContain(behavior.category); + expect(BEHAVIOR_LEVELS, behavior.id).toContain(behavior.level); + } + }); + + it('states every behavior as a testable requirement', () => { + for (const behavior of BEHAVIORS) { + expect(behavior.statement?.trim(), behavior.id).toBeTruthy(); + expect(behavior.statement.length, `${behavior.id} statement too terse`).toBeGreaterThan(20); + } + }); + + // A capability gate that names a non-existent capability would silently make + // the behavior inapplicable everywhere. + it('gates behaviors only on capabilities the matrix defines', () => { + for (const behavior of BEHAVIORS) { + if (!behavior.capability) continue; + expect(Object.keys(CAPABILITY_MATRIX), behavior.id).toContain(behavior.capability); + } + }); + + it('covers every core conformance category', () => { + const covered = new Set(BEHAVIORS.map((behavior) => behavior.category)); + for (const category of [ + 'products', + 'purchases', + 'completion', + 'restoration', + 'subscriptions', + 'lifecycle', + 'errors', + 'identifiers', + 'capabilities', + ]) { + expect(covered, `no behavior covers ${category}`).toContain(category); + } + }); + + it('binds the suite version to a released spec version', () => { + expect(SUITE_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + expect(specVersion()).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('resolves behaviors by id and rejects unknown ids', () => { + expect(behaviorById(BEHAVIORS[0].id)).toBe(BEHAVIORS[0]); + expect(() => behaviorById('nope.not-real')).toThrow(/Unknown conformance behavior/); + }); + + it('keeps every capability-matrix store addressable by the runner', async () => { + for (const store of CAPABILITY_STORES) { + const report = await runConformance( + { implementation: `matrix-addressability-${store}`, store, behaviors: {} }, + { behaviors: [] }, + ); + expect(report.store).toBe(store); + } + }); +}); diff --git a/packages/docs/public/ecosystem.webp b/packages/docs/public/ecosystem.webp deleted file mode 100644 index 403b68595..000000000 Binary files a/packages/docs/public/ecosystem.webp and /dev/null differ diff --git a/packages/docs/src/lib/searchData.ts b/packages/docs/src/lib/searchData.ts index 174090052..ae394349b 100644 --- a/packages/docs/src/lib/searchData.ts +++ b/packages/docs/src/lib/searchData.ts @@ -917,14 +917,15 @@ export const apiData: ApiItem[] = [ title: 'VerifyPurchaseResultAndroid', category: 'Types (Android)', description: - 'Android verification result: autoRenewing, cancelDate, renewalDate, transactionId', + 'Android verification result: isValid, autoRenewing, cancelDate, renewalDate, transactionId', path: '/docs/types/verify-purchase#verify-purchase-result-android', }, { id: 'verify-purchase-result-horizon', title: 'VerifyPurchaseResultHorizon', category: 'Types (Horizon)', - description: 'Meta Quest verification result: success, grantTime', + description: + 'Meta Quest verification result: isValid, grantTime, deprecated success alias', path: '/docs/types/verify-purchase#verify-purchase-result-horizon', }, diff --git a/packages/docs/src/pages/docs/foundation/one-pager.tsx b/packages/docs/src/pages/docs/foundation/one-pager.tsx index d80191cd5..59e2fd731 100644 --- a/packages/docs/src/pages/docs/foundation/one-pager.tsx +++ b/packages/docs/src/pages/docs/foundation/one-pager.tsx @@ -198,7 +198,10 @@ function OnePager() { Conformance Tests - Cross-platform test matrix ensuring behavioral consistency + Shared behavioral suites executed against every Android store + implementation and every IAPKit verification provider, plus a + machine-checked store capability matrix. Framework binding + coverage is in progress. diff --git a/packages/docs/src/pages/docs/foundation/roadmap-budget.tsx b/packages/docs/src/pages/docs/foundation/roadmap-budget.tsx index 9185c51b7..4cbd9a08f 100644 --- a/packages/docs/src/pages/docs/foundation/roadmap-budget.tsx +++ b/packages/docs/src/pages/docs/foundation/roadmap-budget.tsx @@ -68,10 +68,13 @@ function RoadmapBudget() { Conformance test suite v1 - Basic cross-platform tests ensuring behavioral consistency - across generated types + Shared behavioral expectations executed against every store + implementation and verification provider, backed by a + machine-checked capability matrix. Android stores, Apple, IAPKit + providers, React Native IAP, and Expo IAP are covered; Flutter, + KMP, MAUI, and Godot adapters are next. - Planned + In Progress Founding supporter outreach diff --git a/packages/docs/src/pages/docs/foundation/sponsorship.tsx b/packages/docs/src/pages/docs/foundation/sponsorship.tsx index 76228fe15..e97fe12c8 100644 --- a/packages/docs/src/pages/docs/foundation/sponsorship.tsx +++ b/packages/docs/src/pages/docs/foundation/sponsorship.tsx @@ -65,8 +65,9 @@ function Sponsorship() { Conformance and test matrix - Cross-platform tests catch regressions before they hit your - production apps + Shared behavioral tests run every store implementation through + the same expectations, so normalization regressions are caught + before they reach your production apps diff --git a/packages/docs/src/pages/docs/types/verify-purchase.tsx b/packages/docs/src/pages/docs/types/verify-purchase.tsx index b9a77d69d..b8f90739a 100644 --- a/packages/docs/src/pages/docs/types/verify-purchase.tsx +++ b/packages/docs/src/pages/docs/types/verify-purchase.tsx @@ -178,6 +178,12 @@ function VerifyPurchase() { + + + isValid + + Whether the entitlement verification succeeded + autoRenewing @@ -314,10 +320,19 @@ function VerifyPurchase() { - success + isValid Whether the entitlement verification succeeded + + + success + + + Deprecated alias for isValid; scheduled for + removal in OpenIAP 4.0 + + grantTime diff --git a/packages/docs/src/pages/introduction.tsx b/packages/docs/src/pages/introduction.tsx index 3f499a8ef..5e15a6702 100644 --- a/packages/docs/src/pages/introduction.tsx +++ b/packages/docs/src/pages/introduction.tsx @@ -140,21 +140,6 @@ function Introduction() { native code for each target platform.

-
- - OpenIAP Architecture - GraphQL schema generates native modules - -

- View ecosystem documentation → -

-
-

Code Generation

The{' '} diff --git a/packages/docs/src/styles/pages.css b/packages/docs/src/styles/pages.css index 5268df7b4..63bdf5b3f 100644 --- a/packages/docs/src/styles/pages.css +++ b/packages/docs/src/styles/pages.css @@ -424,24 +424,6 @@ margin-bottom: 0.75rem; } -.intro-image-container { - margin: 1.5rem 0; - text-align: center; -} - -.intro-image { - max-width: 100%; - height: auto; - border-radius: 8px; - border: 1px solid var(--border-color); -} - -.intro-image-caption { - font-size: 0.85rem; - color: var(--text-secondary); - margin-top: 0.75rem; -} - .intro-code-output { background: var(--bg-secondary); border-radius: 8px; diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index b02d8b295..e803dccbf 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -148,14 +148,18 @@ android { java.srcDirs("src/amazon/java") manifest.srcFile("src/amazon/AndroidManifest.xml") } + // src/conformanceTest/java holds the shared behavioral conformance + // suite. It is compiled into every store flavor's unit tests so a + // behavior is declared once and executed against all of them; each + // flavor supplies only a StoreConformanceAdapter from its own set. named("testPlay") { - java.srcDirs("src/testPlay/java") + java.srcDirs("src/testPlay/java", "src/conformanceTest/java") } named("testHorizon") { - java.srcDirs("src/testHorizon/java") + java.srcDirs("src/testHorizon/java", "src/conformanceTest/java") } named("testAmazon") { - java.srcDirs("src/testAmazon/java") + java.srcDirs("src/testAmazon/java", "src/conformanceTest/java") } } diff --git a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt index 5ec9fa58f..7a5bbc3db 100644 --- a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt @@ -26,6 +26,7 @@ import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener +import dev.hyo.openiap.utils.toActiveSubscription import dev.hyo.openiap.utils.verifyPurchaseWithIapkit import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CancellationException @@ -394,6 +395,24 @@ internal fun buildAmazonPurchase( */ internal suspend fun unsupportedRedeemOfferCode(): Boolean = false +internal fun amazonPurchaseError( + status: String?, + sku: String, +): OpenIapError? = when (status) { + "SUCCESSFUL" -> null + "ALREADY_PURCHASED" -> + OpenIapError.ItemAlreadyOwned("Amazon reported the item is already purchased").withProductId(sku) + "INVALID_SKU" -> OpenIapError.SkuNotFound(sku) + "NOT_SUPPORTED" -> + OpenIapError.FeatureNotSupported("Amazon Appstore IAP is not supported on this device").withProductId(sku) + "INACTIVE_BASE_SUBSCRIPTION" -> + OpenIapError.ItemUnavailable("Amazon add-on purchase requires an active base subscription").withProductId(sku) + "PENDING" -> OpenIapError.DeferredPurchase().withProductId(sku) + "FAILED" -> + OpenIapError.PurchaseFailed("Amazon purchase did not complete successfully").withProductId(sku) + else -> OpenIapError.UnknownError("Unrecognized Amazon purchase response").withProductId(sku) +} + class OpenIapModule( private val context: Context, ) : OpenIapProtocol, PurchasingListener { @@ -619,19 +638,7 @@ class OpenIapModule( .filter { purchase -> purchase.isAutoRenewing && (ids.isEmpty() || purchase.productId in ids) } - .map { purchase -> - ActiveSubscription( - autoRenewingAndroid = purchase.autoRenewingAndroid, - basePlanIdAndroid = purchase.currentPlanId, - currentPlanId = purchase.currentPlanId, - isActive = purchase.purchaseState == PurchaseState.Purchased, - productId = purchase.productId, - purchaseToken = purchase.purchaseToken, - purchaseTokenAndroid = purchase.purchaseToken, - transactionDate = purchase.transactionDate, - transactionId = purchase.transactionId ?: purchase.id - ) - } + .map { purchase -> purchase.toActiveSubscription() } } } @@ -738,35 +745,9 @@ class OpenIapModule( } listOf(purchase) } - PurchaseResponse.RequestStatus.ALREADY_PURCHASED -> { - val error = OpenIapError.ItemAlreadyOwned("Amazon reported the item is already purchased") - .withProductId(sku) - emitPurchaseErrorAndThrow(error) - } - PurchaseResponse.RequestStatus.INVALID_SKU -> { - val error = OpenIapError.SkuNotFound(sku) - emitPurchaseErrorAndThrow(error) - } - PurchaseResponse.RequestStatus.NOT_SUPPORTED -> { - val error = OpenIapError.FeatureNotSupported("Amazon Appstore IAP is not supported on this device") - .withProductId(sku) - emitPurchaseErrorAndThrow(error) - } - PurchaseResponse.RequestStatus.INACTIVE_BASE_SUBSCRIPTION -> { - val error = OpenIapError.ItemUnavailable( - "Amazon add-on purchase requires an active base subscription", - ).withProductId(sku) - emitPurchaseErrorAndThrow(error) - } - PurchaseResponse.RequestStatus.PENDING -> { - val error = OpenIapError.DeferredPurchase().withProductId(sku) - emitPurchaseErrorAndThrow(error) - } - PurchaseResponse.RequestStatus.FAILED -> { - val error = OpenIapError.UserCancelled("Amazon purchase failed or was cancelled") - .withProductId(sku) - emitPurchaseErrorAndThrow(error) - } + else -> emitPurchaseErrorAndThrow( + checkNotNull(amazonPurchaseError(response.requestStatus.name, sku)) + ) } } } catch (_: OpenIapError) { diff --git a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/utils/AmazonBillingConverters.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/utils/AmazonBillingConverters.kt new file mode 100644 index 000000000..7c8b32894 --- /dev/null +++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/utils/AmazonBillingConverters.kt @@ -0,0 +1,19 @@ +package dev.hyo.openiap.utils + +import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.PurchaseAndroid +import dev.hyo.openiap.PurchaseState + +/** Mirrors the Play and Horizon `toActiveSubscription()` seam. */ +fun PurchaseAndroid.toActiveSubscription(): ActiveSubscription = ActiveSubscription( + autoRenewingAndroid = autoRenewingAndroid, + basePlanIdAndroid = currentPlanId, + currentPlanId = currentPlanId, + isActive = purchaseState == PurchaseState.Purchased, + productId = productId, + purchaseToken = purchaseToken, + purchaseTokenAndroid = purchaseToken, + transactionDate = transactionDate, + // Restored receipts can arrive without a transactionId. + transactionId = transactionId ?: id, +) diff --git a/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt b/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt new file mode 100644 index 000000000..0b1fb22e1 --- /dev/null +++ b/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/ConformanceBehaviors.kt @@ -0,0 +1,52 @@ +package dev.hyo.openiap.conformance + +// Generated by packages/conformance/scripts/generate-behavior-ids.mjs +// Do not edit. Behavior ids are the versioned public contract; see +// packages/conformance/src/spec/behaviors.mjs. + +object ConformanceBehaviors { + const val SUITE_VERSION = "1.0.0" + + const val PRODUCTS_FETCH_RETURNS_REQUESTED_SKUS = "products.fetch-returns-requested-skus" + const val PRODUCTS_FETCH_NORMALIZES_REQUIRED_FIELDS = "products.fetch-normalizes-required-fields" + const val PRODUCTS_FETCH_EMPTY_SKU_LIST_IS_AN_ERROR = "products.fetch-empty-sku-list-is-an-error" + const val PRODUCTS_FETCH_SEPARATES_IN_APP_AND_SUBSCRIPTION_TYPES = "products.fetch-separates-in-app-and-subscription-types" + const val PURCHASES_REQUEST_EMITS_PURCHASE_UPDATED_ON_SUCCESS = "purchases.request-emits-purchase-updated-on-success" + const val PURCHASES_REQUEST_EMITS_ERROR_ON_USER_CANCEL = "purchases.request-emits-error-on-user-cancel" + const val PURCHASES_ALREADY_OWNED_SURFACES_ALREADY_OWNED_ERROR = "purchases.already-owned-surfaces-already-owned-error" + const val PURCHASES_PENDING_PURCHASE_IS_NOT_DELIVERED_AS_PURCHASED = "purchases.pending-purchase-is-not-delivered-as-purchased" + const val PURCHASES_UNKNOWN_SKU_SURFACES_SKU_NOT_FOUND = "purchases.unknown-sku-surfaces-sku-not-found" + const val COMPLETION_FINISH_REMOVES_TRANSACTION_FROM_PENDING = "completion.finish-removes-transaction-from-pending" + const val COMPLETION_FINISH_IS_IDEMPOTENT = "completion.finish-is-idempotent" + const val COMPLETION_UNFINISHED_PURCHASE_REMAINS_AVAILABLE = "completion.unfinished-purchase-remains-available" + const val RESTORATION_AVAILABLE_PURCHASES_RETURNS_OWNED_ITEMS = "restoration.available-purchases-returns-owned-items" + const val RESTORATION_AVAILABLE_PURCHASES_EXCLUDES_CONSUMED_ITEMS = "restoration.available-purchases-excludes-consumed-items" + const val RESTORATION_AVAILABLE_PURCHASES_IS_EMPTY_FOR_NEW_USER = "restoration.available-purchases-is-empty-for-new-user" + const val SUBSCRIPTIONS_ACTIVE_SUBSCRIPTION_IS_REPORTED_ACTIVE = "subscriptions.active-subscription-is-reported-active" + const val SUBSCRIPTIONS_PENDING_SUBSCRIPTION_IS_NOT_ACTIVE = "subscriptions.pending-subscription-is-not-active" + const val SUBSCRIPTIONS_UNKNOWN_STATE_SUBSCRIPTION_IS_NOT_ACTIVE = "subscriptions.unknown-state-subscription-is-not-active" + const val SUBSCRIPTIONS_GROUPS_KEEP_INDEPENDENT_IDENTIFIERS = "subscriptions.groups-keep-independent-identifiers" + const val SUBSCRIPTIONS_HAS_ACTIVE_AGREES_WITH_GET_ACTIVE = "subscriptions.has-active-agrees-with-get-active" + const val LIFECYCLE_PURCHASE_STARTS_ACTIVE_ENTITLEMENT = "lifecycle.purchase-starts-active-entitlement" + const val LIFECYCLE_EXPIRY_ENDS_ENTITLEMENT = "lifecycle.expiry-ends-entitlement" + const val LIFECYCLE_GRACE_PERIOD_RETAINS_ENTITLEMENT = "lifecycle.grace-period-retains-entitlement" + const val LIFECYCLE_BILLING_RETRY_SUSPENDS_ENTITLEMENT = "lifecycle.billing-retry-suspends-entitlement" + const val LIFECYCLE_REFUND_ENDS_ENTITLEMENT = "lifecycle.refund-ends-entitlement" + const val LIFECYCLE_REVOKE_ENDS_ENTITLEMENT = "lifecycle.revoke-ends-entitlement" + const val LIFECYCLE_CANCEL_RETAINS_ENTITLEMENT_UNTIL_EXPIRY = "lifecycle.cancel-retains-entitlement-until-expiry" + const val ERRORS_STORE_CODES_NORMALIZE_TO_SPEC_ERROR_CODES = "errors.store-codes-normalize-to-spec-error-codes" + const val ERRORS_UNRECOGNIZED_STORE_CODE_NORMALIZES_TO_UNKNOWN = "errors.unrecognized-store-code-normalizes-to-unknown" + const val ERRORS_UNSUPPORTED_CODES_ARE_NOT_SYNTHESIZED = "errors.unsupported-codes-are-not-synthesized" + const val VERIFICATION_RESULT_EXPOSES_UNIFORM_VALIDITY = "verification.result-exposes-uniform-validity" + const val IDENTIFIERS_PURCHASE_CARRIES_A_CONCRETE_STORE = "identifiers.purchase-carries-a-concrete-store" + const val IDENTIFIERS_PURCHASE_TOKEN_IS_STABLE_ACROSS_READS = "identifiers.purchase-token-is-stable-across-reads" + const val CAPABILITIES_UNSUPPORTED_OPERATIONS_DEGRADE_PREDICTABLY = "capabilities.unsupported-operations-degrade-predictably" + const val CAPABILITIES_DECLARED_CAPABILITIES_MATCH_THE_MATRIX = "capabilities.declared-capabilities-match-the-matrix" + + /** Capability level per store, from packages/gql/src/capability-matrix.mjs. */ + val CAPABILITY_MATRIX: Map> = mapOf( + "pendingPurchases" to mapOf("Google" to "required", "Horizon" to "required", "Amazon" to "required"), + "subscriptionBillingIssue" to mapOf("Google" to "required", "Horizon" to "unsupported", "Amazon" to "unsupported"), + "offerCodeRedemption" to mapOf("Google" to "required", "Horizon" to "unsupported", "Amazon" to "unsupported"), + ) +} diff --git a/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt b/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt new file mode 100644 index 000000000..d9c426f43 --- /dev/null +++ b/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt @@ -0,0 +1,68 @@ +package dev.hyo.openiap.conformance + +import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.ErrorCode +import dev.hyo.openiap.IapStore +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.PurchaseAndroid + +/** + * Capabilities a store may or may not provide, so "this store cannot do X" is + * data rather than a missing test file. + */ +enum class StoreCapability { + /** Store reports a distinct PENDING purchase state (deferred payment). */ + PendingPurchases, + + /** Store reports subscription billing-issue / suspension signals. */ + SubscriptionBillingIssue, + + /** Store exposes an offer-code redemption entry point. */ + OfferCodeRedemption, +} + +data class StoreErrorCase( + val nativeCode: String, + val expected: ErrorCode, + val actual: OpenIapError, +) + +/** + * The seam [StoreConformanceSuite] drives. Each Gradle flavor supplies one + * implementation from its own test source set; this is the only place flavors + * are allowed to differ. + */ +interface StoreConformanceAdapter { + /** The store discriminator this implementation must stamp on purchases. */ + val store: IapStore + + /** Behaviors this store supports. See [StoreCapability]. */ + val capabilities: Set + + /** The flavor's `toActiveSubscription()` binding. */ + fun toActiveSubscription(purchase: PurchaseAndroid): ActiveSubscription + + /** Store-native failure values passed through the production mapper. */ + val normativeErrorCases: List + + /** The production mapper's fail-closed result for an unknown native value. */ + val unrecognizedError: OpenIapError + + /** A documented unsupported operation result, or null when none is selected. */ + fun unsupportedOperationResult(): Boolean? +} + +fun playBillingErrorCases(mapper: (Int) -> OpenIapError): List = listOf( + StoreErrorCase("1", ErrorCode.UserCancelled, mapper(1)), + StoreErrorCase("2", ErrorCode.ServiceError, mapper(2)), + StoreErrorCase("3", ErrorCode.BillingUnavailable, mapper(3)), + StoreErrorCase("4", ErrorCode.ItemUnavailable, mapper(4)), + StoreErrorCase("5", ErrorCode.DeveloperError, mapper(5)), + StoreErrorCase("6", ErrorCode.ServiceError, mapper(6)), + StoreErrorCase("7", ErrorCode.AlreadyOwned, mapper(7)), + StoreErrorCase("8", ErrorCode.ItemNotOwned, mapper(8)), + StoreErrorCase("-1", ErrorCode.ServiceDisconnected, mapper(-1)), + StoreErrorCase("-2", ErrorCode.FeatureNotSupported, mapper(-2)), + StoreErrorCase("-3", ErrorCode.ServiceTimeout, mapper(-3)), + StoreErrorCase("12", ErrorCode.NetworkError, mapper(12)), +) diff --git a/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceSuite.kt b/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceSuite.kt new file mode 100644 index 000000000..fee36f244 --- /dev/null +++ b/packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceSuite.kt @@ -0,0 +1,221 @@ +package dev.hyo.openiap.conformance + +import dev.hyo.openiap.ErrorCode +import dev.hyo.openiap.IapStore +import dev.hyo.openiap.PurchaseAndroid +import dev.hyo.openiap.PurchaseState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Behavioral conformance expectations for every Android store, declared once + * and compiled into the testPlay, testHorizon, and testAmazon source sets. + * + * Adding a store means adding a [StoreConformanceAdapter], not another copy of + * these tests. + */ +abstract class StoreConformanceSuite { + + protected abstract val adapter: StoreConformanceAdapter + + // --- Spec binding ------------------------------------------------------ + + /** + * Behavior ids from packages/conformance this suite demonstrates. Asserted + * against the generated [ConformanceBehaviors] so a renamed or retired id + * fails here instead of silently losing coverage. + */ + private val coveredBehaviors = listOf( + ConformanceBehaviors.SUBSCRIPTIONS_ACTIVE_SUBSCRIPTION_IS_REPORTED_ACTIVE, + ConformanceBehaviors.SUBSCRIPTIONS_PENDING_SUBSCRIPTION_IS_NOT_ACTIVE, + ConformanceBehaviors.SUBSCRIPTIONS_UNKNOWN_STATE_SUBSCRIPTION_IS_NOT_ACTIVE, + ConformanceBehaviors.SUBSCRIPTIONS_GROUPS_KEEP_INDEPENDENT_IDENTIFIERS, + ConformanceBehaviors.ERRORS_STORE_CODES_NORMALIZE_TO_SPEC_ERROR_CODES, + ConformanceBehaviors.ERRORS_UNRECOGNIZED_STORE_CODE_NORMALIZES_TO_UNKNOWN, + ConformanceBehaviors.IDENTIFIERS_PURCHASE_CARRIES_A_CONCRETE_STORE, + ConformanceBehaviors.CAPABILITIES_DECLARED_CAPABILITIES_MATCH_THE_MATRIX, + ) + + private val unsupportedStoreBehaviors = listOf( + ConformanceBehaviors.CAPABILITIES_UNSUPPORTED_OPERATIONS_DEGRADE_PREDICTABLY, + ) + + @Test + fun `suite declares the spec behaviors it covers`() { + val declarations = coveredBehaviors + unsupportedStoreBehaviors + assertEquals(9, declarations.size) + assertEquals(declarations.size, declarations.toSet().size) + for (id in declarations) { + assertTrue("behavior id must be non-blank", id.isNotBlank()) + assertTrue("behavior id must be namespaced: $id", id.contains('.')) + } + } + + // --- Entitlement integrity (assertions with financial consequence) ---- + + @Test + fun `purchased subscription is an active entitlement`() { + val active = adapter.toActiveSubscription( + purchase("dev.hyo.martie.premium.monthly", "token-premium", PurchaseState.Purchased), + ) + + assertTrue( + "${adapter.store}: a Purchased subscription must be an active entitlement", + active.isActive, + ) + } + + // Unconditional: producing a Pending state is a capability, but what + // Pending means is not negotiable. + @Test + fun `pending subscription is not an active entitlement`() { + val pending = adapter.toActiveSubscription( + purchase("dev.hyo.martie.premium.monthly", "token-pending", PurchaseState.Pending), + ) + + assertFalse( + "${adapter.store}: a Pending purchase is unpaid and must not be an active entitlement", + pending.isActive, + ) + } + + @Test + fun `unknown-state subscription is not an active entitlement`() { + val unknown = adapter.toActiveSubscription( + purchase("dev.hyo.martie.premium.monthly", "token-unknown", PurchaseState.Unknown), + ) + + assertFalse( + "${adapter.store}: an Unknown-state purchase must not be an active entitlement", + unknown.isActive, + ) + } + + // --- Identifier normalization ---------------------------------------- + + @Test + fun `active subscriptions keep independent product ids for multiple groups`() { + val premium = adapter.toActiveSubscription( + purchase("dev.hyo.martie.premium.monthly", "token-premium"), + ) + val pro = adapter.toActiveSubscription( + purchase("dev.hyo.martie.pro.monthly", "token-pro"), + ) + + assertEquals("dev.hyo.martie.premium.monthly", premium.productId) + assertEquals("dev.hyo.martie.premium.monthly", premium.currentPlanId) + assertEquals("token-premium", premium.purchaseToken) + assertEquals("dev.hyo.martie.pro.monthly", pro.productId) + assertEquals("dev.hyo.martie.pro.monthly", pro.currentPlanId) + assertEquals("token-pro", pro.purchaseToken) + } + + @Test + fun `active subscription carries the purchase token on both token fields`() { + val active = adapter.toActiveSubscription( + purchase("dev.hyo.martie.premium.monthly", "token-premium"), + ) + + assertEquals("token-premium", active.purchaseToken) + assertEquals("token-premium", active.purchaseTokenAndroid) + } + + // --- Normalized error codes ------------------------------------------- + // Adapters bind these assertions to their store-native production mapper. + + @Test + fun `store response codes normalize to the specified error codes`() { + for (errorCase in adapter.normativeErrorCases) { + assertEquals( + "${adapter.store}: ${errorCase.nativeCode} must normalize to ${errorCase.expected.rawValue}", + errorCase.expected.rawValue, + errorCase.actual.code, + ) + } + } + + @Test + fun `unrecognized store response codes normalize to Unknown`() { + assertEquals( + "${adapter.store}: an unrecognized response code must normalize to Unknown", + ErrorCode.Unknown.rawValue, + adapter.unrecognizedError.code, + ) + } + + @Test + fun `unsupported offer code redemption returns its documented no-op`() { + val result = adapter.unsupportedOperationResult() + if (StoreCapability.OfferCodeRedemption in adapter.capabilities) { + assertEquals(null, result) + } else { + assertEquals(false, result) + } + } + + // --- Capabilities ------------------------------------------------------ + + /** + * An adapter that declares capabilities its store does not have would let + * capability-gated checks silently pass. The matrix is the authority. + */ + @Test + fun `declared capabilities match the specification matrix`() { + val expectations = mapOf( + StoreCapability.PendingPurchases to "pendingPurchases", + StoreCapability.SubscriptionBillingIssue to "subscriptionBillingIssue", + StoreCapability.OfferCodeRedemption to "offerCodeRedemption", + ) + + for ((capability, behavior) in expectations) { + val level = ConformanceBehaviors.CAPABILITY_MATRIX[behavior] + ?.get(adapter.store.name) + ?: error("capability matrix has no $behavior entry for ${adapter.store}") + + val declared = capability in adapter.capabilities + assertEquals( + "${adapter.store}: $behavior is \"$level\" in the matrix but declared=$declared", + level != "unsupported", + declared, + ) + } + } + + // --- Store discriminator --------------------------------------------- + + @Test + fun `adapter declares a concrete store discriminator`() { + assertTrue( + "a store implementation must not report IapStore.Unknown", + adapter.store != IapStore.Unknown, + ) + } + + // --- Fixtures --------------------------------------------------------- + + private fun purchase( + productId: String, + token: String, + state: PurchaseState = PurchaseState.Purchased, + ): PurchaseAndroid = PurchaseAndroid( + autoRenewingAndroid = true, + currentPlanId = productId, + dataAndroid = "{}", + id = token, + ids = listOf(productId), + isAcknowledgedAndroid = true, + isAutoRenewing = true, + packageNameAndroid = "dev.hyo.martie", + productId = productId, + purchaseState = state, + purchaseToken = token, + quantity = 1, + signatureAndroid = null, + store = adapter.store, + transactionDate = 1_700_000_000_000.0, + transactionId = token, + ) + +} diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index f4dda7299..ca650febc 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -1395,7 +1395,7 @@ class OpenIapModule( "Horizon verifyPurchase requires appId to be set during initConnection" ) val horizonResult = verifyPurchaseWithHorizon(props, horizonAppId, TAG) - if (!horizonResult.success) { + if (!horizonResult.isValid) { throw OpenIapError.InvalidPurchaseVerification } horizonResult diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt index 8829e395d..2f0e15acc 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt @@ -155,11 +155,13 @@ internal object HorizonBillingConverters { ) } + // Horizon maps PENDING through fromHorizonState, so this must gate on + // Purchased: a pending purchase is unpaid and is not an entitlement. fun HorizonPurchase.toActiveSubscription(): ActiveSubscription = ActiveSubscription( autoRenewingAndroid = isAutoRenewing(), basePlanIdAndroid = null, currentPlanId = null, - isActive = true, + isActive = PurchaseState.fromHorizonState(getPurchaseState()) == PurchaseState.Purchased, productId = products.firstOrNull().orEmpty(), purchaseToken = purchaseToken, purchaseTokenAndroid = purchaseToken, @@ -172,7 +174,7 @@ internal object HorizonBillingConverters { autoRenewingAndroid = autoRenewingAndroid, basePlanIdAndroid = currentPlanId, currentPlanId = currentPlanId, - isActive = true, + isActive = purchaseState == PurchaseState.Purchased, productId = productId, purchaseToken = purchaseToken, purchaseTokenAndroid = purchaseToken, 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 94114a587..0d7cb5ef1 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 @@ -1343,6 +1343,16 @@ public interface PurchaseCommon { val transactionDate: Double } +/** + * Validity shared by every store-specific purchase verification result. + */ +public interface VerifyPurchaseResultCommon { + /** + * Whether the purchase is valid, without inspecting the concrete result variant. + */ + val isValid: Boolean +} + // MARK: - Objects public data class ActiveSubscription( @@ -3961,6 +3971,11 @@ public data class VerifyPurchaseResultAndroid( val deferredSku: String? = null, val freeTrialEndDate: Double, val gracePeriodEndDate: Double, + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + override val isValid: Boolean, val parentProductId: String, val productId: String, val productType: String, @@ -3971,7 +3986,7 @@ public data class VerifyPurchaseResultAndroid( val term: String, val termSku: String, val testTransaction: Boolean -) : VerifyPurchaseResult { +) : VerifyPurchaseResultCommon, VerifyPurchaseResult { companion object { fun fromJson(json: Map): VerifyPurchaseResultAndroid { @@ -3984,6 +3999,7 @@ public data class VerifyPurchaseResultAndroid( deferredSku = json["deferredSku"] as? String, freeTrialEndDate = (json["freeTrialEndDate"] as? Number)?.toDouble() ?: 0.0, gracePeriodEndDate = (json["gracePeriodEndDate"] as? Number)?.toDouble() ?: 0.0, + isValid = json["isValid"] as? Boolean ?: false, parentProductId = json["parentProductId"] as? String ?: "", productId = json["productId"] as? String ?: "", productType = json["productType"] as? String ?: "", @@ -4008,6 +4024,7 @@ public data class VerifyPurchaseResultAndroid( "deferredSku" to deferredSku, "freeTrialEndDate" to freeTrialEndDate, "gracePeriodEndDate" to gracePeriodEndDate, + "isValid" to isValid, "parentProductId" to parentProductId, "productId" to productId, "productType" to productType, @@ -4030,16 +4047,24 @@ public data class VerifyPurchaseResultHorizon( * Unix timestamp (seconds) when the entitlement was granted. */ val grantTime: Double? = null, + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + override val isValid: Boolean, /** * Whether the entitlement verification succeeded. + * @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. */ + @Deprecated("Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0.") val success: Boolean -) : VerifyPurchaseResult { +) : VerifyPurchaseResultCommon, VerifyPurchaseResult { companion object { fun fromJson(json: Map): VerifyPurchaseResultHorizon { return VerifyPurchaseResultHorizon( grantTime = (json["grantTime"] as? Number)?.toDouble(), + isValid = json["isValid"] as? Boolean ?: false, success = json["success"] as? Boolean ?: false, ) } @@ -4048,6 +4073,7 @@ public data class VerifyPurchaseResultHorizon( override fun toJson(): Map = mapOf( "__typename" to "VerifyPurchaseResultHorizon", "grantTime" to grantTime, + "isValid" to isValid, "success" to success, ) } @@ -4056,7 +4082,7 @@ public data class VerifyPurchaseResultIOS( /** * Whether the receipt is valid */ - val isValid: Boolean, + override val isValid: Boolean, /** * JWS representation */ @@ -4069,7 +4095,7 @@ public data class VerifyPurchaseResultIOS( * Receipt data string */ val receiptData: String -) : VerifyPurchaseResult { +) : VerifyPurchaseResultCommon, VerifyPurchaseResult { companion object { fun fromJson(json: Map): VerifyPurchaseResultIOS { @@ -5519,7 +5545,7 @@ public sealed interface Purchase : PurchaseCommon { } } -public sealed interface VerifyPurchaseResult { +public sealed interface VerifyPurchaseResult : VerifyPurchaseResultCommon { fun toJson(): Map companion object { @@ -5704,11 +5730,11 @@ public interface MutationResolver { */ suspend fun syncIOS(): Boolean /** - * Verify a purchase against your own backend. Returns a platform-specific - * variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - * Inspect the concrete variant before reading fields. + * Verify a purchase against your own backend. Every VerifyPurchaseResult + * variant exposes isValid, so entitlement can be gated without inspecting the + * concrete type. Variants add their own metadata on top: IOS carries + * receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + * carries grantTime. * See: https://openiap.dev/docs/features/validation#verify-purchase */ suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult @@ -6093,11 +6119,11 @@ public data class MutationHandlers( */ val syncIOS: MutationSyncIOSHandler? = null, /** - * Verify a purchase against your own backend. Returns a platform-specific - * variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - * Inspect the concrete variant before reading fields. + * Verify a purchase against your own backend. Every VerifyPurchaseResult + * variant exposes isValid, so entitlement can be gated without inspecting the + * concrete type. Variants add their own metadata on top: IOS carries + * receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + * carries grantTime. * See: https://openiap.dev/docs/features/validation#verify-purchase */ val verifyPurchase: MutationVerifyPurchaseHandler? = null, 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 eed7395cb..da72698cf 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 @@ -81,8 +81,14 @@ suspend fun verifyPurchaseWithGooglePlay( } try { - gson.fromJson(responseBody, VerifyPurchaseResultAndroid::class.java) + // Play returns the purchase record only on 2xx; every other status + // threw above. Parse through the generated decoder so omitted Play + // fields receive their Kotlin defaults instead of Gson leaving + // non-null constructor properties as JVM nulls. + val mapType = object : TypeToken>() {}.type + val parsed = gson.fromJson>(responseBody, mapType) ?: throw OpenIapError.InvalidPurchaseVerification + VerifyPurchaseResultAndroid.fromJson(parsed + ("isValid" to true)) } catch (jsonError: JsonSyntaxException) { OpenIapLog.warn("Failed to parse purchase verification response: ${jsonError.message}", tag) throw OpenIapError.InvalidPurchaseVerification @@ -154,8 +160,10 @@ suspend fun verifyPurchaseWithHorizon( val success = parsed["success"] as? Boolean ?: false val grantTime = (parsed["grant_time"] as? Number)?.toDouble() + @Suppress("DEPRECATION") VerifyPurchaseResultHorizon( grantTime = grantTime, + isValid = success, success = success ) } catch (jsonError: JsonSyntaxException) { 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 fd9f84656..b308721df 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 @@ -127,6 +127,32 @@ class PurchaseVerificationValidatorTest { assertEquals(true, result.autoRenewing) assertEquals(false, result.betaProduct) assertEquals(1, result.quantity) + // Play sends no validity field; a parsed 2xx purchase record is the + // signal, and gson would otherwise leave this false. + assertTrue(result.isValid) + } + + @Test + fun `verifyPurchaseWithGooglePlay defaults fields omitted by Play`() = runTest { + val props = VerifyPurchaseProps( + google = VerifyPurchaseGoogleOptions( + accessToken = "token", + isSub = false, + packageName = "dev.hyo.app", + purchaseToken = "purchaseToken", + sku = "premium" + ) + ) + + val result = verifyPurchaseWithGooglePlay( + props, + "TEST_TAG" + ) { _ -> FakeHttpURLConnection(200, """{"purchaseState":0}""") } + + assertTrue(result.isValid) + assertEquals("", result.parentProductId) + assertEquals("", result.productId) + assertEquals("", result.receiptId) } @Test @@ -152,6 +178,47 @@ class PurchaseVerificationValidatorTest { } } + @Test + fun `verifyPurchaseWithHorizon maps success onto isValid`() = runTest { + val props = VerifyPurchaseProps( + horizon = VerifyPurchaseHorizonOptions( + accessToken = "token", + sku = "premium_monthly", + userId = "user-1" + ) + ) + + val result = verifyPurchaseWithHorizon( + props, + "app-id", + "TEST_TAG" + ) { _ -> FakeHttpURLConnection(200, """{"success":true,"grant_time":1744148687}""") } + + assertTrue(result.isValid) + // The schema documents grantTime in seconds; IAPKit's millisecond + // conversion is internal to its own storage, not this field. + assertEquals(1744148687.0, result.grantTime!!, 0.0) + } + + @Test + fun `verifyPurchaseWithHorizon reports an unsuccessful entitlement as invalid`() = runTest { + val props = VerifyPurchaseProps( + horizon = VerifyPurchaseHorizonOptions( + accessToken = "token", + sku = "premium_monthly", + userId = "user-1" + ) + ) + + val result = verifyPurchaseWithHorizon( + props, + "app-id", + "TEST_TAG" + ) { _ -> FakeHttpURLConnection(200, """{"success":false}""") } + + assertEquals(false, result.isValid) + } + @Test fun `verifyPurchaseWithIapkit throws without android store props`() = runTest { val props = RequestVerifyPurchaseWithIapkitProps( diff --git a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/conformance/AmazonStoreConformanceTest.kt b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/conformance/AmazonStoreConformanceTest.kt new file mode 100644 index 000000000..af6bfb9a9 --- /dev/null +++ b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/conformance/AmazonStoreConformanceTest.kt @@ -0,0 +1,45 @@ +package dev.hyo.openiap.conformance + +import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.IapStore +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.PurchaseAndroid +import dev.hyo.openiap.ErrorCode +import dev.hyo.openiap.amazonPurchaseError +import dev.hyo.openiap.unsupportedRedeemOfferCode +import dev.hyo.openiap.utils.toActiveSubscription +import kotlinx.coroutines.runBlocking + +/** + * Amazon Appstore's binding into the shared conformance suite. + * The behavioral expectations live in [StoreConformanceSuite]. + */ +class AmazonStoreConformanceTest : StoreConformanceSuite() { + override val adapter = object : StoreConformanceAdapter { + override val store = IapStore.Amazon + + override val capabilities = setOf(StoreCapability.PendingPurchases) + + override fun toActiveSubscription(purchase: PurchaseAndroid): ActiveSubscription = + purchase.toActiveSubscription() + + override val normativeErrorCases = listOf( + errorCase("ALREADY_PURCHASED", ErrorCode.AlreadyOwned), + errorCase("INVALID_SKU", ErrorCode.SkuNotFound), + errorCase("NOT_SUPPORTED", ErrorCode.FeatureNotSupported), + errorCase("INACTIVE_BASE_SUBSCRIPTION", ErrorCode.ItemUnavailable), + errorCase("PENDING", ErrorCode.DeferredPayment), + errorCase("FAILED", ErrorCode.PurchaseError), + ) + + override val unrecognizedError = checkNotNull(amazonPurchaseError(null, "sku")) + + override fun unsupportedOperationResult(): Boolean = + runBlocking { unsupportedRedeemOfferCode() } + + private fun errorCase( + status: String, + expected: ErrorCode, + ) = StoreErrorCase(status, expected, checkNotNull(amazonPurchaseError(status, "sku"))) + } +} diff --git a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/conformance/HorizonStoreConformanceTest.kt b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/conformance/HorizonStoreConformanceTest.kt new file mode 100644 index 000000000..1efe6a35a --- /dev/null +++ b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/conformance/HorizonStoreConformanceTest.kt @@ -0,0 +1,37 @@ +package dev.hyo.openiap.conformance + +import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.IapStore +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.PurchaseAndroid +import dev.hyo.openiap.fromBillingResponseCode +import dev.hyo.openiap.utils.HorizonBillingConverters.toActiveSubscription +import dev.hyo.openiap.unsupportedRedeemOfferCode +import kotlinx.coroutines.runBlocking + +/** + * Meta Horizon's binding into the shared conformance suite. + * The behavioral expectations live in [StoreConformanceSuite]. + */ +class HorizonStoreConformanceTest : StoreConformanceSuite() { + override val adapter = object : StoreConformanceAdapter { + override val store = IapStore.Horizon + + // Horizon's Billing Compatibility SDK implements Play Billing 7.0, + // which predates the suspension signal, and exposes no offer-code + // redemption entry point. It does report PENDING. + override val capabilities = setOf( + StoreCapability.PendingPurchases, + ) + + override fun toActiveSubscription(purchase: PurchaseAndroid): ActiveSubscription = + purchase.toActiveSubscription() + + override val normativeErrorCases = playBillingErrorCases(OpenIapError::fromBillingResponseCode) + + override val unrecognizedError = OpenIapError.fromBillingResponseCode(9999) + + override fun unsupportedOperationResult(): Boolean = + runBlocking { unsupportedRedeemOfferCode() } + } +} diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/conformance/PlayStoreConformanceTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/conformance/PlayStoreConformanceTest.kt new file mode 100644 index 000000000..097bf4fbf --- /dev/null +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/conformance/PlayStoreConformanceTest.kt @@ -0,0 +1,33 @@ +package dev.hyo.openiap.conformance + +import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.IapStore +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.PurchaseAndroid +import dev.hyo.openiap.fromBillingResponseCode +import dev.hyo.openiap.utils.toActiveSubscription + +/** + * Google Play's binding into the shared conformance suite. + * The behavioral expectations live in [StoreConformanceSuite]. + */ +class PlayStoreConformanceTest : StoreConformanceSuite() { + override val adapter = object : StoreConformanceAdapter { + override val store = IapStore.Google + + override val capabilities = setOf( + StoreCapability.PendingPurchases, + StoreCapability.SubscriptionBillingIssue, + StoreCapability.OfferCodeRedemption, + ) + + override fun toActiveSubscription(purchase: PurchaseAndroid): ActiveSubscription = + purchase.toActiveSubscription() + + override val normativeErrorCases = playBillingErrorCases(OpenIapError::fromBillingResponseCode) + + override val unrecognizedError = OpenIapError.fromBillingResponseCode(9999) + + override fun unsupportedOperationResult(): Boolean? = null + } +} diff --git a/packages/gql/codegen/plugins/csharp.ts b/packages/gql/codegen/plugins/csharp.ts index 16636416a..1024594bb 100644 --- a/packages/gql/codegen/plugins/csharp.ts +++ b/packages/gql/codegen/plugins/csharp.ts @@ -357,6 +357,7 @@ export class CSharpPlugin extends CodegenPlugin { const sortedFields = [...irInterface.fields].sort((a, b) => a.name.localeCompare(b.name)); for (const field of sortedFields) { this.emitDoc(field.description, ' '); + this.emitDeprecation(field.description, ' '); const propType = this.propertyType(field.type); const propName = this.fieldNameCase(field.name); this.emit(` ${propType} ${propName} { get; }`); @@ -385,16 +386,40 @@ export class CSharpPlugin extends CodegenPlugin { for (const name of concrete) { this.emit(`[JsonDerivedType(typeof(${name}), "${name}")]`); } - // Concrete members implement any shared interface directly. We don't - // attach the interface to the abstract record because that would force - // us to emit `abstract` overrides for every interface member and double - // the property declarations on each concrete record. const parent = this.nestedUnionParents.get(irUnion.name); - const inheritance = parent ? ` : ${parent}` : ''; - this.emit(`public abstract record ${irUnion.name}${inheritance};`); + const baseTypes = [parent, ...irUnion.sharedInterfaces].filter(Boolean); + const inheritance = baseTypes.length > 0 ? ` : ${baseTypes.join(', ')}` : ''; + const sharedFields = this.sharedInterfaceFields(irUnion); + if (sharedFields.length === 0) { + this.emit(`public abstract record ${irUnion.name}${inheritance};`); + this.emit(''); + return; + } + + this.emit(`public abstract record ${irUnion.name}${inheritance}`); + this.emit('{'); + for (const field of sharedFields) { + this.emitDoc(field.description, ' '); + this.emitDeprecation(field.description, ' '); + const propType = this.propertyType(field.type); + const propName = this.fieldNameCase(field.name); + this.emit(` public abstract ${propType} ${propName} { get; init; }`); + } + this.emit('}'); this.emit(''); } + private sharedInterfaceFields(irUnion: IRUnion): IRField[] { + const fields = new Map(); + for (const interfaceName of irUnion.sharedInterfaces) { + const irInterface = this.schema.interfaces.find((item) => item.name === interfaceName); + for (const field of irInterface?.fields ?? []) { + fields.set(field.name, field); + } + } + return [...fields.values()].sort((a, b) => a.name.localeCompare(b.name)); + } + private flattenUnionMembers(irUnion: IRUnion): string[] { const out: string[] = []; for (const member of irUnion.members) { @@ -439,7 +464,7 @@ export class CSharpPlugin extends CodegenPlugin { const inheritance = baseTypes.length > 0 ? ` : ${baseTypes.join(', ')}` : ''; this.emit(`public sealed record ${irObject.name}${inheritance}`); this.emit('{'); - this.emitProperties(sortedFields); + this.emitProperties(sortedFields, this.inheritedUnionFieldNames(irObject)); this.emit('}'); this.emit(''); } @@ -450,21 +475,42 @@ export class CSharpPlugin extends CodegenPlugin { // are dropped since C# records cannot multi-inherit — consumers use the // primary union (e.g., ProductOrSubscription is handled via wrapper). const baseTypes: string[] = []; + const inheritedInterfaces = new Set(); if (irObject.unions.length > 0) { baseTypes.push(irObject.unions[0]); + const baseUnion = this.schema.unions.find((item) => item.name === irObject.unions[0]); + for (const interfaceName of baseUnion?.sharedInterfaces ?? []) { + inheritedInterfaces.add(interfaceName); + } } for (const iface of irObject.interfaces) { - baseTypes.push(iface); + if (!inheritedInterfaces.has(iface)) { + baseTypes.push(iface); + } } return baseTypes; } - private emitProperties(fields: IRField[]): void { + private inheritedUnionFieldNames(irObject: IRObject): Set { + const names = new Set(); + const baseUnionName = irObject.unions[0]; + if (!baseUnionName) return names; + const baseUnion = this.schema.unions.find((item) => item.name === baseUnionName); + for (const field of baseUnion ? this.sharedInterfaceFields(baseUnion) : []) { + names.add(field.name); + } + return names; + } + + private emitProperties(fields: IRField[], inheritedFields = new Set()): void { fields.forEach((field) => { this.emitDoc(field.description, ' '); + this.emitDeprecation(field.description, ' '); + const isDeprecated = this.deprecationReason(field.description) !== null; const propType = this.propertyType(field.type); const propName = this.fieldNameCase(field.name); const jsonName = field.name; + const overrideModifier = inheritedFields.has(field.name) ? 'override ' : ''; this.emit(` [JsonPropertyName("${jsonName}")]`); // Required vs. nullable — non-nullable scalars/objects get the C# @@ -473,16 +519,21 @@ export class CSharpPlugin extends CodegenPlugin { if (field.type.nullable) { const defaultValue = this.buildDefaultValueExpression(field); const initializer = defaultValue ? ` = ${defaultValue};` : ''; - this.emit(` public ${propType} ${propName} { get; init; }${initializer}`); + this.emit(` public ${overrideModifier}${propType} ${propName} { get; init; }${initializer}`); } else if (field.defaultValue !== undefined) { const defaultValue = this.buildDefaultValueExpression(field); if (defaultValue) { - this.emit(` public ${propType} ${propName} { get; init; } = ${defaultValue};`); + this.emit(` public ${overrideModifier}${propType} ${propName} { get; init; } = ${defaultValue};`); } else { - this.emit(` public required ${propType} ${propName} { get; init; }`); + this.emit(` public ${overrideModifier}required ${propType} ${propName} { get; init; }`); } + } else if (isDeprecated) { + // C# rejects ObsoleteAttribute on required members (CS9042). Keep the + // non-null wire type while allowing callers to initialize only the + // replacement field. + this.emit(` public ${overrideModifier}${propType} ${propName} { get; init; }`); } else { - this.emit(` public required ${propType} ${propName} { get; init; }`); + this.emit(` public ${overrideModifier}required ${propType} ${propName} { get; init; }`); } }); } @@ -517,6 +568,16 @@ export class CSharpPlugin extends CodegenPlugin { return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n').replace(/\t/g, '\\t')}"`; } + private emitDeprecation(description: string | undefined, indent: string = ''): void { + const reason = this.deprecationReason(description); + if (!reason) return; + this.emit(`${indent}[Obsolete(${this.csharpStringLiteral(reason)})]`); + } + + private deprecationReason(description: string | undefined): string | null { + return description?.match(/(?:^|\n)@deprecated\s+([^\n]+)/)?.[1]?.trim() || null; + } + private generateResultUnionObject(irObject: IRObject): void { this.emitDoc(irObject.description); const entries = [...irObject.resultUnionEntries!].sort((a, b) => a.fieldName.localeCompare(b.fieldName)); diff --git a/packages/gql/codegen/plugins/swift.ts b/packages/gql/codegen/plugins/swift.ts index 1fb8ce58a..8acd8da02 100644 --- a/packages/gql/codegen/plugins/swift.ts +++ b/packages/gql/codegen/plugins/swift.ts @@ -151,6 +151,7 @@ export class SwiftPlugin extends CodegenPlugin { const sortedFields = [...irInterface.fields].sort((a, b) => a.name.localeCompare(b.name)); for (const field of sortedFields) { this.generateDocComment(field.description, ' '); + this.generateDeprecationAnnotation(field.description, ' '); const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); this.emit(` var ${propertyName}: ${propertyType} { get }`); @@ -189,6 +190,7 @@ export class SwiftPlugin extends CodegenPlugin { // Properties for (const field of sortedFields) { this.generateDocComment(field.description, ' '); + this.generateDeprecationAnnotation(field.description, ' '); const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); @@ -253,6 +255,7 @@ export class SwiftPlugin extends CodegenPlugin { // Properties for (const field of sortedFields) { this.generateDocComment(field.description, ' '); + this.generateDeprecationAnnotation(field.description, ' '); const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(field.name)); this.emit(` public var ${propertyName}: ${propertyType}`); @@ -520,6 +523,7 @@ export class SwiftPlugin extends CodegenPlugin { const interfaceFieldsArray = [...interfaceFields.entries()].sort((a, b) => a[0].localeCompare(b[0])); interfaceFieldsArray.forEach(([fieldName, field], index) => { this.generateDocComment(field.description, ' '); + this.generateDeprecationAnnotation(field.description, ' '); const propertyType = this.getPropertyType(field.type); const propertyName = this.escapeKeyword(this.fieldNameCase(fieldName)); @@ -738,4 +742,11 @@ export class SwiftPlugin extends CodegenPlugin { this.emit(`${indent}/// ${line}`); } } + + private generateDeprecationAnnotation(description: string | undefined, indent: string = ''): void { + const reason = description?.match(/(?:^|\n)@deprecated\s+([^\n]+)/)?.[1]?.trim(); + if (!reason) return; + const escapedReason = reason.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + this.emit(`${indent}@available(*, deprecated, message: "${escapedReason}")`); + } } diff --git a/packages/gql/src/api.graphql b/packages/gql/src/api.graphql index 8fbf4d644..2cea7568e 100644 --- a/packages/gql/src/api.graphql +++ b/packages/gql/src/api.graphql @@ -78,11 +78,11 @@ extend type Mutation { # Future deepLinkToSubscriptions(options: DeepLinkOptions): VoidResult! """ - Verify a purchase against your own backend. Returns a platform-specific - variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - Inspect the concrete variant before reading fields. + Verify a purchase against your own backend. Every VerifyPurchaseResult + variant exposes isValid, so entitlement can be gated without inspecting the + concrete type. Variants add their own metadata on top: IOS carries + receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + carries grantTime. See: https://openiap.dev/docs/features/validation#verify-purchase """ # Future diff --git a/packages/gql/src/capability-matrix.mjs b/packages/gql/src/capability-matrix.mjs new file mode 100644 index 000000000..b557783cf --- /dev/null +++ b/packages/gql/src/capability-matrix.mjs @@ -0,0 +1,193 @@ +/** + * Store capability matrix — SSOT for behavior that differs by store. + * + * The schema defines what the API is; this defines which stores must implement + * each behavior, so "Amazon does not emit SubscriptionBillingIssue" is + * machine-checkable data instead of prose in a docstring. + * + * Levels: `required` (MUST), `optional` (MAY), `unsupported` (CANNOT — the + * documented no-op is asserted, so a half-implementation fails too). + * + * `capability-matrix.test.ts` requires every IapStore member to appear in every + * entry, so adding a store without deciding its capabilities fails CI. + */ + +/** @typedef {'required' | 'optional' | 'unsupported'} CapabilityLevel */ + +export const CAPABILITY_LEVELS = Object.freeze([ + 'required', + 'optional', + 'unsupported', +]); + +/** + * Stores that participate in the capability matrix. + * Mirrors `IapStore` minus `Unknown`, which is a fallback discriminator rather + * than an implementation. + */ +export const CAPABILITY_STORES = Object.freeze([ + 'Apple', + 'Google', + 'Amazon', + 'Horizon', +]); + +/** + * Behavior -> per-store level. + * + * `evidence` points at the code or docs that justify a non-`required` level, so + * a reviewer can check the claim rather than trust it. + */ +export const CAPABILITY_MATRIX = Object.freeze({ + fetchProducts: { + description: 'Fetch products and subscriptions from the store.', + stores: { Apple: 'required', Google: 'required', Amazon: 'required', Horizon: 'required' }, + }, + + requestPurchase: { + description: 'Initiate a purchase or subscription flow.', + stores: { Apple: 'required', Google: 'required', Amazon: 'required', Horizon: 'required' }, + }, + + finishTransaction: { + description: 'Complete a transaction after verification.', + stores: { Apple: 'required', Google: 'required', Amazon: 'required', Horizon: 'required' }, + }, + + getAvailablePurchases: { + description: 'List restorable/active purchases for the current user.', + stores: { Apple: 'required', Google: 'required', Amazon: 'required', Horizon: 'required' }, + }, + + getActiveSubscriptions: { + description: + 'Report active subscriptions. A purchase that is not in the Purchased state is never an active entitlement.', + stores: { Apple: 'required', Google: 'required', Amazon: 'required', Horizon: 'required' }, + }, + + pendingPurchases: { + description: + 'Represent a deferred/unpaid purchase, and never treat one as an active entitlement.', + stores: { + Apple: 'required', + Google: 'required', + Amazon: 'required', + Horizon: 'required', + }, + notes: { + Apple: + 'Shape differs: StoreKit surfaces Product.PurchaseResult.pending as an ErrorCode.DeferredPayment error (packages/apple/Sources/OpenIapModule.swift), whereas Android delivers a Purchase carrying PurchaseState.Pending.', + }, + evidence: { + Amazon: + 'packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt — pending purchases are enabled and PurchaseResponse.RequestStatus.PENDING maps to DeferredPurchase.', + }, + }, + + subscriptionBillingIssue: { + description: + 'Emit IapEvent.SubscriptionBillingIssue when a subscription enters a billing-retry/suspended state.', + stores: { + Apple: 'required', + Google: 'required', + Amazon: 'unsupported', + Horizon: 'unsupported', + }, + evidence: { + Amazon: + 'packages/gql/src/type.graphql (IapEvent.SubscriptionBillingIssue) — Amazon Appstore exposes no suspension signal.', + Horizon: + 'Horizon Billing Compatibility SDK implements Play Billing 7.0, which predates isSuspended.', + }, + }, + + offerCodeRedemption: { + description: 'Expose an offer-code redemption entry point.', + stores: { + Apple: 'required', + Google: 'required', + Amazon: 'unsupported', + Horizon: 'unsupported', + }, + evidence: { + Amazon: + 'packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/OpenRedeemOfferCodeAmazonNoOpTest.kt', + Horizon: + 'packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/OpenRedeemOfferCodeHorizonNoOpTest.kt', + }, + }, + + alreadyOwnedError: { + description: + 'Surface ErrorCode.AlreadyOwned when a purchase is attempted for an item the user already owns.', + stores: { + Apple: 'unsupported', + Google: 'required', + Amazon: 'required', + Horizon: 'required', + }, + evidence: { + Apple: + 'StoreKit has no already-owned failure: re-purchasing an owned non-consumable succeeds and returns the existing transaction. See packages/apple/Sources/Models/OpenIapError.swift (errorCode(for:)).', + }, + }, + + billingServiceLifecycleErrors: { + description: + 'Surface ErrorCode.BillingUnavailable / ServiceDisconnected / ServiceTimeout for billing-service lifecycle failures.', + stores: { + Apple: 'unsupported', + Google: 'required', + Amazon: 'required', + Horizon: 'required', + }, + evidence: { + Apple: + 'StoreKit exposes no long-lived billing-service connection, so these conditions do not arise. See packages/apple/Sources/Models/OpenIapError.swift (errorCode(for:)).', + }, + }, + + storeWebhookLifecycle: { + description: + 'Deliver server-side lifecycle notifications that IAPKit normalizes into subscription state transitions.', + stores: { + Apple: 'required', + Google: 'required', + Amazon: 'optional', + Horizon: 'unsupported', + }, + evidence: { + Amazon: + 'packages/kit/convex/purchases/amazon.ts (reconcileAmazonPurchases) — reconciliation by polling rather than push notifications.', + Horizon: + 'packages/kit/convex/purchases/horizon.ts — verification only; Meta exposes no subscription notification stream.', + }, + }, +}); + +/** + * @param {string} behavior + * @param {string} store + * @returns {CapabilityLevel} + */ +export function capabilityLevel(behavior, store) { + const entry = CAPABILITY_MATRIX[behavior]; + if (!entry) throw new Error(`Unknown capability behavior: ${behavior}`); + const level = entry.stores[store]; + if (!level) throw new Error(`Capability ${behavior} has no entry for store ${store}`); + return level; +} + +/** Behaviors a given store must implement. */ +export function requiredBehaviors(store) { + return Object.keys(CAPABILITY_MATRIX).filter( + (behavior) => CAPABILITY_MATRIX[behavior].stores[store] === 'required', + ); +} + +/** Behaviors a given store must NOT implement. */ +export function unsupportedBehaviors(store) { + return Object.keys(CAPABILITY_MATRIX).filter( + (behavior) => CAPABILITY_MATRIX[behavior].stores[store] === 'unsupported', + ); +} diff --git a/packages/gql/src/capability-matrix.test.ts b/packages/gql/src/capability-matrix.test.ts new file mode 100644 index 000000000..74b694aab --- /dev/null +++ b/packages/gql/src/capability-matrix.test.ts @@ -0,0 +1,96 @@ +import { isEnumType } from 'graphql'; +import { describe, expect, it } from 'vitest'; +import { parseSchema } from '../codegen/core/parser.js'; +import { + CAPABILITY_LEVELS, + CAPABILITY_MATRIX, + CAPABILITY_STORES, + capabilityLevel, + requiredBehaviors, + unsupportedBehaviors, +} from './capability-matrix.mjs'; + +function schemaStores(): string[] { + const storeEnum = parseSchema().schema.getType('IapStore'); + if (!isEnumType(storeEnum)) throw new Error('IapStore is not an enum type'); + return storeEnum + .getValues() + .map((value) => value.name) + .filter((name) => name !== 'Unknown'); +} + +describe('store capability matrix', () => { + // Adding a store to the schema without deciding its capabilities must fail + // CI rather than silently inherit another store's behavior. + it('covers exactly the stores declared in the IapStore enum', () => { + expect([...CAPABILITY_STORES].sort()).toEqual(schemaStores().sort()); + }); + + it('assigns every covered store a level for every behavior', () => { + for (const [behavior, entry] of Object.entries(CAPABILITY_MATRIX)) { + expect(Object.keys(entry.stores).sort(), `${behavior} store coverage`).toEqual( + [...CAPABILITY_STORES].sort(), + ); + } + }); + + it('uses only the defined capability levels', () => { + for (const [behavior, entry] of Object.entries(CAPABILITY_MATRIX)) { + for (const [store, level] of Object.entries(entry.stores)) { + expect(CAPABILITY_LEVELS, `${behavior}.${store}`).toContain(level); + } + } + }); + + it('describes every behavior', () => { + for (const [behavior, entry] of Object.entries(CAPABILITY_MATRIX)) { + expect(entry.description?.trim(), `${behavior} description`).toBeTruthy(); + } + }); + + // Non-required levels are claims about a store's limitations; evidence keeps + // them auditable. + it('cites evidence for every optional or unsupported level', () => { + for (const [behavior, entry] of Object.entries(CAPABILITY_MATRIX)) { + for (const [store, level] of Object.entries(entry.stores)) { + if (level === 'required') continue; + expect( + entry.evidence?.[store]?.trim(), + `${behavior}.${store} is "${level}" and needs evidence`, + ).toBeTruthy(); + } + } + }); + + it('keeps core purchase behavior required for every store', () => { + for (const store of CAPABILITY_STORES) { + for (const behavior of [ + 'fetchProducts', + 'requestPurchase', + 'finishTransaction', + 'getAvailablePurchases', + 'getActiveSubscriptions', + ]) { + expect(capabilityLevel(behavior, store), `${behavior} on ${store}`).toBe('required'); + } + } + }); + + it('exposes required and unsupported behavior sets per store', () => { + expect(requiredBehaviors('Google')).toContain('pendingPurchases'); + expect(requiredBehaviors('Amazon')).toContain('pendingPurchases'); + expect(unsupportedBehaviors('Apple')).toContain('alreadyOwnedError'); + expect(requiredBehaviors('Apple')).not.toContain('alreadyOwnedError'); + }); + + // "required" must not imply "identical" — record shape differences. + it('documents required behaviors whose delivery shape differs by store', () => { + expect(CAPABILITY_MATRIX.pendingPurchases.notes?.Apple).toMatch(/DeferredPayment/); + expect(capabilityLevel('pendingPurchases', 'Apple')).toBe('required'); + }); + + it('rejects unknown behaviors and stores', () => { + expect(() => capabilityLevel('notARealBehavior', 'Google')).toThrow(/Unknown capability/); + expect(() => capabilityLevel('fetchProducts', 'Samsung')).toThrow(/no entry for store/); + }); +}); diff --git a/packages/gql/src/codegen-defaults.test.ts b/packages/gql/src/codegen-defaults.test.ts index 9ea4bc53b..b05022769 100644 --- a/packages/gql/src/codegen-defaults.test.ts +++ b/packages/gql/src/codegen-defaults.test.ts @@ -8,6 +8,7 @@ import type { IREnum, IRField, IRSchema, IRType } from '../codegen/core/types'; const stringType: IRType = { kind: 'scalar', name: 'String', nullable: false }; const floatType: IRType = { kind: 'scalar', name: 'Float', nullable: false }; +const booleanType: IRType = { kind: 'scalar', name: 'Boolean', nullable: false }; function field(name: string, type: IRType, defaultValue?: unknown): IRField { return { @@ -53,6 +54,73 @@ function objectSchema(fields: IRField[], enums: IREnum[]): IRSchema { } describe('codegen defaults', () => { + it('exposes shared interface fields through C# union bases', () => { + const isValid = field('isValid', booleanType); + const output = new CSharpPlugin({ outputPath: 'Types.cs' }).generate({ + ...schema([]), + interfaces: [{ name: 'ResultCommon', fields: [isValid] }], + unions: [ + { + name: 'Result', + members: [{ name: 'ResultAndroid', isNestedUnion: false }], + sharedInterfaces: ['ResultCommon'], + }, + ], + objects: [ + { + name: 'ResultAndroid', + fields: [isValid], + interfaces: ['ResultCommon'], + unions: ['Result'], + isResultUnion: false, + }, + ], + }); + + expect(output).toContain('public abstract record Result : ResultCommon'); + expect(output).toContain('public abstract bool IsValid { get; init; }'); + expect(output).toContain('public sealed record ResultAndroid : Result'); + expect(output).toContain('public override required bool IsValid { get; init; }'); + }); + + it('only marks fields inherited from the emitted C# base union as overrides', () => { + const primaryId = field('primaryId', stringType); + const secondaryOnly = field('secondaryOnly', stringType); + const output = new CSharpPlugin({ outputPath: 'Types.cs' }).generate({ + ...schema([]), + interfaces: [ + { name: 'PrimaryCommon', fields: [primaryId] }, + { name: 'SecondaryCommon', fields: [secondaryOnly] }, + ], + unions: [ + { + name: 'PrimaryResult', + members: [{ name: 'CombinedResult', isNestedUnion: false }], + sharedInterfaces: ['PrimaryCommon'], + }, + { + name: 'SecondaryResult', + members: [{ name: 'CombinedResult', isNestedUnion: false }], + sharedInterfaces: ['SecondaryCommon'], + }, + ], + objects: [ + { + name: 'CombinedResult', + fields: [primaryId, secondaryOnly], + interfaces: ['PrimaryCommon', 'SecondaryCommon'], + unions: ['PrimaryResult', 'SecondaryResult'], + isResultUnion: false, + }, + ], + }); + + expect(output).toContain('public sealed record CombinedResult : PrimaryResult, SecondaryCommon'); + expect(output).toContain('public override required string PrimaryId { get; init; }'); + expect(output).toContain('public required string SecondaryOnly { get; init; }'); + expect(output).not.toContain('public override required string SecondaryOnly { get; init; }'); + }); + it('wraps multiline C# documentation in one XML summary element', () => { const documentedField = field('value', stringType); documentedField.description = 'First line.\nSecond .'; diff --git a/packages/gql/src/deprecation-transformer.test.ts b/packages/gql/src/deprecation-transformer.test.ts index 6f7599d54..3a5a80351 100644 --- a/packages/gql/src/deprecation-transformer.test.ts +++ b/packages/gql/src/deprecation-transformer.test.ts @@ -128,7 +128,7 @@ describe('deprecation documentation transformation', () => { """Legacy offer metadata.""" type LegacyOffer @openiapDeprecated(reason: "Use DiscountOffer instead. Scheduled for removal in OpenIAP 3.0.") { """Legacy identifier.""" - legacyId: String @deprecated(reason: "Use id instead. Scheduled for removal in OpenIAP 3.0.") + legacyId: String! @deprecated(reason: "Use id instead. Scheduled for removal in OpenIAP 3.0.") } """Legacy billing selector.""" @@ -161,6 +161,15 @@ describe('deprecation documentation transformation', () => { expect(kotlin).toContain( ' @Deprecated("Use id instead. Scheduled for removal in OpenIAP 3.0.", ReplaceWith("id"))\n val legacyId:', ); + expect(new SwiftPlugin({ outputPath: 'Types.swift' }).generate(schema)).toContain( + ' @available(*, deprecated, message: "Use id instead. Scheduled for removal in OpenIAP 3.0.")\n public var legacyId:', + ); + expect(new CSharpPlugin({ outputPath: 'Types.cs' }).generate(schema)).toContain( + ' [Obsolete("Use id instead. Scheduled for removal in OpenIAP 3.0.")]\n [JsonPropertyName("legacyId")]', + ); + expect(new CSharpPlugin({ outputPath: 'Types.cs' }).generate(schema)).toContain( + ' public string LegacyId { get; init; }', + ); expect(kotlin).toContain( '@Deprecated("Use BillingProgram instead. Scheduled for removal in OpenIAP 3.0.", ReplaceWith("BillingProgram"))\npublic enum class LegacyBillingMode', ); @@ -169,6 +178,26 @@ describe('deprecation documentation transformation', () => { ); }); + it('preserves shared-interface deprecations on Swift union accessors', () => { + const schema = transform(` + interface ResultCommon { + legacy: String @deprecated(reason: "Use current instead. Scheduled for removal in OpenIAP 3.0.") + } + type FirstResult implements ResultCommon { + legacy: String @deprecated(reason: "Use current instead. Scheduled for removal in OpenIAP 3.0.") + } + type SecondResult implements ResultCommon { + legacy: String @deprecated(reason: "Use current instead. Scheduled for removal in OpenIAP 3.0.") + } + union Result = FirstResult | SecondResult + `); + + const swift = new SwiftPlugin({ outputPath: 'Types.swift' }).generate(schema); + expect(swift).toContain( + ' @available(*, deprecated, message: "Use current instead. Scheduled for removal in OpenIAP 3.0.")\n public var legacy:', + ); + }); + it('preserves type-level reasons on operation roots', () => { const schema = transform(` """Legacy query root.""" diff --git a/packages/gql/src/generated-compatibility.test.ts b/packages/gql/src/generated-compatibility.test.ts index 4d91cdaf2..01edfea29 100644 --- a/packages/gql/src/generated-compatibility.test.ts +++ b/packages/gql/src/generated-compatibility.test.ts @@ -321,7 +321,9 @@ describe('generated compatibility', () => { const implementors = interfaceImplementors(); const unionOwners = interfaceUnionOwners(); - expect(entries).toEqual([]); + // The per-language assertions below are the real check; this guards against + // the list silently emptying and making them vacuous. + expect(entries.length).toBeGreaterThan(0); for (const file of generatedFiles) { const source = generated(file); const representableEntries = entries.filter((entry) => { diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index 5c1f7b963..89dc753c5 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -2213,6 +2213,13 @@ public interface PurchaseCommon double TransactionDate { get; } } +///

Validity shared by every store-specific purchase verification result. +public interface VerifyPurchaseResultCommon +{ + /// Whether the purchase is valid, without inspecting the concrete result variant. + bool IsValid { get; } +} + // ============================================================================ // Unions // ============================================================================ @@ -2220,7 +2227,19 @@ public interface PurchaseCommon [JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")] [JsonDerivedType(typeof(ProductAndroid), "ProductAndroid")] [JsonDerivedType(typeof(ProductIOS), "ProductIOS")] -public abstract record Product : ProductOrSubscription; +public abstract record Product : ProductOrSubscription, ProductCommon +{ + public abstract string Currency { get; init; } + public abstract string? DebugDescription { get; init; } + public abstract string Description { get; init; } + public abstract string? DisplayName { get; init; } + public abstract string DisplayPrice { get; init; } + public abstract string Id { get; init; } + public abstract IapPlatform Platform { get; init; } + public abstract double? Price { get; init; } + public abstract string Title { get; init; } + public abstract ProductType Type { get; init; } +} [JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")] [JsonDerivedType(typeof(ProductAndroid), "ProductAndroid")] @@ -2232,18 +2251,55 @@ public abstract record ProductOrSubscription; [JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")] [JsonDerivedType(typeof(ProductSubscriptionAndroid), "ProductSubscriptionAndroid")] [JsonDerivedType(typeof(ProductSubscriptionIOS), "ProductSubscriptionIOS")] -public abstract record ProductSubscription : ProductOrSubscription; +public abstract record ProductSubscription : ProductOrSubscription, ProductCommon +{ + public abstract string Currency { get; init; } + public abstract string? DebugDescription { get; init; } + public abstract string Description { get; init; } + public abstract string? DisplayName { get; init; } + public abstract string DisplayPrice { get; init; } + public abstract string Id { get; init; } + public abstract IapPlatform Platform { get; init; } + public abstract double? Price { get; init; } + public abstract string Title { get; init; } + public abstract ProductType Type { get; init; } +} [JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")] [JsonDerivedType(typeof(PurchaseAndroid), "PurchaseAndroid")] [JsonDerivedType(typeof(PurchaseIOS), "PurchaseIOS")] -public abstract record Purchase; +public abstract record Purchase : PurchaseCommon +{ + /// + /// The current plan identifier. This is: + /// - On Android: the basePlanId (e.g., "premium", "premium-year") + /// - On iOS: the productId (e.g., "com.example.premium_monthly", "com.example.premium_yearly") + /// This provides a unified way to identify which specific plan/tier the user is subscribed to. + /// + public abstract string? CurrentPlanId { get; init; } + public abstract string Id { get; init; } + public abstract IReadOnlyList? Ids { get; init; } + public abstract bool IsAutoRenewing { get; init; } + public abstract string ProductId { get; init; } + public abstract PurchaseState PurchaseState { get; init; } + /// Unified purchase token (iOS JWS, Android purchaseToken) + public abstract string? PurchaseToken { get; init; } + public abstract int Quantity { get; init; } + /// Store where purchase was made + public abstract IapStore Store { get; init; } + /// Unix timestamp in milliseconds since January 1, 1970 UTC. + public abstract double TransactionDate { get; init; } +} [JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")] [JsonDerivedType(typeof(VerifyPurchaseResultAndroid), "VerifyPurchaseResultAndroid")] [JsonDerivedType(typeof(VerifyPurchaseResultHorizon), "VerifyPurchaseResultHorizon")] [JsonDerivedType(typeof(VerifyPurchaseResultIOS), "VerifyPurchaseResultIOS")] -public abstract record VerifyPurchaseResult; +public abstract record VerifyPurchaseResult : VerifyPurchaseResultCommon +{ + /// Whether the purchase is valid, without inspecting the concrete result variant. + public abstract bool IsValid { get; init; } +} // ============================================================================ // Objects @@ -2901,14 +2957,14 @@ public sealed record PricingPhasesAndroid public required IReadOnlyList PricingPhaseList { get; init; } } -public sealed record ProductAndroid : Product, ProductCommon +public sealed record ProductAndroid : Product { [JsonPropertyName("currency")] - public required string Currency { get; init; } + public override required string Currency { get; init; } [JsonPropertyName("debugDescription")] - public string? DebugDescription { get; init; } + public override string? DebugDescription { get; init; } [JsonPropertyName("description")] - public required string Description { get; init; } + public override required string Description { get; init; } /// /// Standardized Android one-time product purchase options and offers. /// Native metadata uses Android-suffixed fields. @@ -2917,17 +2973,17 @@ public sealed record ProductAndroid : Product, ProductCommon [JsonPropertyName("discountOffers")] public IReadOnlyList? DiscountOffers { get; init; } [JsonPropertyName("displayName")] - public string? DisplayName { get; init; } + public override string? DisplayName { get; init; } [JsonPropertyName("displayPrice")] - public required string DisplayPrice { get; init; } + public override required string DisplayPrice { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("nameAndroid")] public required string NameAndroid { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; + public override IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; [JsonPropertyName("price")] - public double? Price { get; init; } + public override double? Price { get; init; } /// /// Product-level status code indicating fetch result (Android 8.0+) /// OK = product fetched successfully @@ -2945,35 +3001,35 @@ public sealed record ProductAndroid : Product, ProductCommon [JsonPropertyName("subscriptionOffers")] public IReadOnlyList? SubscriptionOffers { get; init; } [JsonPropertyName("title")] - public required string Title { get; init; } + public override required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; + public override ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; } -public sealed record ProductIOS : Product, ProductCommon +public sealed record ProductIOS : Product { [JsonPropertyName("currency")] - public required string Currency { get; init; } + public override required string Currency { get; init; } [JsonPropertyName("debugDescription")] - public string? DebugDescription { get; init; } + public override string? DebugDescription { get; init; } [JsonPropertyName("description")] - public required string Description { get; init; } + public override required string Description { get; init; } [JsonPropertyName("displayName")] - public string? DisplayName { get; init; } + public override string? DisplayName { get; init; } [JsonPropertyName("displayNameIOS")] public required string DisplayNameIOS { get; init; } [JsonPropertyName("displayPrice")] - public required string DisplayPrice { get; init; } + public override required string DisplayPrice { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("isFamilyShareableIOS")] public required bool IsFamilyShareableIOS { get; init; } [JsonPropertyName("jsonRepresentationIOS")] public required string JsonRepresentationIOS { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; + public override IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; [JsonPropertyName("price")] - public double? Price { get; init; } + public override double? Price { get; init; } /// /// iOS 26.4+ subscription pricing terms, including billing plan metadata for /// monthly subscriptions with a 12-month commitment. @@ -2989,33 +3045,33 @@ public sealed record ProductIOS : Product, ProductCommon [JsonPropertyName("subscriptionOffers")] public IReadOnlyList? SubscriptionOffers { get; init; } [JsonPropertyName("title")] - public required string Title { get; init; } + public override required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; + public override ProductType Type { get; init; } = global::OpenIap.ProductType.InApp; [JsonPropertyName("typeIOS")] public required ProductTypeIOS TypeIOS { get; init; } } -public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCommon +public sealed record ProductSubscriptionAndroid : ProductSubscription { [JsonPropertyName("currency")] - public required string Currency { get; init; } + public override required string Currency { get; init; } [JsonPropertyName("debugDescription")] - public string? DebugDescription { get; init; } + public override string? DebugDescription { get; init; } [JsonPropertyName("description")] - public required string Description { get; init; } + public override required string Description { get; init; } [JsonPropertyName("displayName")] - public string? DisplayName { get; init; } + public override string? DisplayName { get; init; } [JsonPropertyName("displayPrice")] - public required string DisplayPrice { get; init; } + public override required string DisplayPrice { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("nameAndroid")] public required string NameAndroid { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; + public override IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.Android; [JsonPropertyName("price")] - public double? Price { get; init; } + public override double? Price { get; init; } /// /// Product-level status code indicating fetch result (Android 8.0+) /// OK = product fetched successfully @@ -3033,12 +3089,12 @@ public sealed record ProductSubscriptionAndroid : ProductSubscription, ProductCo [JsonPropertyName("subscriptionOffers")] public required IReadOnlyList SubscriptionOffers { get; init; } [JsonPropertyName("title")] - public required string Title { get; init; } + public override required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; + public override ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; } -public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon +public sealed record ProductSubscriptionIOS : ProductSubscription { /// /// Subscriptions included in this Apple subscription bundle. Empty or null for @@ -3047,19 +3103,19 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon [JsonPropertyName("bundledSubscriptionsIOS")] public IReadOnlyList? BundledSubscriptionsIOS { get; init; } [JsonPropertyName("currency")] - public required string Currency { get; init; } + public override required string Currency { get; init; } [JsonPropertyName("debugDescription")] - public string? DebugDescription { get; init; } + public override string? DebugDescription { get; init; } [JsonPropertyName("description")] - public required string Description { get; init; } + public override required string Description { get; init; } [JsonPropertyName("displayName")] - public string? DisplayName { get; init; } + public override string? DisplayName { get; init; } [JsonPropertyName("displayNameIOS")] public required string DisplayNameIOS { get; init; } [JsonPropertyName("displayPrice")] - public required string DisplayPrice { get; init; } + public override required string DisplayPrice { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("introductoryPriceAsAmountIOS")] public string? IntroductoryPriceAsAmountIOS { get; init; } [JsonPropertyName("introductoryPriceIOS")] @@ -3075,9 +3131,9 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon [JsonPropertyName("jsonRepresentationIOS")] public required string JsonRepresentationIOS { get; init; } [JsonPropertyName("platform")] - public IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; + public override IapPlatform Platform { get; init; } = global::OpenIap.IapPlatform.IOS; [JsonPropertyName("price")] - public double? Price { get; init; } + public override double? Price { get; init; } /// /// iOS 26.4+ subscription pricing terms, including billing plan metadata for /// monthly subscriptions with a 12-month commitment. @@ -3099,31 +3155,31 @@ public sealed record ProductSubscriptionIOS : ProductSubscription, ProductCommon [JsonPropertyName("subscriptionPeriodUnitIOS")] public SubscriptionPeriodIOS? SubscriptionPeriodUnitIOS { get; init; } [JsonPropertyName("title")] - public required string Title { get; init; } + public override required string Title { get; init; } [JsonPropertyName("type")] - public ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; + public override ProductType Type { get; init; } = global::OpenIap.ProductType.Subs; [JsonPropertyName("typeIOS")] public required ProductTypeIOS TypeIOS { get; init; } } -public sealed record PurchaseAndroid : Purchase, PurchaseCommon +public sealed record PurchaseAndroid : Purchase { [JsonPropertyName("autoRenewingAndroid")] public bool? AutoRenewingAndroid { get; init; } [JsonPropertyName("currentPlanId")] - public string? CurrentPlanId { get; init; } + public override string? CurrentPlanId { get; init; } [JsonPropertyName("dataAndroid")] public string? DataAndroid { get; init; } [JsonPropertyName("developerPayloadAndroid")] public string? DeveloperPayloadAndroid { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("ids")] - public IReadOnlyList? Ids { get; init; } + public override IReadOnlyList? Ids { get; init; } [JsonPropertyName("isAcknowledgedAndroid")] public bool? IsAcknowledgedAndroid { get; init; } [JsonPropertyName("isAutoRenewing")] - public required bool IsAutoRenewing { get; init; } + public override required bool IsAutoRenewing { get; init; } /// /// Whether the subscription is suspended (Android) /// A suspended subscription means the user's payment method failed and they need to fix it. @@ -3148,21 +3204,21 @@ public sealed record PurchaseAndroid : Purchase, PurchaseCommon [JsonPropertyName("pendingPurchaseUpdateAndroid")] public PendingPurchaseUpdateAndroid? PendingPurchaseUpdateAndroid { get; init; } [JsonPropertyName("productId")] - public required string ProductId { get; init; } + public override required string ProductId { get; init; } [JsonPropertyName("purchaseState")] - public required PurchaseState PurchaseState { get; init; } + public override required PurchaseState PurchaseState { get; init; } [JsonPropertyName("purchaseToken")] - public string? PurchaseToken { get; init; } + public override string? PurchaseToken { get; init; } [JsonPropertyName("quantity")] - public required int Quantity { get; init; } + public override required int Quantity { get; init; } [JsonPropertyName("signatureAndroid")] public string? SignatureAndroid { get; init; } /// Store where purchase was made [JsonPropertyName("store")] - public required IapStore Store { get; init; } + public override required IapStore Store { get; init; } /// Unix timestamp in milliseconds since January 1, 1970 UTC. [JsonPropertyName("transactionDate")] - public required double TransactionDate { get; init; } + public override required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] public string? TransactionId { get; init; } /// @@ -3202,7 +3258,7 @@ public sealed record PurchaseError public SubResponseCodeAndroid? SubResponseCodeAndroid { get; init; } } -public sealed record PurchaseIOS : Purchase, PurchaseCommon +public sealed record PurchaseIOS : Purchase { /// /// Advanced Commerce API metadata (iOS 18.4+). @@ -3243,17 +3299,17 @@ public sealed record PurchaseIOS : Purchase, PurchaseCommon [JsonPropertyName("currencySymbolIOS")] public string? CurrencySymbolIOS { get; init; } [JsonPropertyName("currentPlanId")] - public string? CurrentPlanId { get; init; } + public override string? CurrentPlanId { get; init; } [JsonPropertyName("environmentIOS")] public string? EnvironmentIOS { get; init; } [JsonPropertyName("expirationDateIOS")] public double? ExpirationDateIOS { get; init; } [JsonPropertyName("id")] - public required string Id { get; init; } + public override required string Id { get; init; } [JsonPropertyName("ids")] - public IReadOnlyList? Ids { get; init; } + public override IReadOnlyList? Ids { get; init; } [JsonPropertyName("isAutoRenewing")] - public required bool IsAutoRenewing { get; init; } + public override required bool IsAutoRenewing { get; init; } [JsonPropertyName("isUpgradedIOS")] public bool? IsUpgradedIOS { get; init; } [JsonPropertyName("offerIOS")] @@ -3272,13 +3328,13 @@ public sealed record PurchaseIOS : Purchase, PurchaseCommon [JsonPropertyName("previousOriginalTransactionIdIOS")] public string? PreviousOriginalTransactionIdIOS { get; init; } [JsonPropertyName("productId")] - public required string ProductId { get; init; } + public override required string ProductId { get; init; } [JsonPropertyName("purchaseState")] - public required PurchaseState PurchaseState { get; init; } + public override required PurchaseState PurchaseState { get; init; } [JsonPropertyName("purchaseToken")] - public string? PurchaseToken { get; init; } + public override string? PurchaseToken { get; init; } [JsonPropertyName("quantity")] - public required int Quantity { get; init; } + public override required int Quantity { get; init; } [JsonPropertyName("quantityIOS")] public int? QuantityIOS { get; init; } [JsonPropertyName("reasonIOS")] @@ -3300,14 +3356,14 @@ public sealed record PurchaseIOS : Purchase, PurchaseCommon public string? RevocationTypeIOS { get; init; } /// Store where purchase was made [JsonPropertyName("store")] - public required IapStore Store { get; init; } + public override required IapStore Store { get; init; } [JsonPropertyName("storefrontCountryCodeIOS")] public string? StorefrontCountryCodeIOS { get; init; } [JsonPropertyName("subscriptionGroupIdIOS")] public string? SubscriptionGroupIdIOS { get; init; } /// Unix timestamp in milliseconds since January 1, 1970 UTC. [JsonPropertyName("transactionDate")] - public required double TransactionDate { get; init; } + public override required double TransactionDate { get; init; } [JsonPropertyName("transactionId")] public required string TransactionId { get; init; } [JsonPropertyName("transactionReasonIOS")] @@ -3714,6 +3770,12 @@ public sealed record VerifyPurchaseResultAndroid : VerifyPurchaseResult public required double FreeTrialEndDate { get; init; } [JsonPropertyName("gracePeriodEndDate")] public required double GracePeriodEndDate { get; init; } + /// + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + /// + [JsonPropertyName("isValid")] + public override required bool IsValid { get; init; } [JsonPropertyName("parentProductId")] public required string ParentProductId { get; init; } [JsonPropertyName("productId")] @@ -3745,16 +3807,26 @@ public sealed record VerifyPurchaseResultHorizon : VerifyPurchaseResult /// Unix timestamp (seconds) when the entitlement was granted. [JsonPropertyName("grantTime")] public double? GrantTime { get; init; } - /// Whether the entitlement verification succeeded. + /// + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + /// + [JsonPropertyName("isValid")] + public override required bool IsValid { get; init; } + /// + /// Whether the entitlement verification succeeded. + /// @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. + /// + [Obsolete("Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0.")] [JsonPropertyName("success")] - public required bool Success { get; init; } + public bool Success { get; init; } } public sealed record VerifyPurchaseResultIOS : VerifyPurchaseResult { /// Whether the receipt is valid [JsonPropertyName("isValid")] - public required bool IsValid { get; init; } + public override required bool IsValid { get; init; } /// JWS representation [JsonPropertyName("jwsRepresentation")] public required string JwsRepresentation { get; init; } @@ -4643,11 +4715,11 @@ public interface MutationResolver Task SyncIOSAsync(); /// - /// Verify a purchase against your own backend. Returns a platform-specific - /// variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - /// Inspect the concrete variant before reading fields. + /// Verify a purchase against your own backend. Every VerifyPurchaseResult + /// variant exposes isValid, so entitlement can be gated without inspecting the + /// concrete type. Variants add their own metadata on top: IOS carries + /// receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + /// carries grantTime. /// See: https://openiap.dev/docs/features/validation#verify-purchase /// Task VerifyPurchaseAsync(VerifyPurchaseProps options); diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 7184add30..c31510286 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -1289,6 +1289,16 @@ public interface PurchaseCommon { val transactionDate: Double } +/** + * Validity shared by every store-specific purchase verification result. + */ +public interface VerifyPurchaseResultCommon { + /** + * Whether the purchase is valid, without inspecting the concrete result variant. + */ + val isValid: Boolean +} + // MARK: - Objects public data class ActiveSubscription( @@ -3907,6 +3917,11 @@ public data class VerifyPurchaseResultAndroid( val deferredSku: String? = null, val freeTrialEndDate: Double, val gracePeriodEndDate: Double, + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + override val isValid: Boolean, val parentProductId: String, val productId: String, val productType: String, @@ -3917,7 +3932,7 @@ public data class VerifyPurchaseResultAndroid( val term: String, val termSku: String, val testTransaction: Boolean -) : VerifyPurchaseResult { +) : VerifyPurchaseResultCommon, VerifyPurchaseResult { companion object { fun fromJson(json: Map): VerifyPurchaseResultAndroid { @@ -3930,6 +3945,7 @@ public data class VerifyPurchaseResultAndroid( deferredSku = json["deferredSku"] as? String, freeTrialEndDate = (json["freeTrialEndDate"] as? Number)?.toDouble() ?: 0.0, gracePeriodEndDate = (json["gracePeriodEndDate"] as? Number)?.toDouble() ?: 0.0, + isValid = json["isValid"] as? Boolean ?: false, parentProductId = json["parentProductId"] as? String ?: "", productId = json["productId"] as? String ?: "", productType = json["productType"] as? String ?: "", @@ -3954,6 +3970,7 @@ public data class VerifyPurchaseResultAndroid( "deferredSku" to deferredSku, "freeTrialEndDate" to freeTrialEndDate, "gracePeriodEndDate" to gracePeriodEndDate, + "isValid" to isValid, "parentProductId" to parentProductId, "productId" to productId, "productType" to productType, @@ -3976,16 +3993,24 @@ public data class VerifyPurchaseResultHorizon( * Unix timestamp (seconds) when the entitlement was granted. */ val grantTime: Double? = null, + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + override val isValid: Boolean, /** * Whether the entitlement verification succeeded. + * @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. */ + @Deprecated("Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0.") val success: Boolean -) : VerifyPurchaseResult { +) : VerifyPurchaseResultCommon, VerifyPurchaseResult { companion object { fun fromJson(json: Map): VerifyPurchaseResultHorizon { return VerifyPurchaseResultHorizon( grantTime = (json["grantTime"] as? Number)?.toDouble(), + isValid = json["isValid"] as? Boolean ?: false, success = json["success"] as? Boolean ?: false, ) } @@ -3994,6 +4019,7 @@ public data class VerifyPurchaseResultHorizon( override fun toJson(): Map = mapOf( "__typename" to "VerifyPurchaseResultHorizon", "grantTime" to grantTime, + "isValid" to isValid, "success" to success, ) } @@ -4002,7 +4028,7 @@ public data class VerifyPurchaseResultIOS( /** * Whether the receipt is valid */ - val isValid: Boolean, + override val isValid: Boolean, /** * JWS representation */ @@ -4015,7 +4041,7 @@ public data class VerifyPurchaseResultIOS( * Receipt data string */ val receiptData: String -) : VerifyPurchaseResult { +) : VerifyPurchaseResultCommon, VerifyPurchaseResult { companion object { fun fromJson(json: Map): VerifyPurchaseResultIOS { @@ -5465,7 +5491,7 @@ public sealed interface Purchase : PurchaseCommon { } } -public sealed interface VerifyPurchaseResult { +public sealed interface VerifyPurchaseResult : VerifyPurchaseResultCommon { fun toJson(): Map companion object { @@ -5650,11 +5676,11 @@ public interface MutationResolver { */ suspend fun syncIOS(): Boolean /** - * Verify a purchase against your own backend. Returns a platform-specific - * variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - * Inspect the concrete variant before reading fields. + * Verify a purchase against your own backend. Every VerifyPurchaseResult + * variant exposes isValid, so entitlement can be gated without inspecting the + * concrete type. Variants add their own metadata on top: IOS carries + * receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + * carries grantTime. * See: https://openiap.dev/docs/features/validation#verify-purchase */ suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult @@ -6039,11 +6065,11 @@ public data class MutationHandlers( */ val syncIOS: MutationSyncIOSHandler? = null, /** - * Verify a purchase against your own backend. Returns a platform-specific - * variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - * Inspect the concrete variant before reading fields. + * Verify a purchase against your own backend. Every VerifyPurchaseResult + * variant exposes isValid, so entitlement can be gated without inspecting the + * concrete type. Variants add their own metadata on top: IOS carries + * receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + * carries grantTime. * See: https://openiap.dev/docs/features/validation#verify-purchase */ val verifyPurchase: MutationVerifyPurchaseHandler? = null, diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 89d702a11..fa039e64c 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -518,6 +518,12 @@ public protocol PurchaseCommon: Codable { var transactionDate: Double { get } } +/// Validity shared by every store-specific purchase verification result. +public protocol VerifyPurchaseResultCommon: Codable { + /// Whether the purchase is valid, without inspecting the concrete result variant. + var isValid: Bool { get } +} + // MARK: - Objects public struct ActiveSubscription: Codable { @@ -1374,7 +1380,7 @@ public struct ValidTimeWindowAndroid: Codable { public var startTimeMillis: String } -public struct VerifyPurchaseResultAndroid: Codable { +public struct VerifyPurchaseResultAndroid: Codable, VerifyPurchaseResultCommon { public var autoRenewing: Bool public var betaProduct: Bool public var cancelDate: Double? = nil @@ -1383,6 +1389,9 @@ public struct VerifyPurchaseResultAndroid: Codable { public var deferredSku: String? = nil public var freeTrialEndDate: Double public var gracePeriodEndDate: Double + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + public var isValid: Bool public var parentProductId: String public var productId: String public var productType: String @@ -1397,14 +1406,19 @@ public struct VerifyPurchaseResultAndroid: Codable { /// Result from Meta Horizon verify_entitlement API. /// Returns verification status and grant time for the entitlement. -public struct VerifyPurchaseResultHorizon: Codable { +public struct VerifyPurchaseResultHorizon: Codable, VerifyPurchaseResultCommon { /// Unix timestamp (seconds) when the entitlement was granted. public var grantTime: Double? = nil + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + public var isValid: Bool /// Whether the entitlement verification succeeded. + /// @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. + @available(*, deprecated, message: "Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0.") public var success: Bool } -public struct VerifyPurchaseResultIOS: Codable { +public struct VerifyPurchaseResultIOS: Codable, VerifyPurchaseResultCommon { /// Whether the receipt is valid public var isValid: Bool /// JWS representation @@ -2558,10 +2572,22 @@ public enum Purchase: Codable, PurchaseCommon { } } -public enum VerifyPurchaseResult: Codable { +public enum VerifyPurchaseResult: Codable, VerifyPurchaseResultCommon { case verifyPurchaseResultAndroid(VerifyPurchaseResultAndroid) case verifyPurchaseResultIos(VerifyPurchaseResultIOS) case verifyPurchaseResultHorizon(VerifyPurchaseResultHorizon) + + /// Whether the purchase is valid, without inspecting the concrete result variant. + public var isValid: Bool { + switch self { + case let .verifyPurchaseResultAndroid(value): + return value.isValid + case let .verifyPurchaseResultIos(value): + return value.isValid + case let .verifyPurchaseResultHorizon(value): + return value.isValid + } + } } // MARK: - Root Operations @@ -2687,11 +2713,11 @@ public protocol MutationResolver { /// Force sync transactions with the App Store (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/sync-ios func syncIOS() async throws -> Bool - /// Verify a purchase against your own backend. Returns a platform-specific - /// variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - /// Inspect the concrete variant before reading fields. + /// Verify a purchase against your own backend. Every VerifyPurchaseResult + /// variant exposes isValid, so entitlement can be gated without inspecting the + /// concrete type. Variants add their own metadata on top: IOS carries + /// receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + /// carries grantTime. /// See: https://openiap.dev/docs/features/validation#verify-purchase func verifyPurchase(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResult /// Verify via a managed provider without standing up your own server. The diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index b839a4840..94fcbe735 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -1134,6 +1134,12 @@ abstract class PurchaseCommon { double get transactionDate; } +/// Validity shared by every store-specific purchase verification result. +abstract class VerifyPurchaseResultCommon { + /// Whether the purchase is valid, without inspecting the concrete result variant. + bool get isValid; +} + // MARK: - Objects class ActiveSubscription { @@ -3769,7 +3775,7 @@ class ValidTimeWindowAndroid { } } -class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { +class VerifyPurchaseResultAndroid extends VerifyPurchaseResult implements VerifyPurchaseResultCommon { const VerifyPurchaseResultAndroid({ required this.autoRenewing, required this.betaProduct, @@ -3779,6 +3785,7 @@ class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { this.deferredSku, required this.freeTrialEndDate, required this.gracePeriodEndDate, + required this.isValid, required this.parentProductId, required this.productId, required this.productType, @@ -3799,6 +3806,9 @@ class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { final String? deferredSku; final double freeTrialEndDate; final double gracePeriodEndDate; + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + final bool isValid; final String parentProductId; final String productId; final String productType; @@ -3820,6 +3830,7 @@ class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { deferredSku: json['deferredSku'] as String?, freeTrialEndDate: (json['freeTrialEndDate'] as num).toDouble(), gracePeriodEndDate: (json['gracePeriodEndDate'] as num).toDouble(), + isValid: json['isValid'] as bool, parentProductId: json['parentProductId'] as String, productId: json['productId'] as String, productType: json['productType'] as String, @@ -3845,6 +3856,7 @@ class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { 'deferredSku': deferredSku, 'freeTrialEndDate': freeTrialEndDate, 'gracePeriodEndDate': gracePeriodEndDate, + 'isValid': isValid, 'parentProductId': parentProductId, 'productId': productId, 'productType': productType, @@ -3861,20 +3873,26 @@ class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { /// Result from Meta Horizon verify_entitlement API. /// Returns verification status and grant time for the entitlement. -class VerifyPurchaseResultHorizon extends VerifyPurchaseResult { +class VerifyPurchaseResultHorizon extends VerifyPurchaseResult implements VerifyPurchaseResultCommon { const VerifyPurchaseResultHorizon({ this.grantTime, + required this.isValid, required this.success, }); /// Unix timestamp (seconds) when the entitlement was granted. final double? grantTime; + /// Whether the purchase is valid. Uniform across every VerifyPurchaseResult + /// variant so callers can gate entitlement without inspecting the concrete type. + final bool isValid; /// Whether the entitlement verification succeeded. + /// @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. final bool success; factory VerifyPurchaseResultHorizon.fromJson(Map json) { return VerifyPurchaseResultHorizon( grantTime: (json['grantTime'] as num?)?.toDouble(), + isValid: json['isValid'] as bool, success: json['success'] as bool, ); } @@ -3884,12 +3902,13 @@ class VerifyPurchaseResultHorizon extends VerifyPurchaseResult { return { '__typename': 'VerifyPurchaseResultHorizon', 'grantTime': grantTime, + 'isValid': isValid, 'success': success, }; } } -class VerifyPurchaseResultIOS extends VerifyPurchaseResult { +class VerifyPurchaseResultIOS extends VerifyPurchaseResult implements VerifyPurchaseResultCommon { const VerifyPurchaseResultIOS({ required this.isValid, required this.jwsRepresentation, @@ -5296,7 +5315,7 @@ sealed class Purchase implements PurchaseCommon { Map toJson(); } -sealed class VerifyPurchaseResult { +sealed class VerifyPurchaseResult implements VerifyPurchaseResultCommon { const VerifyPurchaseResult(); factory VerifyPurchaseResult.fromJson(Map json) { @@ -5312,6 +5331,10 @@ sealed class VerifyPurchaseResult { throw ArgumentError('Unknown __typename for VerifyPurchaseResult: $typeName'); } + /// Whether the purchase is valid, without inspecting the concrete result variant. + @override + bool get isValid; + Map toJson(); } @@ -5461,11 +5484,11 @@ abstract class MutationResolver { /// Force sync transactions with the App Store (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/sync-ios Future syncIOS(); - /// Verify a purchase against your own backend. Returns a platform-specific - /// variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - /// + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - /// receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - /// Inspect the concrete variant before reading fields. + /// Verify a purchase against your own backend. Every VerifyPurchaseResult + /// variant exposes isValid, so entitlement can be gated without inspecting the + /// concrete type. Variants add their own metadata on top: IOS carries + /// receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + /// carries grantTime. /// See: https://openiap.dev/docs/features/validation#verify-purchase Future verifyPurchase({ VerifyPurchaseAppleOptions? apple, diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index bb0b4a65d..0b60a5973 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -3243,6 +3243,8 @@ class ValidTimeWindowAndroid: return dict class VerifyPurchaseResultAndroid: + ## Whether the purchase is valid. Uniform across every VerifyPurchaseResult variant so callers can gate entitlement without inspecting the concrete type. + var is_valid: bool = false var auto_renewing: bool = false var beta_product: bool = false var cancel_date: Variant = null @@ -3264,6 +3266,8 @@ class VerifyPurchaseResultAndroid: static func from_dict(data: Dictionary) -> VerifyPurchaseResultAndroid: var obj = VerifyPurchaseResultAndroid.new() + if data.has("isValid") and data["isValid"] != null: + obj.is_valid = data["isValid"] if data.has("autoRenewing") and data["autoRenewing"] != null: obj.auto_renewing = data["autoRenewing"] if data.has("betaProduct") and data["betaProduct"] != null: @@ -3304,6 +3308,7 @@ class VerifyPurchaseResultAndroid: func to_dict() -> Dictionary: var dict = {} + dict["isValid"] = is_valid dict["autoRenewing"] = auto_renewing dict["betaProduct"] = beta_product if cancel_date != null: @@ -3330,13 +3335,17 @@ class VerifyPurchaseResultAndroid: ## Result from Meta Horizon verify_entitlement API. Returns verification status and grant time for the entitlement. class VerifyPurchaseResultHorizon: - ## Whether the entitlement verification succeeded. + ## Whether the purchase is valid. Uniform across every VerifyPurchaseResult variant so callers can gate entitlement without inspecting the concrete type. + var is_valid: bool = false + ## Whether the entitlement verification succeeded. @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. var success: bool = false ## Unix timestamp (seconds) when the entitlement was granted. var grant_time: Variant = null static func from_dict(data: Dictionary) -> VerifyPurchaseResultHorizon: var obj = VerifyPurchaseResultHorizon.new() + if data.has("isValid") and data["isValid"] != null: + obj.is_valid = data["isValid"] if data.has("success") and data["success"] != null: obj.success = data["success"] if data.has("grantTime") and data["grantTime"] != null: @@ -3345,6 +3354,7 @@ class VerifyPurchaseResultHorizon: func to_dict() -> Dictionary: var dict = {} + dict["isValid"] = is_valid dict["success"] = success if grant_time != null: dict["grantTime"] = grant_time @@ -5721,7 +5731,7 @@ class Mutation: const return_type = "VoidResult" const is_array = false - ## Verify a purchase against your own backend. Returns a platform-specific variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. Inspect the concrete variant before reading fields. See: https://openiap.dev/docs/features/validation#verify-purchase + ## Verify a purchase against your own backend. Every VerifyPurchaseResult variant exposes isValid, so entitlement can be gated without inspecting the concrete type. Variants add their own metadata on top: IOS carries receipt/JWS fields, Android carries Play Store receipt fields, and Horizon carries grantTime. See: https://openiap.dev/docs/features/validation#verify-purchase class verifyPurchaseField: const name = "verifyPurchase" const snake_name = "verify_purchase" @@ -6231,7 +6241,7 @@ static func deep_link_to_subscriptions_args(options: Variant = null) -> Dictiona args["options"] = options return args -## Verify a purchase against your own backend. Returns a platform-specific variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. Inspect the concrete variant before reading fields. See: https://openiap.dev/docs/features/validation#verify-purchase +## Verify a purchase against your own backend. Every VerifyPurchaseResult variant exposes isValid, so entitlement can be gated without inspecting the concrete type. Variants add their own metadata on top: IOS carries receipt/JWS fields, Android carries Play Store receipt fields, and Horizon carries grantTime. See: https://openiap.dev/docs/features/validation#verify-purchase static func verify_purchase_args(options: VerifyPurchaseProps) -> Dictionary: var args = {} if options != null: diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 7e9696c79..29d5ca10f 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -885,11 +885,11 @@ export interface Mutation { */ syncIOS: Promise; /** - * Verify a purchase against your own backend. Returns a platform-specific - * variant of VerifyPurchaseResult — VerifyPurchaseResultIOS exposes isValid - * + receipt/JWS metadata, VerifyPurchaseResultAndroid carries Play Store - * receipt fields (no isValid), and VerifyPurchaseResultHorizon uses success. - * Inspect the concrete variant before reading fields. + * Verify a purchase against your own backend. Every VerifyPurchaseResult + * variant exposes isValid, so entitlement can be gated without inspecting the + * concrete type. Variants add their own metadata on top: IOS carries + * receipt/JWS fields, Android carries Play Store receipt fields, and Horizon + * carries grantTime. * See: https://openiap.dev/docs/features/validation#verify-purchase */ verifyPurchase: Promise; @@ -2187,7 +2187,7 @@ export interface VerifyPurchaseProps { export type VerifyPurchaseResult = VerifyPurchaseResultAndroid | VerifyPurchaseResultHorizon | VerifyPurchaseResultIOS; -export interface VerifyPurchaseResultAndroid { +export interface VerifyPurchaseResultAndroid extends VerifyPurchaseResultCommon { autoRenewing: boolean; betaProduct: boolean; cancelDate?: (number | null); @@ -2196,6 +2196,11 @@ export interface VerifyPurchaseResultAndroid { deferredSku?: (string | null); freeTrialEndDate: number; gracePeriodEndDate: number; + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + isValid: boolean; parentProductId: string; productId: string; productType: string; @@ -2208,18 +2213,32 @@ export interface VerifyPurchaseResultAndroid { testTransaction: boolean; } +/** Validity shared by every store-specific purchase verification result. */ +export interface VerifyPurchaseResultCommon { + /** Whether the purchase is valid, without inspecting the concrete result variant. */ + isValid: boolean; +} + /** * Result from Meta Horizon verify_entitlement API. * Returns verification status and grant time for the entitlement. */ -export interface VerifyPurchaseResultHorizon { +export interface VerifyPurchaseResultHorizon extends VerifyPurchaseResultCommon { /** Unix timestamp (seconds) when the entitlement was granted. */ grantTime?: (number | null); - /** Whether the entitlement verification succeeded. */ + /** + * Whether the purchase is valid. Uniform across every VerifyPurchaseResult + * variant so callers can gate entitlement without inspecting the concrete type. + */ + isValid: boolean; + /** + * Whether the entitlement verification succeeded. + * @deprecated Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0. + */ success: boolean; } -export interface VerifyPurchaseResultIOS { +export interface VerifyPurchaseResultIOS extends VerifyPurchaseResultCommon { /** Whether the receipt is valid */ isValid: boolean; /** JWS representation */ diff --git a/packages/gql/src/schema-deprecations.test.mjs b/packages/gql/src/schema-deprecations.test.mjs index a2fb0ce7b..ed040dbe0 100644 --- a/packages/gql/src/schema-deprecations.test.mjs +++ b/packages/gql/src/schema-deprecations.test.mjs @@ -111,11 +111,21 @@ type Query { expect(OPENIAP_REMOVAL_NOTICE_PATTERN.test(deprecations.entries[0].reason)).toBe(true); }); - it('contains no scheduled repository deprecations after the OpenIAP 3 removal', () => { + // Every scheduled deprecation is listed here on purpose: an unlisted one is + // either an accident or a removal someone forgot to carry out. + it('schedules only the deprecations this repository has agreed to', () => { const deprecations = extractSchemaDeprecations(repositorySchemaSources()); expect(deprecations.issues).toEqual([]); - expect(deprecations.entries).toEqual([]); + expect( + deprecations.entries.map((entry) => ({ owner: entry.ownerPath, reason: entry.reason })), + ).toEqual([ + { + owner: 'VerifyPurchaseResultHorizon.success', + reason: + 'Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0.', + }, + ]); expect(deprecations.typeReasons).toEqual(new Map()); expect(deprecations.operationArguments).toEqual([]); }); diff --git a/packages/gql/src/type-android.graphql b/packages/gql/src/type-android.graphql index 35999415c..3f2afb7b7 100644 --- a/packages/gql/src/type-android.graphql +++ b/packages/gql/src/type-android.graphql @@ -509,18 +509,31 @@ input VerifyPurchaseHorizonOptions { Result from Meta Horizon verify_entitlement API. Returns verification status and grant time for the entitlement. """ -type VerifyPurchaseResultHorizon { +type VerifyPurchaseResultHorizon implements VerifyPurchaseResultCommon { + """ + Whether the purchase is valid. Uniform across every VerifyPurchaseResult + variant so callers can gate entitlement without inspecting the concrete type. + """ + isValid: Boolean! """ Whether the entitlement verification succeeded. """ success: Boolean! + @deprecated( + reason: "Renamed to isValid so every VerifyPurchaseResult variant answers validity the same way. Scheduled for removal in OpenIAP 4.0." + ) """ Unix timestamp (seconds) when the entitlement was granted. """ grantTime: Float } -type VerifyPurchaseResultAndroid { +type VerifyPurchaseResultAndroid implements VerifyPurchaseResultCommon { + """ + Whether the purchase is valid. Uniform across every VerifyPurchaseResult + variant so callers can gate entitlement without inspecting the concrete type. + """ + isValid: Boolean! autoRenewing: Boolean! betaProduct: Boolean! cancelDate: Float diff --git a/packages/gql/src/type-ios.graphql b/packages/gql/src/type-ios.graphql index feebc2834..f07389cd0 100644 --- a/packages/gql/src/type-ios.graphql +++ b/packages/gql/src/type-ios.graphql @@ -431,7 +431,7 @@ input VerifyPurchaseAppleOptions { sku: String! } -type VerifyPurchaseResultIOS { +type VerifyPurchaseResultIOS implements VerifyPurchaseResultCommon { """ Whether the receipt is valid """ diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index 1b24fc8e2..43ea47dfc 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -277,6 +277,16 @@ input VerifyPurchaseProps { horizon: VerifyPurchaseHorizonOptions } +""" +Validity shared by every store-specific purchase verification result. +""" +interface VerifyPurchaseResultCommon { + """ + Whether the purchase is valid, without inspecting the concrete result variant. + """ + isValid: Boolean! +} + union VerifyPurchaseResult = | VerifyPurchaseResultAndroid | VerifyPurchaseResultIOS diff --git a/packages/kit/Dockerfile b/packages/kit/Dockerfile index 316d3c751..c7c28ed0c 100644 --- a/packages/kit/Dockerfile +++ b/packages/kit/Dockerfile @@ -23,6 +23,7 @@ COPY packages/docs/package.json ./packages/docs/ # root lockfile and `--frozen-lockfile` rejects the install with # "lockfile had changes, but lockfile is frozen". COPY packages/mcp-server/package.json ./packages/mcp-server/ +COPY packages/conformance/package.json ./packages/conformance/ RUN bun install --frozen-lockfile --filter @hyodotdev/openiap-kit # --- Build the unified app (Vite SPA + compiled Bun server) -------------- diff --git a/packages/kit/convex/webhooks/conformance.test.ts b/packages/kit/convex/webhooks/conformance.test.ts index c5fb799fe..5b28f8e37 100644 --- a/packages/kit/convex/webhooks/conformance.test.ts +++ b/packages/kit/convex/webhooks/conformance.test.ts @@ -1,18 +1,9 @@ -// End-to-end conformance harness driving the full webhook → state -// machine → entitlement decision path, using pre-canned ASN v2 + RTDN -// payloads. This is the "sandbox-without-Apple/Google" suite — every -// scenario starts from a deterministic notification payload and -// asserts the resulting `subscriptions` row + entitlement boolean. +// Provider conformance for webhook -> state machine -> entitlement, using +// pre-canned ASN v2 + RTDN payloads (the "sandbox-without-Apple/Google" suite). // -// The harness exercises: -// 1. `normalizeAppleAsn` / `normalizeGoogleRtdn` (webhook receiver) -// 2. `applySubscriptionTransition` (state machine) -// 3. `entitlementActive` (status route) -// -// Each scenario is a script of `(input event) -> (expected after)` -// transitions so we cover the multi-step lifecycle (purchase → renew -// → cancel → expire, billing-retry → recovery, refund, etc.) rather -// than just a single edge. +// Scenarios are declared once as abstract lifecycle events; each provider's +// adapter renders them into its own wire payload. Adding a provider means +// writing an adapter, not another scenario list. import { describe, expect, it } from "vitest"; @@ -24,25 +15,34 @@ import { type AppleDecodedRenewalInfo, type GoogleRtdnPayload, type GoogleSubscriptionInfo, + type NormalizedWebhookEvent, } from "./shared"; import { applySubscriptionTransition, entitlementActive, type CurrentSubscription, } from "../subscriptions/stateMachine"; +import { behaviorsByCategory } from "../../../conformance/src/spec/behaviors.mjs"; -type AppleStep = { - payload: AppleAsnPayload; - transaction?: AppleDecodedTransaction | null; - renewalInfo?: AppleDecodedRenewalInfo | null; - expect: ExpectAfter; -}; +// --------------------------------------------------------------------------- +// Abstract lifecycle vocabulary +// --------------------------------------------------------------------------- -type GoogleStep = { - payload: GoogleRtdnPayload; - subscriptionInfo?: GoogleSubscriptionInfo | null; - expect: ExpectAfter; -}; +type LifecycleEvent = + | "InitialPurchase" + | "Renew" + | "DisableAutoRenew" + | "Expire" + | "EnterGracePeriod" + | "RecoverFromGracePeriod" + | "EnterBillingRetry" + | "RecoverFromBillingRetry" + // Refund (money returned) and Revoke (entitlement withdrawn) are distinct + // signals on both providers and land in different normalized states. + | "Refund" + | "Revoke" + | "Pause" + | "Resume"; type ExpectAfter = { state: NonNullable["state"]; @@ -51,131 +51,57 @@ type ExpectAfter = { cancellationReason?: NonNullable["cancellationReason"]; }; -function runAppleScenario( - steps: AppleStep[], - productId = "com.example.premium", -) { - let current: CurrentSubscription = null; - for (const [index, step] of steps.entries()) { - const normalized = normalizeAppleAsn({ - payload: step.payload, - transaction: { - originalTransactionId: "txn-1", - productId, - ...(step.transaction ?? {}), - }, - renewalInfo: step.renewalInfo, - }); - const transition = applySubscriptionTransition(current, { - type: normalized.type, - productId: normalized.productId, - subscriptionState: normalized.subscriptionState, - expiresAt: normalized.expiresAt, - renewsAt: normalized.renewsAt, - cancellationReason: normalized.cancellationReason, - currency: normalized.currency, - priceAmountMicros: normalized.priceAmountMicros, - }); - // Fail loudly when the state machine returns no next-state on a step - // that expects forward progress; the prior `transition.next ?? current` - // would silently keep the old `current` and let same-state assertions - // (e.g. Active → Active on DID_RENEW) pass without exercising the - // transition. - expect( - transition.next, - `step ${index} produced no next state`, - ).toBeTruthy(); - current = transition.next ?? current; - expect(current?.state, `step ${index} state`).toBe(step.expect.state); - expect(transition.active, `step ${index} active`).toBe(step.expect.active); - if (step.expect.willRenew !== undefined) { - expect(current?.willRenew, `step ${index} willRenew`).toBe( - step.expect.willRenew, - ); - } - if (step.expect.cancellationReason !== undefined) { - expect( - current?.cancellationReason, - `step ${index} cancellationReason`, - ).toBe(step.expect.cancellationReason); - } - } - return current; -} +type Step = { event: LifecycleEvent; expect: ExpectAfter }; -function runGoogleScenario(steps: GoogleStep[], productId = "premium_monthly") { - let current: CurrentSubscription = null; - for (const [index, step] of steps.entries()) { - const normalized = normalizeGoogleRtdn({ - payload: step.payload, - subscriptionInfo: step.subscriptionInfo, - }); - const transition = applySubscriptionTransition(current, { - type: normalized.type, - productId: normalized.productId ?? productId, - subscriptionState: normalized.subscriptionState, - expiresAt: normalized.expiresAt, - renewsAt: normalized.renewsAt, - cancellationReason: normalized.cancellationReason, - currency: normalized.currency, - priceAmountMicros: normalized.priceAmountMicros, - }); - // Fail loudly when the state machine returns no next-state on a step - // that expects forward progress; the prior `transition.next ?? current` - // would silently keep the old `current` and let same-state assertions - // (e.g. Active → Active on DID_RENEW) pass without exercising the - // transition. - expect( - transition.next, - `step ${index} produced no next state`, - ).toBeTruthy(); - current = transition.next ?? current; - expect(current?.state, `google step ${index} state`).toBe( - step.expect.state, - ); - expect(transition.active, `google step ${index} active`).toBe( - step.expect.active, - ); - if (step.expect.willRenew !== undefined) { - expect(current?.willRenew, `google step ${index} willRenew`).toBe( - step.expect.willRenew, - ); - } - if (step.expect.cancellationReason !== undefined) { - expect( - current?.cancellationReason, - `google step ${index} cancellationReason`, - ).toBe(step.expect.cancellationReason); - } - } - return current; -} +type Scenario = { + name: string; + steps: Step[]; + /** Entitlement expected after the final step. */ + entitledAtEnd: boolean; + /** Behavior ids from packages/conformance this scenario demonstrates. */ + covers: string[]; +}; + +type StepContext = { + index: number; + productId: string; + purchaseToken: string; +}; + +type ProviderAdapter = { + name: string; + productId: string; + /** Abstract events this provider's notification stream can express. */ + supports: ReadonlySet; + normalize(event: LifecycleEvent, ctx: StepContext): NormalizedWebhookEvent; +}; const FUTURE = 9_999_999_999_000; -describe("conformance: Apple lifecycle scenarios", () => { - it("purchase → renew → cancel → expire", () => { - const final = runAppleScenario([ +// --------------------------------------------------------------------------- +// Scenarios — declared once, run against every capable provider +// --------------------------------------------------------------------------- + +const SCENARIOS: Scenario[] = [ + { + name: "purchase -> renew -> cancel -> expire", + entitledAtEnd: false, + covers: [ + "lifecycle.purchase-starts-active-entitlement", + "lifecycle.cancel-retains-entitlement-until-expiry", + "lifecycle.expiry-ends-entitlement", + ], + steps: [ { - payload: applePayload("SUBSCRIBED", "INITIAL_BUY", "u-1"), - transaction: { originalTransactionId: "1", expiresDate: FUTURE }, + event: "InitialPurchase", expect: { state: "Active", active: true, willRenew: true }, }, { - payload: applePayload("DID_RENEW", undefined, "u-2"), - transaction: { - originalTransactionId: "1", - expiresDate: FUTURE + 1, - }, + event: "Renew", expect: { state: "Active", active: true, willRenew: true }, }, { - payload: applePayload( - "DID_CHANGE_RENEWAL_STATUS", - "AUTO_RENEW_DISABLED", - "u-3", - ), - transaction: { originalTransactionId: "1", expiresDate: FUTURE + 1 }, + event: "DisableAutoRenew", expect: { state: "Active", active: true, @@ -184,138 +110,135 @@ describe("conformance: Apple lifecycle scenarios", () => { }, }, { - payload: applePayload("EXPIRED", undefined, "u-4"), - transaction: { originalTransactionId: "1", expiresDate: 0 }, - renewalInfo: { expirationIntent: 1 }, - expect: { - state: "Expired", - active: false, - willRenew: false, - cancellationReason: "UserCanceled", - }, - }, - ]); - expect(entitlementActive(final!)).toBe(false); - }); - - it("grace-period → recovery", () => { - const final = runAppleScenario([ - { - payload: applePayload("SUBSCRIBED", "INITIAL_BUY", "b-1"), - transaction: { originalTransactionId: "2", expiresDate: FUTURE }, - expect: { state: "Active", active: true }, + event: "Expire", + expect: { state: "Expired", active: false, willRenew: false }, }, + ], + }, + { + name: "grace period -> recovery", + entitledAtEnd: true, + covers: ["lifecycle.grace-period-retains-entitlement"], + steps: [ + { event: "InitialPurchase", expect: { state: "Active", active: true } }, { - payload: applePayload("DID_FAIL_TO_RENEW", "GRACE_PERIOD", "b-2"), - transaction: { originalTransactionId: "2", expiresDate: FUTURE }, + event: "EnterGracePeriod", expect: { state: "InGracePeriod", active: true }, }, { - payload: applePayload("DID_RENEW", "BILLING_RECOVERY", "b-3"), - transaction: { - originalTransactionId: "2", - expiresDate: FUTURE + 100, - }, + event: "RecoverFromGracePeriod", expect: { state: "Active", active: true, willRenew: true }, }, - ]); - expect(entitlementActive(final!)).toBe(true); - }); - - it("refund flow flips state to Refunded and de-entitles", () => { - const final = runAppleScenario([ + ], + }, + { + name: "billing retry -> recovery", + entitledAtEnd: true, + covers: ["lifecycle.billing-retry-suspends-entitlement"], + steps: [ + { event: "InitialPurchase", expect: { state: "Active", active: true } }, + { + event: "EnterBillingRetry", + expect: { state: "InBillingRetry", active: false }, + }, { - payload: applePayload("SUBSCRIBED", "INITIAL_BUY", "r-1"), - transaction: { originalTransactionId: "3", expiresDate: FUTURE }, + event: "RecoverFromBillingRetry", expect: { state: "Active", active: true }, }, + ], + }, + { + name: "refund de-entitles", + entitledAtEnd: false, + covers: ["lifecycle.refund-ends-entitlement"], + steps: [ + { event: "InitialPurchase", expect: { state: "Active", active: true } }, { - payload: applePayload("REFUND", undefined, "r-2"), - transaction: { originalTransactionId: "3", expiresDate: FUTURE }, + event: "Refund", expect: { state: "Refunded", active: false, cancellationReason: "Refunded", }, }, - ]); - // The state machine flagged this user as not entitled — verify the - // entitlement helper agrees instead of trusting it implicitly. - expect(entitlementActive(final!)).toBe(false); - }); -}); + ], + }, + { + name: "revoke de-entitles", + entitledAtEnd: false, + covers: ["lifecycle.revoke-ends-entitlement"], + steps: [ + { event: "InitialPurchase", expect: { state: "Active", active: true } }, + { event: "Revoke", expect: { state: "Revoked", active: false } }, + ], + }, + { + name: "pause -> resume", + entitledAtEnd: true, + covers: [], + steps: [ + { event: "InitialPurchase", expect: { state: "Active", active: true } }, + { event: "Pause", expect: { state: "Paused", active: false } }, + { event: "Resume", expect: { state: "Active", active: true } }, + ], + }, +]; -describe("conformance: Google lifecycle scenarios", () => { - it("purchase → renew → on-hold → recovered", () => { - const final = runGoogleScenario([ - { - payload: googleSubPayload("g-1", 4, "tok-1"), - subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, - expect: { state: "Active", active: true }, - }, - { - payload: googleSubPayload("g-2", 2, "tok-1"), - subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, - expect: { state: "Active", active: true }, - }, - { - payload: googleSubPayload("g-3", 5, "tok-1"), - subscriptionInfo: { state: "SUBSCRIPTION_STATE_ON_HOLD" }, - expect: { state: "InBillingRetry", active: false }, - }, - { - payload: googleSubPayload("g-4", 1, "tok-1"), - subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, - expect: { state: "Active", active: true }, - }, - ]); - expect(entitlementActive(final!)).toBe(true); - }); +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- - it("voided purchase flips to Refunded", () => { - const final = runGoogleScenario([ - { - payload: googleSubPayload("v-1", 4, "tok-vp"), - subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, - expect: { state: "Active", active: true }, - }, - { - payload: { - messageId: "v-2", - eventTimeMillis: 1, - voidedPurchaseNotification: { purchaseToken: "tok-vp" }, - }, - expect: { state: "Refunded", active: false }, - }, - ]); - expect(entitlementActive(final!)).toBe(false); - }); +function runScenario(adapter: ProviderAdapter, scenario: Scenario) { + let current: CurrentSubscription = null; - it("paused → resumed", () => { - const final = runGoogleScenario([ - { - payload: googleSubPayload("p-1", 4, "tok-p"), - subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, - expect: { state: "Active", active: true }, - }, - { - payload: googleSubPayload("p-2", 10, "tok-p"), - subscriptionInfo: { state: "SUBSCRIPTION_STATE_PAUSED" }, - expect: { state: "Paused", active: false }, - }, - { - // Resume in real RTDN comes back as RECOVERED (1) — pause- - // schedule-changed (11) is only the schedule update, not the - // actual end-of-pause signal. PR #123 (https://github.com/hyodotdev/openiap/pull/123) review caught the - // earlier draft mapping that treated 11 as Resumed. - payload: googleSubPayload("p-3", 1, "tok-p"), - subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, - expect: { state: "Active", active: true }, - }, - ]); - expect(entitlementActive(final!)).toBe(true); + scenario.steps.forEach((step, index) => { + const normalized = adapter.normalize(step.event, { + index, + productId: adapter.productId, + purchaseToken: `${adapter.name}-token`, + }); + + const transition = applySubscriptionTransition(current, { + type: normalized.type, + productId: normalized.productId ?? adapter.productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + }); + + // Without this, a `transition.next ?? current` fallback would let + // same-state assertions (Active -> Active on renew) pass on a no-op. + expect( + transition.next, + `${adapter.name}/${scenario.name} step ${index} (${step.event}) produced no next state`, + ).toBeTruthy(); + + current = transition.next ?? current; + + const where = `${adapter.name}/${scenario.name} step ${index} (${step.event})`; + expect(current?.state, `${where} state`).toBe(step.expect.state); + expect(transition.active, `${where} active`).toBe(step.expect.active); + if (step.expect.willRenew !== undefined) { + expect(current?.willRenew, `${where} willRenew`).toBe( + step.expect.willRenew, + ); + } + if (step.expect.cancellationReason !== undefined) { + expect(current?.cancellationReason, `${where} cancellationReason`).toBe( + step.expect.cancellationReason, + ); + } }); -}); + + return current; +} + +// --------------------------------------------------------------------------- +// Apple adapter +// --------------------------------------------------------------------------- function applePayload( notificationType: string, @@ -331,6 +254,84 @@ function applePayload( }; } +const APPLE_EVENTS: Record< + LifecycleEvent, + { + type: string; + subtype?: string; + expiresDate?: number; + expirationIntent?: number; + } | null +> = { + InitialPurchase: { + type: "SUBSCRIBED", + subtype: "INITIAL_BUY", + expiresDate: FUTURE, + }, + Renew: { type: "DID_RENEW", expiresDate: FUTURE + 1 }, + DisableAutoRenew: { + type: "DID_CHANGE_RENEWAL_STATUS", + subtype: "AUTO_RENEW_DISABLED", + expiresDate: FUTURE + 1, + }, + Expire: { type: "EXPIRED", expiresDate: 0, expirationIntent: 1 }, + EnterGracePeriod: { + type: "DID_FAIL_TO_RENEW", + subtype: "GRACE_PERIOD", + expiresDate: FUTURE, + }, + RecoverFromGracePeriod: { + type: "DID_RENEW", + subtype: "BILLING_RECOVERY", + expiresDate: FUTURE + 100, + }, + EnterBillingRetry: { type: "DID_FAIL_TO_RENEW", expiresDate: FUTURE }, + RecoverFromBillingRetry: { + type: "DID_RENEW", + subtype: "BILLING_RECOVERY", + expiresDate: FUTURE + 100, + }, + Refund: { type: "REFUND", expiresDate: FUTURE }, + Revoke: { type: "REVOKE", expiresDate: FUTURE }, + // Apple has no subscription pause/resume notification. + Pause: null, + Resume: null, +}; + +const appleAdapter: ProviderAdapter = { + name: "apple", + productId: "com.example.premium", + supports: new Set( + (Object.keys(APPLE_EVENTS) as LifecycleEvent[]).filter( + (key) => APPLE_EVENTS[key] !== null, + ), + ), + normalize(event, ctx) { + const spec = APPLE_EVENTS[event]; + if (!spec) throw new Error(`apple adapter cannot express ${event}`); + + const transaction: AppleDecodedTransaction = { + originalTransactionId: ctx.purchaseToken, + productId: ctx.productId, + expiresDate: spec.expiresDate, + }; + const renewalInfo: AppleDecodedRenewalInfo | undefined = + spec.expirationIntent === undefined + ? undefined + : { expirationIntent: spec.expirationIntent }; + + return normalizeAppleAsn({ + payload: applePayload(spec.type, spec.subtype, `${event}-${ctx.index}`), + transaction, + renewalInfo, + }); + }, +}; + +// --------------------------------------------------------------------------- +// Google adapter +// --------------------------------------------------------------------------- + function googleSubPayload( messageId: string, notificationType: number, @@ -348,3 +349,214 @@ function googleSubPayload( }, }; } + +// RTDN subscription notification types. +const RTDN = { + RECOVERED: 1, + RENEWED: 2, + CANCELED: 3, + PURCHASED: 4, + ON_HOLD: 5, + IN_GRACE_PERIOD: 6, + REVOKED: 12, + EXPIRED: 13, + PAUSED: 10, +} as const; + +// A Google refund is a voidedPurchaseNotification: a different top-level +// payload shape, not just a different notification type code. +function googleVoidedPayload( + messageId: string, + purchaseToken: string, +): GoogleRtdnPayload { + return { + messageId, + eventTimeMillis: 1_711_000_000_000, + voidedPurchaseNotification: { purchaseToken }, + }; +} + +type GoogleEventSpec = + | { + kind: "subscription"; + notificationType: number; + state?: GoogleSubscriptionInfo["state"]; + } + | { kind: "voided" }; + +const GOOGLE_EVENTS: Record = { + InitialPurchase: { + kind: "subscription", + notificationType: RTDN.PURCHASED, + state: "SUBSCRIPTION_STATE_ACTIVE", + }, + Renew: { + kind: "subscription", + notificationType: RTDN.RENEWED, + state: "SUBSCRIPTION_STATE_ACTIVE", + }, + DisableAutoRenew: { + kind: "subscription", + notificationType: RTDN.CANCELED, + state: "SUBSCRIPTION_STATE_CANCELED", + }, + Expire: { + kind: "subscription", + notificationType: RTDN.EXPIRED, + state: "SUBSCRIPTION_STATE_EXPIRED", + }, + EnterGracePeriod: { + kind: "subscription", + notificationType: RTDN.IN_GRACE_PERIOD, + state: "SUBSCRIPTION_STATE_IN_GRACE_PERIOD", + }, + RecoverFromGracePeriod: { + kind: "subscription", + notificationType: RTDN.RECOVERED, + state: "SUBSCRIPTION_STATE_ACTIVE", + }, + EnterBillingRetry: { + kind: "subscription", + notificationType: RTDN.ON_HOLD, + state: "SUBSCRIPTION_STATE_ON_HOLD", + }, + RecoverFromBillingRetry: { + kind: "subscription", + notificationType: RTDN.RECOVERED, + state: "SUBSCRIPTION_STATE_ACTIVE", + }, + Refund: { kind: "voided" }, + Revoke: { + kind: "subscription", + notificationType: RTDN.REVOKED, + state: "SUBSCRIPTION_STATE_EXPIRED", + }, + Pause: { + kind: "subscription", + notificationType: RTDN.PAUSED, + state: "SUBSCRIPTION_STATE_PAUSED", + }, + // Resume arrives as RECOVERED (1). Pause-schedule-changed (11) is only the + // schedule update, not the end-of-pause signal (see PR #123). + Resume: { + kind: "subscription", + notificationType: RTDN.RECOVERED, + state: "SUBSCRIPTION_STATE_ACTIVE", + }, +}; + +const googleAdapter: ProviderAdapter = { + name: "google", + productId: "premium_monthly", + supports: new Set( + (Object.keys(GOOGLE_EVENTS) as LifecycleEvent[]).filter( + (key) => GOOGLE_EVENTS[key] !== null, + ), + ), + normalize(event, ctx) { + const spec = GOOGLE_EVENTS[event]; + if (!spec) throw new Error(`google adapter cannot express ${event}`); + + const messageId = `${event}-${ctx.index}`; + if (spec.kind === "voided") { + return normalizeGoogleRtdn({ + payload: googleVoidedPayload(messageId, ctx.purchaseToken), + }); + } + + return normalizeGoogleRtdn({ + payload: googleSubPayload( + messageId, + spec.notificationType, + ctx.purchaseToken, + ), + subscriptionInfo: spec.state ? { state: spec.state } : undefined, + }); + }, +}; + +const ADAPTERS: ProviderAdapter[] = [appleAdapter, googleAdapter]; + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +for (const adapter of ADAPTERS) { + describe(`conformance: ${adapter.name} lifecycle scenarios`, () => { + for (const scenario of SCENARIOS) { + const unsupported = scenario.steps + .map((step) => step.event) + .filter((event) => !adapter.supports.has(event)); + + if (unsupported.length > 0) { + // Reported as a skip so a capability gap stays visible. + it.skip(`${scenario.name} (unsupported: ${[...new Set(unsupported)].join(", ")})`, () => {}); + continue; + } + + it(scenario.name, () => { + const final = runScenario(adapter, scenario); + expect( + entitlementActive(final!), + `${adapter.name}/${scenario.name} entitlement`, + ).toBe(scenario.entitledAtEnd); + }); + } + }); +} + +describe("conformance: harness integrity", () => { + it("runs every scenario against at least one provider", () => { + for (const scenario of SCENARIOS) { + const capable = ADAPTERS.filter((adapter) => + scenario.steps.every((step) => adapter.supports.has(step.event)), + ); + expect( + capable.length, + `${scenario.name} has no capable provider`, + ).toBeGreaterThan(0); + } + }); + + // Binds this suite to the versioned spec: a lifecycle behavior added to + // packages/conformance with no scenario demonstrating it fails here. + it("covers every lifecycle behavior in the conformance spec", () => { + const covered = new Set(SCENARIOS.flatMap((scenario) => scenario.covers)); + const specIds = behaviorsByCategory("lifecycle").map( + (behavior: { id: string }) => behavior.id, + ); + + expect(specIds.length).toBeGreaterThan(0); + for (const id of specIds) { + expect(covered, `no scenario covers ${id}`).toContain(id); + } + }); + + it("references only behavior ids the spec defines", () => { + const specIds = new Set( + behaviorsByCategory("lifecycle").map( + (behavior: { id: string }) => behavior.id, + ), + ); + for (const scenario of SCENARIOS) { + for (const id of scenario.covers) { + expect( + specIds, + `${scenario.name} references unknown behavior ${id}`, + ).toContain(id); + } + } + }); + + it("declares provider support for every abstract lifecycle event", () => { + const allEvents = new Set( + SCENARIOS.flatMap((s) => s.steps.map((x) => x.event)), + ); + for (const event of allEvents) { + const capable = ADAPTERS.filter((adapter) => adapter.supports.has(event)); + expect(capable.length, `no provider expresses ${event}`).toBeGreaterThan( + 0, + ); + } + }); +}); diff --git a/scripts/assert-release-tag.mjs b/scripts/assert-release-tag.mjs index 6b131aeb5..2b8bb7045 100644 --- a/scripts/assert-release-tag.mjs +++ b/scripts/assert-release-tag.mjs @@ -11,6 +11,11 @@ const PACKAGE_CONFIG = { tags: (version) => [version, `apple-v${version}`], version: (content) => JSON.parse(content).apple, }, + conformance: { + path: "packages/conformance/package.json", + tags: (version) => [`openiap-conformance-${version}`], + version: (content) => JSON.parse(content).version, + }, expo: { path: "libraries/expo-iap/package.json", tags: (version) => [`expo-iap-${version}`], diff --git a/scripts/audit-deprecation-schedule.mjs b/scripts/audit-deprecation-schedule.mjs index 2bb2f4be5..220ad0a2d 100644 --- a/scripts/audit-deprecation-schedule.mjs +++ b/scripts/audit-deprecation-schedule.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { extractSchemaDeprecations } from "../packages/gql/schema-deprecations.mjs"; import { SCHEMA_FILE_NAMES } from "../packages/gql/schema-files.mjs"; +import { validateVersion } from "./release-branch-policy.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -455,20 +456,63 @@ export const collectRepositorySchemaDeprecations = () => { return extractSchemaDeprecations(sources); }; -export const collectCompletedRemovalFailures = () => { +export const collectSchemaDeprecationFailures = ( + schemaDeprecations, + specVersion, +) => { const failures = []; - const schemaDeprecations = collectRepositorySchemaDeprecations(); for (const issue of schemaDeprecations.issues) { failures.push( `${issue.file}:${issue.line ?? 1}: invalid schema deprecation metadata: ${issue.message}`, ); } - for (const entry of schemaDeprecations.entries) { + + let currentSpecMajor; + try { + const normalizedVersion = validateVersion( + specVersion, + "OpenIAP Spec version", + ); + currentSpecMajor = BigInt(normalizedVersion.split(".")[0]); + } catch (error) { failures.push( - `${entry.file}:${entry.line ?? 1}: completed major train must not retain schema deprecation ${entry.ownerPath}`, + `openiap-versions.json: ${error instanceof Error ? error.message : String(error)}`, ); + return failures; + } + + // A deprecation is a failure only when its removal is due: the train it was + // scheduled for has already shipped. Scheduling one for a future major is how + // the spec is supposed to evolve, so those pass. + for (const entry of schemaDeprecations.entries) { + const removalMatch = /OpenIAP (\d+)\.\d+\.$/.exec(entry.reason); + if (!removalMatch) { + failures.push( + `${entry.file}:${entry.line ?? 1}: schema deprecation ${entry.ownerPath} must name its removal train`, + ); + continue; + } + const removalMajor = BigInt(removalMatch[1]); + if (removalMajor <= currentSpecMajor) { + failures.push( + `${entry.file}:${entry.line ?? 1}: schema deprecation ${entry.ownerPath} is due for removal in OpenIAP ${removalMajor} (spec is ${currentSpecMajor})`, + ); + } } + return failures; +}; + +export const collectCompletedRemovalFailures = () => { + const failures = []; + const schemaDeprecations = collectRepositorySchemaDeprecations(); + const specVersion = JSON.parse( + fs.readFileSync(path.join(root, "openiap-versions.json"), "utf8"), + ).spec; + failures.push( + ...collectSchemaDeprecationFailures(schemaDeprecations, specVersion), + ); + for (const rule of completedRemovalRules) { for (const match of collectForbiddenMatches(rule)) { failures.push( diff --git a/scripts/audit-deprecation-schedule.test.mjs b/scripts/audit-deprecation-schedule.test.mjs index 1aad7e774..b7e6ae8e6 100644 --- a/scripts/audit-deprecation-schedule.test.mjs +++ b/scripts/audit-deprecation-schedule.test.mjs @@ -1,15 +1,20 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; +import { extractSchemaDeprecations } from "../packages/gql/schema-deprecations.mjs"; import { activeDocsForbiddenTokens, collectForbiddenMatches, collectMissingRequiredTexts, collectRepositorySchemaDeprecations, + collectSchemaDeprecationFailures, completedRemovalRules, } from "./audit-deprecation-schedule.mjs"; +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + test("completed removal inventory covers every coordinated package", () => { assert.deepEqual( completedRemovalRules.map((rule) => rule.label), @@ -170,8 +175,48 @@ test("required text guard fails closed for missing files and values", () => { ); }); -test("repository schema has no completed-train deprecations", () => { +test("repository schema deprecations all target a future removal train", () => { const deprecations = collectRepositorySchemaDeprecations(); assert.deepEqual(deprecations.issues, []); - assert.deepEqual(deprecations.entries, []); + + const specMajor = Number( + JSON.parse( + fs.readFileSync(path.join(repoRoot, "openiap-versions.json"), "utf8"), + ).spec.split(".")[0], + ); + for (const entry of deprecations.entries) { + const removalMajor = Number(/OpenIAP (\d+)\.\d+\.$/.exec(entry.reason)?.[1]); + assert.ok( + Number.isFinite(removalMajor), + `${entry.ownerPath} must name its removal train`, + ); + assert.ok( + removalMajor > specMajor, + `${entry.ownerPath} is overdue: scheduled for OpenIAP ${removalMajor}, spec is ${specMajor}`, + ); + } +}); + +test("overdue schema deprecations are reported as failures", () => { + const overdue = extractSchemaDeprecations([ + { + sourceId: "overdue.graphql", + sdl: `type Query { + old: String @deprecated(reason: "Use current. Scheduled for removal in OpenIAP 1.0.") +}`, + }, + ]); + assert.equal(overdue.entries.length, 1); + assert.match(overdue.entries[0].reason, /OpenIAP 1\.0\.$/); + assert.match( + collectSchemaDeprecationFailures(overdue, "2.0.0")[0], + /is due for removal in OpenIAP 1 \(spec is 2\)/, + ); +}); + +test("schema deprecation audit rejects malformed spec versions", () => { + assert.deepEqual( + collectSchemaDeprecationFailures({ entries: [], issues: [] }, "not-semver"), + ["openiap-versions.json: Invalid OpenIAP Spec version: 'not-semver'"], + ); }); diff --git a/scripts/audit-docs.test.ts b/scripts/audit-docs.test.ts index 2f3ca5094..fd7b443bf 100644 --- a/scripts/audit-docs.test.ts +++ b/scripts/audit-docs.test.ts @@ -1,8 +1,123 @@ -import { describe, expect, test } from 'bun:test'; -import { auditActiveCodeExampleSource, auditCanonicalOfferDocs, type CanonicalOfferDocsSources } from './audit-docs'; +import { describe, expect, test } from "bun:test"; +import { + auditActiveCodeExampleSource, + auditCanonicalOfferDocs, + auditVerifyPurchaseDocs, + type CanonicalOfferDocsSources, +} from "./audit-docs"; + +describe("verify purchase type docs", () => { + const valid = ` +
isValid
+
isValid
+
isValid
successDeprecated alias for isValid
+ `; + const generatedTypes = `export interface VerifyPurchaseResultCommon { isValid: boolean; }`; + + test("accepts uniform validity and the deprecated Horizon alias", () => { + expect( + auditVerifyPurchaseDocs("verify-purchase.tsx", valid, generatedTypes), + ).toEqual([]); + }); + + test("rejects a missing Horizon validity field", () => { + const drifts = auditVerifyPurchaseDocs( + "verify-purchase.tsx", + valid.replace( + "isValidsuccess", + "success", + ), + generatedTypes, + ); + expect(drifts.some((drift) => drift.message.includes("Horizon"))).toBe( + true, + ); + }); + + test("rejects a missing iOS validity field", () => { + const drifts = auditVerifyPurchaseDocs( + "verify-purchase.tsx", + valid.replace("isValid", ""), + generatedTypes, + ); + expect(drifts.some((drift) => drift.message.includes("iOS"))).toBe(true); + }); + + test("rejects duplicate required fields in one result table", () => { + const duplicate = valid.replace( + '', + '
', + ); + + const drifts = auditVerifyPurchaseDocs( + "verify-purchase.tsx", + duplicate, + generatedTypes, + ); + + expect(drifts.some((drift) => drift.message.includes("iOS"))).toBe(true); + }); + + test("rejects unrelated deprecation prose outside the success row", () => { + const invalid = valid.replace( + "", + "

Deprecated elsewhere

", + ); + + const drifts = auditVerifyPurchaseDocs( + "verify-purchase.tsx", + invalid, + generatedTypes, + ); + + expect( + drifts.some((drift) => + drift.message.includes("deprecated isValid alias"), + ), + ).toBe(true); + }); + + test("rejects a deprecated success row without the isValid alias", () => { + const invalid = valid.replace( + "Deprecated alias for isValid", + "Deprecated legacy field", + ); + + const drifts = auditVerifyPurchaseDocs( + "verify-purchase.tsx", + invalid, + generatedTypes, + ); + + expect( + drifts.some((drift) => + drift.message.includes("deprecated isValid alias"), + ), + ).toBe(true); + }); + + test("rejects duplicate Horizon success rows", () => { + const successRow = + ""; + const invalid = valid.replace(successRow, `${successRow}${successRow}`); + + const drifts = auditVerifyPurchaseDocs( + "verify-purchase.tsx", + invalid, + generatedTypes, + ); + + expect( + drifts.some((drift) => + drift.message.includes("deprecated isValid alias"), + ), + ).toBe(true); + }); +}); const VALID_GENERATED_OFFER_TYPES = { - typescript: "export type DiscountOfferType = 'introductory' | 'promotional' | 'one-time';", + typescript: + "export type DiscountOfferType = 'introductory' | 'promotional' | 'one-time';", swift: ` enum DiscountOfferType: String { case introductory = "introductory" @@ -31,9 +146,15 @@ ${source} \`}`; const offerTypeBlockPattern = (language: OfferTypeLanguage): RegExp => - new RegExp(`\\{\\\`[\\s\\S]*?\\\`\\}`); - -const replaceRequired = (source: string, search: string | RegExp, replacement: string): string => { + new RegExp( + `\\{\\\`[\\s\\S]*?\\\`\\}`, + ); + +const replaceRequired = ( + source: string, + search: string | RegExp, + replacement: string, +): string => { const replaced = source.replace(search, replacement); if (replaced === source) { throw new Error(`Required fixture replacement did not match: ${search}`); @@ -41,59 +162,77 @@ const replaceRequired = (source: string, search: string | RegExp, replacement: s return replaced; }; -const VALID_DISCOUNT_OFFER_TYPE_BLOCKS = (Object.entries(VALID_GENERATED_OFFER_TYPES) as [OfferTypeLanguage, string][]) +const VALID_DISCOUNT_OFFER_TYPE_BLOCKS = ( + Object.entries(VALID_GENERATED_OFFER_TYPES) as [OfferTypeLanguage, string][] +) .map(([language, source]) => offerTypeBlock(language, source)) - .join('\n'); + .join("\n"); -const renderPage = (name: string, body: string): string => `const ${name} = () => (<>${body}); export default ${name};`; +const renderPage = (name: string, body: string): string => + `const ${name} = () => (<>${body}); export default ${name};`; -describe('active docs code-example audit', () => { - test('flags recurring cross-language phantom patterns', () => { +describe("active docs code-example audit", () => { + test("flags recurring cross-language phantom patterns", () => { const source = [ '{`@Deprecated("old") Task Run()`}', '{`iap.purchaseUpdatedStream.listen(onPurchase); iap.finishTransaction(purchase);`}', '{`let store = OpenIapStore.shared; subscription.remove()`}', '{`await verifyPurchase({ purchase, serverUrl: url }); await requestPurchase({ sku: "x" });`}', '{`println("Offer token: ${offer.offerToken}")`}', - ].join('\n'); - - const drifts = auditActiveCodeExampleSource('/tmp/active.tsx', source); - expect(drifts.map((drift) => drift.rule)).toEqual(['R11', 'R11', 'R11', 'R11', 'R11', 'R11', 'R11', 'R11']); + ].join("\n"); + + const drifts = auditActiveCodeExampleSource("/tmp/active.tsx", source); + expect(drifts.map((drift) => drift.rule)).toEqual([ + "R11", + "R11", + "R11", + "R11", + "R11", + "R11", + "R11", + "R11", + ]); }); - test('accepts the current listener and purchase shapes', () => { + test("accepts the current listener and purchase shapes", () => { const source = [ '{`iap.purchaseUpdatedListener.listen(onPurchase); await iap.finishTransaction(purchase: purchase);`}', '{`await requestPurchase({ request: { apple: { sku: "x" } }, type: "in-app" });`}', - ].join('\n'); + ].join("\n"); - expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([]); + expect(auditActiveCodeExampleSource("/tmp/active.tsx", source)).toEqual([]); }); - test('audits formatted multiline CodeBlock children', () => { + test("audits formatted multiline CodeBlock children", () => { const source = ` {\`iap.purchaseUpdatedStream.listen(onPurchase);\`} `; - expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([expect.objectContaining({ rule: 'R11', line: 2 })]); + expect(auditActiveCodeExampleSource("/tmp/active.tsx", source)).toEqual([ + expect.objectContaining({ rule: "R11", line: 2 }), + ]); }); - test('flags offer-token logging across formatted lines', () => { + test("flags offer-token logging across formatted lines", () => { const source = `{\`println( offer.offerToken )\`}`; - expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([expect.objectContaining({ rule: 'R11', line: 1 })]); + expect(auditActiveCodeExampleSource("/tmp/active.tsx", source)).toEqual([ + expect.objectContaining({ rule: "R11", line: 1 }), + ]); }); - test('flags a top-level Godot purchase sku', () => { + test("flags a top-level Godot purchase sku", () => { const source = `{\`var props = Types.RequestPurchaseProps.new() props.sku = "premium"\`}`; - expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([expect.objectContaining({ rule: 'R11', line: 1 })]); + expect(auditActiveCodeExampleSource("/tmp/active.tsx", source)).toEqual([ + expect.objectContaining({ rule: "R11", line: 1 }), + ]); }); - test('flags obsolete Kotlin and KMP requestPurchase named arguments', () => { + test("flags obsolete Kotlin and KMP requestPurchase named arguments", () => { const source = [ '{`iapStore.requestPurchase(activity = activity, props = request)`}', '{`kmpIAP.requestPurchase(props = request)`}', @@ -101,26 +240,26 @@ props.sku = "premium"\`}`; '{`OpenIapStore().requestPurchase(props = request)`}', '{`stores[0].requestPurchase(props = request)`}', '{`store./* current instance */requestPurchase(props = request)`}', - ].join('\n'); - - expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([ - expect.objectContaining({ rule: 'R11' }), - expect.objectContaining({ rule: 'R11' }), - expect.objectContaining({ rule: 'R11' }), - expect.objectContaining({ rule: 'R11' }), - expect.objectContaining({ rule: 'R11' }), - expect.objectContaining({ rule: 'R11' }), + ].join("\n"); + + expect(auditActiveCodeExampleSource("/tmp/active.tsx", source)).toEqual([ + expect.objectContaining({ rule: "R11" }), + expect.objectContaining({ rule: "R11" }), + expect.objectContaining({ rule: "R11" }), + expect.objectContaining({ rule: "R11" }), + expect.objectContaining({ rule: "R11" }), + expect.objectContaining({ rule: "R11" }), ]); }); - test('accepts current Kotlin and KMP requestPurchase calls', () => { + test("accepts current Kotlin and KMP requestPurchase calls", () => { const source = [ '{`iapStore.requestPurchase(request)`}', '{`kmpIAP.requestPurchase(RequestPurchaseProps(...))`}', '{`requestPurchase(validLocalProps)`}', - ].join('\n'); + ].join("\n"); - expect(auditActiveCodeExampleSource('/tmp/active.tsx', source)).toEqual([]); + expect(auditActiveCodeExampleSource("/tmp/active.tsx", source)).toEqual([]); }); }); @@ -133,24 +272,24 @@ const validOfferDocsSources = ( } = {}, ): CanonicalOfferDocsSources => ({ discountOffer: { - file: '/tmp/discount-offer.tsx', + file: "/tmp/discount-offer.tsx", source: renderPage( - 'DiscountOfferPage', + "DiscountOfferPage", overrides.discountOffer ?? `

DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, ), }, subscriptionOffer: { - file: '/tmp/subscription-offer.tsx', + file: "/tmp/subscription-offer.tsx", source: renderPage( - 'SubscriptionOfferPage', + "SubscriptionOfferPage", overrides.subscriptionOffer ?? - '

SubscriptionOffer maps to Product.SubscriptionOffer and ProductDetails.SubscriptionOfferDetails.

', + "

SubscriptionOffer maps to Product.SubscriptionOffer and ProductDetails.SubscriptionOfferDetails.

", ), }, searchData: { - file: '/tmp/searchData.ts', + file: "/tmp/searchData.ts", source: overrides.searchData ?? `export const apiData = [ @@ -169,41 +308,49 @@ ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, ];`, }, generatedOfferTypes: Object.fromEntries( - (Object.entries(VALID_GENERATED_OFFER_TYPES) as [OfferTypeLanguage, string][]).map(([language, source]) => [ + ( + Object.entries(VALID_GENERATED_OFFER_TYPES) as [ + OfferTypeLanguage, + string, + ][] + ).map(([language, source]) => [ language, { file: `/tmp/generated-${language}-types`, source: overrides.generatedOfferTypes?.[language] ?? source, }, ]), - ) as CanonicalOfferDocsSources['generatedOfferTypes'], + ) as CanonicalOfferDocsSources["generatedOfferTypes"], }); -describe('canonical offer docs audit', () => { - test('accepts canonical one-time, subscription, and search semantics', () => { +describe("canonical offer docs audit", () => { + test("accepts canonical one-time, subscription, and search semantics", () => { expect(auditCanonicalOfferDocs(validOfferDocsSources())).toEqual([]); }); - test('derives TypeScript wire values from the generated SSOT', () => { + test("derives TypeScript wire values from the generated SSOT", () => { const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ generatedOfferTypes: { - typescript: "export type DiscountOfferType = 'introductory' | 'promotional' | 'one-time' | 'seasonal';", + typescript: + "export type DiscountOfferType = 'introductory' | 'promotional' | 'one-time' | 'seasonal';", }, }), ); expect(drifts).toEqual([ expect.objectContaining({ - rule: 'R12', - message: expect.stringContaining("'introductory', 'promotional', 'one-time', and 'seasonal'"), + rule: "R12", + message: expect.stringContaining( + "'introductory', 'promotional', 'one-time', and 'seasonal'", + ), }), ]); }); test.each([ [ - 'swift', + "swift", replaceRequired( VALID_GENERATED_OFFER_TYPES.swift, ' case oneTime = "one-time"', @@ -211,51 +358,63 @@ describe('canonical offer docs audit', () => { ), ], [ - 'kotlin', - replaceRequired(VALID_GENERATED_OFFER_TYPES.kotlin, ' OneTime("one-time")', ' OneTime("one-time"),\n Seasonal("seasonal")'), + "kotlin", + replaceRequired( + VALID_GENERATED_OFFER_TYPES.kotlin, + ' OneTime("one-time")', + ' OneTime("one-time"),\n Seasonal("seasonal")', + ), ], [ - 'dart', - replaceRequired(VALID_GENERATED_OFFER_TYPES.dart, " OneTime('one-time');", " OneTime('one-time'),\n Seasonal('seasonal');"), + "dart", + replaceRequired( + VALID_GENERATED_OFFER_TYPES.dart, + " OneTime('one-time');", + " OneTime('one-time'),\n Seasonal('seasonal');", + ), ], - ] as const)('derives %s members from the generated SSOT', (language, generatedSource) => { - const drifts = auditCanonicalOfferDocs( - validOfferDocsSources({ - generatedOfferTypes: { - [language]: generatedSource, - }, - }), - ); + ] as const)( + "derives %s members from the generated SSOT", + (language, generatedSource) => { + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ + generatedOfferTypes: { + [language]: generatedSource, + }, + }), + ); - expect(drifts).toEqual([ - expect.objectContaining({ - rule: 'R12', - message: expect.stringContaining( - `The canonical DiscountOffer ${language} snippet must declare exactly the generated DiscountOfferType members`, - ), - }), - ]); - }); + expect(drifts).toEqual([ + expect.objectContaining({ + rule: "R12", + message: expect.stringContaining( + `The canonical DiscountOffer ${language} snippet must declare exactly the generated DiscountOfferType members`, + ), + }), + ]); + }, + ); - test('does not cascade docs errors when the TypeScript SSOT is invalid', () => { + test("does not cascade docs errors when the TypeScript SSOT is invalid", () => { const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ generatedOfferTypes: { - typescript: 'export interface NotDiscountOfferType {}', + typescript: "export interface NotDiscountOfferType {}", }, }), ); expect(drifts).toEqual([ expect.objectContaining({ - file: '/tmp/generated-typescript-types', - rule: 'R12', - message: 'The generated TypeScript SSOT must declare DiscountOfferType as a string-literal union.', + file: "/tmp/generated-typescript-types", + rule: "R12", + message: + "The generated TypeScript SSOT must declare DiscountOfferType as a string-literal union.", }), ]); }); - test('flags missing one-time Android native semantics', () => { + test("flags missing one-time Android native semantics", () => { const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ discountOffer: `

A generic cross-platform discount.

@@ -265,17 +424,17 @@ ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, expect(drifts).toEqual([ expect.objectContaining({ - rule: 'R12', - message: expect.stringContaining('OneTimePurchaseOfferDetails'), + rule: "R12", + message: expect.stringContaining("OneTimePurchaseOfferDetails"), }), expect.objectContaining({ - rule: 'R12', - message: expect.stringContaining('one-time product offers'), + rule: "R12", + message: expect.stringContaining("one-time product offers"), }), ]); }); - test('does not accept comments or CodeBlocks as native semantic evidence', () => { + test("does not accept comments or CodeBlocks as native semantic evidence", () => { const decoyBlock = `{\` ProductDetails.OneTimePurchaseOfferDetails Product.SubscriptionOffer @@ -298,45 +457,47 @@ Android one-time expect(drifts).toEqual([ expect.objectContaining({ - file: '/tmp/discount-offer.tsx', - message: expect.stringContaining('OneTimePurchaseOfferDetails'), + file: "/tmp/discount-offer.tsx", + message: expect.stringContaining("OneTimePurchaseOfferDetails"), }), expect.objectContaining({ - file: '/tmp/discount-offer.tsx', - message: expect.stringContaining('one-time product offers'), + file: "/tmp/discount-offer.tsx", + message: expect.stringContaining("one-time product offers"), }), expect.objectContaining({ - file: '/tmp/subscription-offer.tsx', - message: expect.stringContaining('Product.SubscriptionOffer'), + file: "/tmp/subscription-offer.tsx", + message: expect.stringContaining("Product.SubscriptionOffer"), }), expect.objectContaining({ - file: '/tmp/subscription-offer.tsx', - message: expect.stringContaining('ProductDetails.SubscriptionOfferDetails'), + file: "/tmp/subscription-offer.tsx", + message: expect.stringContaining( + "ProductDetails.SubscriptionOfferDetails", + ), }), ]); }); - test('does not accept unused JSX declarations as rendered semantic evidence', () => { + test("does not accept unused JSX declarations as rendered semantic evidence", () => { const sources = validOfferDocsSources({ discountOffer: `

A generic cross-platform discount.

${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, }); sources.discountOffer.source += - '\nconst UNUSED_DECOY =
ProductDetails.OneTimePurchaseOfferDetails Android one-time product offers
;'; + "\nconst UNUSED_DECOY =
ProductDetails.OneTimePurchaseOfferDetails Android one-time product offers
;"; expect(auditCanonicalOfferDocs(sources)).toEqual([ expect.objectContaining({ - file: '/tmp/discount-offer.tsx', - message: expect.stringContaining('OneTimePurchaseOfferDetails'), + file: "/tmp/discount-offer.tsx", + message: expect.stringContaining("OneTimePurchaseOfferDetails"), }), expect.objectContaining({ - file: '/tmp/discount-offer.tsx', - message: expect.stringContaining('one-time product offers'), + file: "/tmp/discount-offer.tsx", + message: expect.stringContaining("one-time product offers"), }), ]); }); - test('audits prose rendered by local JSX components', () => { + test("audits prose rendered by local JSX components", () => { const sources = validOfferDocsSources({ discountOffer: `

DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

@@ -347,14 +508,14 @@ ${sources.discountOffer.source}`; expect(auditCanonicalOfferDocs(sources)).toEqual([ expect.objectContaining({ - file: '/tmp/discount-offer.tsx', - rule: 'R12', - message: expect.stringContaining('WinBack'), + file: "/tmp/discount-offer.tsx", + rule: "R12", + message: expect.stringContaining("WinBack"), }), ]); }); - test('ignores forbidden claims that occur only in comments or CodeBlocks', () => { + test("ignores forbidden claims that occur only in comments or CodeBlocks", () => { const commentsAndExamples = `
{/* Product.SubscriptionOffer SubscriptionOfferDetails WinBack */} {\`Product.SubscriptionOffer SubscriptionOfferDetails WinBack\`} @@ -376,7 +537,7 @@ ${sources.discountOffer.source}`; ).toEqual([]); }); - test('flags subscription mappings and invented WinBack claims on DiscountOffer', () => { + test("flags subscription mappings and invented WinBack claims on DiscountOffer", () => { const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ discountOffer: `

Android one-time products use OneTimePurchaseOfferDetails.

@@ -389,62 +550,70 @@ ${VALID_DISCOUNT_OFFER_TYPE_BLOCKS}`, expect(drifts).toEqual([ expect.objectContaining({ line: 2, - message: expect.stringContaining('Product.SubscriptionOffer'), + message: expect.stringContaining("Product.SubscriptionOffer"), }), expect.objectContaining({ line: 2, - message: expect.stringContaining('SubscriptionOfferDetails'), + message: expect.stringContaining("SubscriptionOfferDetails"), }), expect.objectContaining({ line: 3, - message: expect.stringContaining('WinBack'), + message: expect.stringContaining("WinBack"), }), ]); }); - test('flags an invented WinBack claim on SubscriptionOffer', () => { + test("flags an invented WinBack claim on SubscriptionOffer", () => { const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ subscriptionOffer: - '

SubscriptionOffer maps to Product.SubscriptionOffer and ProductDetails.SubscriptionOfferDetails, and includes WinBack.

', + "

SubscriptionOffer maps to Product.SubscriptionOffer and ProductDetails.SubscriptionOfferDetails, and includes WinBack.

", }), ); expect(drifts).toEqual([ expect.objectContaining({ - rule: 'R12', + rule: "R12", line: 1, - message: expect.stringContaining('WinBack'), + message: expect.stringContaining("WinBack"), }), ]); }); - test('requires both native subscription offer mappings', () => { + test("requires both native subscription offer mappings", () => { for (const [subscriptionOffer, missingType] of [ - ['

SubscriptionOffer maps to ProductDetails.SubscriptionOfferDetails.

', 'Product.SubscriptionOffer'], - ['

SubscriptionOffer maps to Product.SubscriptionOffer.

', 'ProductDetails.SubscriptionOfferDetails'], - ['

A generic subscription offer.

', 'Product.SubscriptionOffer'], + [ + "

SubscriptionOffer maps to ProductDetails.SubscriptionOfferDetails.

", + "Product.SubscriptionOffer", + ], + [ + "

SubscriptionOffer maps to Product.SubscriptionOffer.

", + "ProductDetails.SubscriptionOfferDetails", + ], + ["

A generic subscription offer.

", "Product.SubscriptionOffer"], ] as const) { - const drifts = auditCanonicalOfferDocs(validOfferDocsSources({ subscriptionOffer })); + const drifts = auditCanonicalOfferDocs( + validOfferDocsSources({ subscriptionOffer }), + ); expect(drifts).toContainEqual( expect.objectContaining({ - rule: 'R12', + rule: "R12", message: expect.stringContaining(missingType), }), ); } }); - test('flags incorrect DiscountOfferType wire casing and extra members', () => { + test("flags incorrect DiscountOfferType wire casing and extra members", () => { for (const declaration of [ "type DiscountOfferType = 'Introductory' | 'Promotional' | 'OneTime';", "type DiscountOfferType = 'introductory' | 'promotional' | 'one-time' | 'legacy';", ]) { const discountOffer = replaceRequired( VALID_DISCOUNT_OFFER_TYPE_BLOCKS, - offerTypeBlockPattern('typescript'), - offerTypeBlock('typescript', declaration), + offerTypeBlockPattern("typescript"), + offerTypeBlock("typescript", declaration), ); const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ @@ -455,18 +624,20 @@ ${discountOffer}`, expect(drifts).toEqual([ expect.objectContaining({ - rule: 'R12', + rule: "R12", line: 3, - message: expect.stringContaining("exactly the generated wire values 'introductory', 'promotional', and 'one-time'"), + message: expect.stringContaining( + "exactly the generated wire values 'introductory', 'promotional', and 'one-time'", + ), }), ]); } }); - test('accepts a multiline TypeScript union with leading delimiters', () => { + test("accepts a multiline TypeScript union with leading delimiters", () => { const discountOffer = replaceRequired( VALID_DISCOUNT_OFFER_TYPE_BLOCKS, - offerTypeBlockPattern('typescript'), + offerTypeBlockPattern("typescript"), `{\` type DiscountOfferType = | 'introductory' @@ -485,10 +656,10 @@ ${discountOffer}`, ).toEqual([]); }); - test('accepts a parenthesized TypeScript union', () => { + test("accepts a parenthesized TypeScript union", () => { const discountOffer = replaceRequired( VALID_DISCOUNT_OFFER_TYPE_BLOCKS, - offerTypeBlockPattern('typescript'), + offerTypeBlockPattern("typescript"), `{\` type DiscountOfferType = ( | 'introductory' @@ -508,10 +679,10 @@ ${discountOffer}`, ).toEqual([]); }); - test('ignores commented TypeScript declarations', () => { + test("ignores commented TypeScript declarations", () => { const discountOffer = replaceRequired( VALID_DISCOUNT_OFFER_TYPE_BLOCKS, - offerTypeBlockPattern('typescript'), + offerTypeBlockPattern("typescript"), `{\` // type DiscountOfferType = 'introductory' | 'promotional' | 'one-time'; \`}`, @@ -525,28 +696,32 @@ ${discountOffer}`, expect(drifts).toEqual([ expect.objectContaining({ - rule: 'R12', - message: expect.stringContaining('TypeScript snippet'), + rule: "R12", + message: expect.stringContaining("TypeScript snippet"), }), ]); }); - test('ignores generated-language enum declarations inside comments and strings', () => { - for (const language of ['swift', 'kotlin', 'dart'] as const) { + test("ignores generated-language enum declarations inside comments and strings", () => { + for (const language of ["swift", "kotlin", "dart"] as const) { const canonical = VALID_GENERATED_OFFER_TYPES[language]; const stringDecoy = - language === 'swift' + language === "swift" ? `let decoy = """ ${canonical} """` - : language === 'kotlin' + : language === "kotlin" ? `val decoy = """ ${canonical} """` : `const decoy = r''' ${canonical} ''';`; - const wrongDeclaration = replaceRequired(canonical, 'one-time', 'OneTime'); + const wrongDeclaration = replaceRequired( + canonical, + "one-time", + "OneTime", + ); const brokenBlock = offerTypeBlock( language, `/* @@ -555,7 +730,11 @@ ${canonical} ${stringDecoy} ${wrongDeclaration}`, ); - const discountOffer = replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern(language), brokenBlock); + const discountOffer = replaceRequired( + VALID_DISCOUNT_OFFER_TYPE_BLOCKS, + offerTypeBlockPattern(language), + brokenBlock, + ); expect( auditCanonicalOfferDocs( @@ -566,24 +745,28 @@ ${discountOffer}`, ), ).toEqual([ expect.objectContaining({ - rule: 'R12', + rule: "R12", message: expect.stringContaining(`${language} snippet`), }), ]); } }); - test('ignores nested Swift enum declarations when selecting the canonical declaration', () => { + test("ignores nested Swift enum declarations when selecting the canonical declaration", () => { const canonical = VALID_GENERATED_OFFER_TYPES.swift; - const wrongDeclaration = replaceRequired(canonical, 'one-time', 'OneTime'); + const wrongDeclaration = replaceRequired(canonical, "one-time", "OneTime"); const brokenBlock = offerTypeBlock( - 'swift', + "swift", `struct Decoy { ${canonical} } ${wrongDeclaration}`, ); - const discountOffer = replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern('swift'), brokenBlock); + const discountOffer = replaceRequired( + VALID_DISCOUNT_OFFER_TYPE_BLOCKS, + offerTypeBlockPattern("swift"), + brokenBlock, + ); expect( auditCanonicalOfferDocs( @@ -594,16 +777,16 @@ ${discountOffer}`, ), ).toEqual([ expect.objectContaining({ - rule: 'R12', - message: expect.stringContaining('swift snippet'), + rule: "R12", + message: expect.stringContaining("swift snippet"), }), ]); }); - test('flags incorrect generated-language DiscountOfferType wire values', () => { + test("flags incorrect generated-language DiscountOfferType wire values", () => { for (const [language, brokenBlock] of [ [ - 'swift', + "swift", `{\` enum DiscountOfferType: String { case introductory = "introductory" @@ -613,7 +796,7 @@ enum DiscountOfferType: String { \`}`, ], [ - 'kotlin', + "kotlin", `{\` enum class DiscountOfferType(val rawValue: String) { Introductory("introductory"), @@ -623,7 +806,7 @@ enum class DiscountOfferType(val rawValue: String) { \`}`, ], [ - 'dart', + "dart", `{\` enum DiscountOfferType { Introductory('introductory'), @@ -633,7 +816,11 @@ enum DiscountOfferType { \`}`, ], ] as const) { - const discountOffer = replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern(language), brokenBlock); + const discountOffer = replaceRequired( + VALID_DISCOUNT_OFFER_TYPE_BLOCKS, + offerTypeBlockPattern(language), + brokenBlock, + ); const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ discountOffer: `

DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

@@ -643,17 +830,17 @@ ${discountOffer}`, expect(drifts).toEqual([ expect.objectContaining({ - rule: 'R12', + rule: "R12", message: expect.stringContaining(`${language} snippet`), }), ]); } }); - test('flags unmatched extra generated-language enum members', () => { + test("flags unmatched extra generated-language enum members", () => { for (const [language, brokenBlock] of [ [ - 'swift', + "swift", `{\` enum DiscountOfferType: String { case introductory = "introductory" @@ -664,7 +851,7 @@ enum DiscountOfferType: String { \`}`, ], [ - 'kotlin', + "kotlin", `{\` enum class DiscountOfferType(val rawValue: String) { Introductory("introductory"), @@ -675,7 +862,7 @@ enum class DiscountOfferType(val rawValue: String) { \`}`, ], [ - 'dart', + "dart", `{\` enum DiscountOfferType { Introductory('introductory'), @@ -686,7 +873,11 @@ enum DiscountOfferType { \`}`, ], ] as const) { - const discountOffer = replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern(language), brokenBlock); + const discountOffer = replaceRequired( + VALID_DISCOUNT_OFFER_TYPE_BLOCKS, + offerTypeBlockPattern(language), + brokenBlock, + ); const drifts = auditCanonicalOfferDocs( validOfferDocsSources({ discountOffer: `

DiscountOffer represents one-time products on Android via ProductDetails.OneTimePurchaseOfferDetails.

@@ -696,14 +887,14 @@ ${discountOffer}`, expect(drifts).toEqual([ expect.objectContaining({ - rule: 'R12', + rule: "R12", message: expect.stringContaining(`${language} snippet`), }), ]); } }); - test('accepts valid Swift combined cases and Dart double quotes', () => { + test("accepts valid Swift combined cases and Dart double quotes", () => { const swiftCombined = `{\` enum DiscountOfferType: String { case introductory = "introductory", @@ -719,8 +910,12 @@ enum DiscountOfferType { } \`}`; const discountOffer = replaceRequired( - replaceRequired(VALID_DISCOUNT_OFFER_TYPE_BLOCKS, offerTypeBlockPattern('swift'), swiftCombined), - offerTypeBlockPattern('dart'), + replaceRequired( + VALID_DISCOUNT_OFFER_TYPE_BLOCKS, + offerTypeBlockPattern("swift"), + swiftCombined, + ), + offerTypeBlockPattern("dart"), dartDoubleQuoted, ); @@ -734,7 +929,7 @@ ${discountOffer}`, ).toEqual([]); }); - test('flags legacy native search routes and missing canonical entries', () => { + test("flags legacy native search routes and missing canonical entries", () => { const legacySearchData = `export const apiData = [ { title: 'DiscountOffer', @@ -745,41 +940,47 @@ ${discountOffer}`, path: '/docs/types/android/subscription-offer-android', }, ];`; - const wrongRouteDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: legacySearchData })); + const wrongRouteDrifts = auditCanonicalOfferDocs( + validOfferDocsSources({ searchData: legacySearchData }), + ); expect(wrongRouteDrifts).toEqual([ expect.objectContaining({ line: 4, - message: expect.stringContaining('/docs/types/discount-offer'), + message: expect.stringContaining("/docs/types/discount-offer"), }), expect.objectContaining({ line: 8, - message: expect.stringContaining('/docs/types/subscription-offer'), + message: expect.stringContaining("/docs/types/subscription-offer"), }), ]); - const missingEntryDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: 'export const apiData = [];' })); + const missingEntryDrifts = auditCanonicalOfferDocs( + validOfferDocsSources({ searchData: "export const apiData = [];" }), + ); expect(missingEntryDrifts).toEqual([ expect.objectContaining({ - message: expect.stringContaining('canonical DiscountOffer entry'), + message: expect.stringContaining("canonical DiscountOffer entry"), }), expect.objectContaining({ - message: expect.stringContaining('canonical SubscriptionOffer entry'), + message: expect.stringContaining("canonical SubscriptionOffer entry"), }), ]); }); - test('ignores commented search entries and path-like description strings', () => { + test("ignores commented search entries and path-like description strings", () => { const commentedEntries = `export const apiData = []; // { title: 'DiscountOffer', path: '/docs/types/discount-offer' } /* { title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' } */`; - const commentedDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: commentedEntries })); + const commentedDrifts = auditCanonicalOfferDocs( + validOfferDocsSources({ searchData: commentedEntries }), + ); expect(commentedDrifts).toEqual([ expect.objectContaining({ - message: expect.stringContaining('canonical DiscountOffer entry'), + message: expect.stringContaining("canonical DiscountOffer entry"), }), expect.objectContaining({ - message: expect.stringContaining('canonical SubscriptionOffer entry'), + message: expect.stringContaining("canonical SubscriptionOffer entry"), }), ]); @@ -795,20 +996,22 @@ ${discountOffer}`, path: '/wrong-subscription-path', }, ];`; - const pathDrifts = auditCanonicalOfferDocs(validOfferDocsSources({ searchData: misleadingDescriptions })); + const pathDrifts = auditCanonicalOfferDocs( + validOfferDocsSources({ searchData: misleadingDescriptions }), + ); expect(pathDrifts).toEqual([ expect.objectContaining({ line: 5, - message: expect.stringContaining('/wrong-discount-path'), + message: expect.stringContaining("/wrong-discount-path"), }), expect.objectContaining({ line: 10, - message: expect.stringContaining('/wrong-subscription-path'), + message: expect.stringContaining("/wrong-subscription-path"), }), ]); }); - test('finds canonical search paths across indentation and nested formatting', () => { + test("finds canonical search paths across indentation and nested formatting", () => { const reformattedSearchData = `export const apiData = [ \t{ \t\tmetadata: { @@ -820,10 +1023,14 @@ ${discountOffer}`, { metadata: { path: '/internal/subscription-offer-metadata' }, title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' }, ];`; - expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData: reformattedSearchData }))).toEqual([]); + expect( + auditCanonicalOfferDocs( + validOfferDocsSources({ searchData: reformattedSearchData }), + ), + ).toEqual([]); }); - test('accepts parenthesized and typed apiData array initializers', () => { + test("accepts parenthesized and typed apiData array initializers", () => { const entries = `[ { title: 'DiscountOffer', path: '/docs/types/discount-offer' }, { title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' }, @@ -833,11 +1040,13 @@ ${discountOffer}`, `export const apiData = ${entries} as const;`, `export const apiData = ${entries} satisfies readonly SearchItem[];`, ]) { - expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); + expect( + auditCanonicalOfferDocs(validOfferDocsSources({ searchData })), + ).toEqual([]); } }); - test('accepts wrapped apiData elements and string properties', () => { + test("accepts wrapped apiData elements and string properties", () => { const searchData = `export const apiData = [ ({ title: ('DiscountOffer' as const), @@ -849,10 +1058,12 @@ ${discountOffer}`, } as const), ];`; - expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); + expect( + auditCanonicalOfferDocs(validOfferDocsSources({ searchData })), + ).toEqual([]); }); - test('ignores nested apiData shadow declarations', () => { + test("ignores nested apiData shadow declarations", () => { const searchData = `export const apiData = [ { title: 'DiscountOffer', path: '/docs/types/discount-offer' }, { title: 'SubscriptionOffer', path: '/docs/types/subscription-offer' }, @@ -862,29 +1073,33 @@ function shadow() { return apiData; }`; - expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); + expect( + auditCanonicalOfferDocs(validOfferDocsSources({ searchData })), + ).toEqual([]); }); - test('parses search entries after nested template literals', () => { + test("parses search entries after nested template literals", () => { const searchData = [ - 'export const apiData = [', - ' {', + "export const apiData = [", + " {", " title: 'DiscountOffer',", - ' description: `outer ${`}`}`,', + " description: `outer ${`}`}`,", " path: '/docs/types/discount-offer',", - ' },', - ' {', + " },", + " {", " title: 'SubscriptionOffer',", - ' description: `outer ${`}`}`,', + " description: `outer ${`}`}`,", " path: '/docs/types/subscription-offer',", - ' },', - '];', - ].join('\n'); + " },", + "];", + ].join("\n"); - expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); + expect( + auditCanonicalOfferDocs(validOfferDocsSources({ searchData })), + ).toEqual([]); }); - test('ignores braces inside search strings and comments', () => { + test("ignores braces inside search strings and comments", () => { const edgeCases = [ `export const apiData = [ { @@ -925,7 +1140,9 @@ function shadow() { ]; for (const searchData of edgeCases) { - expect(auditCanonicalOfferDocs(validOfferDocsSources({ searchData }))).toEqual([]); + expect( + auditCanonicalOfferDocs(validOfferDocsSources({ searchData })), + ).toEqual([]); } }); }); diff --git a/scripts/audit-docs.ts b/scripts/audit-docs.ts index 5cc735585..417b9fceb 100644 --- a/scripts/audit-docs.ts +++ b/scripts/audit-docs.ts @@ -30,26 +30,57 @@ * Read knowledge/internal/07-docs-consistency.md for the rules this * script enforces. */ -import { readFileSync, statSync } from 'node:fs'; -import { readdir } from 'node:fs/promises'; -import { join, relative, resolve } from 'node:path'; -import ts from 'typescript'; -import { GENERATED_SYNC_MANIFEST } from '../packages/gql/generated-sync-manifest.mjs'; -import { assertSpecMatchesNativeFloor } from './release-branch-policy.mjs'; - -const REPO_ROOT = resolve(import.meta.dir, '..'); -const DOC_ROOTS = [resolve(REPO_ROOT, 'packages/docs/src/pages/docs/apis'), resolve(REPO_ROOT, 'packages/docs/src/pages/docs/types')]; -const DOC_PAGES_DIR = resolve(REPO_ROOT, 'packages/docs/src/pages'); -const ACTIVE_DOCS_ROOT = resolve(REPO_ROOT, 'packages/docs/src/pages/docs'); -const TYPES_FILE = resolve(REPO_ROOT, GENERATED_SYNC_MANIFEST.typescript.source); -const RELEASE_NOTES_FILE = resolve(REPO_ROOT, 'packages/docs/src/pages/docs/updates/releases.tsx'); -const VERSIONING_FILE = resolve(REPO_ROOT, 'packages/docs/src/lib/versioning.ts'); -const DOC_VERSIONS_FILE = resolve(REPO_ROOT, 'packages/docs/openiap-versions.json'); -const ROOT_VERSIONS_FILE = resolve(REPO_ROOT, 'openiap-versions.json'); -const DOC_VERSION_METADATA_FILE = resolve(REPO_ROOT, 'packages/docs/src/generated/version-metadata.json'); -const DISCOUNT_OFFER_DOC_FILE = resolve(REPO_ROOT, 'packages/docs/src/pages/docs/types/discount-offer.tsx'); -const SUBSCRIPTION_OFFER_DOC_FILE = resolve(REPO_ROOT, 'packages/docs/src/pages/docs/types/subscription-offer.tsx'); -const SEARCH_DATA_FILE = resolve(REPO_ROOT, 'packages/docs/src/lib/searchData.ts'); +import { readFileSync, statSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import ts from "typescript"; +import { GENERATED_SYNC_MANIFEST } from "../packages/gql/generated-sync-manifest.mjs"; +import { assertSpecMatchesNativeFloor } from "./release-branch-policy.mjs"; + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const DOC_ROOTS = [ + resolve(REPO_ROOT, "packages/docs/src/pages/docs/apis"), + resolve(REPO_ROOT, "packages/docs/src/pages/docs/types"), +]; +const DOC_PAGES_DIR = resolve(REPO_ROOT, "packages/docs/src/pages"); +const ACTIVE_DOCS_ROOT = resolve(REPO_ROOT, "packages/docs/src/pages/docs"); +const TYPES_FILE = resolve( + REPO_ROOT, + GENERATED_SYNC_MANIFEST.typescript.source, +); +const RELEASE_NOTES_FILE = resolve( + REPO_ROOT, + "packages/docs/src/pages/docs/updates/releases.tsx", +); +const VERSIONING_FILE = resolve( + REPO_ROOT, + "packages/docs/src/lib/versioning.ts", +); +const DOC_VERSIONS_FILE = resolve( + REPO_ROOT, + "packages/docs/openiap-versions.json", +); +const ROOT_VERSIONS_FILE = resolve(REPO_ROOT, "openiap-versions.json"); +const DOC_VERSION_METADATA_FILE = resolve( + REPO_ROOT, + "packages/docs/src/generated/version-metadata.json", +); +const DISCOUNT_OFFER_DOC_FILE = resolve( + REPO_ROOT, + "packages/docs/src/pages/docs/types/discount-offer.tsx", +); +const SUBSCRIPTION_OFFER_DOC_FILE = resolve( + REPO_ROOT, + "packages/docs/src/pages/docs/types/subscription-offer.tsx", +); +const SEARCH_DATA_FILE = resolve( + REPO_ROOT, + "packages/docs/src/lib/searchData.ts", +); +const VERIFY_PURCHASE_DOC_FILE = resolve( + REPO_ROOT, + "packages/docs/src/pages/docs/types/verify-purchase.tsx", +); const GENERATED_OFFER_TYPE_FILES = { typescript: TYPES_FILE, swift: resolve(REPO_ROOT, GENERATED_SYNC_MANIFEST.swift.source), @@ -73,7 +104,10 @@ export type CanonicalOfferDocsSources = { discountOffer: SourceFile; subscriptionOffer: SourceFile; searchData: SourceFile; - generatedOfferTypes: Record<'typescript' | 'swift' | 'kotlin' | 'dart', SourceFile>; + generatedOfferTypes: Record< + "typescript" | "swift" | "kotlin" | "dart", + SourceFile + >; }; type CodeExampleRule = { @@ -82,73 +116,198 @@ type CodeExampleRule = { message: string; }; +function requiredInterfaceFields( + source: string, + interfaceName: string, +): string[] { + const sourceFile = ts.createSourceFile( + "generated-types.ts", + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + for (const statement of sourceFile.statements) { + if ( + !ts.isInterfaceDeclaration(statement) || + statement.name.text !== interfaceName + ) + continue; + return statement.members + .filter(ts.isPropertySignature) + .filter((member) => !member.questionToken && ts.isIdentifier(member.name)) + .map((member) => (member.name as ts.Identifier).text); + } + return []; +} + +export function auditVerifyPurchaseDocs( + file: string, + source: string, + generatedTypesSource: string, +): Drift[] { + const section = (id: string, nextId?: string): string => { + const start = source.indexOf(`id="${id}"`); + if (start < 0) return ""; + const end = nextId + ? source.indexOf(`id="${nextId}"`, start) + : source.length; + return source.slice(start, end < 0 ? source.length : end); + }; + const ios = section( + "verify-purchase-result-ios", + "verify-purchase-result-android", + ); + const android = section( + "verify-purchase-result-android", + "verify-purchase-result-horizon", + ); + const horizon = section("verify-purchase-result-horizon"); + const drifts: Drift[] = []; + const requiredFields = requiredInterfaceFields( + generatedTypesSource, + "VerifyPurchaseResultCommon", + ); + if (requiredFields.length === 0) { + return [ + { + file: TYPES_FILE, + line: 1, + rule: "R14", + message: + "The generated TypeScript SSOT must declare required VerifyPurchaseResultCommon fields.", + }, + ]; + } + for (const [name, content] of [ + ["iOS", ios], + ["Android", android], + ["Horizon", horizon], + ] as const) { + const documentedFields = [ + ...content.matchAll(/
\s*
isValid
successDeprecated alias for isValid
successLegacy alias
successDeprecated alias for isValid
\s*([^<]+)<\/code>\s*<\/td>/g), + ].map((match) => match[1]); + for (const field of requiredFields) { + const occurrences = documentedFields.filter( + (documented) => documented === field, + ).length; + if (occurrences !== 1) { + drifts.push({ + file, + line: 1, + rule: "R14", + message: `${name} VerifyPurchaseResult docs must list the required ${field} field exactly once.`, + }); + } + } + } + const horizonSuccessRows = [ + ...horizon.matchAll(/]*>[\s\S]*?<\/tr>/gi), + ] + .map((match) => match[0]) + .filter((row) => /\s*success\s*<\/code>/i.test(row)); + const horizonSuccessRow = horizonSuccessRows[0]; + if ( + horizonSuccessRows.length !== 1 || + !horizonSuccessRow || + !/deprecated/i.test(horizonSuccessRow) || + !/\s*isValid\s*<\/code>/i.test(horizonSuccessRow) + ) { + drifts.push({ + file, + line: 1, + rule: "R14", + message: + "Horizon VerifyPurchaseResult docs must mark success as a deprecated isValid alias.", + }); + } + return drifts; +} + const CODE_EXAMPLE_RULES: CodeExampleRule[] = [ { - language: 'csharp', + language: "csharp", pattern: /@Deprecated|Task<(?:Boolean|String|List<)|\bList\b/, - message: 'C# examples must use C# attributes and primitive/collection types (`[Obsolete]`, `bool`, `string`, `IReadOnlyList`).', + message: + "C# examples must use C# attributes and primitive/collection types (`[Obsolete]`, `bool`, `string`, `IReadOnlyList`).", }, { - language: 'csharp', - pattern: /\?:\s*return|\bwhen\s*\(|(?:^|\n)\s*(?:else|null)\s*->|\?\.let\s*\{|\bprintln\s*\(/m, - message: 'C# example contains Kotlin syntax.', + language: "csharp", + pattern: + /\?:\s*return|\bwhen\s*\(|(?:^|\n)\s*(?:else|null)\s*->|\?\.let\s*\{|\bprintln\s*\(/m, + message: "C# example contains Kotlin syntax.", }, { - language: 'dart', - pattern: /\b(?:purchaseUpdatedStream|purchaseErrorStream|userChoiceBillingStream)\b/, + language: "dart", + pattern: + /\b(?:purchaseUpdatedStream|purchaseErrorStream|userChoiceBillingStream)\b/, message: - 'Flutter examples must use the current listener streams (`purchaseUpdatedListener`, `purchaseErrorListener`, or `userChoiceBillingAndroid`).', + "Flutter examples must use the current listener streams (`purchaseUpdatedListener`, `purchaseErrorListener`, or `userChoiceBillingAndroid`).", }, { - language: 'dart', + language: "dart", pattern: /\.finishTransaction\(\s*(?!purchase\s*:)[A-Za-z_]\w*\s*(?:,|\))/, - message: 'Flutter `finishTransaction` requires the named `purchase:` argument.', + message: + "Flutter `finishTransaction` requires the named `purchase:` argument.", }, { pattern: /OpenIapStore\.shared/, - message: 'Apple/native store examples must construct or inject `OpenIapStore`; no `shared` singleton exists.', + message: + "Apple/native store examples must construct or inject `OpenIapStore`; no `shared` singleton exists.", }, { - pattern: /\b(?:verifyPurchase|verify_purchase)\b[\s\S]{0,400}\b(?:serverUrl|server_url)\b/, - message: '`verifyPurchase` accepts platform verification options, not a Purchase plus server URL.', + pattern: + /\b(?:verifyPurchase|verify_purchase)\b[\s\S]{0,400}\b(?:serverUrl|server_url)\b/, + message: + "`verifyPurchase` accepts platform verification options, not a Purchase plus server URL.", }, { - language: 'typescript', + language: "typescript", pattern: /requestPurchase\(\{\s*(?:sku|purchaseToken|replacementMode)\s*:/, - message: 'TypeScript `requestPurchase` must use the `request` platform union and explicit `type`.', + message: + "TypeScript `requestPurchase` must use the `request` platform union and explicit `type`.", }, { - language: 'swift', + language: "swift", pattern: /\bsubscription\.remove\(\)/, - message: 'Swift listener tokens are removed with `OpenIapModule.shared.removeListener(subscription)`.', + message: + "Swift listener tokens are removed with `OpenIapModule.shared.removeListener(subscription)`.", }, { - language: 'gdscript', - pattern: /\bvar\s+([A-Za-z_]\w*)\s*=\s*(?:Types\.)?RequestPurchaseProps\.new\(\)[\s\S]{0,200}\b\1\.sku\s*=/, - message: 'RequestPurchaseProps has no top-level sku; populate one request branch or use in_app().', + language: "gdscript", + pattern: + /\bvar\s+([A-Za-z_]\w*)\s*=\s*(?:Types\.)?RequestPurchaseProps\.new\(\)[\s\S]{0,200}\b\1\.sku\s*=/, + message: + "RequestPurchaseProps has no top-level sku; populate one request branch or use in_app().", }, { - language: 'kotlin', + language: "kotlin", pattern: /\.\s*requestPurchase\s*\(\s*(?:activity|props)\s*=/, message: - 'Kotlin and KMP `requestPurchase` accept one positional `RequestPurchaseProps` argument; `activity` and `props` are not parameters.', + "Kotlin and KMP `requestPurchase` accept one positional `RequestPurchaseProps` argument; `activity` and `props` are not parameters.", }, { - pattern: /(?:console\.log|println|print|Console\.WriteLine|Log\.[a-z]+)\([^)]{0,200}\b(?:offerToken|offer_token|OfferToken)\b/, - message: 'Offer tokens must not be written to application logs.', + pattern: + /(?:console\.log|println|print|Console\.WriteLine|Log\.[a-z]+)\([^)]{0,200}\b(?:offerToken|offer_token|OfferToken)\b/, + message: "Offer tokens must not be written to application logs.", }, ]; -export function auditActiveCodeExampleSource(filePath: string, src: string): Drift[] { +export function auditActiveCodeExampleSource( + filePath: string, + src: string, +): Drift[] { if (resolve(filePath) === RELEASE_NOTES_FILE) return []; const drifts: Drift[] = []; - const blockRe = /]*\blanguage="([^"]+)"[^>]*>\s*\{`([\s\S]*?)`\}\s*<\/CodeBlock>/g; + const blockRe = + /]*\blanguage="([^"]+)"[^>]*>\s*\{`([\s\S]*?)`\}\s*<\/CodeBlock>/g; let blockMatch: RegExpExecArray | null; while ((blockMatch = blockRe.exec(src)) !== null) { const language = blockMatch[1]; const block = blockMatch[2]; - const auditedBlock = language === 'kotlin' ? stripCommentsPreservingLayout(block) : block; + const auditedBlock = + language === "kotlin" ? stripCommentsPreservingLayout(block) : block; const blockOffset = blockMatch.index + blockMatch[0].indexOf(block); for (const rule of CODE_EXAMPLE_RULES) { @@ -158,7 +317,7 @@ export function auditActiveCodeExampleSource(filePath: string, src: string): Dri drifts.push({ file: filePath, line: lineNumberAt(src, blockOffset + violation.index), - rule: 'R11', + rule: "R11", message: `${rule.message} (language: ${language})`, }); } @@ -166,9 +325,18 @@ export function auditActiveCodeExampleSource(filePath: string, src: string): Dri return drifts; } -function findSearchEntriesByTitle(source: string, title: string): { line: number; path: string | null; pathLine: number }[] { +function findSearchEntriesByTitle( + source: string, + title: string, +): { line: number; path: string | null; pathLine: number }[] { const entries: { line: number; path: string | null; pathLine: number }[] = []; - const sourceFile = ts.createSourceFile('searchData.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sourceFile = ts.createSourceFile( + "searchData.ts", + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); let apiData: ts.ArrayLiteralExpression | null = null; const unwrapExpression = (expression: ts.Expression): ts.Expression => { @@ -189,7 +357,11 @@ function findSearchEntriesByTitle(source: string, title: string): { line: number for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement)) continue; for (const declaration of statement.declarationList.declarations) { - if (!ts.isIdentifier(declaration.name) || declaration.name.text !== 'apiData' || !declaration.initializer) { + if ( + !ts.isIdentifier(declaration.name) || + declaration.name.text !== "apiData" || + !declaration.initializer + ) { continue; } const initializer = unwrapExpression(declaration.initializer); @@ -199,14 +371,18 @@ function findSearchEntriesByTitle(source: string, title: string): { line: number if (!apiData) return entries; - const propertyName = (property: ts.ObjectLiteralElementLike): string | null => { + const propertyName = ( + property: ts.ObjectLiteralElementLike, + ): string | null => { if (!property.name) return null; if (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) { return property.name.text; } return null; }; - const stringValue = (property: ts.ObjectLiteralElementLike): string | null => { + const stringValue = ( + property: ts.ObjectLiteralElementLike, + ): string | null => { if (!ts.isPropertyAssignment(property)) return null; const initializer = unwrapExpression(property.initializer); return ts.isStringLiteralLike(initializer) ? initializer.text : null; @@ -215,34 +391,59 @@ function findSearchEntriesByTitle(source: string, title: string): { line: number for (const element of apiData.elements) { const entry = unwrapExpression(element); if (!ts.isObjectLiteralExpression(entry)) continue; - const titleProperty = entry.properties.find((property) => propertyName(property) === 'title'); + const titleProperty = entry.properties.find( + (property) => propertyName(property) === "title", + ); if (!titleProperty || stringValue(titleProperty) !== title) continue; - const pathProperty = entry.properties.find((property) => propertyName(property) === 'path'); - const line = sourceFile.getLineAndCharacterOfPosition(titleProperty.getStart(sourceFile)).line + 1; + const pathProperty = entry.properties.find( + (property) => propertyName(property) === "path", + ); + const line = + sourceFile.getLineAndCharacterOfPosition( + titleProperty.getStart(sourceFile), + ).line + 1; entries.push({ line, path: pathProperty ? stringValue(pathProperty) : null, - pathLine: pathProperty ? sourceFile.getLineAndCharacterOfPosition(pathProperty.getStart(sourceFile)).line + 1 : line, + pathLine: pathProperty + ? sourceFile.getLineAndCharacterOfPosition( + pathProperty.getStart(sourceFile), + ).line + 1 + : line, }); } return entries; } -function parseTypeScriptDiscountOfferType(source: string): { index: number; members: string[] | null } | null { - const sourceFile = ts.createSourceFile('discount-offer-types.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +function parseTypeScriptDiscountOfferType( + source: string, +): { index: number; members: string[] | null } | null { + const sourceFile = ts.createSourceFile( + "discount-offer-types.ts", + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); const declaration = sourceFile.statements.find( (statement): statement is ts.TypeAliasDeclaration => - ts.isTypeAliasDeclaration(statement) && statement.name.text === 'DiscountOfferType', + ts.isTypeAliasDeclaration(statement) && + statement.name.text === "DiscountOfferType", ); if (!declaration) return null; let offerType: ts.TypeNode = declaration.type; while (ts.isParenthesizedTypeNode(offerType)) offerType = offerType.type; - const rawMembers = ts.isUnionTypeNode(offerType) ? offerType.types : [offerType]; + const rawMembers = ts.isUnionTypeNode(offerType) + ? offerType.types + : [offerType]; const members: string[] = []; for (const rawMember of rawMembers) { - if (!ts.isLiteralTypeNode(rawMember) || !ts.isStringLiteral(rawMember.literal)) { + if ( + !ts.isLiteralTypeNode(rawMember) || + !ts.isStringLiteral(rawMember.literal) + ) { return { index: declaration.getStart(sourceFile), members: null, @@ -257,8 +458,11 @@ function parseTypeScriptDiscountOfferType(source: string): { index: number; memb }; } -function findTypeScriptDiscountOfferType(source: string): { line: number; members: string[] | null } | null { - const blockRe = /]*\blanguage="typescript"[^>]*>\s*\{`([\s\S]*?)`\}\s*<\/CodeBlock>/g; +function findTypeScriptDiscountOfferType( + source: string, +): { line: number; members: string[] | null } | null { + const blockRe = + /]*\blanguage="typescript"[^>]*>\s*\{`([\s\S]*?)`\}\s*<\/CodeBlock>/g; let blockMatch: RegExpExecArray | null; while ((blockMatch = blockRe.exec(source)) !== null) { @@ -267,7 +471,10 @@ function findTypeScriptDiscountOfferType(source: string): { line: number; member if (!declaration) continue; return { - line: lineNumberAt(source, blockMatch.index + blockMatch[0].indexOf(block) + declaration.index), + line: lineNumberAt( + source, + blockMatch.index + blockMatch[0].indexOf(block) + declaration.index, + ), members: declaration.members, }; } @@ -275,10 +482,13 @@ function findTypeScriptDiscountOfferType(source: string): { line: number; member return null; } -type NamedOfferTypeLanguage = 'swift' | 'kotlin' | 'dart'; +type NamedOfferTypeLanguage = "swift" | "kotlin" | "dart"; -function maskNativeNonCodePreservingLayout(source: string, maskStrings: boolean): string { - const output = source.split(''); +function maskNativeNonCodePreservingLayout( + source: string, + maskStrings: boolean, +): string { + const output = source.split(""); let quote: "'" | '"' | null = null; let tripleQuoted = false; let escaped = false; @@ -290,17 +500,17 @@ function maskNativeNonCodePreservingLayout(source: string, maskStrings: boolean) const next = source[i + 1]; if (inLineComment) { - if (ch === '\n') { + if (ch === "\n") { inLineComment = false; } else { - output[i] = ' '; + output[i] = " "; } continue; } if (inBlockComment) { - if (ch !== '\n') output[i] = ' '; - if (ch === '*' && next === '/') { - output[i + 1] = ' '; + if (ch !== "\n") output[i] = " "; + if (ch === "*" && next === "/") { + output[i + 1] = " "; inBlockComment = false; i += 1; } @@ -309,20 +519,20 @@ function maskNativeNonCodePreservingLayout(source: string, maskStrings: boolean) if (quote !== null) { if (tripleQuoted && source.slice(i, i + 3) === quote.repeat(3)) { if (maskStrings) { - output[i] = ' '; - output[i + 1] = ' '; - output[i + 2] = ' '; + output[i] = " "; + output[i + 1] = " "; + output[i + 2] = " "; } quote = null; tripleQuoted = false; i += 2; continue; } - if (maskStrings && ch !== '\n') output[i] = ' '; + if (maskStrings && ch !== "\n") output[i] = " "; if (tripleQuoted) continue; if (escaped) { escaped = false; - } else if (ch === '\\') { + } else if (ch === "\\") { escaped = true; } else if (ch === quote) { quote = null; @@ -333,42 +543,45 @@ function maskNativeNonCodePreservingLayout(source: string, maskStrings: boolean) quote = ch; tripleQuoted = source.slice(i, i + 3) === ch.repeat(3); if (maskStrings) { - output[i] = ' '; + output[i] = " "; if (tripleQuoted) { - output[i + 1] = ' '; - output[i + 2] = ' '; + output[i + 1] = " "; + output[i + 2] = " "; } } if (tripleQuoted) i += 2; continue; } - if (ch === '/' && next === '/') { - output[i] = ' '; - output[i + 1] = ' '; + if (ch === "/" && next === "/") { + output[i] = " "; + output[i + 1] = " "; inLineComment = true; i += 1; continue; } - if (ch === '/' && next === '*') { - output[i] = ' '; - output[i + 1] = ' '; + if (ch === "/" && next === "*") { + output[i] = " "; + output[i + 1] = " "; inBlockComment = true; i += 1; } } - return output.join(''); + return output.join(""); } function stripCommentsPreservingLayout(source: string): string { return maskNativeNonCodePreservingLayout(source, false); } -function findMatchingBrace(source: string, openingBrace: number): number | null { +function findMatchingBrace( + source: string, + openingBrace: number, +): number | null { let depth = 1; for (let i = openingBrace + 1; i < source.length; i += 1) { - if (source[i] === '{') depth += 1; - if (source[i] !== '}') continue; + if (source[i] === "{") depth += 1; + if (source[i] !== "}") continue; depth -= 1; if (depth === 0) return i; } @@ -378,26 +591,30 @@ function findMatchingBrace(source: string, openingBrace: number): number | null function braceDepthAt(source: string, index: number): number { let depth = 0; for (let i = 0; i < index; i += 1) { - if (source[i] === '{') depth += 1; - else if (source[i] === '}') depth = Math.max(0, depth - 1); + if (source[i] === "{") depth += 1; + else if (source[i] === "}") depth = Math.max(0, depth - 1); } return depth; } -function findTopLevelMemberBoundary(source: string, language: NamedOfferTypeLanguage): number { +function findTopLevelMemberBoundary( + source: string, + language: NamedOfferTypeLanguage, +): number { let depth = 0; for (let i = 0; i < source.length; i += 1) { - if (source[i] === '{') { + if (source[i] === "{") { depth += 1; continue; } - if (source[i] === '}') { + if (source[i] === "}") { depth = Math.max(0, depth - 1); continue; } if (depth !== 0) continue; - if (language === 'dart' && source[i] === ';') return i; - if (language === 'kotlin' && /^companion\s+object\b/.test(source.slice(i))) return i; + if (language === "dart" && source[i] === ";") return i; + if (language === "kotlin" && /^companion\s+object\b/.test(source.slice(i))) + return i; } return source.length; } @@ -409,17 +626,18 @@ function parseNamedDiscountOfferTypeMembers( const code = stripCommentsPreservingLayout(source); const structuralCode = maskNativeNonCodePreservingLayout(source, true); const declarationRe = - language === 'swift' + language === "swift" ? /\benum\s+DiscountOfferType\b[^{}]*\{/g - : language === 'kotlin' + : language === "kotlin" ? /\benum\s+class\s+DiscountOfferType(?:\s*\([^)]*\))?\s*\{/g : /\benum\s+DiscountOfferType\s*\{/g; - const declarations: { index: number; bodyStart: number; bodyEnd: number }[] = []; + const declarations: { index: number; bodyStart: number; bodyEnd: number }[] = + []; let declaration: RegExpExecArray | null; while ((declaration = declarationRe.exec(structuralCode)) !== null) { if (braceDepthAt(structuralCode, declaration.index) !== 0) continue; - const openingBrace = declaration.index + declaration[0].lastIndexOf('{'); + const openingBrace = declaration.index + declaration[0].lastIndexOf("{"); const closingBrace = findMatchingBrace(structuralCode, openingBrace); if (closingBrace === null) { return { index: declaration.index, members: null }; @@ -436,18 +654,28 @@ function parseNamedDiscountOfferTypeMembers( } const selected = declarations[0]; - const structuralBody = structuralCode.slice(selected.bodyStart, selected.bodyEnd); + const structuralBody = structuralCode.slice( + selected.bodyStart, + selected.bodyEnd, + ); const memberBoundary = findTopLevelMemberBoundary(structuralBody, language); const memberRe = - language === 'swift' + language === "swift" ? /(?:\bcase|,)\s*([A-Za-z]\w*)\s*=\s*"([^"]+)"/g - : language === 'kotlin' + : language === "kotlin" ? /\b([A-Z]\w*)\s*\(\s*"([^"]+)"\s*\)/g : /\b([A-Z]\w*)\s*\(\s*(['"])([^'"]+)\2\s*\)/g; - const memberSection = code.slice(selected.bodyStart, selected.bodyStart + memberBoundary); - const members = Array.from(memberSection.matchAll(memberRe), (match) => `${match[1]}=${match[language === 'dart' ? 3 : 2]}`); - const unmatchedMembers = memberSection.replace(memberRe, '').replace(/[\s,;]/g, '').length > 0; + const memberSection = code.slice( + selected.bodyStart, + selected.bodyStart + memberBoundary, + ); + const members = Array.from( + memberSection.matchAll(memberRe), + (match) => `${match[1]}=${match[language === "dart" ? 3 : 2]}`, + ); + const unmatchedMembers = + memberSection.replace(memberRe, "").replace(/[\s,;]/g, "").length > 0; return { index: selected.index, @@ -459,7 +687,10 @@ function findNamedDiscountOfferTypeMembers( source: string, language: NamedOfferTypeLanguage, ): { line: number; members: string[] | null } | null { - const blockRe = new RegExp(`]*\\blanguage="${language}"[^>]*>\\s*\\{\`([\\s\\S]*?)\`\\}\\s*`, 'g'); + const blockRe = new RegExp( + `]*\\blanguage="${language}"[^>]*>\\s*\\{\`([\\s\\S]*?)\`\\}\\s*`, + "g", + ); let blockMatch: RegExpExecArray | null; while ((blockMatch = blockRe.exec(source)) !== null) { @@ -468,7 +699,10 @@ function findNamedDiscountOfferTypeMembers( if (!declaration) continue; return { - line: lineNumberAt(source, blockMatch.index + blockMatch[0].indexOf(block) + declaration.index), + line: lineNumberAt( + source, + blockMatch.index + blockMatch[0].indexOf(block) + declaration.index, + ), members: declaration.members, }; } @@ -487,8 +721,14 @@ type RenderedProse = { * CodeBlock examples are deliberately excluded from the evidence. */ function collectRenderedProse(source: string): RenderedProse { - const sourceFile = ts.createSourceFile('offer-doc.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); - const segments: RenderedProse['segments'] = []; + const sourceFile = ts.createSourceFile( + "offer-doc.tsx", + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + const segments: RenderedProse["segments"] = []; let outputLength = 0; const append = (text: string, sourceStart: number): void => { @@ -499,7 +739,9 @@ function collectRenderedProse(source: string): RenderedProse { }; const collectedComponents = new Set(); - let resolveComponent: (expression: ts.Expression) => ts.FunctionLikeDeclaration | null; + let resolveComponent: ( + expression: ts.Expression, + ) => ts.FunctionLikeDeclaration | null; let collectFunction: (component: ts.FunctionLikeDeclaration) => void; const collectLocalComponent = (tagName: ts.JsxTagNameExpression): boolean => { @@ -512,7 +754,7 @@ function collectRenderedProse(source: string): RenderedProse { const collectJsx = (node: ts.Node): void => { if (ts.isJsxElement(node)) { - if (node.openingElement.tagName.getText(sourceFile) === 'CodeBlock') { + if (node.openingElement.tagName.getText(sourceFile) === "CodeBlock") { return; } if (collectLocalComponent(node.openingElement.tagName)) return; @@ -520,7 +762,7 @@ function collectRenderedProse(source: string): RenderedProse { return; } if (ts.isJsxSelfClosingElement(node)) { - if (node.tagName.getText(sourceFile) === 'CodeBlock') return; + if (node.tagName.getText(sourceFile) === "CodeBlock") return; collectLocalComponent(node.tagName); return; } @@ -539,13 +781,19 @@ function collectRenderedProse(source: string): RenderedProse { } if (ts.isStringLiteralLike(expression)) { append(expression.text, expression.getStart(sourceFile)); - } else if (ts.isJsxElement(expression) || ts.isJsxSelfClosingElement(expression) || ts.isJsxFragment(expression)) { + } else if ( + ts.isJsxElement(expression) || + ts.isJsxSelfClosingElement(expression) || + ts.isJsxFragment(expression) + ) { collectJsx(expression); } } }; - const unwrapRenderedExpression = (expression: ts.Expression): ts.Expression => { + const unwrapRenderedExpression = ( + expression: ts.Expression, + ): ts.Expression => { let current = expression; while ( ts.isParenthesizedExpression(current) || @@ -560,7 +808,11 @@ function collectRenderedProse(source: string): RenderedProse { const collectExpression = (expression: ts.Expression): void => { const rendered = unwrapRenderedExpression(expression); - if (ts.isJsxElement(rendered) || ts.isJsxSelfClosingElement(rendered) || ts.isJsxFragment(rendered)) { + if ( + ts.isJsxElement(rendered) || + ts.isJsxSelfClosingElement(rendered) || + ts.isJsxFragment(rendered) + ) { collectJsx(rendered); } else if (ts.isConditionalExpression(rendered)) { collectExpression(rendered.whenTrue); @@ -588,7 +840,9 @@ function collectRenderedProse(source: string): RenderedProse { visitReturn(component.body); }; - resolveComponent = (expression: ts.Expression): ts.FunctionLikeDeclaration | null => { + resolveComponent = ( + expression: ts.Expression, + ): ts.FunctionLikeDeclaration | null => { const unwrapped = unwrapRenderedExpression(expression); if (ts.isArrowFunction(unwrapped) || ts.isFunctionExpression(unwrapped)) { return unwrapped; @@ -599,12 +853,19 @@ function collectRenderedProse(source: string): RenderedProse { if (!ts.isIdentifier(unwrapped)) return null; for (const statement of sourceFile.statements) { - if (ts.isFunctionDeclaration(statement) && statement.name?.text === unwrapped.text) { + if ( + ts.isFunctionDeclaration(statement) && + statement.name?.text === unwrapped.text + ) { return statement; } if (!ts.isVariableStatement(statement)) continue; for (const declaration of statement.declarationList.declarations) { - if (ts.isIdentifier(declaration.name) && declaration.name.text === unwrapped.text && declaration.initializer) { + if ( + ts.isIdentifier(declaration.name) && + declaration.name.text === unwrapped.text && + declaration.initializer + ) { return resolveComponent(declaration.initializer); } } @@ -616,8 +877,12 @@ function collectRenderedProse(source: string): RenderedProse { for (const statement of sourceFile.statements) { if ( ts.isFunctionDeclaration(statement) && - statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) && - statement.modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) + statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword, + ) && + statement.modifiers.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ) ) { component = statement; break; @@ -630,15 +895,23 @@ function collectRenderedProse(source: string): RenderedProse { if (component) collectFunction(component); return { - text: segments.map((segment) => segment.text).join(' '), + text: segments.map((segment) => segment.text).join(" "), segments, }; } -function renderedSourceOffset(rendered: RenderedProse, outputOffset: number): number { - const segment = [...rendered.segments].reverse().find((candidate) => candidate.outputStart <= outputOffset); +function renderedSourceOffset( + rendered: RenderedProse, + outputOffset: number, +): number { + const segment = [...rendered.segments] + .reverse() + .find((candidate) => candidate.outputStart <= outputOffset); if (!segment) return 0; - return segment.sourceStart + Math.min(outputOffset - segment.outputStart, segment.text.length); + return ( + segment.sourceStart + + Math.min(outputOffset - segment.outputStart, segment.text.length) + ); } /** @@ -650,7 +923,9 @@ function renderedSourceOffset(rendered: RenderedProse, outputOffset: number): nu * * Sources are injected to keep this check deterministic and fault-testable. */ -export function auditCanonicalOfferDocs(sources: CanonicalOfferDocsSources): Drift[] { +export function auditCanonicalOfferDocs( + sources: CanonicalOfferDocsSources, +): Drift[] { const drifts: Drift[] = []; const discount = sources.discountOffer; const subscription = sources.subscriptionOffer; @@ -661,33 +936,45 @@ export function auditCanonicalOfferDocs(sources: CanonicalOfferDocsSources): Dri drifts.push({ file: discount.file, line: 1, - rule: 'R12', - message: 'DiscountOffer must reference the Android `ProductDetails.OneTimePurchaseOfferDetails` native source.', + rule: "R12", + message: + "DiscountOffer must reference the Android `ProductDetails.OneTimePurchaseOfferDetails` native source.", }); } const oneTimeAndroidClaim = - /\bone-time\b[\s\S]{0,240}\b(?:Android|Google Play)\b/i.test(discountProse.text) || - /\b(?:Android|Google Play)\b[\s\S]{0,240}\bone-time\b/i.test(discountProse.text); + /\bone-time\b[\s\S]{0,240}\b(?:Android|Google Play)\b/i.test( + discountProse.text, + ) || + /\b(?:Android|Google Play)\b[\s\S]{0,240}\bone-time\b/i.test( + discountProse.text, + ); if (!oneTimeAndroidClaim) { drifts.push({ file: discount.file, line: 1, - rule: 'R12', - message: 'DiscountOffer must state that it represents one-time product offers on Android/Google Play.', + rule: "R12", + message: + "DiscountOffer must state that it represents one-time product offers on Android/Google Play.", }); } - const generatedTypeScriptOfferType = parseTypeScriptDiscountOfferType(sources.generatedOfferTypes.typescript.source); + const generatedTypeScriptOfferType = parseTypeScriptDiscountOfferType( + sources.generatedOfferTypes.typescript.source, + ); const expectedOfferTypeMembers = generatedTypeScriptOfferType?.members; if (!expectedOfferTypeMembers) { drifts.push({ file: sources.generatedOfferTypes.typescript.file, line: generatedTypeScriptOfferType - ? lineNumberAt(sources.generatedOfferTypes.typescript.source, generatedTypeScriptOfferType.index) + ? lineNumberAt( + sources.generatedOfferTypes.typescript.source, + generatedTypeScriptOfferType.index, + ) : 1, - rule: 'R12', - message: 'The generated TypeScript SSOT must declare DiscountOfferType as a string-literal union.', + rule: "R12", + message: + "The generated TypeScript SSOT must declare DiscountOfferType as a string-literal union.", }); } @@ -699,31 +986,41 @@ export function auditCanonicalOfferDocs(sources: CanonicalOfferDocsSources): Dri actualOfferTypeMembers !== null && actualOfferTypeMembers !== undefined && actualOfferTypeMembers.length === expectedOfferTypeMembers.length && - expectedOfferTypeMembers.every((member) => actualOfferTypeMembers.includes(member)); + expectedOfferTypeMembers.every((member) => + actualOfferTypeMembers.includes(member), + ); if (expectedOfferTypeMembers && !hasExactOfferTypeMembers) { drifts.push({ file: discount.file, line: typeScriptOfferType?.line ?? 1, - rule: 'R12', + rule: "R12", message: `The canonical DiscountOffer TypeScript snippet must declare DiscountOfferType with exactly the generated wire values ${formatQuotedList(expectedOfferTypeMembers ?? [])}.`, }); } - for (const language of ['swift', 'kotlin', 'dart'] as const) { + for (const language of ["swift", "kotlin", "dart"] as const) { const generatedSource = sources.generatedOfferTypes[language]; - const generatedDeclaration = parseNamedDiscountOfferTypeMembers(generatedSource.source, language); + const generatedDeclaration = parseNamedDiscountOfferTypeMembers( + generatedSource.source, + language, + ); const expectedMembers = generatedDeclaration?.members; if (!expectedMembers) { drifts.push({ file: generatedSource.file, - line: generatedDeclaration ? lineNumberAt(generatedSource.source, generatedDeclaration.index) : 1, - rule: 'R12', + line: generatedDeclaration + ? lineNumberAt(generatedSource.source, generatedDeclaration.index) + : 1, + rule: "R12", message: `The generated ${language} SSOT must declare DiscountOfferType members with explicit wire values.`, }); continue; } - const declaration = findNamedDiscountOfferTypeMembers(discount.source, language); + const declaration = findNamedDiscountOfferTypeMembers( + discount.source, + language, + ); const actualMembers = declaration?.members; const hasExactMembers = actualMembers !== null && @@ -735,7 +1032,7 @@ export function auditCanonicalOfferDocs(sources: CanonicalOfferDocsSources): Dri drifts.push({ file: discount.file, line: declaration?.line ?? 1, - rule: 'R12', + rule: "R12", message: `The canonical DiscountOffer ${language} snippet must declare exactly the generated DiscountOfferType members and wire values.`, }); } @@ -747,17 +1044,22 @@ export function auditCanonicalOfferDocs(sources: CanonicalOfferDocsSources): Dri { pattern: /\bProduct\.SubscriptionOffer\b/, message: - 'DiscountOffer must not map to StoreKit Product.SubscriptionOffer; use the canonical SubscriptionOffer page for subscription discounts.', + "DiscountOffer must not map to StoreKit Product.SubscriptionOffer; use the canonical SubscriptionOffer page for subscription discounts.", }, { pattern: /\b(?:ProductDetails\.)?SubscriptionOfferDetails\b/, - message: 'DiscountOffer must not map to Play SubscriptionOfferDetails; it represents one-time product offers.', + message: + "DiscountOffer must not map to Play SubscriptionOfferDetails; it represents one-time product offers.", }, ]; - if (expectedOfferTypeMembers && !expectedOfferTypeMembers.includes('win-back')) { + if ( + expectedOfferTypeMembers && + !expectedOfferTypeMembers.includes("win-back") + ) { forbiddenDiscountClaims.push({ pattern: /\bWinBack\b/i, - message: 'DiscountOffer must not claim WinBack support; WinBack is not a generated DiscountOfferType member.', + message: + "DiscountOffer must not claim WinBack support; WinBack is not a generated DiscountOfferType member.", }); } @@ -766,45 +1068,59 @@ export function auditCanonicalOfferDocs(sources: CanonicalOfferDocsSources): Dri if (!match) continue; drifts.push({ file: discount.file, - line: lineNumberAt(discount.source, renderedSourceOffset(discountProse, match.index)), - rule: 'R12', + line: lineNumberAt( + discount.source, + renderedSourceOffset(discountProse, match.index), + ), + rule: "R12", message: claim.message, }); } const subscriptionWinBack = /\bWinBack\b/i.exec(subscriptionProse.text); - if (subscriptionWinBack && expectedOfferTypeMembers && !expectedOfferTypeMembers.includes('win-back')) { + if ( + subscriptionWinBack && + expectedOfferTypeMembers && + !expectedOfferTypeMembers.includes("win-back") + ) { drifts.push({ file: subscription.file, - line: lineNumberAt(subscription.source, renderedSourceOffset(subscriptionProse, subscriptionWinBack.index)), - rule: 'R12', - message: 'SubscriptionOffer must not claim WinBack support; WinBack is not a generated DiscountOfferType member.', + line: lineNumberAt( + subscription.source, + renderedSourceOffset(subscriptionProse, subscriptionWinBack.index), + ), + rule: "R12", + message: + "SubscriptionOffer must not claim WinBack support; WinBack is not a generated DiscountOfferType member.", }); } for (const [pattern, nativeType] of [ - [/\bProduct\.SubscriptionOffer\b/, 'Product.SubscriptionOffer'], - [/\b(?:ProductDetails\.)?SubscriptionOfferDetails\b/, 'ProductDetails.SubscriptionOfferDetails'], + [/\bProduct\.SubscriptionOffer\b/, "Product.SubscriptionOffer"], + [ + /\b(?:ProductDetails\.)?SubscriptionOfferDetails\b/, + "ProductDetails.SubscriptionOfferDetails", + ], ] as const) { if (pattern.test(subscriptionProse.text)) continue; drifts.push({ file: subscription.file, line: 1, - rule: 'R12', + rule: "R12", message: `SubscriptionOffer must reference its native ${nativeType} source.`, }); } for (const [title, expectedPath] of [ - ['DiscountOffer', '/docs/types/discount-offer'], - ['SubscriptionOffer', '/docs/types/subscription-offer'], + ["DiscountOffer", "/docs/types/discount-offer"], + ["SubscriptionOffer", "/docs/types/subscription-offer"], ] as const) { const entries = findSearchEntriesByTitle(sources.searchData.source, title); if (entries.length === 0) { drifts.push({ file: sources.searchData.file, line: 1, - rule: 'R12', + rule: "R12", message: `Search data must include a canonical ${title} entry pointing to ${expectedPath}.`, }); continue; @@ -814,7 +1130,7 @@ export function auditCanonicalOfferDocs(sources: CanonicalOfferDocsSources): Dri drifts.push({ file: sources.searchData.file, line: entries[1].line, - rule: 'R12', + rule: "R12", message: `Search data must contain exactly one canonical ${title} entry.`, }); } @@ -824,8 +1140,8 @@ export function auditCanonicalOfferDocs(sources: CanonicalOfferDocsSources): Dri drifts.push({ file: sources.searchData.file, line: entry.pathLine, - rule: 'R12', - message: `The canonical ${title} search entry must point to ${expectedPath}, not ${entry.path ?? 'a missing path'}.`, + rule: "R12", + message: `The canonical ${title} search entry must point to ${expectedPath}, not ${entry.path ?? "a missing path"}.`, }); } } @@ -846,7 +1162,7 @@ async function walkTsxFiles(root: string): Promise { const full = join(dir, entry.name); if (entry.isDirectory()) { await recurse(full); - } else if (entry.isFile() && entry.name.endsWith('.tsx')) { + } else if (entry.isFile() && entry.name.endsWith(".tsx")) { out.push(full); } } @@ -862,24 +1178,37 @@ async function walkTsxFiles(root: string): Promise { * documented parameter exists on any generated shape. */ function buildKnownFields(): Set { - const src = readFileSync(TYPES_FILE, 'utf8'); + const src = readFileSync(TYPES_FILE, "utf8"); const fields = new Set(); - const sourceFile = ts.createSourceFile(TYPES_FILE, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sourceFile = ts.createSourceFile( + TYPES_FILE, + src, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); for (const statement of sourceFile.statements) { - const isExported = statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + const isExported = statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ); if (!isExported) continue; const members = ts.isInterfaceDeclaration(statement) ? statement.members - : ts.isTypeAliasDeclaration(statement) && ts.isTypeLiteralNode(statement.type) + : ts.isTypeAliasDeclaration(statement) && + ts.isTypeLiteralNode(statement.type) ? statement.type.members : undefined; if (!members) continue; for (const member of members) { if (!ts.isPropertySignature(member) || !member.name) continue; - if (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) || ts.isNumericLiteral(member.name)) { + if ( + ts.isIdentifier(member.name) || + ts.isStringLiteral(member.name) || + ts.isNumericLiteral(member.name) + ) { fields.add(member.name.text); } } @@ -899,8 +1228,8 @@ function buildKnownFields(): Set { * non-field mentions to enumerate cleanly). */ function parseDocPage(filePath: string) { - const src = readFileSync(filePath, 'utf8'); - const lines = src.split('\n'); + const src = readFileSync(filePath, "utf8"); + const lines = src.split("\n"); const linkTargets: { line: number; href: string }[] = []; const fieldMentions: { line: number; field: string }[] = []; @@ -921,7 +1250,7 @@ function parseDocPage(filePath: string) { } if (line.includes('className="api-params"')) inParamsList = true; if (inParamsList) { - if (line.includes('')) { + if (line.includes("")) { inParamsList = false; liExpectingField = false; continue; @@ -935,7 +1264,11 @@ function parseDocPage(filePath: string) { // notation. Take the LEAF identifier — that's the field on // some intermediate type. const leaf = token.split(/[.[\s]/).pop() ?? token; - if (/^[a-z_$][\w$]*$/.test(leaf) && leaf.length > 1 && !RESERVED_WORDS.has(leaf)) { + if ( + /^[a-z_$][\w$]*$/.test(leaf) && + leaf.length > 1 && + !RESERVED_WORDS.has(leaf) + ) { fieldMentions.push({ line: lineNo, field: leaf }); } liExpectingField = false; @@ -949,7 +1282,8 @@ function parseDocPage(filePath: string) { const PACKAGE_RELEASE_ITEM_RE = /\b(?:openiap-(?:gql|apple|google)|react-native-iap|expo-iap|flutter_inapp_purchase|godot-iap|kmp-iap|maui-iap)\s+v?\d+\.\d+\.\d+(?:[-\w.]+)?\b/; -const GITHUB_RELEASE_LINK_RE = /href=["']https:\/\/github\.com\/hyodotdev\/openiap\/releases\/tag\/[^"']+["']/; +const GITHUB_RELEASE_LINK_RE = + /href=["']https:\/\/github\.com\/hyodotdev\/openiap\/releases\/tag\/[^"']+["']/; function lineNumberAt(src: string, index: number): number { let line = 1; @@ -961,10 +1295,10 @@ function lineNumberAt(src: string, index: number): number { function formatQuotedList(values: string[]): string { const quoted = values.map((value) => `'${value}'`); - if (quoted.length === 0) return 'no generated values'; + if (quoted.length === 0) return "no generated values"; if (quoted.length === 1) return quoted[0]; if (quoted.length === 2) return `${quoted[0]} and ${quoted[1]}`; - return `${quoted.slice(0, -1).join(', ')}, and ${quoted.at(-1)}`; + return `${quoted.slice(0, -1).join(", ")}, and ${quoted.at(-1)}`; } /** @@ -974,20 +1308,21 @@ function formatQuotedList(values: string[]): string { * package text is a docs regression. */ function auditReleaseNotePackageLinks(filePath: string): Drift[] { - const src = readFileSync(filePath, 'utf8'); + const src = readFileSync(filePath, "utf8"); const drifts: Drift[] = []; - const headingRe = /]*>\s*(Planned Package Releases|Package Releases)\s*<\/h5>/g; + const headingRe = + /]*>\s*(Planned Package Releases|Package Releases)\s*<\/h5>/g; let headingMatch: RegExpExecArray | null; while ((headingMatch = headingRe.exec(src)) !== null) { const heading = headingMatch[1]; - const ulStart = src.indexOf('', ulStart); + const ulEnd = src.indexOf("", ulStart); if (ulEnd === -1) continue; const ul = src.slice(ulStart, ulEnd); - if (heading !== 'Package Releases') continue; + if (heading !== "Package Releases") continue; const liRe = /]*>([\s\S]*?)<\/li>/g; let liMatch: RegExpExecArray | null; @@ -999,9 +1334,9 @@ function auditReleaseNotePackageLinks(filePath: string): Drift[] { drifts.push({ file: filePath, line: lineNumberAt(src, ulStart + liMatch.index), - rule: 'R9', + rule: "R9", message: - '`Package Releases` entries must link package/version items to their GitHub Release URL. Use `Planned Package Releases` only while a release is not published.', + "`Package Releases` entries must link package/version items to their GitHub Release URL. Use `Planned Package Releases` only while a release is not published.", }); } } @@ -1009,10 +1344,19 @@ function auditReleaseNotePackageLinks(filePath: string): Drift[] { return drifts; } -function readJsonRecord(filePath: string, drifts: Drift[], rule: string, label: string): Record | null { +function readJsonRecord( + filePath: string, + drifts: Drift[], + rule: string, + label: string, +): Record | null { try { - const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown; + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { drifts.push({ file: filePath, line: 1, @@ -1033,23 +1377,28 @@ function readJsonRecord(filePath: string, drifts: Drift[], rule: string, label: } } -function requireRegexValue(filePath: string, pattern: RegExp, drifts: Drift[], label: string): string | null { +function requireRegexValue( + filePath: string, + pattern: RegExp, + drifts: Drift[], + label: string, +): string | null { if (!statSyncSafe(filePath)) { drifts.push({ file: filePath, line: 1, - rule: 'R10', + rule: "R10", message: `${label} source file is missing.`, }); return null; } - const source = readFileSync(filePath, 'utf8'); + const source = readFileSync(filePath, "utf8"); const value = source.match(pattern)?.[1]?.trim(); if (!value) { drifts.push({ file: filePath, line: 1, - rule: 'R10', + rule: "R10", message: `${label} source value was not found.`, }); return null; @@ -1057,14 +1406,19 @@ function requireRegexValue(filePath: string, pattern: RegExp, drifts: Drift[], l return value; } -function requireJsonString(filePath: string, key: string, drifts: Drift[], label: string): string | null { - const data = readJsonRecord(filePath, drifts, 'R10', label); +function requireJsonString( + filePath: string, + key: string, + drifts: Drift[], + label: string, +): string | null { + const data = readJsonRecord(filePath, drifts, "R10", label); const value = data?.[key]; - if (typeof value !== 'string' || value.trim() === '') { + if (typeof value !== "string" || value.trim() === "") { drifts.push({ file: filePath, line: 1, - rule: 'R10', + rule: "R10", message: `${label} missing "${key}" string.`, }); return null; @@ -1080,8 +1434,18 @@ function metadataKeyLine(source: string, key: string): number { function auditVersionMetadata(): Drift[] { const drifts: Drift[] = []; - const rootVersions = readJsonRecord(ROOT_VERSIONS_FILE, drifts, 'R10', 'openiap-versions.json'); - const docsVersions = readJsonRecord(DOC_VERSIONS_FILE, drifts, 'R10', 'packages/docs/openiap-versions.json'); + const rootVersions = readJsonRecord( + ROOT_VERSIONS_FILE, + drifts, + "R10", + "openiap-versions.json", + ); + const docsVersions = readJsonRecord( + DOC_VERSIONS_FILE, + drifts, + "R10", + "packages/docs/openiap-versions.json", + ); if (rootVersions) { try { assertSpecMatchesNativeFloor(rootVersions); @@ -1089,8 +1453,11 @@ function auditVersionMetadata(): Drift[] { drifts.push({ file: ROOT_VERSIONS_FILE, line: 1, - rule: 'R10', - message: error instanceof Error ? error.message : 'OpenIAP Spec must match the native version floor.', + rule: "R10", + message: + error instanceof Error + ? error.message + : "OpenIAP Spec must match the native version floor.", }); } } @@ -1101,94 +1468,106 @@ function auditVersionMetadata(): Drift[] { drifts.push({ file: DOC_VERSIONS_FILE, line: 1, - rule: 'R10', - message: 'Docs openiap-versions.json must be a real synced copy of the root openiap-versions.json for Vercel.', + rule: "R10", + message: + "Docs openiap-versions.json must be a real synced copy of the root openiap-versions.json for Vercel.", }); } } - const metadata = readJsonRecord(DOC_VERSION_METADATA_FILE, drifts, 'R10', 'packages/docs/src/generated/version-metadata.json'); + const metadata = readJsonRecord( + DOC_VERSION_METADATA_FILE, + drifts, + "R10", + "packages/docs/src/generated/version-metadata.json", + ); if (metadata) { - const metadataSource = readFileSync(DOC_VERSION_METADATA_FILE, 'utf8'); + const metadataSource = readFileSync(DOC_VERSION_METADATA_FILE, "utf8"); const expected: Record = { - _generatedBy: 'scripts/sync-versions.sh', + _generatedBy: "scripts/sync-versions.sh", expoPackageVersion: requireJsonString( - resolve(REPO_ROOT, 'libraries/expo-iap/package.json'), - 'version', + resolve(REPO_ROOT, "libraries/expo-iap/package.json"), + "version", drifts, - 'expo-iap package.json', + "expo-iap package.json", ), reactNativePackageVersion: requireJsonString( - resolve(REPO_ROOT, 'libraries/react-native-iap/package.json'), - 'version', + resolve(REPO_ROOT, "libraries/react-native-iap/package.json"), + "version", drifts, - 'react-native-iap package.json', + "react-native-iap package.json", ), flutterPackageVersion: requireRegexValue( - resolve(REPO_ROOT, 'libraries/flutter_inapp_purchase/pubspec.yaml'), + resolve(REPO_ROOT, "libraries/flutter_inapp_purchase/pubspec.yaml"), /^version:\s*(.+)$/m, drifts, - 'flutter_inapp_purchase pubspec.yaml version', + "flutter_inapp_purchase pubspec.yaml version", ), godotPackageVersion: requireRegexValue( - resolve(REPO_ROOT, 'libraries/godot-iap/addons/godot-iap/plugin.cfg'), + resolve(REPO_ROOT, "libraries/godot-iap/addons/godot-iap/plugin.cfg"), /^version="([^"]+)"$/m, drifts, - 'godot-iap plugin.cfg version', + "godot-iap plugin.cfg version", ), kmpPackageVersion: requireRegexValue( - resolve(REPO_ROOT, 'libraries/kmp-iap/gradle.properties'), + resolve(REPO_ROOT, "libraries/kmp-iap/gradle.properties"), /^libraryVersion=(.+)$/m, drifts, - 'kmp-iap libraryVersion', + "kmp-iap libraryVersion", ), mauiPackageId: requireRegexValue( - resolve(REPO_ROOT, 'libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj'), + resolve( + REPO_ROOT, + "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + ), /([^<]+)<\/PackageId>/, drifts, - 'MAUI PackageId', + "MAUI PackageId", ), mauiPackageVersion: requireRegexValue( - resolve(REPO_ROOT, 'libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj'), + resolve( + REPO_ROOT, + "libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj", + ), /([^<]+)<\/PackageVersion>/, drifts, - 'MAUI PackageVersion', + "MAUI PackageVersion", ), googleCompileSdk: requireRegexValue( - resolve(REPO_ROOT, 'packages/google/openiap/build.gradle.kts'), + resolve(REPO_ROOT, "packages/google/openiap/build.gradle.kts"), /compileSdk\s*=\s*(\d+)/, drifts, - 'openiap-google compileSdk', + "openiap-google compileSdk", ), googleMinSdk: requireRegexValue( - resolve(REPO_ROOT, 'packages/google/openiap/build.gradle.kts'), + resolve(REPO_ROOT, "packages/google/openiap/build.gradle.kts"), /minSdk\s*=\s*(\d+)/, drifts, - 'openiap-google minSdk', + "openiap-google minSdk", ), googlePlayBillingVersion: requireRegexValue( - resolve(REPO_ROOT, 'packages/google/openiap/build.gradle.kts'), + resolve(REPO_ROOT, "packages/google/openiap/build.gradle.kts"), /val\s+playBillingVersion\s*=\s*"([^"]+)"/, drifts, - 'openiap-google Play Billing version', + "openiap-google Play Billing version", ), kmpCompileSdk: requireRegexValue( - resolve(REPO_ROOT, 'libraries/kmp-iap/gradle/libs.versions.toml'), + resolve(REPO_ROOT, "libraries/kmp-iap/gradle/libs.versions.toml"), /^android-compileSdk = "([^"]+)"/m, drifts, - 'kmp-iap android-compileSdk', + "kmp-iap android-compileSdk", ), kmpMinSdk: requireRegexValue( - resolve(REPO_ROOT, 'libraries/kmp-iap/gradle/libs.versions.toml'), + resolve(REPO_ROOT, "libraries/kmp-iap/gradle/libs.versions.toml"), /^android-minSdk = "([^"]+)"/m, drifts, - 'kmp-iap android-minSdk', + "kmp-iap android-minSdk", ), kmpTargetSdk: requireRegexValue( - resolve(REPO_ROOT, 'libraries/kmp-iap/gradle/libs.versions.toml'), + resolve(REPO_ROOT, "libraries/kmp-iap/gradle/libs.versions.toml"), /^android-targetSdk = "([^"]+)"/m, drifts, - 'kmp-iap android-targetSdk', + "kmp-iap android-targetSdk", ), }; @@ -1198,7 +1577,7 @@ function auditVersionMetadata(): Drift[] { drifts.push({ file: DOC_VERSION_METADATA_FILE, line: metadataKeyLine(metadataSource, key), - rule: 'R10', + rule: "R10", message: `${key} must match the package/library SSOT value "${expectedValue}". Run ./scripts/sync-versions.sh.`, }); } @@ -1209,29 +1588,31 @@ function auditVersionMetadata(): Drift[] { drifts.push({ file: VERSIONING_FILE, line: 1, - rule: 'R10', - message: 'versioning.ts is missing.', + rule: "R10", + message: "versioning.ts is missing.", }); return drifts; } - const source = readFileSync(VERSIONING_FILE, 'utf8'); - if (!source.includes('../generated/version-metadata.json')) { + const source = readFileSync(VERSIONING_FILE, "utf8"); + if (!source.includes("../generated/version-metadata.json")) { drifts.push({ file: VERSIONING_FILE, line: 1, - rule: 'R10', - message: 'versioning.ts must read framework package versions from generated version-metadata.json.', + rule: "R10", + message: + "versioning.ts must read framework package versions from generated version-metadata.json.", }); } - for (const forbidden of ['../../../../libraries/', '../../../../packages/']) { + for (const forbidden of ["../../../../libraries/", "../../../../packages/"]) { const index = source.indexOf(forbidden); if (index !== -1) { drifts.push({ file: VERSIONING_FILE, line: lineNumberAt(source, index), - rule: 'R10', - message: 'versioning.ts must not raw-import files outside packages/docs; Vercel deploys the docs package root.', + rule: "R10", + message: + "versioning.ts must not raw-import files outside packages/docs; Vercel deploys the docs package root.", }); } } @@ -1240,66 +1621,66 @@ function auditVersionMetadata(): Drift[] { } const RESERVED_WORDS = new Set([ - 'true', - 'false', - 'null', - 'void', - 'any', - 'never', - 'string', - 'number', - 'boolean', - 'object', - 'undefined', - 'this', - 'self', - 'super', - 'async', - 'await', - 'yield', - 'try', - 'catch', - 'finally', - 'throw', - 'return', - 'if', - 'else', - 'for', - 'while', - 'do', - 'switch', - 'case', - 'break', - 'continue', - 'const', - 'let', - 'var', - 'function', - 'class', - 'extends', - 'implements', - 'interface', - 'type', - 'enum', - 'public', - 'private', - 'protected', - 'static', - 'readonly', - 'abstract', - 'as', - 'is', - 'in', - 'of', - 'new', - 'delete', - 'typeof', - 'instanceof', - 'import', - 'export', - 'from', - 'default', - 'package', + "true", + "false", + "null", + "void", + "any", + "never", + "string", + "number", + "boolean", + "object", + "undefined", + "this", + "self", + "super", + "async", + "await", + "yield", + "try", + "catch", + "finally", + "throw", + "return", + "if", + "else", + "for", + "while", + "do", + "switch", + "case", + "break", + "continue", + "const", + "let", + "var", + "function", + "class", + "extends", + "implements", + "interface", + "type", + "enum", + "public", + "private", + "protected", + "static", + "readonly", + "abstract", + "as", + "is", + "in", + "of", + "new", + "delete", + "typeof", + "instanceof", + "import", + "export", + "from", + "default", + "package", ]); /** @@ -1307,11 +1688,14 @@ const RESERVED_WORDS = new Set([ * containing folder's `index.tsx`). */ function linkResolves(target: string): boolean { - const [pathPart] = target.split('#'); + const [pathPart] = target.split("#"); // strip leading /docs and trailing slash - const slug = pathPart.replace(/^\/docs\/?/, '').replace(/\/$/, ''); - if (!slug) return statSyncSafe(join(DOC_PAGES_DIR, 'docs/index.tsx')); - const candidates = [join(DOC_PAGES_DIR, 'docs', `${slug}.tsx`), join(DOC_PAGES_DIR, 'docs', slug, 'index.tsx')]; + const slug = pathPart.replace(/^\/docs\/?/, "").replace(/\/$/, ""); + if (!slug) return statSyncSafe(join(DOC_PAGES_DIR, "docs/index.tsx")); + const candidates = [ + join(DOC_PAGES_DIR, "docs", `${slug}.tsx`), + join(DOC_PAGES_DIR, "docs", slug, "index.tsx"), + ]; return candidates.some(statSyncSafe); } @@ -1337,98 +1721,98 @@ async function main() { // Common framework + JS / Dart / Kotlin words that appear in code // examples without being IAP fields. Excluded from the fallback. const SAFE_WORDS = new Set([ - 'console', - 'log', - 'error', - 'warn', - 'instance', - 'shared', - 'connected', - 'await', - 'use', - 'effect', - 'callback', - 'fn', - 'cb', - 'i', - 'j', - 'k', - 'p', - 'a', - 'b', - 'c', - 'x', - 'y', - 'value', - 'name', - 'message', - 'code', - 'reason', - 'data', - 'json', - 'token', - 'url', - 'sku', - 'id', - 'type', - 'platform', - 'native', - 'success', - 'result', - 'error', - 'props', - 'options', - 'params', - 'config', - 'request', - 'args', - 'purchase', - 'product', - 'subscription', + "console", + "log", + "error", + "warn", + "instance", + "shared", + "connected", + "await", + "use", + "effect", + "callback", + "fn", + "cb", + "i", + "j", + "k", + "p", + "a", + "b", + "c", + "x", + "y", + "value", + "name", + "message", + "code", + "reason", + "data", + "json", + "token", + "url", + "sku", + "id", + "type", + "platform", + "native", + "success", + "result", + "error", + "props", + "options", + "params", + "config", + "request", + "args", + "purchase", + "product", + "subscription", // Top-level scalar/list function parameters. These legitimately appear // in API parameter lists but are not generated object fields. - 'program', - 'subscriptionIds', - 'tokenType', - 'groupId', - 'noticeType', - 'continued', - 'reconnect', - 'cancel', - 'open', - 'close', - 'state', - 'status', - 'group', - 'ok', - 'os', - 'isIOS', - 'isAndroid', - 'productId', - 'orderId', - 'transactionId', - 'purchaseToken', - 'currency', - 'price', - 'count', - 'index', - 'size', - 'length', - 'env', - 'process', - 'self', - 'this', - 'super', - 'continuation', - 'deferred', - 'completion', - 'handler', - 'listener', - 'emitter', - 'subscriber', - 'unsubscribe', - 'remove', - 'add', + "program", + "subscriptionIds", + "tokenType", + "groupId", + "noticeType", + "continued", + "reconnect", + "cancel", + "open", + "close", + "state", + "status", + "group", + "ok", + "os", + "isIOS", + "isAndroid", + "productId", + "orderId", + "transactionId", + "purchaseToken", + "currency", + "price", + "count", + "index", + "size", + "length", + "env", + "process", + "self", + "this", + "super", + "continuation", + "deferred", + "completion", + "handler", + "listener", + "emitter", + "subscriber", + "unsubscribe", + "remove", + "add", ]); const allDocPages: string[] = []; @@ -1446,7 +1830,7 @@ async function main() { drifts.push({ file, line, - rule: 'R5', + rule: "R5", message: ` does not resolve to an existing /docs page.`, }); } @@ -1461,7 +1845,7 @@ async function main() { drifts.push({ file, line, - rule: 'R3', + rule: "R3", message: `${field} is not a known field on any generated TypeScript type. Did you rename or invent it?`, }); } @@ -1470,41 +1854,50 @@ async function main() { const activeDocPages = await walkTsxFiles(ACTIVE_DOCS_ROOT); for (const file of activeDocPages) { if (resolve(file) === RELEASE_NOTES_FILE) continue; - drifts.push(...auditActiveCodeExampleSource(file, readFileSync(file, 'utf8'))); + drifts.push( + ...auditActiveCodeExampleSource(file, readFileSync(file, "utf8")), + ); } drifts.push(...auditReleaseNotePackageLinks(RELEASE_NOTES_FILE)); drifts.push(...auditVersionMetadata()); + drifts.push( + ...auditVerifyPurchaseDocs( + VERIFY_PURCHASE_DOC_FILE, + readFileSync(VERIFY_PURCHASE_DOC_FILE, "utf8"), + readFileSync(TYPES_FILE, "utf8"), + ), + ); drifts.push( ...auditCanonicalOfferDocs({ discountOffer: { file: DISCOUNT_OFFER_DOC_FILE, - source: readFileSync(DISCOUNT_OFFER_DOC_FILE, 'utf8'), + source: readFileSync(DISCOUNT_OFFER_DOC_FILE, "utf8"), }, subscriptionOffer: { file: SUBSCRIPTION_OFFER_DOC_FILE, - source: readFileSync(SUBSCRIPTION_OFFER_DOC_FILE, 'utf8'), + source: readFileSync(SUBSCRIPTION_OFFER_DOC_FILE, "utf8"), }, searchData: { file: SEARCH_DATA_FILE, - source: readFileSync(SEARCH_DATA_FILE, 'utf8'), + source: readFileSync(SEARCH_DATA_FILE, "utf8"), }, generatedOfferTypes: { typescript: { file: GENERATED_OFFER_TYPE_FILES.typescript, - source: readFileSync(GENERATED_OFFER_TYPE_FILES.typescript, 'utf8'), + source: readFileSync(GENERATED_OFFER_TYPE_FILES.typescript, "utf8"), }, swift: { file: GENERATED_OFFER_TYPE_FILES.swift, - source: readFileSync(GENERATED_OFFER_TYPE_FILES.swift, 'utf8'), + source: readFileSync(GENERATED_OFFER_TYPE_FILES.swift, "utf8"), }, kotlin: { file: GENERATED_OFFER_TYPE_FILES.kotlin, - source: readFileSync(GENERATED_OFFER_TYPE_FILES.kotlin, 'utf8'), + source: readFileSync(GENERATED_OFFER_TYPE_FILES.kotlin, "utf8"), }, dart: { file: GENERATED_OFFER_TYPE_FILES.dart, - source: readFileSync(GENERATED_OFFER_TYPE_FILES.dart, 'utf8'), + source: readFileSync(GENERATED_OFFER_TYPE_FILES.dart, "utf8"), }, }, }), @@ -1516,11 +1909,11 @@ async function main() { // legitimately appear in `
    ` lists without // being a field of any type — the audit can't tell them apart from // genuine drift without knowing each function's signature. - const hardFailures = drifts.filter((d) => d.rule !== 'R3'); - const warnings = drifts.filter((d) => d.rule === 'R3'); + const hardFailures = drifts.filter((d) => d.rule !== "R3"); + const warnings = drifts.filter((d) => d.rule === "R3"); if (hardFailures.length === 0 && warnings.length === 0) { - console.log('audit-docs: clean — 0 drift detected'); + console.log("audit-docs: clean — 0 drift detected"); process.exit(0); } @@ -1530,7 +1923,7 @@ async function main() { const rel = relative(REPO_ROOT, d.file); console.log(` [${d.rule}] ${rel}:${d.line}\n ${d.message}`); } - console.log(''); + console.log(""); } if (hardFailures.length > 0) { @@ -1542,13 +1935,13 @@ async function main() { process.exit(1); } - console.log('audit-docs: no hard failures (warnings above are advisory)'); + console.log("audit-docs: no hard failures (warnings above are advisory)"); process.exit(0); } if (import.meta.main) { main().catch((err) => { - console.error('audit-docs: fatal error'); + console.error("audit-docs: fatal error"); console.error(err); process.exit(2); }); diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index ae5ff6919..c93be2459 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -1587,6 +1587,169 @@ const GOOGLE_FLAVOR_MODULES = [ "packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt", ]; +const CONFORMANCE_SUITE_DIR = "src/conformanceTest/java"; + +const GOOGLE_CONFORMANCE_ADAPTERS = { + testPlay: + "packages/google/openiap/src/testPlay/java/dev/hyo/openiap/conformance/PlayStoreConformanceTest.kt", + testHorizon: + "packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/conformance/HorizonStoreConformanceTest.kt", + testAmazon: + "packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/conformance/AmazonStoreConformanceTest.kt", +}; + +// The conformance suite is the versioned public contract. These files carry +// behavior ids that appear in published reports, so losing one silently would +// invalidate every claim made against it. +function checkConformanceSuite() { + for (const relativePath of [ + "packages/conformance/package.json", + "packages/conformance/README.md", + "packages/conformance/src/spec/behaviors.mjs", + "packages/conformance/src/spec/version.mjs", + "packages/conformance/src/runner/runner.mjs", + "packages/conformance/src/runner/report.mjs", + "packages/conformance/scripts/generate-behavior-ids.mjs", + "packages/conformance/scripts/coverage-report.mjs", + "packages/gql/src/capability-matrix.mjs", + // Per-implementation adapters. Deleting one silently drops that + // implementation from the coverage report instead of failing. + "packages/apple/Tests/OpenIapTests/StoreConformanceTests.swift", + "libraries/react-native-iap/src/__tests__/conformance.test.ts", + "libraries/expo-iap/src/__tests__/conformance.test.ts", + ]) { + expectFile(relativePath); + } + + // Every MUST behavior needs at least one implementation covering it. + try { + execFileSync( + process.execPath, + [ + path.resolve(root, "packages/conformance/scripts/coverage-report.mjs"), + "--check", + ], + { stdio: "pipe" }, + ); + } catch (error) { + const output = [error?.stdout, error?.stderr] + .filter(Boolean) + .map((buffer) => buffer.toString().trim()) + .join(" "); + fail(`Conformance coverage gate failed. ${output}`); + } + + // Generated behavior ids must match the spec, or the native suites assert + // against identifiers the spec no longer defines. + try { + execFileSync( + process.execPath, + [ + path.resolve(root, "packages/conformance/scripts/generate-behavior-ids.mjs"), + "--check", + ], + { stdio: "pipe" }, + ); + } catch (error) { + const output = [error?.stdout, error?.stderr] + .filter(Boolean) + .map((buffer) => buffer.toString().trim()) + .join(" "); + fail(`Conformance behavior ids are out of sync. ${output}`); + } +} + +// bun's workspace resolver needs every member's package.json before +// --frozen-lockfile will plan the install, so a new workspace package breaks +// the kit image until its manifest is copied. This has already cost two +// rounds (mcp-server, conformance) and only fails inside Docker. +function checkKitDockerfileCopiesWorkspaceManifests() { + const dockerfile = read("packages/kit/Dockerfile"); + for (const name of listDirectories("packages")) { + if (!exists(`packages/${name}/package.json`)) continue; + if (!dockerfile.includes(`COPY packages/${name}/package.json`)) { + fail( + `packages/kit/Dockerfile must COPY packages/${name}/package.json before bun install --frozen-lockfile`, + ); + } + } +} + +// Conformance fixtures ship a fake store and test scaffolding. They must stay +// out of every published artifact, so the exclusions are asserted rather than +// assumed. +function checkConformanceNotPublished() { + const expoIgnore = read("libraries/expo-iap/.npmignore"); + if (!/^__tests__$/m.test(expoIgnore)) { + fail( + "libraries/expo-iap/.npmignore must exclude __tests__ so conformance fixtures are not published", + ); + } + + const rnFiles = readJson("libraries/react-native-iap/package.json").files ?? []; + if (!rnFiles.includes("!**/__tests__")) { + fail( + 'libraries/react-native-iap/package.json "files" must keep "!**/__tests__" so conformance fixtures are not published', + ); + } + + // The podspec ships Sources only; Tests holds the Apple conformance suite. + const podspec = read("packages/apple/openiap.podspec"); + if (!/source_files\s*=.*Sources/.test(podspec) || /source_files\s*=.*Tests/.test(podspec)) { + fail("packages/apple/openiap.podspec must publish Sources only, never Tests"); + } + + // conformanceTest belongs to unit-test variants; wiring it into a shipped + // source set would compile it into the AAR. + const gradle = read("packages/google/openiap/build.gradle.kts"); + for (const sourceSet of ["main", "play", "horizon", "amazon"]) { + const block = new RegExp( + `named\\("${sourceSet}"\\)\\s*\\{([\\s\\S]*?)\\n\\s{8}\\}`, + ).exec(gradle)?.[1]; + if (block?.includes(CONFORMANCE_SUITE_DIR)) { + fail( + `packages/google/openiap/build.gradle.kts: ${sourceSet} must not include "${CONFORMANCE_SUITE_DIR}" — it would ship in the AAR`, + ); + } + } +} + +// The shared suite only protects stores that actually compile it, so a missing +// adapter or an unwired srcDir silently drops that store's coverage. +function checkGoogleStoreConformanceSuite() { + expectFile( + "packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceSuite.kt", + ); + expectFile( + "packages/google/openiap/src/conformanceTest/java/dev/hyo/openiap/conformance/StoreConformanceAdapter.kt", + ); + + const gradle = read("packages/google/openiap/build.gradle.kts"); + for (const [sourceSet, adapterPath] of Object.entries( + GOOGLE_CONFORMANCE_ADAPTERS, + )) { + expectFile(adapterPath); + if (!exists(adapterPath)) continue; + + expectIncludes( + adapterPath, + [": StoreConformanceSuite()", "override val adapter"], + `${adapterPath} must bind into the shared conformance suite`, + ); + + // Read the raw text: the shared Kotlin masking helper blanks string + // literals, which would erase the srcDir path being checked. + const block = new RegExp( + `named\\("${sourceSet}"\\)\\s*\\{([\\s\\S]*?)\\n\\s{8}\\}`, + ).exec(gradle)?.[1]; + if (!block?.includes(CONFORMANCE_SUITE_DIR)) { + fail( + `packages/google/openiap/build.gradle.kts: ${sourceSet} must include "${CONFORMANCE_SUITE_DIR}" so the shared conformance suite runs for that flavor`, + ); + } + } +} + // Kotlin comments and string literals may contain brackets or commas (e.g. // `// (optional)` or `"a, b"`) that must not affect the structural // bracket-depth and argument-split scans below. Blank their contents out with @@ -8798,6 +8961,10 @@ for (const issue of collectPurchasePayloadParityFailures(root)) { checkGqlRuntimeExports(); checkOperationRegistry(); checkGoogleFlavorHandlerWiring(); +checkConformanceSuite(); +checkConformanceNotPublished(); +checkKitDockerfileCopiesWorkspaceManifests(); +checkGoogleStoreConformanceSuite(); checkFrameworkOperationBindings(); checkExpoRouterExample("libraries/expo-iap/example", "src/utils/constants.ts"); checkReactNativeClassic(); diff --git a/scripts/release-branch-policy.mjs b/scripts/release-branch-policy.mjs index e27f9473e..39372b4d3 100644 --- a/scripts/release-branch-policy.mjs +++ b/scripts/release-branch-policy.mjs @@ -14,6 +14,10 @@ const versionSources = { label: "openiap-apple", read: (root) => readJson(root, "openiap-versions.json").apple, }, + conformance: { + label: "openiap-conformance", + read: (root) => readJson(root, "packages/conformance/package.json").version, + }, docs: { label: "OpenIAP Spec", read: (root) => readJson(root, "openiap-versions.json").spec, diff --git a/scripts/release-branch-policy.test.mjs b/scripts/release-branch-policy.test.mjs index 75811f52b..5d103b28e 100644 --- a/scripts/release-branch-policy.test.mjs +++ b/scripts/release-branch-policy.test.mjs @@ -461,6 +461,7 @@ test("existing release tags must match metadata, origin, and release-branch ance const cases = [ ["apple", "3.1.0", '{"apple":"3.1.0"}'], ["google", "google-3.1.0", '{"google":"3.1.0"}'], + ["conformance", "openiap-conformance-3.1.0", '{"version":"3.1.0"}'], ["expo", "expo-iap-3.1.0", '{"version":"3.1.0"}'], ["react-native", "react-native-iap-3.1.0", '{"version":"3.1.0"}'], ["flutter", "flutter-iap-3.1.0", "version: 3.1.0\n"],