diff --git a/.claude/commands/audit-iapkit.md b/.claude/commands/audit-iapkit.md new file mode 100644 index 000000000..4839e7708 --- /dev/null +++ b/.claude/commands/audit-iapkit.md @@ -0,0 +1,130 @@ +--- +name: audit-iapkit +description: Audit the IAPKit product surface against OpenIAP as the source of truth, then fix the drift it finds. Use when the user asks to check whether IAPKit reflects OpenIAP updates, audit kit docs, or reconcile kit.openiap.dev with openiap.dev. +--- + +# Audit IAPKit Against OpenIAP + +IAPKit is a deployable SaaS, not a library, so it sits outside the GQL type-sync +chain that keeps the SDKs aligned. Nothing regenerates its site copy when the +spec, the stores, or the SDKs move, so its documentation drifts silently. This +workflow finds that drift and fixes it. + +Read `packages/kit/CONVENTION.md` before editing anything under `packages/kit`. + +## Direction of truth + +```text +OpenIAP spec + packages/kit implementation → IAPKit site copy + (authoritative) (must follow) +``` + +Precedence when surfaces disagree: implementation > `packages/docs` > +`packages/kit` prose. `packages/docs` outranks kit prose only where the code +does not decide the question (product positioning, support claims). Never +"fix" the code to match a doc without saying so explicitly. + +## Workflow + +```text +1. Establish what changed upstream + ↓ +2. Check every prose claim against the implementation + ↓ +3. Check kit against packages/docs for contradictions + ↓ +4. Apply mechanical fixes; escalate product calls + ↓ +5. Verify +``` + +## Steps + +### 1. Establish what changed upstream + +```bash +# Spec and SDK movement since the kit surface was last reviewed. +git log --oneline -20 -- packages/gql/src/type.graphql openiap-versions.json +# Least recently reviewed kit files first — that is where drift concentrates. +for f in $(git ls-files packages/kit/src/pages/docs/sections packages/kit/src/content); do + echo "$(git log -1 --format='%ad' --date=short -- "$f") $f" +done | sort +``` + +Also check upstream store documentation for anything the kit pages describe: +App Store Server API, Google Play Developer API, Amazon RVS, Meta Horizon, and +the Vega SDK release notes. + +### 2. Check prose against the implementation + +This is the highest-value pass. For every checkable claim on the kit site, find +the code that implements it and confirm the claim matches. Cite `file:line` for +both sides. + +Highest-yield targets, in order: + +- **Verification order and cryptography** — `packages/kit/convex/purchases/*.ts`. + A page saying IAPKit verifies something it does not verify is the worst class + of error. +- **Error codes** — confirm each documented code can actually reach a caller. + Codes raised internally and re-wrapped before the response must not be listed. +- **Endpoints, fields, and limits** — `packages/kit/server/api/v1/**`, + especially `route-input-schemas.ts` for which fields are required. A field the + server requires but the docs call optional makes every following example 400. +- **Negative verdicts that return 200** — outcomes that are not errors but are + documented as if they were, or not documented at all. +- **Numbers** — retry counts, rate limits, size caps, file sizes, retention + windows. These rot silently; recompute rather than trusting the page. + +### 3. Check kit against `packages/docs` + +The two sites describe one product. Find statements that contradict each other +and decide which side is right from the code, then fix the wrong side. + +```bash +bun run audit:docs +``` + +### 4. Apply fixes, escalate decisions + +Fix mechanically when the correct text is determined by the code: a wrong fact, +an unreachable error code, a stale number, a broken link, a naming violation. + +Escalate to the user, do not guess, when the fix requires a product call: +what the product officially claims to support, support channels, pricing or +plan statements, legal document content, restructuring a page, or consolidating +pages that have published URLs. + +Constraints that override any finding: + +- **Production is read-only.** Never run a mutation or action against the + production Convex deployment, from the dashboard runner or anywhere else, and + never hand-edit production documents. Reads are fine when the user asks; + report aggregates, not customer emails. Full rule in the root `AGENTS.md`. +- **Webhook direction.** The only supported direction is store → IAPKit. Never + document an IAPKit → SDK/mobile webhook, SSE, WebSocket, push relay, or + long-poll feed. See the root `AGENTS.md`. +- **Brand.** `OpenIAP` and `IAPKit`, never `Open IAP`, `IAP Kit`, or bare `Kit`. +- **Reader-first standard.** `knowledge/internal/05-docs-patterns.md`. Remove + filler and state each fact once; do not restyle prose that is already clear. +- **Screenshots.** A figure that contradicts corrected text is worse than no + figure. Open the image before trusting its caption. + +### 5. Verify + +```bash +bun run --filter @hyodotdev/openiap-kit lint +bun run --filter @hyodotdev/openiap-kit test +bun run --filter @hyodotdev/openiap-kit smoke:server +bun run audit:kit-contract +bun run audit:docs +``` + +`packages/kit` changes also trigger the CI-equivalent gate in +`.husky/pre-commit`, which mirrors `deploy-kit.yml`. + +## Report + +Group findings as **fixed** (with file:line), **needs a decision** (with the +options and your recommendation), and **rejected** (with the reason). Say +plainly when a surface is in good shape rather than manufacturing work. diff --git a/.claude/commands/verify-all.md b/.claude/commands/verify-all.md index 75574b723..5a5034775 100644 --- a/.claude/commands/verify-all.md +++ b/.claude/commands/verify-all.md @@ -20,6 +20,10 @@ bun run audit:parity # Stable main / prerelease next branch contract. bun run audit:release-state node --test scripts/release-branch-policy.test.mjs + +# Native build / Swift CodeQL path filters. +bun run audit:ci-paths +bun run audit:agents ``` This fails if a new non-Godot library, Expo example route/product ID, generated @@ -318,6 +322,8 @@ set -euo pipefail (cd scripts/agent && bun run compile:ai && bun test && bun run typecheck) bun run audit:parity bun run audit:release-state +bun run audit:ci-paths +bun run audit:agents bun test \ --path-ignore-patterns='**/build/**' \ --path-ignore-patterns='**/.build/**' \ diff --git a/.claude/skills/openiap-workflows/SKILL.md b/.claude/skills/openiap-workflows/SKILL.md index 532d57266..2e544ad7e 100644 --- a/.claude/skills/openiap-workflows/SKILL.md +++ b/.claude/skills/openiap-workflows/SKILL.md @@ -1,6 +1,6 @@ --- name: openiap-workflows -description: Use for OpenIAP monorepo work that should follow the repository's slash-command workflows when the user asks in natural language instead of typing a slash command, including review-pr, audit-code, compile-knowledge, verify-all, e2e-tests, stable or prerelease package releases, resolve-issue, commit/push/PR, generated type sync, package-specific checks, GitHub review threads, and project conventions from AGENTS.md. +description: Use for OpenIAP monorepo work that should follow the repository's slash-command workflows when the user asks in natural language instead of typing a slash command, including review-pr, audit-code, audit-security, audit-iapkit, compile-knowledge, verify-all, e2e-tests, stable or prerelease package releases, resolve-issue, commit/push/PR, generated type sync, package-specific checks, GitHub review threads, and project conventions from AGENTS.md. --- # OpenIAP Workflows (Claude Code) @@ -20,6 +20,8 @@ reading the command file (or invoke the slash command directly when available): bots, and remove temporary CodeRabbit trigger and terminal skip/unavailable top-level comments when the loop is clean - Audit code against knowledge rules → `.claude/commands/audit-code.md` (`/audit-code`) +- Audit supply-chain security / SBOM → `.claude/commands/audit-security.md` (`/audit-security`) +- Reconcile IAPKit with OpenIAP → `.claude/commands/audit-iapkit.md` (`/audit-iapkit`) - Compile knowledge / rebuild AI context → `.claude/commands/compile-knowledge.md` (`/compile-knowledge`) - Resolve a GitHub issue → `.claude/commands/resolve-issue.md` (`/resolve-issue`) - Verify all / monorepo health check → `.claude/commands/verify-all.md` (`/verify-all`) diff --git a/.codex/skills/openiap-workflows/SKILL.md b/.codex/skills/openiap-workflows/SKILL.md index 5f07bdfdb..66f75e7b1 100644 --- a/.codex/skills/openiap-workflows/SKILL.md +++ b/.codex/skills/openiap-workflows/SKILL.md @@ -1,6 +1,6 @@ --- name: openiap-workflows -description: Use for OpenIAP monorepo work that should follow the repository's shared agent workflows, including review-pr, audit-code, compile-knowledge, verify-all, e2e-tests, stable or prerelease package releases, resolve-issue, commit/push/PR, generated type sync, package-specific checks, GitHub review threads, and project conventions from AGENTS.md. +description: Use for OpenIAP monorepo work that should follow the repository's shared agent workflows, including review-pr, audit-code, audit-security, audit-iapkit, compile-knowledge, verify-all, e2e-tests, stable or prerelease package releases, resolve-issue, commit/push/PR, generated type sync, package-specific checks, GitHub review threads, and project conventions from AGENTS.md. --- # OpenIAP Workflows @@ -40,6 +40,8 @@ natural-language requests, execute the matching workflow: `.claude/commands/audit-code.md`. - Audit SBOM quality, release provenance, workflow permissions, or supply-chain/security posture: read `.claude/commands/audit-security.md`. +- Reconcile the IAPKit site with OpenIAP, audit kit docs, or check whether + IAPKit reflects a spec/store update: read `.claude/commands/audit-iapkit.md`. - Compile knowledge or rebuild AI context: read `.claude/commands/compile-knowledge.md`. - Resolve a GitHub issue: read `.claude/commands/resolve-issue.md`. diff --git a/.github/workflows/ci-expo-iap.yml b/.github/workflows/ci-expo-iap.yml index 532a67496..b477871e2 100644 --- a/.github/workflows/ci-expo-iap.yml +++ b/.github/workflows/ci-expo-iap.yml @@ -9,8 +9,10 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-expo-iap.yml" + - "!**/*.md" push: branches: [main, next] paths: @@ -19,8 +21,10 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-expo-iap.yml" + - "!**/*.md" permissions: contents: read diff --git a/.github/workflows/ci-flutter-inapp-purchase.yml b/.github/workflows/ci-flutter-inapp-purchase.yml index e3fe4ec85..7b30c791a 100644 --- a/.github/workflows/ci-flutter-inapp-purchase.yml +++ b/.github/workflows/ci-flutter-inapp-purchase.yml @@ -9,9 +9,11 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-flutter-inapp-purchase.yml" - ".github/workflows/release-flutter.yml" + - "!**/*.md" push: branches: [main, next] paths: @@ -20,9 +22,11 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-flutter-inapp-purchase.yml" - ".github/workflows/release-flutter.yml" + - "!**/*.md" permissions: contents: read @@ -111,12 +115,12 @@ jobs: run: bash scripts/verify-apple-swiftpm-consumer-build.sh apple-cocoapods: - name: iOS CocoaPods consumer (Flutter 3.44 + Xcode 16.4) + name: iOS CocoaPods consumer (Flutter 3.44 + Xcode 26.6) if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 60 env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/.github/workflows/ci-godot-iap.yml b/.github/workflows/ci-godot-iap.yml index eb6d3af65..77873dc20 100644 --- a/.github/workflows/ci-godot-iap.yml +++ b/.github/workflows/ci-godot-iap.yml @@ -10,6 +10,8 @@ on: - "libraries/godot-iap/**" - "packages/gql/codegen/plugins/gdscript.ts" - "packages/gql/src/generated/types.gd" + - "openiap-versions.json" + - "!**/*.md" push: branches: [main, next] paths: @@ -19,6 +21,8 @@ on: - "libraries/godot-iap/**" - "packages/gql/codegen/plugins/gdscript.ts" - "packages/gql/src/generated/types.gd" + - "openiap-versions.json" + - "!**/*.md" permissions: contents: read diff --git a/.github/workflows/ci-kmp-iap.yml b/.github/workflows/ci-kmp-iap.yml index a1775cb15..10af6af23 100644 --- a/.github/workflows/ci-kmp-iap.yml +++ b/.github/workflows/ci-kmp-iap.yml @@ -9,6 +9,7 @@ on: - "openiap-versions.json" - "scripts/ci/retry-gradle.sh" - ".github/workflows/ci-kmp-iap.yml" + - "!**/*.md" push: branches: [main, next] paths: @@ -17,6 +18,7 @@ on: - "openiap-versions.json" - "scripts/ci/retry-gradle.sh" - ".github/workflows/ci-kmp-iap.yml" + - "!**/*.md" permissions: contents: read @@ -66,7 +68,7 @@ jobs: ios-compile-check: name: iOS Compile Check - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/ci-maui-iap.yml b/.github/workflows/ci-maui-iap.yml index c68d26109..60d08f107 100644 --- a/.github/workflows/ci-maui-iap.yml +++ b/.github/workflows/ci-maui-iap.yml @@ -13,6 +13,7 @@ on: - "openiap-versions.json" - "scripts/ci/retry-gradle.sh" - ".github/workflows/ci-maui-iap.yml" + - "!**/*.md" push: branches: [main, next] paths: @@ -25,6 +26,7 @@ on: - "openiap-versions.json" - "scripts/ci/retry-gradle.sh" - ".github/workflows/ci-maui-iap.yml" + - "!**/*.md" permissions: contents: read diff --git a/.github/workflows/ci-react-native-iap.yml b/.github/workflows/ci-react-native-iap.yml index 3bc563935..2509e9724 100644 --- a/.github/workflows/ci-react-native-iap.yml +++ b/.github/workflows/ci-react-native-iap.yml @@ -9,8 +9,10 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-react-native-iap.yml" + - "!**/*.md" push: branches: [main, next] paths: @@ -19,8 +21,10 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-react-native-iap.yml" + - "!**/*.md" permissions: contents: read diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4b7dc833..61239f385 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,13 @@ on: permissions: contents: read +# Cancel the superseded run on every PR push; main pushes always run to completion. +concurrency: + group: ci-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 jobs: audit-release-state: @@ -153,6 +158,8 @@ jobs: uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter with: + # Without this, '!' patterns are OR-ed positives that match nearly everything. + predicate-quantifier: some-with-excludes filters: | gql: - 'packages/gql/**' @@ -163,18 +170,21 @@ jobs: - 'openiap-versions.json' - '.github/workflows/ci.yml' - 'libraries/maui-iap/src/OpenIap.Maui/Types.cs' + - '!**/*.md' android: - 'packages/google/**' - 'packages/gql/**' - 'scripts/**' - 'openiap-versions.json' - '.github/workflows/ci.yml' + - '!**/*.md' ios: - 'packages/apple/**' - 'packages/gql/**' - 'scripts/**' - 'openiap-versions.json' - '.github/workflows/ci.yml' + - '!**/*.md' docs: - 'packages/docs/**' - 'packages/gql/src/generated/**' @@ -291,6 +301,12 @@ jobs: - name: Run IAPKit spec contract audit run: node scripts/audit-kit-spec-contract.mjs + - name: Audit CI path filters + run: npm run audit:ci-paths + + - name: Audit agent surfaces + run: npm run audit:agents + test-gql: name: Test GQL Types runs-on: ubuntu-latest @@ -388,9 +404,9 @@ jobs: test-ios: name: Test iOS - runs-on: macos-15 needs: changes if: needs.changes.outputs.ios == 'true' + runs-on: macos-26 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b2256a93f..244668826 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -17,7 +17,7 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 jobs: codeql-scope: @@ -45,27 +45,53 @@ jobs: id: core uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 with: + # Without this, '!' patterns are OR-ed positives that match nearly everything. + predicate-quantifier: some-with-excludes filters: | swift_core: - 'packages/apple/**' - 'libraries/kmp-iap/native/InAppPurchaseBridge/**' + - 'libraries/kmp-iap/openiap-versions.json' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' - name: Detect Swift wrapper changes if: github.event_name == 'pull_request' id: wrappers uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 with: + predicate-quantifier: some-with-excludes filters: | react-native: - 'libraries/react-native-iap/**' + - 'libraries-versions.jsonc' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' expo: - 'libraries/expo-iap/**' + - 'libraries-versions.jsonc' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' expo-onside: - 'libraries/expo-iap/**' + - 'libraries-versions.jsonc' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' flutter: - 'libraries/flutter_inapp_purchase/**' + - 'libraries-versions.jsonc' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' godot: - 'libraries/godot-iap/**' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' analyze: name: Analyze (${{ matrix.language }}) @@ -274,11 +300,38 @@ jobs: with: category: /language:java-kotlin/component:${{ matrix.component }} + pick-mac-runner: + # Same-repo PRs may offload the SPM-heavy Swift legs to the self-hosted + # Mac while its heartbeat (MAC_CI) is fresh; anything else stays hosted. + runs-on: ubuntu-latest + outputs: + runner: ${{ steps.pick.outputs.runner }} + steps: + - id: pick + working-directory: ${{ runner.temp }} + env: + HEARTBEAT: ${{ vars.MAC_CI }} + # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs + # and pushes may use it; everyone else builds on hosted runners. + OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} + run: | + runner='macos-26' + case "$HEARTBEAT" in + ''|*[!0-9]*) ;; + *) + if [ "$OWNER_PR" = "true" ] \ + && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then + runner='self-mac' + fi + ;; + esac + echo "runner=$runner" >> "$GITHUB_OUTPUT" + analyze-swift: name: Analyze (swift) - needs: codeql-scope + needs: [codeql-scope, pick-mac-runner] if: needs.codeql-scope.outputs.swift_core == 'true' - runs-on: macos-15 + runs-on: ${{ needs.pick-mac-runner.outputs.runner }} timeout-minutes: 45 permissions: contents: read @@ -294,6 +347,13 @@ jobs: with: xcode-version: ${{ env.XCODE_VERSION }} + # Tests run natively, before CodeQL's tracer exists: the tracer forces + # the x86_64 loader path, which cannot load arm64 test bundles on + # self-hosted Apple Silicon. Extraction only needs a traced compile. + - name: Test Apple core + working-directory: packages/apple + run: swift test + - name: Initialize CodeQL uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: @@ -301,11 +361,13 @@ jobs: build-mode: manual queries: security-extended - - name: Build and test Apple core + # Clean so the traced build recompiles everything; a cache hit would + # leave the CodeQL database empty. + - name: Build Apple core for analysis working-directory: packages/apple run: | swift package clean - swift test + swift build --build-tests - name: Build KMP Swift bridge working-directory: libraries/kmp-iap/native/InAppPurchaseBridge @@ -320,16 +382,21 @@ jobs: analyze-swift-wrappers: name: Analyze (swift / ${{ matrix.component }}) - needs: codeql-scope + needs: [codeql-scope, pick-mac-runner] if: >- needs.codeql-scope.outputs.swift_wrappers == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ${{ (github.event_name == 'pull_request' || matrix.component == 'godot') && 'macos-26' || 'xcode-27' }} + runs-on: >- + ${{ github.event_name != 'pull_request' + && (matrix.component == 'godot' && 'macos-26' || 'xcode-27') + || needs.pick-mac-runner.outputs.runner }} timeout-minutes: 90 permissions: contents: read security-events: write + env: + LANG: en_US.UTF-8 strategy: fail-fast: false matrix: @@ -347,12 +414,24 @@ jobs: node-version: 20 - name: Setup Ruby - if: matrix.component == 'react-native' + if: >- + matrix.component == 'react-native' && + needs.pick-mac-runner.outputs.runner != 'self-mac' uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: "3.3" working-directory: libraries/react-native-iap/example + # setup-ruby hard-codes /Users/runner, which does not exist on the + # self-hosted Mac; its Homebrew Ruby already satisfies the Gemfile. + - name: Use Homebrew Ruby (self-mac) + if: >- + matrix.component == 'react-native' && + needs.pick-mac-runner.outputs.runner == 'self-mac' + run: | + echo "/opt/homebrew/opt/ruby/bin" >> "$GITHUB_PATH" + echo "$(/opt/homebrew/opt/ruby/bin/gem environment gemdir)/bin" >> "$GITHUB_PATH" + - name: Setup Bun if: startsWith(matrix.component, 'expo') uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -373,6 +452,15 @@ jobs: with: xcode-version: "26.6" + # Native test pass before the tracer exists; see the core job's note. + - name: Test Godot Swift wrapper + if: matrix.component == 'godot' + working-directory: libraries/godot-iap + run: | + make setup-swiftgodot + cd ios-gdextension + swift test + - name: Initialize CodeQL uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: @@ -434,7 +522,8 @@ jobs: run: | make setup-swiftgodot cd ios-gdextension - swift test + swift package clean + swift build --build-tests - name: Analyze uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index 4e723afc2..17f78dde6 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -28,7 +28,7 @@ concurrency: cancel-in-progress: false env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 COCOAPODS_VERSION: 1.15.2 RELEASE_BRANCH: ${{ github.ref_name }} @@ -57,7 +57,7 @@ jobs: validate-ios: needs: [release-branch] - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 30 steps: - name: Checkout @@ -83,7 +83,7 @@ jobs: permissions: actions: write contents: write - runs-on: macos-15 + runs-on: macos-26 steps: - name: Checkout diff --git a/.github/workflows/release-flutter.yml b/.github/workflows/release-flutter.yml index 241c2f74a..9c420cba3 100644 --- a/.github/workflows/release-flutter.yml +++ b/.github/workflows/release-flutter.yml @@ -96,10 +96,10 @@ jobs: validate-ios: needs: [release-branch] - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 60 env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 defaults: run: working-directory: libraries/flutter_inapp_purchase diff --git a/.github/workflows/release-kmp.yml b/.github/workflows/release-kmp.yml index a2b6080a7..b6ee16143 100644 --- a/.github/workflows/release-kmp.yml +++ b/.github/workflows/release-kmp.yml @@ -25,7 +25,7 @@ concurrency: env: GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx4g -XX:+UseParallelGC" - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 RELEASE_BRANCH: ${{ github.ref_name }} permissions: @@ -85,7 +85,7 @@ jobs: validate-ios: needs: [release-branch] - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 60 defaults: run: @@ -130,7 +130,7 @@ jobs: permissions: actions: write contents: write - runs-on: macos-15 + runs-on: macos-26 defaults: run: working-directory: libraries/kmp-iap diff --git a/.github/workflows/release-react-native.yml b/.github/workflows/release-react-native.yml index 4c88c246a..008b8c606 100644 --- a/.github/workflows/release-react-native.yml +++ b/.github/workflows/release-react-native.yml @@ -108,10 +108,10 @@ jobs: validate-ios: needs: [release-branch] - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 60 env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 defaults: run: working-directory: libraries/react-native-iap diff --git a/.husky/pre-commit b/.husky/pre-commit index c837dcda0..6bb8da91c 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -61,6 +61,20 @@ echo "🔎 IAPKit spec contract audit — running CI mirror…" node --test scripts/audit-kit-spec-contract.test.mjs node scripts/audit-kit-spec-contract.mjs +# Codex, Claude, and Grok must stay pointed at the same workflows. +if git diff --cached --name-only --diff-filter=ACMRD \ + | grep -qE '^(\.claude/|\.codex/|AGENTS\.md$|CLAUDE\.md$|GEMINI\.md$|scripts/audit-agent-surfaces(\.test)?\.mjs$)'; then + echo "🤝 agent surface audit…" + bun run audit:agents +fi + +# Path filters gate native builds; a filter edit must not silently skip them. +if git diff --cached --name-only --diff-filter=ACMR \ + | grep -qE '^(\.github/workflows/|scripts/audit-ci-path-filters(\.test)?\.mjs$)'; then + echo "🧭 CI path filter audit…" + bun run audit:ci-paths +fi + # Paths-aware kit pre-commit gate. Only runs when staged changes touch # packages/kit/**, so unrelated edits to apple/google/gql/docs/libraries # aren't blocked. diff --git a/AGENTS.md b/AGENTS.md index a1ed985c5..7aef50bf7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,27 @@ including its stricter release-note limits. - **Android functions in packages/google**: NO `Android` suffix (it's Android-only) - **Cross-platform functions**: NO suffix +### Production Data Guardrail + +- **Never run a mutation, action, or any write against a production + deployment.** This includes the Convex dashboard function runner, the Convex + CLI (`npx convex run --prod`), and any HTTP call to a production endpoint that + is not a plain read. The dashboard's runner preselects whatever function it + used last — which has included destructive mutations such as + `drainAccountDeletionBatch` — so confirm the selected function before you + press Run, and prefer not opening the runner at all. +- Reading production is allowed when the user asks for it: table views, row + counts, logs, and health. Report aggregates and never copy customer emails or + other personal data into a transcript, commit, or issue. +- Never edit, insert, or delete a document from the production data browser. + Schema and data changes ship through a reviewed deploy, not by hand. +- The **dev** deployment is the place to try things. If a check genuinely needs + a query that is not deployed, add it to `packages/kit/convex`, exercise it on + dev, and let it reach production through the normal deploy. +- If you are unsure which deployment is selected, stop and confirm. The Convex + dashboard shows it next to the project name, for example + `Production • healthy-kudu-836`. + ### Webhook Direction Guardrail - The only supported webhook direction is **store → IAPKit**: Apple App Store @@ -300,9 +321,15 @@ Cursor-specific files. | `$review-self` | Review and improve current work until stable | `$review-self` or `$review-self ` | | `$loop-review` | Start from current main, review, PR, and merge | `$loop-review` | | `$rebase-main` | Pull main and safely rebase the current branch | `$rebase-main` | +| `$generate-doc` | Write OpenIAP docs and pre-release release notes | `$generate-doc` | +| `$add-showcase-app` | Add apps to the "Who uses OpenIAP?" showcase | `$add-showcase-app` | +| `$opencollective-steward` | Manage OpenCollective profile and updates | `$opencollective-steward` | +| `$iapkit-e2e-petgu` | IAPKit product-sync E2E with the Petgu app | `$iapkit-e2e-petgu` | +| `$iapkit-e2e-martie` | IAPKit local receipt-validation E2E with Martie | `$iapkit-e2e-martie` | | `/review-pr` | Review PR comments, fix issues, resolve threads | `/review-pr 65` or `/review-pr ` | | `/audit-code` | Audit code against knowledge rules and latest APIs | `/audit-code` | | `/audit-security` | Audit SBOM, provenance, and supply-chain posture | `/audit-security` | +| `/audit-iapkit` | Reconcile the IAPKit surface with OpenIAP updates | `/audit-iapkit` | | `/compile-knowledge` | Compile the shared AI agent context | `/compile-knowledge` | | `/resolve-issue` | Analyze an issue, label it, and fix/comment | `/resolve-issue 88` | | `/verify-all` | Run the full monorepo health check | `/verify-all` | diff --git a/README.md b/README.md index b0112306c..f8af1588f 100644 --- a/README.md +++ b/README.md @@ -83,15 +83,16 @@ For bug reports, please [open an issue](https://github.com/hyodotdev/openiap/iss ## Sponsors +

- Meta + Meta        - Amazon Developer + Amazon Developer

diff --git a/libraries/expo-iap/README.md b/libraries/expo-iap/README.md index facc3cae1..8617e10a8 100644 --- a/libraries/expo-iap/README.md +++ b/libraries/expo-iap/README.md @@ -5,11 +5,11 @@ [![Version](http://img.shields.io/npm/v/expo-iap.svg?style=flat-square)](https://npmjs.org/package/expo-iap) [![Download](http://img.shields.io/npm/dm/expo-iap.svg?style=flat-square)](https://npmjs.org/package/expo-iap) [![OpenIAP](https://img.shields.io/badge/OpenIAP-Compliant-green?style=flat-square)](https://openiap.dev) [![CI](https://github.com/hyodotdev/openiap/actions/workflows/ci-expo-iap.yml/badge.svg?branch=main)](https://github.com/hyodotdev/openiap/actions/workflows/ci-expo-iap.yml?query=branch%3Amain) [![codecov](https://codecov.io/gh/hyodotdev/openiap/branch/main/graph/badge.svg?component=expo-iap)](https://app.codecov.io/gh/hyodotdev/openiap/tree/main/libraries/expo-iap) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fhyochan%2Fexpo-iap.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fhyochan%2Fexpo-iap?ref=badge_shield&issueType=license) -Expo IAP is a powerful in-app purchase solution for Expo and React Native applications that conforms to the Open IAP specification. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support. +Expo IAP is a powerful in-app purchase solution for Expo and React Native applications that conforms to the OpenIAP specification. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support. If you're shipping an app with expo-iap, we’d love to hear about it—please share your product and feedback in [expo-iap Q&A Discussions](https://github.com/hyodotdev/openiap/discussions/categories/expo-iap). Community stories help us keep improving the ecosystem. -Open IAP +OpenIAP diff --git a/libraries/expo-iap/example/scripts/vega-build-config.mjs b/libraries/expo-iap/example/scripts/vega-build-config.mjs index 2399ee801..666dc2072 100644 --- a/libraries/expo-iap/example/scripts/vega-build-config.mjs +++ b/libraries/expo-iap/example/scripts/vega-build-config.mjs @@ -65,4 +65,11 @@ id = "/com.amazonappstore.iap.tester@IIAPTesterUI" [needs] [[needs.module]] id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" + +[[needs.module]] +id = "/com.amazon.vega.os@IVega_1_2" + +[os.version] +target = "1.2" +min = "1.2" `; diff --git a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts index 137d261de..7c502ac65 100644 --- a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts +++ b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts @@ -896,6 +896,8 @@ describe('vega project generation', () => { expect(manifest).toContain( 'id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService"', ); + expect(manifest).toContain('id = "/com.amazon.vega.os@IVega_1_2"'); + expect(manifest).toContain('[os.version]\ntarget = "1.2"\nmin = "1.2"'); expect(createVegaEntryPoint()).toContain( 'AppRegistry.registerComponent(appName, () => App);', ); diff --git a/libraries/expo-iap/plugin/src/withVega.ts b/libraries/expo-iap/plugin/src/withVega.ts index 64dd240c9..ab5fe64be 100644 --- a/libraries/expo-iap/plugin/src/withVega.ts +++ b/libraries/expo-iap/plugin/src/withVega.ts @@ -13,6 +13,9 @@ const DEFAULT_ICON_FILE = 'icon.png'; const DEFAULT_BUILD_TYPE = 'Release'; const DEFAULT_RUNTIME_MODULE = '/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0'; +// Vega SDK 0.24 requires both the OS module and an [os.version] block. +const VEGA_OS_MODULE = '/com.amazon.vega.os@IVega_1_2'; +const VEGA_OS_VERSION = '1.2'; const logOnce = (() => { const printed = new Set(); @@ -155,6 +158,13 @@ id = "/com.amazon.iap.core@IIAPCoreUI" [needs] [[needs.module]] id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" + +[[needs.module]] +id = ${escapeTomlString(VEGA_OS_MODULE)} + +[os.version] +target = ${escapeTomlString(VEGA_OS_VERSION)} +min = ${escapeTomlString(VEGA_OS_VERSION)} `; export const createVegaEntryPoint = (): string => `${GENERATED_JS_MARKER} diff --git a/libraries/flutter_inapp_purchase/README.md b/libraries/flutter_inapp_purchase/README.md index e5a0ead59..160362778 100644 --- a/libraries/flutter_inapp_purchase/README.md +++ b/libraries/flutter_inapp_purchase/README.md @@ -5,9 +5,9 @@ [![Pub Version](https://img.shields.io/pub/v/flutter_inapp_purchase.svg?style=flat-square)](https://pub.dartlang.org/packages/flutter_inapp_purchase) [![Flutter CI](https://github.com/hyodotdev/openiap/actions/workflows/ci-flutter-inapp-purchase.yml/badge.svg?branch=main)](https://github.com/hyodotdev/openiap/actions/workflows/ci-flutter-inapp-purchase.yml?query=branch%3Amain) [![OpenIAP](https://img.shields.io/badge/OpenIAP-Compliant-green?style=flat-square)](https://openiap.dev) [![Coverage Status](https://codecov.io/gh/hyodotdev/openiap/branch/main/graph/badge.svg?component=flutter-inapp-purchase)](https://app.codecov.io/gh/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase) ![License](https://img.shields.io/badge/license-MIT-blue.svg) - A comprehensive Flutter plugin for implementing in-app purchases that conforms to the [Open IAP specification](https://openiap.dev) + A comprehensive Flutter plugin for implementing in-app purchases that conforms to the [OpenIAP specification](https://openiap.dev) -Open IAP +OpenIAP diff --git a/libraries/godot-iap/README.md b/libraries/godot-iap/README.md index 0f324f2da..2720f44d1 100644 --- a/libraries/godot-iap/README.md +++ b/libraries/godot-iap/README.md @@ -9,11 +9,11 @@ [![OpenIAP](https://img.shields.io/badge/OpenIAP-Compliant-green?style=flat-square)](https://openiap.dev) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -A comprehensive in-app purchase plugin for Godot 4.x that conforms to the Open IAP specification +A comprehensive in-app purchase plugin for Godot 4.x that conforms to the OpenIAP specification Requires Godot 4.3+, iOS 17+ for the Swift GDExtension, or Android API 24+. -Open IAP +OpenIAP diff --git a/libraries/kmp-iap/README.md b/libraries/kmp-iap/README.md index b82e6beeb..93cbb615e 100644 --- a/libraries/kmp-iap/README.md +++ b/libraries/kmp-iap/README.md @@ -8,9 +8,9 @@ OpenIAP Compliant License - A comprehensive Kotlin Multiplatform library for in-app purchases on Android and iOS platforms that conforms to the Open IAP specification + A comprehensive Kotlin Multiplatform library for in-app purchases on Android and iOS platforms that conforms to the OpenIAP specification - Open IAP + OpenIAP ## 📚 Documentation diff --git a/libraries/react-native-iap/README.md b/libraries/react-native-iap/README.md index 607b9b609..11630c533 100644 --- a/libraries/react-native-iap/README.md +++ b/libraries/react-native-iap/README.md @@ -5,9 +5,9 @@ [![Version](http://img.shields.io/npm/v/react-native-iap.svg?style=flat-square)](https://npmjs.org/package/react-native-iap) [![Download](http://img.shields.io/npm/dm/react-native-iap.svg?style=flat-square)](https://npmjs.org/package/react-native-iap) [![OpenIAP](https://img.shields.io/badge/OpenIAP-Compliant-green?style=flat-square)](https://openiap.dev) [![Backers and Sponsors](https://img.shields.io/opencollective/all/openiap.svg)](https://opencollective.com/openiap) [![CI - Test](https://github.com/hyodotdev/openiap/actions/workflows/ci-react-native-iap.yml/badge.svg?branch=main)](https://github.com/hyodotdev/openiap/actions/workflows/ci-react-native-iap.yml?query=branch%3Amain) [![codecov](https://codecov.io/gh/hyodotdev/openiap/branch/main/graph/badge.svg?component=react-native-iap)](https://app.codecov.io/gh/hyodotdev/openiap/tree/main/libraries/react-native-iap) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fhyochan%2Freact-native-iap.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fhyochan%2Freact-native-iap?ref=badge_shield&issueType=license) -**React Native IAP** is a high-performance in-app purchase library using Nitro Modules that **conforms to the [Open IAP specification](https://openiap.dev)**. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support. +**React Native IAP** is a high-performance in-app purchase library using Nitro Modules that **conforms to the [OpenIAP specification](https://openiap.dev)**. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support. -Open IAP +OpenIAP ## 📚 Documentation diff --git a/libraries/react-native-iap/example/manifest.toml b/libraries/react-native-iap/example/manifest.toml index 3c911981b..2a1f4d328 100644 --- a/libraries/react-native-iap/example/manifest.toml +++ b/libraries/react-native-iap/example/manifest.toml @@ -34,3 +34,10 @@ id = "/com.amazonappstore.iap.tester@IIAPTesterUI" [needs] [[needs.module]] id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" + +[[needs.module]] +id = "/com.amazon.vega.os@IVega_1_2" + +[os.version] +target = "1.2" +min = "1.2" diff --git a/package.json b/package.json index 9659db3a6..7c0400a17 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "e2e:web": "node scripts/e2e-web-sites.mjs", "audit:deprecations": "node --test scripts/audit-deprecation-schedule.test.mjs && node scripts/audit-deprecation-schedule.mjs", "audit:layout": "node --test scripts/audit-repo-layout.test.mjs && node scripts/audit-repo-layout.mjs", + "audit:ci-paths": "node --test scripts/audit-ci-path-filters.test.mjs && node scripts/audit-ci-path-filters.mjs", + "audit:agents": "node --test scripts/audit-agent-surfaces.test.mjs && node scripts/audit-agent-surfaces.mjs", "audit:parity": "node scripts/audit-non-godot-parity.mjs", "audit:kit-contract": "node --test scripts/audit-kit-spec-contract.test.mjs && node scripts/audit-kit-spec-contract.mjs", "audit:docs": "bun run scripts/audit-docs.ts", diff --git a/packages/docs/README.md b/packages/docs/README.md index 25918f118..0da7bb6f3 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -16,15 +16,16 @@ Visit [openiap.dev](https://openiap.dev) for full documentation. ## Sponsors +

- Meta + Meta        - Amazon Developer + Amazon Developer

diff --git a/packages/docs/src/components/EcosystemDiagram.tsx b/packages/docs/src/components/EcosystemDiagram.tsx index f80efd36c..6bca0d829 100644 --- a/packages/docs/src/components/EcosystemDiagram.tsx +++ b/packages/docs/src/components/EcosystemDiagram.tsx @@ -199,12 +199,12 @@ function Rail({ variant, label, }: { - variant: 'a' | 'b' | 'bypass' | 'iapkit'; - label: string; + variant: 'a' | 'b' | 'bypass' | 'iapkit' | 'iapkit-core'; + label?: string; }) { return ( ); } @@ -336,6 +336,7 @@ function EcosystemDiagram() { +
and for every framework library, and the core packages are bundled into each library. IAPKit is the optional hosted layer for purchase verification, entitlements, store - notifications, and product operations. Select any node to open its + notifications, and product operations, and the core packages can use it + directly without a framework library. Select any node to open its documentation or project. diff --git a/packages/docs/src/pages/docs/setup/store/amazon.tsx b/packages/docs/src/pages/docs/setup/store/amazon.tsx index 2183a3e97..f92ce77bf 100644 --- a/packages/docs/src/pages/docs/setup/store/amazon.tsx +++ b/packages/docs/src/pages/docs/setup/store/amazon.tsx @@ -359,7 +359,7 @@ dotnet build -f net10.0-android -p:OpenIapAndroidStore=amazon`}

Check the{' '}

{`# In the Vega-only React Native target yarn add react-native-iap -yarn add @amazon-devices/keplerscript-appstore-iap-lib@~2.12.13 @amazon-devices/react-native-kepler@^2.0.0 +yarn add @amazon-devices/keplerscript-appstore-iap-lib@~2.13.0 @amazon-devices/react-native-kepler@^2.0.0 yarn add -D @amazon-devices/kepler-cli-platform@~0.22.0 @react-native-community/cli@ @react-native/metro-config@`} {`schema-version = 1 @@ -428,7 +428,14 @@ categories = ["com.amazon.category.main"] [needs] [[needs.module]] -id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService"`} +id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" + +[[needs.module]] +id = "/com.amazon.vega.os@IVega_1_2" + +[os.version] +target = "1.2" +min = "1.2"`}
@@ -438,7 +445,7 @@ id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreServ

Fire OS and Vega OS both use the{' '} IAPKit Amazon payload. Pass the - Amazon user id when available, the Amazon receipt id, and{' '} + Amazon user id (required), the Amazon receipt id, and{' '} expectedProductId for server-side product binding. For Amazon App Tester, first enable{' '} Allow Amazon App Tester / RVS Cloud Sandbox in the diff --git a/packages/docs/src/pages/docs/updates/announcements.tsx b/packages/docs/src/pages/docs/updates/announcements.tsx index a413672fa..b848aea9d 100644 --- a/packages/docs/src/pages/docs/updates/announcements.tsx +++ b/packages/docs/src/pages/docs/updates/announcements.tsx @@ -256,6 +256,7 @@ function Announcements() { Amazon Fire OS and Vega OS support

@@ -430,6 +431,7 @@ function Announcements() { maui-iap

@@ -735,6 +737,7 @@ function Announcements() { godot-iap

@@ -838,6 +841,7 @@ function Announcements() { IAPKit

diff --git a/packages/docs/src/pages/docs/updates/migration.tsx b/packages/docs/src/pages/docs/updates/migration.tsx index 970e1afc9..d683d76f1 100644 --- a/packages/docs/src/pages/docs/updates/migration.tsx +++ b/packages/docs/src/pages/docs/updates/migration.tsx @@ -38,12 +38,15 @@ const migrationGroups = [ ], [ 'checkAlternativeBillingAvailabilityAndroid', - "isBillingProgramAvailableAndroid with the 'external-offer' BillingProgramAndroid value", + 'isBillingProgramAvailableAndroid with the BillingProgramAndroid value your app is enrolled in', + ], + [ + 'showAlternativeBillingDialogAndroid', + 'showBillingProgramInformationDialogAndroid (the in-app Billing Programs dialog); launchExternalLinkAndroid covers the external-link flows (External Offer, External Content Link, Billing Choice external links)', ], - ['showAlternativeBillingDialogAndroid', 'launchExternalLinkAndroid'], [ 'createAlternativeBillingTokenAndroid', - "createBillingProgramReportingDetailsAndroid with the 'external-offer' BillingProgramAndroid value", + 'createBillingProgramReportingDetailsAndroid with the BillingProgramAndroid value your app is enrolled in', ], ], }, diff --git a/packages/docs/src/styles/base.css b/packages/docs/src/styles/base.css index d6dc4b6b4..b3af3bdd3 100644 --- a/packages/docs/src/styles/base.css +++ b/packages/docs/src/styles/base.css @@ -88,6 +88,13 @@ img[src='/sponsors/meta.webp'] { height: 2.75rem; } +/* Wrapped onto its own line above the title, the tiny banner reads as noise. */ +@media (max-width: 640px) { + .announcement-thumb { + display: none; + } +} + img[src='/frameworks/apple.svg'] { filter: var(--apple-logo-filter); } diff --git a/packages/docs/src/styles/ecosystem-diagram.css b/packages/docs/src/styles/ecosystem-diagram.css index 6dd0a955f..48e3443c9 100644 --- a/packages/docs/src/styles/ecosystem-diagram.css +++ b/packages/docs/src/styles/ecosystem-diagram.css @@ -370,9 +370,13 @@ /* ---------- hosted infrastructure --------------------------------------- */ +.eco-rail--iapkit, +.eco-rail--iapkit-core { + opacity: 0.72; +} + .eco-rail--iapkit { height: 42px; - opacity: 0.72; } /* ---------- artwork ------------------------------------------------------- @@ -463,6 +467,12 @@ display: none; } +/* Stacked, there is no second channel beside Core -> Libraries -> IAPKit to + draw this in, so the figcaption is what states it there. */ +.eco-rail--iapkit-core { + display: none; +} + .eco-rail-label { position: relative; z-index: 1; @@ -601,8 +611,17 @@ grid-row: 7; } + /* Core reaches IAPKit without a framework library. Spanning the filler row + keeps the line attached to the Core band however tall Libraries grows. */ + .eco-rail--iapkit-core { + display: flex; + grid-column: 1; + grid-row: 6 / 8; + height: auto; + } + .eco-band--iapkit { - grid-column: 3; + grid-column: 1 / -1; grid-row: 8; } diff --git a/packages/kit/CONVENTION.md b/packages/kit/CONVENTION.md index 3eb7e5ebd..e3f345bce 100644 --- a/packages/kit/CONVENTION.md +++ b/packages/kit/CONVENTION.md @@ -10,6 +10,20 @@ Convex schema as the source of truth for purchase-validation models. For setup, operations, and deploy details, see [`README.md`](./README.md). +## Production Is Read-Only For Agents + +`healthy-kudu-836` is the production deployment and holds real customer data. +Never run a mutation or action against it — not from the Convex dashboard +function runner, not from `npx convex run --prod`, not from anywhere. The +dashboard runner reopens with the last function selected, which has included +`drainAccountDeletionBatch`; check what is selected before running anything. + +Reads are fine when asked for. Report counts and aggregates rather than copying +customer emails or other personal data anywhere. Use the dev deployment for +anything that needs a new function. + +See the root `AGENTS.md` for the full guardrail. + ## Naming - **Brand name in user-facing text/titles**: `IAPKit` (no space). diff --git a/packages/kit/convex/auth.ts b/packages/kit/convex/auth.ts index 8d892ce3d..ec1bf7bc2 100644 --- a/packages/kit/convex/auth.ts +++ b/packages/kit/convex/auth.ts @@ -6,6 +6,11 @@ import { } from "./ResendOTP"; import GitHub, { type GitHubProfile } from "@auth/core/providers/github"; import { api, internal } from "./_generated/api"; +import { + assertEmailSignInWindowOpen, + assertLegacyEmailAccount, + isResendProviderId, +} from "./authWindow"; const CustomAuth = convexAuth({ providers: [ @@ -28,6 +33,8 @@ const CustomAuth = convexAuth({ ], callbacks: { async createOrUpdateUser(ctx, args) { + assertEmailSignInWindowOpen(args.provider.id); + // Check if user exists with the same email const email = args.profile.email; const profileName = @@ -56,6 +63,16 @@ const CustomAuth = convexAuth({ ); if (existingUser) { + // The UI gate (canSignInWithEmail) mirrors this; the boundary enforces it. + if (isResendProviderId(args.provider.id)) { + assertLegacyEmailAccount( + args.provider.id, + await ctx.runQuery(internal.users.internal.hasLegacyEmailAccount, { + userId: existingUser._id, + }), + ); + } + // User exists - update auth user const userId = existingUser._id; @@ -93,7 +110,7 @@ const CustomAuth = convexAuth({ // is created. OAuth providers (github) are exempt — that's the // path we want new users on. const providerId = args.provider.id; - const isResendProvider = providerId.startsWith("resend-otp"); + const isResendProvider = isResendProviderId(providerId); if (isResendProvider) { throw new Error( "New email signups are disabled. Please sign in with GitHub instead.", diff --git a/packages/kit/convex/authWindow.test.ts b/packages/kit/convex/authWindow.test.ts new file mode 100644 index 000000000..fef5d8a7b --- /dev/null +++ b/packages/kit/convex/authWindow.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + EMAIL_SIGN_IN_CLOSES_AT, + EMAIL_SIGN_IN_CLOSES_ON, + assertEmailSignInWindowOpen, + assertLegacyEmailAccount, + isEmailSignInOpen, + isResendProviderId, +} from "./authWindow"; + +describe("email sign-in grace period", () => { + it("names the same day the timestamp encodes", () => { + expect(new Date(EMAIL_SIGN_IN_CLOSES_AT).toISOString()).toContain( + EMAIL_SIGN_IN_CLOSES_ON, + ); + }); + + it("stays open through the whole closing day in UTC", () => { + expect(isEmailSignInOpen(Date.UTC(2026, 8, 30, 0, 0, 0, 0))).toBe(true); + expect(isEmailSignInOpen(EMAIL_SIGN_IN_CLOSES_AT)).toBe(true); + }); + + it("closes at the first moment of the next day", () => { + expect(isEmailSignInOpen(EMAIL_SIGN_IN_CLOSES_AT + 1)).toBe(false); + expect(isEmailSignInOpen(Date.UTC(2026, 9, 1, 0, 0, 0, 0))).toBe(false); + }); +}); + +describe("email sign-in gates", () => { + it("recognizes every resend provider id and nothing else", () => { + expect(isResendProviderId("resend-otp-en")).toBe(true); + expect(isResendProviderId("resend-otp-ko")).toBe(true); + expect(isResendProviderId("github")).toBe(false); + }); + + it("lets email sign-in through while the window is open", () => { + expect(() => + assertEmailSignInWindowOpen("resend-otp-en", EMAIL_SIGN_IN_CLOSES_AT), + ).not.toThrow(); + }); + + it("rejects email sign-in after the window closes", () => { + expect(() => + assertEmailSignInWindowOpen("resend-otp-en", EMAIL_SIGN_IN_CLOSES_AT + 1), + ).toThrow(/closed on 2026-09-30 \(UTC\)/); + }); + + it("never blocks GitHub, even after the window closes", () => { + expect(() => + assertEmailSignInWindowOpen("github", EMAIL_SIGN_IN_CLOSES_AT + 1), + ).not.toThrow(); + }); + + it("accepts OTP for accounts that already used email", () => { + expect(() => assertLegacyEmailAccount("resend-otp-en", true)).not.toThrow(); + }); + + it("rejects OTP for GitHub-created accounts", () => { + expect(() => assertLegacyEmailAccount("resend-otp-en", false)).toThrow( + /continue with GitHub/i, + ); + }); +}); diff --git a/packages/kit/convex/authWindow.ts b/packages/kit/convex/authWindow.ts new file mode 100644 index 000000000..dbe0fde85 --- /dev/null +++ b/packages/kit/convex/authWindow.ts @@ -0,0 +1,45 @@ +// Sunset for the Resend OTP provider. New signups have been GitHub-only since +// 2026-04; this is the grace period in which the remaining email-only accounts +// can still sign in and get merged onto GitHub by matching email. +// +// Not a Convex function module — plain constants shared by auth.ts and +// users/query.ts so the cutoff is written down once. + +export const EMAIL_SIGN_IN_CLOSES_ON = "2026-09-30"; + +// Inclusive of the whole closing day, in UTC. +export const EMAIL_SIGN_IN_CLOSES_AT = Date.UTC(2026, 8, 30, 23, 59, 59, 999); + +export function isEmailSignInOpen(now: number = Date.now()): boolean { + return now <= EMAIL_SIGN_IN_CLOSES_AT; +} + +export function isResendProviderId(providerId: string): boolean { + return providerId.startsWith("resend-otp"); +} + +// Grace period, then GitHub-only. Existing email accounts merge onto GitHub +// by matching email, so closing costs access only when the emails differ. +export function assertEmailSignInWindowOpen( + providerId: string, + now: number = Date.now(), +): void { + if (isResendProviderId(providerId) && !isEmailSignInOpen(now)) { + throw new Error( + `Email sign-in closed on ${EMAIL_SIGN_IN_CLOSES_ON} (UTC). Sign in with GitHub using the same email address.`, + ); + } +} + +// Email OTP is only for accounts that already used it; a GitHub-created +// account keeps using GitHub even while the window is open. +export function assertLegacyEmailAccount( + providerId: string, + hasLegacyEmailAccount: boolean, +): void { + if (isResendProviderId(providerId) && !hasLegacyEmailAccount) { + throw new Error( + "This account uses GitHub sign-in. Please continue with GitHub.", + ); + } +} diff --git a/packages/kit/convex/purchases/ios.test.ts b/packages/kit/convex/purchases/ios.test.ts index d6e61891b..6539bc327 100644 --- a/packages/kit/convex/purchases/ios.test.ts +++ b/packages/kit/convex/purchases/ios.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { recordAppStoreVerifiedSubscription } from "./ios"; +import { + assertVerifiedTransactionBinding, + recordAppStoreVerifiedSubscription, +} from "./ios"; import { applyExpectedProductId, AppStoreProductType, @@ -252,3 +255,55 @@ describe("recordAppStoreVerifiedSubscription", () => { expect(calls).toHaveLength(0); }); }); + +describe("assertVerifiedTransactionBinding", () => { + const verified = { + transactionId: "2000001177054625", + bundleId: "dev.hyo.martie", + environment: "Production" as const, + }; + + it("accepts a response matching the request on all three fields", () => { + expect(() => + assertVerifiedTransactionBinding({ + requestedTransactionId: "2000001177054625", + requestedEnvironment: "Production", + expectedBundleId: "dev.hyo.martie", + verified, + }), + ).not.toThrow(); + }); + + it("rejects a transaction id drift", () => { + expect(() => + assertVerifiedTransactionBinding({ + requestedTransactionId: "9999999999999999", + requestedEnvironment: "Production", + expectedBundleId: "dev.hyo.martie", + verified, + }), + ).toThrow(/transactionId/); + }); + + it("rejects a bundle id that is not the project's", () => { + expect(() => + assertVerifiedTransactionBinding({ + requestedTransactionId: "2000001177054625", + requestedEnvironment: "Production", + expectedBundleId: "dev.other.app", + verified, + }), + ).toThrow(/bundleId/); + }); + + it("rejects an environment drift", () => { + expect(() => + assertVerifiedTransactionBinding({ + requestedTransactionId: "2000001177054625", + requestedEnvironment: "Sandbox", + expectedBundleId: "dev.hyo.martie", + verified, + }), + ).toThrow(/environment/); + }); +}); diff --git a/packages/kit/convex/purchases/ios.ts b/packages/kit/convex/purchases/ios.ts index 4b8150b32..c37a96ca1 100644 --- a/packages/kit/convex/purchases/ios.ts +++ b/packages/kit/convex/purchases/ios.ts @@ -131,6 +131,13 @@ export const verifyAppStoreReceiptInternalV1 = action({ throw error; } + assertVerifiedTransactionBinding({ + requestedTransactionId: decodedPayload.transactionId, + requestedEnvironment: environment, + expectedBundleId: project.iosBundleId, + verified: transactionData, + }); + const remoteId = transactionData.originalTransactionId || transactionData.transactionId || @@ -345,6 +352,41 @@ export async function getAppStoreServerCredentials( }; } +// The device JWS is decode-only (its claims are attacker-writable); Apple's +// response is what SignedDataVerifier proves. Reject any drift between the +// two so a tampered payload cannot select another transaction's verdict. +export function assertVerifiedTransactionBinding(params: { + requestedTransactionId: unknown; + requestedEnvironment: string; + expectedBundleId: string; + verified: Pick< + AppStoreReceiptData, + "transactionId" | "bundleId" | "environment" + >; +}): void { + const { requestedTransactionId, requestedEnvironment, expectedBundleId } = + params; + const verified = params.verified; + + if (verified.transactionId !== requestedTransactionId) { + throw new AppStoreTransactionVerificationFailedError( + `verified transactionId ${String(verified.transactionId)} does not match the requested ${String(requestedTransactionId)}`, + ); + } + + if (verified.bundleId !== expectedBundleId) { + throw new AppStoreTransactionVerificationFailedError( + `verified bundleId ${String(verified.bundleId)} does not match the project's ${expectedBundleId}`, + ); + } + + if (verified.environment !== requestedEnvironment) { + throw new AppStoreTransactionVerificationFailedError( + `verified environment ${String(verified.environment)} does not match the requested ${requestedEnvironment}`, + ); + } +} + async function verifyTransactionWithServerApi(params: { ctx: ActionCtx; decodedJwsPayload: JWSTransactionDecodedPayload; diff --git a/packages/kit/convex/purchases/retry.ts b/packages/kit/convex/purchases/retry.ts index f035828bd..be8da133b 100644 --- a/packages/kit/convex/purchases/retry.ts +++ b/packages/kit/convex/purchases/retry.ts @@ -135,7 +135,7 @@ export async function retryOnTransient( const exponent = attempt - 1; const raw = baseDelayMs * Math.pow(2, exponent); const capped = Math.min(raw, maxDelayMs); - // Full jitter in [0.5, 1.0) of the capped delay — smooths retry + // Jitter in [0.5, 1.0) of the capped delay — smooths retry // bursts without extending worst-case wait beyond the cap. const jittered = capped * (0.5 + Math.random() * 0.5); await sleep(jittered); diff --git a/packages/kit/convex/users/internal.test.ts b/packages/kit/convex/users/internal.test.ts new file mode 100644 index 000000000..c21679665 --- /dev/null +++ b/packages/kit/convex/users/internal.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { RESEND_PROVIDER_IDS, hasAnyResendAccount } from "./internal"; + +describe("hasAnyResendAccount", () => { + it("finds an account on the first provider", async () => { + await expect( + hasAnyResendAccount(async (provider) => + provider === "resend-otp-en" ? { _id: "a" } : null, + ), + ).resolves.toBe(true); + }); + + it("keeps looking past earlier providers", async () => { + const asked: string[] = []; + await expect( + hasAnyResendAccount(async (provider) => { + asked.push(provider); + return provider === "resend-otp-ja" ? { _id: "a" } : null; + }), + ).resolves.toBe(true); + expect(asked).toEqual([...RESEND_PROVIDER_IDS]); + }); + + it("returns false when no provider has an account", async () => { + await expect(hasAnyResendAccount(async () => null)).resolves.toBe(false); + }); +}); diff --git a/packages/kit/convex/users/internal.ts b/packages/kit/convex/users/internal.ts index af25d5c95..df58a2de3 100644 --- a/packages/kit/convex/users/internal.ts +++ b/packages/kit/convex/users/internal.ts @@ -13,6 +13,36 @@ export const findByEmail = internalQuery({ }, }); +// Grace-period gate: email OTP is only for accounts that already used it. +// A GitHub-created account must keep using GitHub even before the cutoff. +export const RESEND_PROVIDER_IDS = [ + "resend-otp-en", + "resend-otp-ko", + "resend-otp-ja", +] as const; + +export async function hasAnyResendAccount( + findAccount: (provider: string) => Promise, +): Promise { + for (const provider of RESEND_PROVIDER_IDS) { + if ((await findAccount(provider)) !== null) return true; + } + return false; +} + +export const hasLegacyEmailAccount = internalQuery({ + args: { userId: v.id("users") }, + handler: async (ctx, args) => + hasAnyResendAccount((provider) => + ctx.db + .query("authAccounts") + .withIndex("userIdAndProvider", (q) => + q.eq("userId", args.userId).eq("provider", provider), + ) + .first(), + ), +}); + // Read budget per cron tick. We walk this many candidate users (oldest // first, capped at the 24h boundary) before yielding to the next tick; // keeps Convex's per-transaction read budget bounded. diff --git a/packages/kit/convex/users/query.ts b/packages/kit/convex/users/query.ts index 0009c0dfd..f1450e2f2 100644 --- a/packages/kit/convex/users/query.ts +++ b/packages/kit/convex/users/query.ts @@ -1,5 +1,7 @@ import { query } from "../_generated/server"; import { v } from "convex/values"; +import { isEmailSignInOpen } from "../authWindow"; +import { hasAnyResendAccount } from "./internal"; /** * Pre-sign-in gate: returns true if a user with the given email @@ -20,10 +22,21 @@ export const canSignInWithEmail = query({ handler: async (ctx, args) => { const normalized = args.email.trim().toLowerCase(); if (normalized.length === 0) return false; + if (!isEmailSignInOpen()) return false; const user = await ctx.db .query("users") .withIndex("email", (q) => q.eq("email", normalized)) .first(); - return user !== null; + if (!user) return false; + // OTP stays limited to accounts that already used it; GitHub-created + // accounts keep using GitHub even during the grace period. + return hasAnyResendAccount((provider) => + ctx.db + .query("authAccounts") + .withIndex("userIdAndProvider", (q) => + q.eq("userId", user._id).eq("provider", provider), + ) + .first(), + ); }, }); diff --git a/packages/kit/public/docs/screenshots/project-create.webp b/packages/kit/public/docs/screenshots/project-create.webp deleted file mode 100644 index 51095bc75..000000000 Binary files a/packages/kit/public/docs/screenshots/project-create.webp and /dev/null differ diff --git a/packages/kit/public/docs/screenshots/signup.webp b/packages/kit/public/docs/screenshots/signup.webp deleted file mode 100644 index 6132d9891..000000000 Binary files a/packages/kit/public/docs/screenshots/signup.webp and /dev/null differ diff --git a/packages/kit/src/components/AuthModal/index.tsx b/packages/kit/src/components/AuthModal/index.tsx index 3e8e9f469..57703dd4d 100644 --- a/packages/kit/src/components/AuthModal/index.tsx +++ b/packages/kit/src/components/AuthModal/index.tsx @@ -6,6 +6,7 @@ import { SiGithub } from "@icons-pack/react-simple-icons"; import { useNavigate } from "react-router-dom"; import { api } from "@/convex"; import { Modal } from "@/components/Modal"; +import { EMAIL_SIGN_IN_CLOSES_ON, isEmailSignInOpen } from "@/utils/constants"; interface AuthModalProps { isOpen: boolean; @@ -68,6 +69,8 @@ export function AuthModal({ isOpen, onClose }: AuthModalProps) { onClose(); }, [isAuthenticating, handleReset, onClose]); + const emailSignInOpen = isEmailSignInOpen(); + const getOtpProvider = () => "resend-otp-en"; const handleSendOtp = async (e: React.FormEvent) => { @@ -229,31 +232,39 @@ export function AuthModal({ isOpen, onClose }: AuthModalProps) { {"You'll be redirected to GitHub to authorize IAPKit"}

- {/* Divider + email-legacy escape hatch. Kept low-key so new - users gravitate toward GitHub, while the ~110 existing - email-only accounts still have an obvious path in. */} -
-
-
-
-
- - {"or"} - -
-
+ {/* Divider + email-legacy escape hatch, shown only while the + grace period is open. After it closes the server rejects + resend-otp outright, so offering the link would dead-end. */} + {emailSignInOpen && ( + <> +
+
+
+
+
+ + {"or"} + +
+
- + + +

+ {`Email sign-in ends ${EMAIL_SIGN_IN_CLOSES_ON} (UTC). After that IAPKit supports GitHub sign-in only — sign in with GitHub using the same email address and your account carries over.`} +

+ + )}
)} diff --git a/packages/kit/src/components/Footer.tsx b/packages/kit/src/components/Footer.tsx index fbacca5c0..00331de36 100644 --- a/packages/kit/src/components/Footer.tsx +++ b/packages/kit/src/components/Footer.tsx @@ -85,6 +85,14 @@ export default function Footer() { {"Blog"} +
  • + + {"FAQ"} + +
  • - © 2025{" "} + © {new Date().getFullYear()}{" "} = [ }, { q: "Which platforms does IAPKit support?", - a: "IAPKit validates receipts for Apple App Store, Google Play, Meta Horizon, and Amazon Appstore. Vega OS receipt-validation support is on the roadmap.", + a: "IAPKit validates receipts for Apple App Store, Google Play, Meta Horizon, and Amazon Appstore. Fire OS and Vega OS both verify through the same Amazon RVS payload.", }, ]; @@ -250,21 +250,16 @@ export default function IapkitJoinsOpenIap() {

    What's next

    -

    This transition frees us to invest in what actually matters:

    +

    + This transition frees us to invest in what actually matters. The list + below is the roadmap as it stood in April 2026 — the platform + integrations and Fire OS / Vega OS validation have since shipped, and + store notifications now flow into IAPKit and keep its subscription + snapshot current. See the documentation for what + ships today. +

      -
    • - Deeper platform integrations — App Store Server API v2, Google Play - Billing v7+, Meta Horizon. -
    • -
    • - New receipt-validation support coming — Fire OS, - Vega OS. -
    • -
    • - Server-side webhooks and real-time notifications — renewals, - refunds, purchase events. -
    • Observability into purchase flows — see exactly where payments break. diff --git a/packages/kit/src/pages/docs/DocsLayout.tsx b/packages/kit/src/pages/docs/DocsLayout.tsx index 7f4040618..b90c8db91 100644 --- a/packages/kit/src/pages/docs/DocsLayout.tsx +++ b/packages/kit/src/pages/docs/DocsLayout.tsx @@ -194,6 +194,7 @@ function DocsNavRow({ - ~3 KB + ~14 KB @@ -68,19 +68,19 @@ export default function AiAssistantsPage() { table, error body shape, structured log line, retry policy, Sentry config, Convex data model, deployment. - ~9 KB + ~25 KB
    - +

    - The IAPKit repository is private, which normally prevents - code-assistants from reasoning about it. Serving the reference as - plain text at a stable URL means any LLM-powered editor (Claude Code, - Cursor, Zed, Continue, etc.) that supports URL loaders can still pull - in accurate context without repo access. + IAPKit is open source, but pointing an assistant at the monorepo costs + a lot of tokens to answer a one-line API question. Serving a condensed + reference as plain text at a stable URL lets any LLM-powered editor + (Claude Code, Cursor, Zed, Continue, etc.) that supports URL loaders + pull accurate context in one fetch.

    @@ -105,7 +105,7 @@ export default function AiAssistantsPage() { > Claude Code plugin guide {" "} - for the setup flow, self-hosted option, and tool list. + for the IAPKit endpoint and key details.

    Using the files

    diff --git a/packages/kit/src/pages/docs/sections/api.tsx b/packages/kit/src/pages/docs/sections/api.tsx index f1a2abd3a..3b499787c 100644 --- a/packages/kit/src/pages/docs/sections/api.tsx +++ b/packages/kit/src/pages/docs/sections/api.tsx @@ -144,8 +144,8 @@ export default function ApiReferencePage() {

    - Grant or fulfill only when isValid === true, the harmonized - state permits that operation, and the store-verified + Grant or fulfill only when isValid === true, the harmonized{" "} + state permits that operation, and the store-verified{" "} productId is present and matches the product your app expected. For Meta Horizon, productId is the SKU IAPKit checked. Amazon responses also identify the server-selected{" "} @@ -433,10 +433,12 @@ async function refreshEntitlements( originalTransactionId explicitly.

    - Administrative subscription endpoints use{" "} - Authorization: Bearer openiap-kit_sk_.... Compatibility - routes with a key in the path remain available, but new server-side and - MCP integrations should keep secret keys out of URLs. + Administrative subscription endpoints require{" "} + Authorization: Bearer openiap-kit_sk_.... IAPKit never + accepts a secret key in a URL — a secret key in a path returns{" "} + 410 SECRET_API_KEY_IN_URL. The compatibility routes that + keep a key in the path accept publishable keys only, for SDK runtimes + that strip request headers.

    {`{ @@ -450,8 +452,19 @@ async function refreshEntitlements( that explicitly ask for a JWS. Do not log or publish JWS values.

    -
    - +

    + state has two distinct vocabularies. The table below is the + verification vocabulary returned by /v1/purchase/verify. + The subscription snapshot endpoints use a lifecycle vocabulary instead —{" "} + Active, InGracePeriod,{" "} + InBillingRetry, Expired, Revoked,{" "} + Refunded, Paused, Unknown — so + gating a snapshot on state === "ENTITLED" never + matches. +

    + +
    +
    @@ -523,6 +536,67 @@ async function refreshEntitlements(
    State
    +

    Subscription endpoints

    +

    + Bind first, then read. POST /v1/subscriptions/bind-user{" "} + associates a store transaction with your own user id; until a purchase + is bound, the read endpoints resolve userId against rows + that were never linked and return an empty snapshot. +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    EndpointKeyNotes
    + POST /v1/subscriptions/bind-user + Publishable + Links a purchase to your user id. Body up to 32 KB. +
    + GET /v1/subscriptions/status + Publishable + Current snapshot for one userId (≤256 chars). + Supports ETag / If-None-Match. +
    + GET /v1/subscriptions/entitlements + Publishable + Entitled product ids for one userId. Already + filtered to non-expired rows, so everything returned is + currently entitled. +
    + GET /v1/subscriptions/list + Secret + Project-wide administrative listing. limit capped + at 200. +
    +
    +

    Response headers

    Verification requests that pass bearer-token shape validation carry a @@ -557,7 +631,7 @@ async function refreshEntitlements(

    Status codes

    -
    +
    @@ -706,6 +780,13 @@ async function refreshEntitlements( className="text-primary underline" > Horizon + + ,{" "} + + Amazon {" "} — per-store error codes and edge cases. diff --git a/packages/kit/src/pages/docs/sections/claude-plugin.tsx b/packages/kit/src/pages/docs/sections/claude-plugin.tsx index 4f9ee4301..543a4d7cc 100644 --- a/packages/kit/src/pages/docs/sections/claude-plugin.tsx +++ b/packages/kit/src/pages/docs/sections/claude-plugin.tsx @@ -14,7 +14,7 @@ export default function ClaudePluginPage() { >

    The OpenIAP plugin connects Claude Code to this IAPKit project through - the hosted /mcp endpoint. Use this page for the Kit-local + the hosted /mcp endpoint. Use this page for the IAPKit endpoint and key details; use the OpenIAP MCP Server guide for the full installation flow, local PR testing, tool list, safety rules, and Example App walkthrough. diff --git a/packages/kit/src/pages/docs/sections/codex-plugin.tsx b/packages/kit/src/pages/docs/sections/codex-plugin.tsx index 55cd582af..1e80c7c24 100644 --- a/packages/kit/src/pages/docs/sections/codex-plugin.tsx +++ b/packages/kit/src/pages/docs/sections/codex-plugin.tsx @@ -13,7 +13,7 @@ export default function CodexPluginPage() { >

    The OpenIAP Codex plugin connects Codex to this IAPKit project through - the hosted /mcp endpoint. Use this page for the Kit-local + the hosted /mcp endpoint. Use this page for the IAPKit endpoint and key details; use the OpenIAP MCP Server guide for the full installation flow, local PR testing, tool list, safety rules, and Example App walkthrough. diff --git a/packages/kit/src/pages/docs/sections/compatibility.tsx b/packages/kit/src/pages/docs/sections/compatibility.tsx index eee860a4c..15f1ef47c 100644 --- a/packages/kit/src/pages/docs/sections/compatibility.tsx +++ b/packages/kit/src/pages/docs/sections/compatibility.tsx @@ -62,7 +62,7 @@ X-OpenIAP-Spec: 3.2.0`}

    Contract enforcement

    - CI compares Kit's response enums with the OpenIAP schema used to + CI compares IAPKit's response enums with the OpenIAP schema used to generate every SDK. Runtime response validation and SDK parser tests then verify that unknown optional metadata degrades without weakening required fields. diff --git a/packages/kit/src/pages/docs/sections/introduction.tsx b/packages/kit/src/pages/docs/sections/introduction.tsx index aed20caba..cb8e5affd 100644 --- a/packages/kit/src/pages/docs/sections/introduction.tsx +++ b/packages/kit/src/pages/docs/sections/introduction.tsx @@ -46,7 +46,7 @@ export default function IntroductionPage() {

    IAPKit itself is the managed receipt-verification server. Your app can call it directly with an openiap-kit_pk_ publishable key, - so you do not need to build a proxy just to verify a purchase. Secret + so you do not need to build a proxy just to verify a purchase. Secret{" "} openiap-kit_sk_ keys are only for administrative work such as MCP, catalog or payload writes, analytics, and store sync. You still need your own authenticated backend when resources on that @@ -77,7 +77,7 @@ export default function IntroductionPage() { } title="Amazon Appstore" - detail="Fire OS receipts verified and periodically refreshed through Amazon RVS. Cloud Sandbox is disabled by default and requires an explicit project opt-in." + detail="Fire OS and Vega OS receipts verified and periodically refreshed through Amazon RVS. Cloud Sandbox is disabled by default and requires an explicit project opt-in." slug="verification/amazon" /> diff --git a/packages/kit/src/pages/docs/sections/operations.tsx b/packages/kit/src/pages/docs/sections/operations.tsx index beee952bb..9ab6fd3aa 100644 --- a/packages/kit/src/pages/docs/sections/operations.tsx +++ b/packages/kit/src/pages/docs/sections/operations.tsx @@ -166,7 +166,7 @@ X-RateLimit-Remaining: 599`}

    /health endpoint

    GET /health returns public service, API contract version, - deployment revision, environment, and response-time metadata without + deployment revision, environment, and a response timestamp without hitting Convex or any external store. Point Fly.io readiness / liveness probes at it; point your own uptime monitors at it too. The response uses Cache-Control: no-store and remains intentionally @@ -204,12 +204,13 @@ X-RateLimit-Remaining: 599`}

    Outbound retries

    - Calls to Google Play's Android Publisher API and Meta Graph API are - wrapped in an exponential-backoff retry (max 3 attempts, base 200 ms, - cap 2 s, full jitter) that fires on HTTP 5xx and Node network errors ( - ECONNRESET, ETIMEDOUT, EAI_AGAIN, - …). 4xx responses — including 404 and 410, which are deterministic — are{" "} - not retried. + Calls to Apple's App Store Server API, Google Play's Android Publisher + API, Amazon RVS, and the Meta Graph API are wrapped in an + exponential-backoff retry (max 3 attempts, base 200 ms, cap 2 s, + jittered to 50–100% of the capped delay) that fires on HTTP 5xx and Node + network errors (ECONNRESET, ETIMEDOUT,{" "} + EAI_AGAIN, …). 4xx responses — including 404 and 410, which + are deterministic — are not retried.

    Sentry

    diff --git a/packages/kit/src/pages/docs/sections/projects.tsx b/packages/kit/src/pages/docs/sections/projects.tsx index 0c54976b4..a1642854c 100644 --- a/packages/kit/src/pages/docs/sections/projects.tsx +++ b/packages/kit/src/pages/docs/sections/projects.tsx @@ -39,17 +39,13 @@ export default function ProjectsPage() {

    Creating a project

    From the organization dashboard, open the Projects tab - and click New project. You supply a display name and - pick the client platform (React Native, Flutter, Kotlin Multiplatform, - native iOS / Android, web, …). The platform tag is informational — it - drives which setup guides the dashboard highlights; it doesn't affect - the verify API itself. + and click Create Project. Enter a{" "} + Project Name, optionally edit the generated{" "} + Project URL slug, and optionally pick a{" "} + Platform or Language. The platform tag is informational + — it drives which setup guides the dashboard highlights; it doesn't + affect the verify API itself.

    -

    Store credentials

    Each project's Settings tab has two store configuration cards:

    @@ -154,7 +150,7 @@ export default function ProjectsPage() { diff --git a/packages/kit/src/pages/docs/sections/quickstart.tsx b/packages/kit/src/pages/docs/sections/quickstart.tsx index b7aabce71..f6fcfb8b0 100644 --- a/packages/kit/src/pages/docs/sections/quickstart.tsx +++ b/packages/kit/src/pages/docs/sections/quickstart.tsx @@ -14,7 +14,7 @@ export default function QuickstartPage() { >

    1. Create your account

    - Sign in with GitHub or email OTP on{" "} + Sign in with GitHub on{" "} kit.openiap.dev - . The onboarding flow asks you to name your first organization before - opening its dashboard. The hosted service is free under fair-use - safeguards on shared community infrastructure; no plan or credit card is - required. + . New accounts are created through GitHub. Accounts created before April + 2026 can still sign in with an email one-time code until{" "} + 2026-09-30 (UTC); after that IAPKit supports GitHub + sign-in only. Signing in with GitHub using the same email address + carries an existing account over. The onboarding flow asks you to name + your first organization before opening its dashboard. The hosted service + is free under fair-use safeguards on shared community infrastructure; no + plan or credit card is required.

    -

    2. Create a project

    @@ -201,6 +200,19 @@ export default function QuickstartPage() { Amazon RVS shared secret.

    +

    + React Native IAP and Expo IAP ship a kitApi helper so you + do not have to build these requests by hand — see the sample on{" "} + + Products + {" "} + and the SDK matrix on{" "} + + Compatibility + + . +

    +

    Expected response:

    {`{ diff --git a/packages/kit/src/pages/docs/sections/release-notes.tsx b/packages/kit/src/pages/docs/sections/release-notes.tsx index 6e5f92378..ae0fd64d7 100644 --- a/packages/kit/src/pages/docs/sections/release-notes.tsx +++ b/packages/kit/src/pages/docs/sections/release-notes.tsx @@ -464,7 +464,7 @@ export default function ReleaseNotesPage() { className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1 font-mono text-xs hover:border-primary/50 hover:text-primary" > - {release.version ? `v${release.version}` : "Hosted update"} + {release.version ? `v${release.version}` : release.tagline} {formatDate(release.date)} diff --git a/packages/kit/src/pages/docs/sections/verification-amazon.tsx b/packages/kit/src/pages/docs/sections/verification-amazon.tsx index 6d255c7c5..b87270470 100644 --- a/packages/kit/src/pages/docs/sections/verification-amazon.tsx +++ b/packages/kit/src/pages/docs/sections/verification-amazon.tsx @@ -9,21 +9,23 @@ export default function VerificationAmazonPage() {

    IAPKit verifies Amazon Appstore receipts through the Receipt Verification Service (RVS). Your app sends the Amazon{" "} - userId and receiptId; IAPKit selects the RVS - environment and supplies the project's credential without exposing - it to the app. + userId (required) and receiptId; IAPKit + selects the RVS environment and supplies the project's credential + without exposing it to the app.

    Amazon settings live beside Google Play and Meta Horizon because Fire OS apps use the Android project surface. Google Play configuration is - not required for an Amazon-only project. + not required for an Amazon-only project. Vega OS apps send the same + userId / receiptId payload to the same endpoint and need no separate + configuration.

    diff --git a/packages/kit/src/pages/docs/sections/verification-apple.tsx b/packages/kit/src/pages/docs/sections/verification-apple.tsx index f760176f3..6eeea1558 100644 --- a/packages/kit/src/pages/docs/sections/verification-apple.tsx +++ b/packages/kit/src/pages/docs/sections/verification-apple.tsx @@ -12,11 +12,13 @@ export default function VerificationApplePage() { >

    Apple verification uses a signed JWS transaction produced by StoreKit 2 - on the device. IAPKit verifies the signature against Apple's root CA, - then calls the App Store Server API with your project's .p8{" "} - key to pull the transaction's current state (refund, revocation, grace - period). Both steps are required to catch refunds issued after the - purchase. + on the device. IAPKit decodes the JWS to read its transaction id, bundle + id, and environment, then calls the App Store Server API with your + project's .p8 key and cryptographically verifies the signed + transaction Apple returns against Apple's root CA. The device's copy of + the JWS is only a lookup key — the authoritative record is the one Apple + signs in its response, which is what catches refunds and revocations + issued after the purchase.

    What you'll need

    @@ -87,9 +89,11 @@ export default function VerificationApplePage() {

    IAPKit reads the JWS environment field off the decoded payload, so the same project can verify both sandbox and production - receipts without a toggle. Just make sure the App Apple ID is set - before you go to production — the JWS signature is bound to it for - production-environment receipts. + receipts without a toggle. Set the App Apple ID before you ship to + production — a production-environment JWS is rejected with{" "} + PROJECT_APP_STORE_APPLE_ID_NOT_CONFIGURED before IAPKit + contacts Apple, because Apple's verification library refuses to run in + the production environment without it. Sandbox does not need it.

    @@ -111,11 +115,11 @@ export default function VerificationApplePage() {

    How refunds are detected

    - After verifying the JWS signature, IAPKit calls the App Store Server - API's getTransactionInfo endpoint to fetch the current - state of that transaction — signature-valid means "the purchase once - happened"; getTransactionInfo tells you if it's still valid - right now. No extra flag on the request is required. + IAPKit calls the App Store Server API's getTransactionInfo{" "} + endpoint to fetch the current state of that transaction — the device's + JWS only proves a purchase once existed; getTransactionInfo{" "} + tells you if it's still valid right now. No extra flag on the request is + required.

    For an active transaction, the decoded JWS payload looks like:

    diff --git a/packages/kit/src/pages/docs/sections/verification-google.tsx b/packages/kit/src/pages/docs/sections/verification-google.tsx index e2458d46f..328a7409f 100644 --- a/packages/kit/src/pages/docs/sections/verification-google.tsx +++ b/packages/kit/src/pages/docs/sections/verification-google.tsx @@ -134,12 +134,31 @@ export default function VerificationGooglePage() {

    Transient retries

    Both v2 calls are wrapped in a 3-attempt exponential-backoff retry - (200ms base, 2s cap, full jitter). The retry fires on HTTP 5xx and Node - network errors (ECONNRESET, ETIMEDOUT,{" "} - EAI_AGAIN, …). 4xx responses — including 404 ("not a - product") and 410 ("token no longer valid") — are not{" "} - retried because re-issuing the call won't help and would only waste - quota. + (200ms base, 2s cap, jitter to 50–100% of the capped delay). The retry + fires on HTTP 5xx and Node network errors (ECONNRESET,{" "} + ETIMEDOUT, EAI_AGAIN, …). A 404 from the + product lookup is not an error — it just means the token is a + subscription, so IAPKit falls through to subscriptionsv2. + When neither catalog knows the token, IAPKit retries the whole pair up + to 3 times over roughly 750 ms, because a purchase verified within a + second of completing can still be propagating inside Play. Every other + 4xx response, including 410 ("token no longer valid"), is{" "} + not retried because re-issuing the call won't help and + would only waste quota. +

    + +

    + Negative verdicts that return 200 +

    +

    + Not every rejection is an error. A revoked or purged token (Play 410) + returns 200 with isValid: false and{" "} + state: UNKNOWN — Google returns the same 410 for a token it + never issued and for a subscription purged 60 days after expiry, so + IAPKit cannot distinguish them; do not retry it. A token that belongs to + a different package returns 200 with{" "} + isValid: false and state: INAUTHENTIC. Gate + entitlement on isValid, not on the HTTP status.

    Error codes

    @@ -162,20 +181,12 @@ export default function VerificationGooglePage() {
    - - - - diff --git a/packages/kit/src/pages/faq.tsx b/packages/kit/src/pages/faq.tsx new file mode 100644 index 000000000..f24d02b25 --- /dev/null +++ b/packages/kit/src/pages/faq.tsx @@ -0,0 +1,14 @@ +import { FAQSection } from "@/components/FAQSection"; +import faqContent from "@/content/faq.md?raw"; +import { parseFaqMarkdown } from "@/utils/faq"; + +export default function FaqPage() { + return ( + + ); +} diff --git a/packages/kit/src/pages/index.tsx b/packages/kit/src/pages/index.tsx index fc5c0311e..f188862e4 100644 --- a/packages/kit/src/pages/index.tsx +++ b/packages/kit/src/pages/index.tsx @@ -14,6 +14,7 @@ import Terms from "./terms-of-service"; import Privacy from "./privacy-policy"; import About from "./about"; import Contact from "./contact"; +import Faq from "./faq"; import NotFound from "./404"; // Public Layout Component (for unauthenticated users) @@ -73,6 +74,22 @@ export default function PublicPages() { } /> + + + + } + /> }> } /> } /> diff --git a/packages/kit/src/pages/landing.tsx b/packages/kit/src/pages/landing.tsx index f7197375b..b9dc1f2b6 100644 --- a/packages/kit/src/pages/landing.tsx +++ b/packages/kit/src/pages/landing.tsx @@ -51,7 +51,7 @@ export default function LandingPage() { style={{ lineHeight: "1.2" }} > - {"Open IAP foundation for your"} + {"OpenIAP foundation for your"}

    { - "We contact each supported store, verify authoritative purchase state, and flag risky transactions before you deliver the item." + "We contact each supported store, verify authoritative purchase state, and return one normalized verdict — isValid, state, and the store-verified productId — before you deliver the item." }

    diff --git a/packages/kit/src/utils/constants.ts b/packages/kit/src/utils/constants.ts index 953a5a558..712b238ed 100644 --- a/packages/kit/src/utils/constants.ts +++ b/packages/kit/src/utils/constants.ts @@ -1,19 +1,7 @@ -// App info -export const APP_NAME = "IAPKit"; -export const APP_URL = "https://openiap-kit.com"; -export const APP_DESCRIPTION = "Next-generation IAP integration solution"; - -// Contact export const SUPPORT_EMAIL = "hyo@hyo.dev"; -export const CONTACT_EMAIL = "hyo@hyo.dev"; - -if (!SUPPORT_EMAIL || !CONTACT_EMAIL) { - throw new Error("SUPPORT_EMAIL and CONTACT_EMAIL must be set"); -} -// Social links -export const SOCIAL_LINKS = { - twitter: "https://twitter.com/openiap-kit", - github: "https://github.com/openiap-kit", - discord: "https://discord.gg/5AQd8BbxWT", -}; +// The cutoff lives with its enforcement; the modal just renders it. +export { + EMAIL_SIGN_IN_CLOSES_ON, + isEmailSignInOpen, +} from "../../convex/authWindow"; diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts index a1fab866f..4d7312ad4 100644 --- a/packages/mcp-server/src/mcp.ts +++ b/packages/mcp-server/src/mcp.ts @@ -659,7 +659,7 @@ function registerIapKitTools(server: McpServer) { registerTool( server, "revenue_analytics", - "Summarize IAPKit subscription purchase and revenue analytics for a date range. Defaults to the current UTC month so Codex can answer questions like 'how many purchases happened this month?'.", + "Summarize IAPKit subscription purchase and revenue analytics for a date range. Defaults to the current UTC month so an assistant can answer questions like 'how many purchases happened this month?'.", { period: z .enum(["this_month", "last_30_days", "last_90_days", "custom"]) @@ -847,7 +847,7 @@ function registerIapKitTools(server: McpServer) { registerTool( server, "sync_products", - "Enqueue an IAPKit product sync job for App Store Connect or Google Play. Use dryRun=true first to inspect what Codex would change; set dryRun=false only when the user explicitly asks to apply the store sync.", + "Enqueue an IAPKit product sync job for App Store Connect or Google Play. Use dryRun=true first to inspect what the sync would change; set dryRun=false only when the user explicitly asks to apply the store sync.", { platform: z.enum(["IOS", "Android"]), direction: z @@ -859,7 +859,7 @@ function registerIapKitTools(server: McpServer) { dryRun: z .boolean() .optional() - .describe("Defaults to true so Codex previews store changes first."), + .describe("Defaults to true so store changes are previewed first."), apiKey: OPTIONAL_API_KEY, baseUrl: OPTIONAL_BASE_URL, }, diff --git a/plugins/openiap/.codex-plugin/plugin.json b/plugins/openiap/.codex-plugin/plugin.json index 772b6c59a..f21e4d837 100644 --- a/plugins/openiap/.codex-plugin/plugin.json +++ b/plugins/openiap/.codex-plugin/plugin.json @@ -6,7 +6,7 @@ "name": "OpenIAP", "url": "https://openiap.dev" }, - "homepage": "https://kit.openiap.dev/docs/ai-assistants/codex-plugin", + "homepage": "https://openiap.dev/docs/guides/mcp-server", "repository": "https://github.com/hyodotdev/openiap", "license": "MIT", "keywords": ["openiap", "iapkit", "in-app-purchases", "mcp", "codex"], diff --git a/scripts/audit-agent-surfaces.mjs b/scripts/audit-agent-surfaces.mjs new file mode 100644 index 000000000..d21cbb2e3 --- /dev/null +++ b/scripts/audit-agent-surfaces.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +// Keeps Codex, Claude, and Grok pointed at the same workflows. Every surface is +// discovered from disk, so adding a command or skill without registering it +// everywhere fails here instead of silently working for one agent only. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const repositoryRoot = path.resolve(path.dirname(scriptPath), ".."); + +const COMMANDS_DIR = ".claude/commands"; +const CODEX_SKILLS_DIR = ".codex/skills"; +const CLAUDE_SKILLS_DIR = ".claude/skills"; +const CODEX_ROUTER = ".codex/skills/openiap-workflows/SKILL.md"; +const CLAUDE_ROUTER = ".claude/skills/openiap-workflows/SKILL.md"; +const INSTRUCTIONS = "AGENTS.md"; + +// Grok and Codex read AGENTS.md directly; these must resolve to it. +export const instructionSymlinks = Object.freeze(["CLAUDE.md", "GEMINI.md"]); + +function read(root, relative) { + return fs.readFileSync(path.join(root, relative), "utf8"); +} + +function listDirectories(root, relative) { + const dir = path.join(root, relative); + + if (!fs.existsSync(dir)) { + return []; + } + + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +export function listCommands(root = repositoryRoot) { + const dir = path.join(root, COMMANDS_DIR); + + if (!fs.existsSync(dir)) { + return []; + } + + return fs + .readdirSync(dir) + .filter((name) => name.endsWith(".md")) + .map((name) => name.replace(/\.md$/u, "")) + .sort(); +} + +export function listSkills(root = repositoryRoot) { + return { + codex: listDirectories(root, CODEX_SKILLS_DIR), + claude: listDirectories(root, CLAUDE_SKILLS_DIR), + }; +} + +export function auditAgentSurfaces(root = repositoryRoot) { + const findings = []; + const commands = listCommands(root); + const { codex, claude } = listSkills(root); + + for (const name of codex) { + if (!claude.includes(name)) { + findings.push( + `${CLAUDE_SKILLS_DIR}/${name}/SKILL.md is missing; Claude cannot use the ${name} skill`, + ); + } + } + + for (const name of claude) { + if (!codex.includes(name)) { + findings.push( + `${CODEX_SKILLS_DIR}/${name}/SKILL.md is missing; Codex cannot use the ${name} skill`, + ); + } + } + + for (const name of codex.filter((entry) => claude.includes(entry))) { + const adapter = read(root, `${CLAUDE_SKILLS_DIR}/${name}/SKILL.md`); + + if (!adapter.includes(`${CODEX_SKILLS_DIR}/${name}/SKILL.md`)) { + findings.push( + `${CLAUDE_SKILLS_DIR}/${name}/SKILL.md must point at its canonical ${CODEX_SKILLS_DIR} body`, + ); + } + } + + // A command only reaches Codex through the router, so an unrouted command is + // invisible to every agent that does not read .claude/commands directly. + const routers = [ + [CODEX_ROUTER, read(root, CODEX_ROUTER)], + [CLAUDE_ROUTER, read(root, CLAUDE_ROUTER)], + ]; + + for (const [file, text] of routers) { + for (const command of commands) { + if (!text.includes(`${COMMANDS_DIR}/${command}.md`)) { + findings.push(`${file} does not route ${command}`); + } + } + } + + const instructions = read(root, INSTRUCTIONS); + + for (const command of commands) { + if (!instructions.includes(`\`/${command}\``)) { + findings.push(`${INSTRUCTIONS} skills table is missing /${command}`); + } + } + + for (const skill of codex) { + if (skill === "openiap-workflows") { + continue; + } + + if (!instructions.includes(`\`$${skill}\``)) { + findings.push(`${INSTRUCTIONS} skills table is missing $${skill}`); + } + } + + for (const link of instructionSymlinks) { + const target = path.join(root, link); + + if (!fs.existsSync(target)) { + findings.push(`${link} is missing; it must symlink to ${INSTRUCTIONS}`); + continue; + } + + if ( + !fs.lstatSync(target).isSymbolicLink() || + fs.readlinkSync(target) !== INSTRUCTIONS + ) { + findings.push(`${link} must be a symlink to ${INSTRUCTIONS}`); + } + } + + return findings.sort(); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + const errors = auditAgentSurfaces(); + + if (errors.length === 0) { + console.log("Agent surface audit: clean."); + } else { + console.error("Agent surface audit failed:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exitCode = 1; + } +} diff --git a/scripts/audit-agent-surfaces.test.mjs b/scripts/audit-agent-surfaces.test.mjs new file mode 100644 index 000000000..a6dea01e1 --- /dev/null +++ b/scripts/audit-agent-surfaces.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + auditAgentSurfaces, + instructionSymlinks, + listCommands, + listSkills, +} from "./audit-agent-surfaces.mjs"; + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +const MIRRORED = [ + ".claude/commands", + ".claude/skills", + ".codex/skills", + "AGENTS.md", +]; + +function withMirroredRepository(mutate, run) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openiap-agents-")); + + try { + for (const entry of MIRRORED) { + const source = path.join(repositoryRoot, entry); + const target = path.join(root, entry); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.cpSync(source, target, { recursive: true }); + } + + for (const link of instructionSymlinks) { + fs.symlinkSync("AGENTS.md", path.join(root, link)); + } + + mutate(root); + run(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test("the repository's agent surfaces agree", () => { + assert.deepEqual(auditAgentSurfaces(), []); +}); + +test("every command is discovered and every skill exists for both agents", () => { + const commands = listCommands(); + const { codex, claude } = listSkills(); + + assert.ok(commands.includes("audit-iapkit")); + assert.deepEqual(codex, claude); +}); + +test("rejects a command no router mentions", () => { + withMirroredRepository( + (root) => { + fs.writeFileSync( + path.join(root, ".claude/commands/audit-orphan.md"), + "---\nname: audit-orphan\n---\n", + ); + }, + (root) => { + const findings = auditAgentSurfaces(root); + assert.ok( + findings.some((f) => + f.includes(".codex/skills/openiap-workflows/SKILL.md does not route audit-orphan"), + ), + ); + assert.ok( + findings.some((f) => + f.includes(".claude/skills/openiap-workflows/SKILL.md does not route audit-orphan"), + ), + ); + assert.ok( + findings.some((f) => f.includes("AGENTS.md skills table is missing /audit-orphan")), + ); + }, + ); +}); + +test("rejects a skill that exists for only one agent", () => { + withMirroredRepository( + (root) => { + fs.rmSync(path.join(root, ".claude/skills/rebase-main"), { + recursive: true, + force: true, + }); + }, + (root) => { + assert.ok( + auditAgentSurfaces(root).some((f) => + f.includes(".claude/skills/rebase-main/SKILL.md is missing"), + ), + ); + }, + ); +}); + +test("rejects a Claude adapter that drops its canonical pointer", () => { + withMirroredRepository( + (root) => { + const file = path.join(root, ".claude/skills/rebase-main/SKILL.md"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replaceAll(".codex/skills/rebase-main/SKILL.md", "somewhere else"), + ); + }, + (root) => { + assert.ok( + auditAgentSurfaces(root).some((f) => + f.includes("must point at its canonical .codex/skills body"), + ), + ); + }, + ); +}); + +test("rejects instruction files that stop resolving to AGENTS.md", () => { + withMirroredRepository( + (root) => { + const link = path.join(root, "CLAUDE.md"); + fs.unlinkSync(link); + fs.writeFileSync(link, "# not a symlink\n"); + }, + (root) => { + assert.ok( + auditAgentSurfaces(root).some( + (f) => f === "CLAUDE.md must be a symlink to AGENTS.md", + ), + ); + }, + ); +}); diff --git a/scripts/audit-ci-path-filters.mjs b/scripts/audit-ci-path-filters.mjs new file mode 100644 index 000000000..dd68c8d8d --- /dev/null +++ b/scripts/audit-ci-path-filters.mjs @@ -0,0 +1,621 @@ +#!/usr/bin/env node + +// Guards the path filters that gate native builds and Swift CodeQL. +// Filters are read from the workflows themselves, so there is no second copy. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parse as parseYaml } from "yaml"; + +const scriptPath = fileURLToPath(import.meta.url); +const repositoryRoot = path.resolve(path.dirname(scriptPath), ".."); + +const DOCS_EXCLUDE = "!**/*.md"; + +export const nativeWorkflows = Object.freeze([ + "ci-expo-iap.yml", + "ci-flutter-inapp-purchase.yml", + "ci-godot-iap.yml", + "ci-kmp-iap.yml", + "ci-maui-iap.yml", + "ci-react-native-iap.yml", +]); + +export const ciFilterJobs = Object.freeze({ + android: "ci:test-android", + docs: "ci:test-docs", + gql: "ci:test-gql", + ios: "ci:test-ios", + web: "ci:web-e2e", +}); + +export const unconditionalCiJobs = Object.freeze([ + "audit-lockfile", + "audit-parity", + "audit-release-state", + "changes", + "test-agent", + "test-conformance", +]); + +function readWorkflow(root, name) { + return parseYaml( + fs.readFileSync(path.join(root, ".github/workflows", name), "utf8"), + ); +} + +function dornyStep(document, jobId, stepId) { + const step = document.jobs[jobId].steps.find((entry) => entry.id === stepId); + return { step, filters: parseYaml(step.with.filters) }; +} + +// The audited vocabulary is exactly three forms, so ordered (GitHub native) +// and polarity-based (dorny some-with-excludes) evaluation coincide. +export function matchesPattern(pattern, file) { + if (pattern === "**/*.md") { + return file.endsWith(".md"); + } + + if (pattern.endsWith("/**")) { + return file.startsWith(pattern.slice(0, -2)); + } + + return file === pattern; +} + +export function matchesFilter(patterns, files) { + const includes = patterns.filter((entry) => !entry.startsWith("!")); + const excludes = patterns + .filter((entry) => entry.startsWith("!")) + .map((entry) => entry.slice(1)); + + return files.some( + (file) => + includes.some((pattern) => matchesPattern(pattern, file)) && + !excludes.some((pattern) => matchesPattern(pattern, file)), + ); +} + +export function readScopes(root = repositoryRoot) { + const codeql = readWorkflow(root, "codeql.yml"); + const ci = readWorkflow(root, "ci.yml"); + const core = dornyStep(codeql, "codeql-scope", "core"); + const wrappers = dornyStep(codeql, "codeql-scope", "wrappers"); + const changes = dornyStep(ci, "changes", "filter"); + const scopes = new Map(); + + scopes.set("codeql:analyze-swift", core.filters.swift_core); + + for (const [component, patterns] of Object.entries(wrappers.filters)) { + scopes.set(`codeql:analyze-swift-wrappers/${component}`, patterns); + } + + for (const [name, patterns] of Object.entries(changes.filters)) { + const jobId = ciFilterJobs[name]; + + if (!jobId) { + throw new Error( + `ci.yml: filter '${name}' has no entry in ciFilterJobs; map it to the job it gates`, + ); + } + + scopes.set(jobId, patterns); + } + + for (const name of nativeWorkflows) { + scopes.set(name, readWorkflow(root, name).on.pull_request.paths); + } + + return scopes; +} + +export function selectJobs(files, root = repositoryRoot) { + return [...readScopes(root)] + .filter(([, patterns]) => matchesFilter(patterns, files)) + .map(([jobId]) => jobId) + .sort(); +} + +const ALL_SWIFT_WRAPPERS = [ + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + "codeql:analyze-swift-wrappers/flutter", + "codeql:analyze-swift-wrappers/godot", + "codeql:analyze-swift-wrappers/react-native", +]; + +export const cases = Object.freeze([ + { + name: "apple-core-docs-only", + files: [ + "packages/apple/README.md", + "packages/apple/CONVENTION.md", + "packages/apple/CONTRIBUTING.md", + ], + jobs: [], + }, + { + name: "apple-core-source", + files: ["packages/apple/Sources/OpenIapModule.swift"], + jobs: [ + "ci-expo-iap.yml", + "ci-flutter-inapp-purchase.yml", + "ci-maui-iap.yml", + "ci-react-native-iap.yml", + "ci:test-ios", + "codeql:analyze-swift", + ], + }, + { + name: "apple-core-generated-types", + files: ["packages/apple/Sources/Models/Types.swift"], + jobs: [ + "ci-expo-iap.yml", + "ci-flutter-inapp-purchase.yml", + "ci-maui-iap.yml", + "ci-react-native-iap.yml", + "ci:test-ios", + "codeql:analyze-swift", + ], + }, + { + name: "apple-core-manifest", + files: ["packages/apple/Package.swift"], + jobs: [ + "ci-expo-iap.yml", + "ci-flutter-inapp-purchase.yml", + "ci-react-native-iap.yml", + "ci:test-ios", + "codeql:analyze-swift", + ], + }, + { + name: "apple-core-build-script", + files: ["packages/apple/scripts/build-xcframework.sh"], + jobs: ["ci-maui-iap.yml", "ci:test-ios", "codeql:analyze-swift"], + }, + { + name: "kmp-swift-bridge-source", + files: [ + "libraries/kmp-iap/native/InAppPurchaseBridge/Sources/InAppPurchaseBridge/InAppPurchaseBridge.swift", + ], + jobs: ["ci-kmp-iap.yml", "codeql:analyze-swift"], + }, + { + name: "kmp-docs-only", + files: [ + "libraries/kmp-iap/AGENTS.md", + "libraries/kmp-iap/CLAUDE.md", + "libraries/kmp-iap/.vscode/README_IOS_DEVICE.md", + ], + jobs: [], + }, + { + name: "kmp-vscode-launch-script", + files: ["libraries/kmp-iap/.vscode/run_ios.sh"], + jobs: ["ci-kmp-iap.yml"], + }, + { + name: "react-native-docs-only", + files: [ + "libraries/react-native-iap/AGENTS.md", + "libraries/react-native-iap/CLAUDE.md", + "libraries/react-native-iap/.claude/commands/commit.md", + "libraries/react-native-iap/LICENSE.md", + "libraries/react-native-iap/example/README.md", + ], + jobs: [], + }, + { + name: "react-native-swift-source", + files: ["libraries/react-native-iap/ios/HybridRnIap.swift"], + jobs: [ + "ci-react-native-iap.yml", + "codeql:analyze-swift-wrappers/react-native", + ], + }, + { + name: "react-native-nitro-spec", + files: [ + "libraries/react-native-iap/src/specs/RnIap.nitro.ts", + "libraries/react-native-iap/nitro.json", + ], + jobs: [ + "ci-react-native-iap.yml", + "codeql:analyze-swift-wrappers/react-native", + ], + }, + { + name: "expo-docs-only", + files: [ + "libraries/expo-iap/README.md", + "libraries/expo-iap/CHANGELOG.md", + "libraries/expo-iap/GEMINI.md", + "libraries/expo-iap/example/README.md", + ], + jobs: [], + }, + { + name: "expo-swift-source", + files: ["libraries/expo-iap/ios/ExpoIapModule.swift"], + jobs: [ + "ci-expo-iap.yml", + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + ], + }, + { + name: "expo-onside-swift-source", + files: ["libraries/expo-iap/ios/onside/OnsideIapModule.swift"], + jobs: [ + "ci-expo-iap.yml", + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + ], + }, + { + name: "expo-onside-podfile-plugin", + files: ["libraries/expo-iap/plugin/src/onsidePodfile.ts"], + jobs: [ + "ci-expo-iap.yml", + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + ], + }, + { + name: "flutter-docs-only", + files: [ + "libraries/flutter_inapp_purchase/CHANGELOG.md", + "libraries/flutter_inapp_purchase/CONVENTION.md", + "libraries/flutter_inapp_purchase/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md", + ], + jobs: [], + }, + { + name: "flutter-build-script", + files: [ + "libraries/flutter_inapp_purchase/scripts/verify-apple-swiftpm-consumer-build.sh", + ], + jobs: [ + "ci-flutter-inapp-purchase.yml", + "codeql:analyze-swift-wrappers/flutter", + ], + }, + { + name: "godot-docs-only", + files: [ + "libraries/godot-iap/.claude/guides/03-ios-plugin.md", + "libraries/godot-iap/EXAMPLES.md", + ], + jobs: [], + }, + { + name: "godot-swift-source", + files: ["libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift"], + jobs: ["ci-godot-iap.yml", "codeql:analyze-swift-wrappers/godot"], + }, + { + name: "godot-addon-behind-symlink", + files: ["libraries/godot-iap/addons/godot-iap/godot_iap.gd"], + jobs: ["ci-godot-iap.yml", "codeql:analyze-swift-wrappers/godot"], + }, + { + name: "google-docs-only", + files: [ + "packages/google/README.md", + "packages/google/ALTERNATIVE_BILLING.md", + ], + jobs: [], + }, + { + name: "google-source", + files: ["packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt"], + jobs: ["ci-kmp-iap.yml", "ci-maui-iap.yml", "ci:test-android"], + }, + { + name: "root-version-manifest", + files: ["openiap-versions.json"], + jobs: [ + ...nativeWorkflows, + "ci:test-android", + "ci:test-gql", + "ci:test-ios", + "codeql:analyze-swift", + ...ALL_SWIFT_WRAPPERS, + ], + }, + { + name: "libraries-versions-manifest", + files: ["libraries-versions.jsonc"], + jobs: [ + "ci-expo-iap.yml", + "ci-flutter-inapp-purchase.yml", + "ci-react-native-iap.yml", + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + "codeql:analyze-swift-wrappers/flutter", + "codeql:analyze-swift-wrappers/react-native", + ], + }, + { + name: "codeql-workflow-edit", + files: [".github/workflows/codeql.yml"], + jobs: ["codeql:analyze-swift", ...ALL_SWIFT_WRAPPERS], + }, + { + name: "maui-binding-source", + files: ["libraries/maui-iap/src/OpenIap.Maui/Types.cs"], + jobs: ["ci-maui-iap.yml", "ci:test-gql"], + }, + { + // kit ships .md as bundled site content, so `web` keeps every markdown path. + name: "kit-content-markdown", + files: ["packages/kit/src/content/privacy-policy.md"], + jobs: ["ci:web-e2e"], + }, + { + name: "docs-site-source", + files: ["packages/docs/src/pages/docs/index.tsx"], + jobs: ["ci:test-docs", "ci:web-e2e"], + }, + { + // docs and web intentionally keep markdown; both are cheap ubuntu jobs. + name: "docs-markdown", + files: ["packages/docs/README.md"], + jobs: ["ci:test-docs", "ci:web-e2e"], + }, + { + name: "scripts-docs-only", + files: ["scripts/agent/README.md"], + jobs: [], + }, + { + name: "mixed-docs-and-source", + files: [ + "libraries/expo-iap/README.md", + "libraries/godot-iap/ios-gdextension/Sources/GodotIap/Binder.swift", + ], + jobs: ["ci-godot-iap.yml", "codeql:analyze-swift-wrappers/godot"], + }, + { + name: "mixed-two-wrappers", + files: [ + "libraries/expo-iap/ios/ExpoIapLog.swift", + "libraries/godot-iap/ios-gdextension/Package.swift", + ], + jobs: [ + "ci-expo-iap.yml", + "ci-godot-iap.yml", + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + "codeql:analyze-swift-wrappers/godot", + ], + }, + { + name: "pr-361-docs-replay", + files: [ + "AGENTS.md", + "libraries/expo-iap/AGENTS.md", + "libraries/godot-iap/CLAUDE.md", + "libraries/maui-iap/CONVENTION.md", + "libraries/react-native-iap/GEMINI.md", + "knowledge/internal/02-architecture.md", + "packages/apple/CONTRIBUTING.md", + ], + jobs: [], + }, +]); + +function findVocabularyViolations(scopes) { + const findings = []; + + for (const [scope, patterns] of scopes) { + const hasExclude = patterns.some((pattern) => pattern.startsWith("!")); + + for (const pattern of patterns) { + if (pattern.startsWith("!") && pattern !== DOCS_EXCLUDE) { + findings.push( + `${scope}: unsupported negation '${pattern}'; only '${DOCS_EXCLUDE}' is proven equivalent across both matchers`, + ); + continue; + } + + if ( + hasExclude && + !pattern.startsWith("!") && + (pattern.endsWith(".md") || pattern.endsWith(".mdx")) + ) { + findings.push( + `${scope}: positive '${pattern}' targets markdown and would be unreachable behind '${DOCS_EXCLUDE}'`, + ); + } + + const body = pattern.startsWith("!") ? pattern.slice(1) : pattern; + const literal = body.endsWith("/**") ? body.slice(0, -3) : body; + + if (body !== "**/*.md" && /[*?+[\]{}]/u.test(literal)) { + findings.push( + `${scope}: pattern '${pattern}' is outside the audited vocabulary`, + ); + } + } + } + + return findings; +} + +function findQuantifierViolations(root) { + const codeql = readWorkflow(root, "codeql.yml"); + const ci = readWorkflow(root, "ci.yml"); + const steps = [ + ["codeql.yml", "codeql-scope", "core", dornyStep(codeql, "codeql-scope", "core")], + [ + "codeql.yml", + "codeql-scope", + "wrappers", + dornyStep(codeql, "codeql-scope", "wrappers"), + ], + ["ci.yml", "changes", "filter", dornyStep(ci, "changes", "filter")], + ]; + + return steps.flatMap(([file, jobId, stepId, { step, filters }]) => { + const negates = Object.values(filters).some((patterns) => + patterns.some((pattern) => pattern.startsWith("!")), + ); + + if (!negates || step.with["predicate-quantifier"] === "some-with-excludes") { + return []; + } + + return [ + `${file}:${jobId}/${stepId}: negated patterns require predicate-quantifier: some-with-excludes`, + ]; + }); +} + +function findCoverageViolations(root) { + const findings = []; + const codeql = readWorkflow(root, "codeql.yml"); + const ci = readWorkflow(root, "ci.yml"); + + for (const [name, document] of [ + ["codeql.yml", codeql], + ["ci.yml", ci], + ]) { + for (const event of ["pull_request", "push"]) { + const trigger = document.on[event] ?? {}; + + if (trigger.paths || trigger["paths-ignore"]) { + findings.push( + `${name}: ${event} must stay unfiltered; job-level gating keeps skipped checks reportable`, + ); + } + } + } + + for (const event of ["schedule", "workflow_dispatch"]) { + if (!(event in codeql.on)) { + findings.push(`codeql.yml: ${event} coverage was removed`); + } + } + + for (const output of ["swift_core", "swift_wrappers", "swift_components"]) { + const expression = codeql.jobs["codeql-scope"].outputs[output] ?? ""; + + if (!expression.includes("github.event_name != 'pull_request'")) { + findings.push(`codeql.yml: ${output} lost its non-pull_request fallback`); + } + } + + for (const stepId of ["core", "wrappers"]) { + const { step } = dornyStep(codeql, "codeql-scope", stepId); + + if (step.if !== "github.event_name == 'pull_request'") { + findings.push( + `codeql.yml: ${stepId} step must stay pull_request-only so other events fall back to full scope`, + ); + } + } + + for (const jobId of unconditionalCiJobs) { + const job = ci.jobs[jobId]; + + if (!job) { + findings.push(`ci.yml: job ${jobId} is missing`); + continue; + } + + if ("needs" in job || "if" in job) { + findings.push(`ci.yml: job ${jobId} must stay unconditional`); + } + } + + // A filter only means something through the job it gates, so pin that wiring. + const { filters: ciFilters } = dornyStep(ci, "changes", "filter"); + + for (const [name, label] of Object.entries(ciFilterJobs)) { + const jobId = label.replace(/^ci:/u, ""); + const job = ci.jobs[jobId]; + + if (!(name in ciFilters)) { + findings.push( + `ci.yml: filter '${name}' was removed but job ${jobId} still gates on it`, + ); + continue; + } + + if (!job) { + findings.push(`ci.yml: job ${jobId} is missing; update ciFilterJobs`); + continue; + } + + if (job.if !== `needs.changes.outputs.${name} == 'true'`) { + findings.push( + `ci.yml: job ${jobId} must gate on needs.changes.outputs.${name}`, + ); + } + + if ( + ci.jobs.changes.outputs[name] !== `\${{ steps.filter.outputs.${name} }}` + ) { + findings.push(`ci.yml: changes job must publish the ${name} filter output`); + } + } + + for (const name of nativeWorkflows) { + const document = readWorkflow(root, name); + const pullRequest = document.on.pull_request.paths; + const push = document.on.push.paths; + + if (JSON.stringify(pullRequest) !== JSON.stringify(push)) { + findings.push(`${name}: push.paths must equal pull_request.paths`); + } + + if (pullRequest.at(-1) !== DOCS_EXCLUDE) { + findings.push(`${name}: '${DOCS_EXCLUDE}' must be the last paths entry`); + } + + if (!pullRequest.some((pattern) => !pattern.startsWith("!"))) { + findings.push(`${name}: paths needs at least one non-negated pattern`); + } + } + + return findings; +} + +export function findPolicyViolations(root = repositoryRoot) { + return [ + ...findQuantifierViolations(root), + ...findVocabularyViolations(readScopes(root)), + ...findCoverageViolations(root), + ]; +} + +export function auditCiPathFilters(root = repositoryRoot) { + const selectionFindings = cases.flatMap(({ name, files, jobs }) => { + const expected = [...jobs].sort(); + const actual = selectJobs(files, root); + + return JSON.stringify(actual) === JSON.stringify(expected) + ? [] + : [`${name}: expected [${expected}], got [${actual}]`]; + }); + + return [...selectionFindings, ...findPolicyViolations(root)].sort(); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + const errors = auditCiPathFilters(); + + if (errors.length === 0) { + console.log("CI path filter audit: clean."); + } else { + console.error("CI path filter audit failed:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exitCode = 1; + } +} diff --git a/scripts/audit-ci-path-filters.test.mjs b/scripts/audit-ci-path-filters.test.mjs new file mode 100644 index 000000000..a82a92e86 --- /dev/null +++ b/scripts/audit-ci-path-filters.test.mjs @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + auditCiPathFilters, + cases, + findPolicyViolations, + matchesFilter, + selectJobs, +} from "./audit-ci-path-filters.mjs"; + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +function withPatchedWorkflows(patch, run) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openiap-ci-paths-")); + + try { + fs.cpSync( + path.join(repositoryRoot, ".github/workflows"), + path.join(root, ".github/workflows"), + { recursive: true }, + ); + patch(path.join(root, ".github/workflows")); + run(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +for (const { name, files, jobs } of cases) { + test(`selects the expected jobs for ${name}`, () => { + assert.deepEqual(selectJobs(files), [...jobs].sort()); + }); +} + +test("workflow path filters satisfy the audited policy", () => { + assert.deepEqual(auditCiPathFilters(), []); +}); + +test("markdown exclusion is final and cannot be re-included", () => { + const patterns = ["packages/apple/**", "!**/*.md"]; + + assert.equal(matchesFilter(patterns, ["packages/apple/README.md"]), false); + assert.equal(matchesFilter(patterns, ["packages/apple/Sources/A.swift"]), true); + // Order must not change the answer; that is what makes one list safe in both + // dorny (polarity) and GitHub native paths (last match wins). + assert.equal( + matchesFilter(["!**/*.md", "packages/apple/**"], ["packages/apple/README.md"]), + false, + ); +}); + +test("rejects negation without the some-with-excludes quantifier", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "codeql.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replaceAll(" predicate-quantifier: some-with-excludes\n", ""), + ); + }, + (root) => { + assert.deepEqual( + findPolicyViolations(root).filter((finding) => + finding.includes("predicate-quantifier"), + ), + [ + "codeql.yml:codeql-scope/core: negated patterns require predicate-quantifier: some-with-excludes", + "codeql.yml:codeql-scope/wrappers: negated patterns require predicate-quantifier: some-with-excludes", + ], + ); + }, + ); +}); + +test("rejects negation forms outside the audited vocabulary", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "codeql.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replace( + " - 'libraries/expo-iap/**'\n - 'libraries-versions.jsonc'", + " - 'libraries/expo-iap/**'\n - '!libraries/expo-iap/docs/**'\n - 'libraries-versions.jsonc'", + ), + ); + }, + (root) => { + assert.ok( + findPolicyViolations(root).some((finding) => + finding.includes("unsupported negation '!libraries/expo-iap/docs/**'"), + ), + ); + }, + ); +}); + +test("rejects a workflow-level paths filter on ci.yml or codeql.yml", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "ci.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replace( + "on:\n pull_request:\n branches:\n - main\n - next\n", + "on:\n pull_request:\n branches:\n - main\n - next\n paths:\n - 'packages/**'\n", + ), + ); + }, + (root) => { + assert.ok( + findPolicyViolations(root).some( + (finding) => finding === "ci.yml: pull_request must stay unfiltered; job-level gating keeps skipped checks reportable", + ), + ); + }, + ); +}); + +test("rejects rewiring a gated job onto the wrong filter", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "ci.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replace( + " if: needs.changes.outputs.ios == 'true'", + " if: needs.changes.outputs.docs == 'true'", + ), + ); + }, + (root) => { + assert.ok( + findPolicyViolations(root).includes( + "ci.yml: job test-ios must gate on needs.changes.outputs.ios", + ), + ); + }, + ); +}); + +test("rejects deleting a filter that a job still gates on", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "ci.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replace( + ` docs: + - 'packages/docs/**' + - 'packages/gql/src/generated/**' + - 'packages/gql/generated-sync-manifest.mjs' + - 'scripts/audit-docs.ts' + - 'scripts/audit-docs.test.ts' + - '.github/workflows/ci.yml' +`, + "", + ), + ); + }, + (root) => { + assert.ok( + findPolicyViolations(root).includes( + "ci.yml: filter 'docs' was removed but job test-docs still gates on it", + ), + ); + }, + ); +}); + +test("rejects gating a job that guards the markdown corpus", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "ci.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replace( + " audit-parity:\n name: Audit SDK Parity\n", + " audit-parity:\n name: Audit SDK Parity\n needs: changes\n", + ), + ); + }, + (root) => { + assert.ok( + findPolicyViolations(root).includes( + "ci.yml: job audit-parity must stay unconditional", + ), + ); + }, + ); +}); diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index ce3573baf..0d3c9e546 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -6026,16 +6026,25 @@ function checkFrameworkDependencyHygiene() { `${releaseNotesWorkflow} release notes must use the release tag when it already exists`, ); } - for (const xcodeReleaseWorkflow of [ + expectIncludes( ".github/workflows/ci.yml", + [ + "runs-on: macos-26", + "XCODE_VERSION: 26.6", + "maxim-lobanov/setup-xcode@", + "xcode-version: ${{ env.XCODE_VERSION }}", + ], + ".github/workflows/ci.yml must pin the macOS/Xcode release image", + ); + for (const xcodeReleaseWorkflow of [ ".github/workflows/release-apple.yml", ".github/workflows/release-kmp.yml", ]) { expectIncludes( xcodeReleaseWorkflow, [ - "runs-on: macos-15", - "XCODE_VERSION: 16.4", + "runs-on: macos-26", + "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], @@ -7447,6 +7456,7 @@ function checkFrameworkDependencyHygiene() { "libraries/expo-iap/plugin/src/withVega.ts", "libraries/expo-iap/example/vega/package.json", "libraries/react-native-iap/example/vega/package.json", + "packages/docs/src/pages/docs/setup/store/amazon.tsx", ]) { expectIncludes( vegaDependencyFile, @@ -7464,6 +7474,36 @@ function checkFrameworkDependencyHygiene() { ["^0.0.7"], "Expo Vega plugin must install the current compatibility Metro config", ); + for (const vegaManifestFile of [ + "libraries/expo-iap/plugin/src/withVega.ts", + "libraries/expo-iap/example/scripts/vega-build-config.mjs", + "libraries/react-native-iap/example/manifest.toml", + "packages/docs/src/pages/docs/setup/store/amazon.tsx", + ]) { + expectIncludes( + vegaManifestFile, + ["/com.amazon.vega.os@IVega_1_2", "[os.version]"], + "Vega manifests must declare the OS module and version required since Vega SDK 0.24", + ); + } + // withVega.ts interpolates the version, so pin the constant there and the + // literal target/min pair in the emitted manifests. + expectIncludes( + "libraries/expo-iap/plugin/src/withVega.ts", + ["const VEGA_OS_VERSION = '1.2'"], + "Expo Vega plugin must pin the Vega OS version constant", + ); + for (const literalVegaManifest of [ + "libraries/expo-iap/example/scripts/vega-build-config.mjs", + "libraries/react-native-iap/example/manifest.toml", + "packages/docs/src/pages/docs/setup/store/amazon.tsx", + ]) { + expectIncludes( + literalVegaManifest, + ['target = "1.2"', 'min = "1.2"'], + "Vega manifests must pin the OS target and minimum version", + ); + } expectOptionalIncludes( "libraries/expo-iap/example/android/settings.gradle", [ @@ -8944,8 +8984,8 @@ function checkXcode27StoreKitCoverage() { "openiap-versions.json", '".github/workflows/release-flutter.yml"', "apple-cocoapods:", - "runs-on: macos-15", - "XCODE_VERSION: 16.4", + "runs-on: macos-26", + "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], diff --git a/scripts/audit-security.test.mjs b/scripts/audit-security.test.mjs index 0ec5bb9df..e45c7129a 100644 --- a/scripts/audit-security.test.mjs +++ b/scripts/audit-security.test.mjs @@ -593,11 +593,28 @@ test("CodeQL scopes Swift pull requests to public macOS runners", () => { /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/u, ); assert.match(scope, /swift_core:/u); - assert.match(swiftCore, /needs: codeql-scope/u); + assert.match(swiftCore, /needs: \[codeql-scope, pick-mac-runner\]/u); assert.match( swiftCore, /if: needs\.codeql-scope\.outputs\.swift_core == 'true'/u, ); + assert.match( + swiftCore, + /runs-on: \$\{\{ needs\.pick-mac-runner\.outputs\.runner \}\}/u, + ); + // The gate may hand a job to the self-hosted Mac only for the owner's own + // pull requests, and it always falls back to the hosted image. + const gate = workflow.slice( + workflow.indexOf(" pick-mac-runner:"), + workflow.indexOf(" analyze-swift:"), + ); + assert.match( + gate, + /github\.event\.pull_request\.user\.login == 'hyochan' && github\.actor == 'hyochan'/u, + ); + assert.match(gate, /github\.event_name == 'pull_request'/u); + assert.match(gate, /runner='macos-26'/u); + assert.match(gate, /-lt 900/u); assert.match(scope, /react-native:/u); assert.match(scope, /expo-onside:/u); assert.match(scope, /flutter:/u); @@ -614,9 +631,15 @@ test("CodeQL scopes Swift pull requests to public macOS runners", () => { wrappers, /github\.event\.pull_request\.head\.repo\.full_name == github\.repository/u, ); + // Pushes keep the xcode-27 split; PR legs stay hosted except godot, which + // may ride the owner-gated Mac. + assert.match( + wrappers, + /github\.event_name != 'pull_request'\s+&& \(matrix\.component == 'godot' && 'macos-26' \|\| 'xcode-27'\)/u, + ); assert.match( wrappers, - /runs-on: \$\{\{ \(github\.event_name == 'pull_request' \|\| matrix\.component == 'godot'\) && 'macos-26' \|\| 'xcode-27' \}\}/u, + /\|\| needs\.pick-mac-runner\.outputs\.runner \}\}/u, ); assert.match( wrappers, diff --git a/scripts/ci/mac-runner-heartbeat.sh b/scripts/ci/mac-runner-heartbeat.sh new file mode 100755 index 000000000..2e267a80b --- /dev/null +++ b/scripts/ci/mac-runner-heartbeat.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Refreshes the MAC_CI heartbeat (unix epoch) while the Actions runner on this +# machine is alive. Workflow gate jobs treat a heartbeat older than 15 minutes +# as "Mac is off" and fall back to GitHub-hosted runners, so nothing hangs when +# the machine sleeps or shuts down. +# +# Install on the Mac Mini (once) as a LaunchAgent (cron edits can hang on +# macOS TCC): ~/Library/LaunchAgents/dev.openiap.mac-ci-heartbeat.plist running +# this script with StartInterval 300 + RunAtLoad, then +# launchctl bootstrap gui/$(id -u) +# Requires `gh auth` with repo admin. +set -eu + +# cron ships a minimal PATH without Homebrew. +PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + +pgrep -q "Runner.Listener" || exit 0 +exec gh variable set MAC_CI --repo hyodotdev/openiap --body "$(date +%s)"
    - PLAY_STORE_PURCHASE_NOT_FOUND - - Token doesn't resolve to a product or subscription — usually a - replay or a subscription purged after 60 days of inactivity. -
    - PLAY_STORE_PURCHASE_VERIFICATION_FAILED + PLAY_STORE_VERIFICATION_ERROR - Auth failure, permission mismatch, or Google returned a shape - IAPKit couldn't interpret. + Every store-side failure after credentials load: token not + found, auth or permission failure, or a response IAPKit could + not interpret. The originating reason is in the message.