diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..d71013f58 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "openiap", + "owner": { + "name": "OpenIAP", + "email": "hyo@hyo.dev" + }, + "description": "OpenIAP plugins for AI coding agents.", + "plugins": [ + { + "name": "openiap", + "source": "./plugins/openiap", + "description": "Inspect and implement in-app purchase flows with OpenIAP and IAPKit through the hosted MCP server (iapkit_* tools).", + "category": "developer-tools", + "homepage": "https://openiap.dev/docs/guides/mcp-server" + } + ] +} diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index 6229b4c4c..f949febe7 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -34,8 +34,9 @@ Complete workflow: branch → commit → push → PR If the staged changes only touch internal agent/workflow files, do not push or create a PR unless the user explicitly asked to publish, PR, or merge them. -Internal workflow files include `.claude/commands/`, `.codex/skills/`, -`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, and agent automation notes. +Internal workflow files include `.claude/commands/`, `.claude/skills/`, +`.codex/skills/`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, and agent +automation notes. For those internal-only changes, prefer a local commit or local working-tree change and report the files changed. If the user explicitly asks to open or diff --git a/.claude/guides/09-kit-package.md b/.claude/guides/09-kit-package.md index b4f20aba0..c9a3eec3c 100644 --- a/.claude/guides/09-kit-package.md +++ b/.claude/guides/09-kit-package.md @@ -48,6 +48,10 @@ convex// └── internal.ts # Internal-only (called from actions) ``` +Sanctioned exception: an operator-only `internalMutation` may stay in +`mutation.ts` when relocating it would change its generated function path +(see `purchases/mutation.ts` — `markReceiptInvalid`). Do not relocate it. + `convex/_generated/*` is regenerated by `bunx convex dev` — never edit. When working on Convex code, **read `convex/_generated/ai/guidelines.md` first**. ## Pre-commit Gate (Paths-Aware, CI-Equivalent) diff --git a/.claude/settings.json b/.claude/settings.json index 11b194707..79f2b7701 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,6 @@ { "permissions": { "allow": [ - "Bash(python3 /tmp/add_kmp_tabs.py)", "Bash(awk:*)" ] } diff --git a/.claude/skills/generate-doc/SKILL.md b/.claude/skills/generate-doc/SKILL.md new file mode 100644 index 000000000..d2cb899fb --- /dev/null +++ b/.claude/skills/generate-doc/SKILL.md @@ -0,0 +1,20 @@ +--- +name: generate-doc +description: Use for OpenIAP documentation generation work, especially release-note entries in packages/docs/src/pages/docs/updates/releases.tsx where package releases are assumed to be deployed and links should be written as shipped release links. +--- + +# Generate OpenIAP Docs (Claude Code) + +The canonical instructions live in `.codex/skills/generate-doc/SKILL.md`. +Read that file first and follow it fully — required reading, release-note +mode, version sources, tag formats, editing rules, multi-package release +trains, and validation are all defined there and apply to any agent. + +## Claude Code Notes + +- Where the canonical file says to use `openiap-workflows`, use the + `.claude/skills/openiap-workflows` skill or the matching + `.claude/commands/*.md` slash command instead. +- Run the same validation commands (`bunx prettier --check`, `bun run build`, + `bun run audit:docs`, `git diff --check`) before reporting the docs change + as done. diff --git a/.claude/skills/iapkit-e2e-martie/SKILL.md b/.claude/skills/iapkit-e2e-martie/SKILL.md new file mode 100644 index 000000000..3b31f0760 --- /dev/null +++ b/.claude/skills/iapkit-e2e-martie/SKILL.md @@ -0,0 +1,26 @@ +--- +name: iapkit-e2e-martie +description: Run IAPKit local receipt-validation E2E with the dev.hyo.martie React Native or Expo examples, the compiled packages/kit server, real Convex, and Apple or Google sandbox purchases. Use when verifying purchase-token or JWS routing, Local (IAPKit) baseUrl behavior, local server logs, receipt validity, transaction finishing, or the Martie purchases view; distinguish safe smoke checks from approval-gated live purchase verticals. +--- + +# IAPKit Martie Receipt E2E (Claude Code) + +The canonical procedure lives in `.codex/skills/iapkit-e2e-martie/SKILL.md`. +Read it and follow it exactly — targets, smoke vs live lanes, safety gates, +preflight, server startup, example configuration, the Martie catalog, the +approval-gated live vertical, its `LIVE RECEIPT PASS` assertions, and cleanup +are all agent-agnostic and apply as written. + +## Claude Code Notes + +- The safety gates are non-negotiable in Claude Code too: never press + Purchase/Subscribe or confirm a store sheet without explicit approval in the + current run, never rotate or reveal IAPKit API keys, and never mutate the + store catalog from this workflow. +- Report results with the same vocabulary: `SMOKE PASS` / `SMOKE FAIL` for the + server smoke lane, `LIVE RECEIPT PASS` only when every live-lane assertion + holds, and `BLOCKED` when a device, account, catalog, key, Convex URL, or + network route is missing. +- Also read the `Local (IAPKit) Receipt Vertical` section of + `.claude/commands/e2e-tests.md` during preflight, as the canonical file + requires. diff --git a/.claude/skills/iapkit-e2e-petgu/SKILL.md b/.claude/skills/iapkit-e2e-petgu/SKILL.md new file mode 100644 index 000000000..6bdb3561a --- /dev/null +++ b/.claude/skills/iapkit-e2e-petgu/SKILL.md @@ -0,0 +1,24 @@ +--- +name: iapkit-e2e-petgu +description: Use for IAPKit product sync E2E testing in packages/kit with the Petgu React Native app, localhost dashboard, App Store Connect, and Google Play Console. Triggers when verifying kit product/entitlement create, update, delete, pull, push, or store sync behavior with Petgu. +--- + +# IAPKit Petgu E2E (Claude Code) + +The canonical procedure lives in `.codex/skills/iapkit-e2e-petgu/SKILL.md`. +Read it and follow it exactly — default targets, safety rules, the standard +workflow, known good Petgu products, expected store behavior, and reporting +requirements are agent-agnostic and apply as written. + +## Claude Code Notes + +- Apple product IDs are permanently unusable after deletion; treat the + canonical safety rules as hard gates and only ever delete temporary SKUs + created for the current verification run. +- Where the canonical file prefers temporary IDs under `dev.hyo.petgu.codex.*`, + IDs under `dev.hyo.petgu.claude.*` are equally acceptable — what matters is + that the SKU is clearly temporary and is removed from IAPKit and both stores + during cleanup. +- Where it says to use Chrome for logged-in Play Console / App Store Connect + tabs, use Claude's browser tooling (Claude in Chrome) against the user's + existing signed-in session; never handle store credentials directly. diff --git a/.claude/skills/opencollective-steward/SKILL.md b/.claude/skills/opencollective-steward/SKILL.md new file mode 100644 index 000000000..6f093b2e8 --- /dev/null +++ b/.claude/skills/opencollective-steward/SKILL.md @@ -0,0 +1,22 @@ +--- +name: opencollective-steward +description: Manage OpenIAP's OpenCollective presence, including profile copy, slug/link migrations, sponsor/backer recognition, update posts, and README/docs sponsor assets. Use when the user asks to draft or publish OpenCollective updates, maintain OpenCollective tiers/profile content, migrate react-native-iap OpenCollective links to openiap, or make supporters feel informed and appreciated. +--- + +# OpenCollective Steward (Claude Code) + +The canonical instructions live in +`.codex/skills/opencollective-steward/SKILL.md`. Read that file and follow it +fully — the core workflow, live edit guardrails, positioning, update post +pattern, canonical README/asset links, publishing checklist, and example copy +are agent-agnostic and apply as written. + +## Claude Code Notes + +- For live OpenCollective writes, use Claude's browser tooling (Claude in + Chrome) with the user's signed-in session. If OpenCollective asks for + sign-in, hand control back to the user; never infer or extract auth tokens + from browser state. +- The Trix-editor guardrail applies verbatim: verify the hidden form input + matches the visible editor content before pressing Save, and abort the save + when they disagree. diff --git a/.claude/skills/openiap-workflows/SKILL.md b/.claude/skills/openiap-workflows/SKILL.md new file mode 100644 index 000000000..2eda30364 --- /dev/null +++ b/.claude/skills/openiap-workflows/SKILL.md @@ -0,0 +1,36 @@ +--- +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/CLAUDE.md/GEMINI.md. +--- + +# OpenIAP Workflows (Claude Code) + +The canonical workflow definitions live in `.claude/commands/*.md` and the +shared operating rules live in `.codex/skills/openiap-workflows/SKILL.md`. +Follow both; this file only adds the Claude Code specifics. + +## Command Mapping + +When the user asks in natural language, execute the matching workflow by +reading the command file (or invoke the slash command directly when available): + +- Review PR comments / fix review feedback → `.claude/commands/review-pr.md` (`/review-pr`) +- Audit code against knowledge rules → `.claude/commands/audit-code.md` (`/audit-code`) +- 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`) +- Device-backed E2E regression → `.claude/commands/e2e-tests.md` (`/e2e-tests`) +- Stable or RC/next releases → `.claude/commands/release.md` (`/release`) +- Commit, push, or create PR → `.claude/commands/commit.md` (`/commit`) + +## Claude Code Notes + +- Read the "Source Of Truth", "Internal Workflow Change Guard", + "Non-Negotiables", and "GitHub Review Threads" sections of + `.codex/skills/openiap-workflows/SKILL.md` and apply them as written; they + are agent-agnostic rules, not Codex-only rules. +- Where that file says to use the Codex Chrome Extension, use Claude's browser + tooling (Claude in Chrome / Playwright) instead. +- Where that file mentions `$skill` syntax, the equivalent in Claude Code is + the matching skill in `.claude/skills/` or the slash command in + `.claude/commands/`. diff --git a/.claude/skills/review-self/SKILL.md b/.claude/skills/review-self/SKILL.md new file mode 100644 index 000000000..2aef49208 --- /dev/null +++ b/.claude/skills/review-self/SKILL.md @@ -0,0 +1,26 @@ +--- +name: review-self +description: Independently review and improve the agent's current implementation, working-tree changes, or pull request; fix actionable in-scope gaps; rerun relevant verification; and recheck at five-minute intervals until the work is stable or genuinely blocked. Use when the user says "review-self", asks Claude to review its own changes, requests a self-review loop, or wants current work monitored for new issues after implementation. +--- + +# Review Self (Claude Code) + +The canonical loop definition lives in `.codex/skills/review-self/SKILL.md`. +Read it and follow every section — authority and scope preservation, target +establishment, the review round, related OpenIAP workflows, the five-minute +recheck contract, safe stopping conditions, and per-round communication are +agent-agnostic and apply as written. + +## Claude Code Notes + +- Where the canonical file routes through `$openiap-workflows`, read the + matching `.claude/commands/*.md` file directly (or use the + `.claude/skills/openiap-workflows` skill). +- For the five-minute recheck, use Claude's real wake-up mechanism for the + current surface (for example a scheduled reminder / wake-up tool in Cowork + or the Agent SDK). If no such mechanism is available in the current session, + complete the current round and report that automatic re-entry could not be + scheduled — never emulate the loop with `sleep 300`, `while true`, or an + abandoned background process. +- Use read-only subagents (Task/Agent tool with an Explore-style agent) for + independent review lenses on large or cross-cutting diffs. diff --git a/.codex/skills/openiap-workflows/SKILL.md b/.codex/skills/openiap-workflows/SKILL.md index 2f9468be2..f8a888dec 100644 --- a/.codex/skills/openiap-workflows/SKILL.md +++ b/.codex/skills/openiap-workflows/SKILL.md @@ -53,9 +53,9 @@ platform rows, connected-device rows, and explicit blocked/unsupported rows. ## Internal Workflow Change Guard Internal agent/workflow-only changes include `.claude/commands/`, -`.codex/skills/`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, and agent automation -notes. Do not create a branch, push, or open a PR for those changes unless the -user explicitly asks to publish, PR, or merge them. +`.claude/skills/`, `.codex/skills/`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, +and agent automation notes. Do not create a branch, push, or open a PR for +those changes unless the user explicitly asks to publish, PR, or merge them. If a user asks to update an internal workflow and does not explicitly ask for a PR, keep the change local and report the changed files. If a PR is already open diff --git a/.github/pr-previews/claude-parity-docs-preview.mp4 b/.github/pr-previews/claude-parity-docs-preview.mp4 new file mode 100644 index 000000000..175d6f02b Binary files /dev/null and b/.github/pr-previews/claude-parity-docs-preview.mp4 differ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..5502593b4 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "openiap": { + "type": "http", + "url": "https://kit.openiap.dev/mcp", + "headers": { + "Authorization": "Bearer ${IAPKIT_API_KEY:-}" + } + } + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 78724b8b8..c58eeca10 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -45,5 +45,6 @@ "editor.insertSpaces": true }, "java.configuration.updateBuildConfiguration": "automatic", - "gradle.nestedProjects": false + "gradle.nestedProjects": false, + "eslint.workingDirectories": [{ "mode": "auto" }] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1ea462fe7..9eef175cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,10 @@ openiap/ │ ├── gql/ # GraphQL schema & type generation │ ├── google/ # Android library │ ├── apple/ # iOS/macOS library -│ └── kit/ # Hosted receipt-validation SaaS (Fly.io app) +│ ├── kit/ # Hosted receipt-validation SaaS (Fly.io app) +│ └── mcp-server/ # IAPKit MCP server (hosted at kit.openiap.dev/mcp) +├── plugins/ +│ └── openiap/ # Codex + Claude Code plugin (skills + MCP config) ├── libraries/ # Framework SDK implementations │ ├── react-native-iap/ # React Native (npm) │ ├── expo-iap/ # Expo (npm) @@ -170,7 +173,8 @@ Codex-compatible local skills in `.codex/skills/`, including for repeated self-review of current work. Codex discovers `review-self` from this repository. Install the globally unique -OpenIAP workflow skills into your local Codex home when needed: +skills (`openiap-workflows` and `generate-doc`) into your local Codex home when +needed: ```bash ./.codex/scripts/install-skills.sh @@ -184,6 +188,35 @@ Keep `$review-self` repo-local. Other repositories provide project-specific skills with the same name, so globally linking it would make the most recently installed project overwrite the others. +## Claude Code Compatibility + +Claude Code gets the same workflow surface without any install step: + +- **Slash commands**: `.claude/commands/*.md` load automatically as + `/review-pr`, `/verify-all`, and so on. +- **Skills**: `.claude/skills//SKILL.md` are Claude Code adapters for + the `.codex/skills//SKILL.md` bodies. The Codex file stays the + canonical procedure; the Claude adapter points at it and adds only + Claude-specific notes (browser tooling, wake-up mechanism, subagents). + When you change a skill under `.codex/skills/`, check whether the matching + `.claude/skills/` adapter needs the same update. +- **MCP server**: the root `.mcp.json` registers the hosted IAPKit MCP + endpoint (`https://kit.openiap.dev/mcp`) as a project-scoped server. + Export `IAPKIT_API_KEY` before launching Claude Code to authenticate. + +For consumers outside this repo, `.claude-plugin/marketplace.json` publishes +the `plugins/openiap` plugin as a Claude Code marketplace: + +```bash +claude plugin marketplace add hyodotdev/openiap +claude plugin install openiap@openiap +``` + +`plugins/openiap` is dual-manifest: `.codex-plugin/plugin.json` (Codex, MCP +config at `.codex-plugin/mcp.json`) and `.claude-plugin/plugin.json` (Claude +Code, inline MCP config). The `skills/` folder is shared by both agents, so +keep its wording agent-neutral. + ## Available Skills (Slash Commands / Codex Workflows) | Skill | Description | Usage | diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index fcb6cee1d..c09f6bd28 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-07-16T23:03:55.935Z +> Last updated: 2026-07-20T00:25:18.244Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -274,7 +274,11 @@ openiap/ │ ├── docs/ # Documentation (React/Vite/Vercel) │ ├── gql/ # GraphQL schema & type generation │ ├── google/ # Android library (Kotlin) -│ └── apple/ # iOS/macOS library (Swift) +│ ├── apple/ # iOS/macOS library (Swift) +│ ├── kit/ # Hosted receipt-validation SaaS (Fly.io app) +│ └── mcp-server/ # IAPKit MCP server (hosted at kit.openiap.dev/mcp) +├── plugins/ +│ └── openiap/ # Codex + Claude Code plugin (skills + MCP config) ├── libraries/ # Framework SDK implementations │ ├── react-native-iap/ # React Native (npm, Yarn 3, Nitro Modules) │ ├── expo-iap/ # Expo (npm, Bun, Expo Modules) @@ -926,6 +930,10 @@ and fails when: examples and native Apple/Google examples - a GraphQL Query/Mutation/Subscription operation is added or removed without updating the operation parity registry +- an Android-relevant registry operation is not wired in every + `packages/google` flavor handler bundle (play / horizon / amazon + `OpenIapModule.kt`) — the generated resolver interfaces stay green on their + own because new bundle fields default to `null` - generated types or shared TS runtime helpers drift from `packages/gql` - framework/package version metadata or Godot Android GDAP dependencies drift from the package/version SSOTs diff --git a/knowledge/internal/02-architecture.md b/knowledge/internal/02-architecture.md index f0719611a..ba5c023f6 100644 --- a/knowledge/internal/02-architecture.md +++ b/knowledge/internal/02-architecture.md @@ -11,7 +11,11 @@ openiap/ │ ├── docs/ # Documentation (React/Vite/Vercel) │ ├── gql/ # GraphQL schema & type generation │ ├── google/ # Android library (Kotlin) -│ └── apple/ # iOS/macOS library (Swift) +│ ├── apple/ # iOS/macOS library (Swift) +│ ├── kit/ # Hosted receipt-validation SaaS (Fly.io app) +│ └── mcp-server/ # IAPKit MCP server (hosted at kit.openiap.dev/mcp) +├── plugins/ +│ └── openiap/ # Codex + Claude Code plugin (skills + MCP config) ├── libraries/ # Framework SDK implementations │ ├── react-native-iap/ # React Native (npm, Yarn 3, Nitro Modules) │ ├── expo-iap/ # Expo (npm, Bun, Expo Modules) diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index fe0cc3b29..e189ef249 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -146,6 +146,10 @@ and fails when: examples and native Apple/Google examples - a GraphQL Query/Mutation/Subscription operation is added or removed without updating the operation parity registry +- an Android-relevant registry operation is not wired in every + `packages/google` flavor handler bundle (play / horizon / amazon + `OpenIapModule.kt`) — the generated resolver interfaces stay green on their + own because new bundle fields default to `null` - generated types or shared TS runtime helpers drift from `packages/gql` - framework/package version metadata or Godot Android GDAP dependencies drift from the package/version SSOTs diff --git a/libraries/expo-iap/plugin/src/__tests__/tsconfig.json b/libraries/expo-iap/plugin/src/__tests__/tsconfig.json new file mode 100644 index 000000000..a5258a476 --- /dev/null +++ b/libraries/expo-iap/plugin/src/__tests__/tsconfig.json @@ -0,0 +1,13 @@ +// Editor-only project for the Jest specs in this directory. The package +// tsconfig excludes __tests__ so expo-module-scripts never emits test files +// into build/, but that leaves VS Code without a project for them and every +// jest global errors with ts(2708). This config re-attaches the directory to +// the package compiler options; ts-jest still type-checks specs at test time. +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["./**/*"], + "exclude": [] +} diff --git a/libraries/expo-iap/src/__tests__/tsconfig.json b/libraries/expo-iap/src/__tests__/tsconfig.json new file mode 100644 index 000000000..a5258a476 --- /dev/null +++ b/libraries/expo-iap/src/__tests__/tsconfig.json @@ -0,0 +1,13 @@ +// Editor-only project for the Jest specs in this directory. The package +// tsconfig excludes __tests__ so expo-module-scripts never emits test files +// into build/, but that leaves VS Code without a project for them and every +// jest global errors with ts(2708). This config re-attaches the directory to +// the package compiler options; ts-jest still type-checks specs at test time. +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["./**/*"], + "exclude": [] +} diff --git a/libraries/expo-iap/src/modules/__tests__/android.test.ts b/libraries/expo-iap/src/modules/__tests__/android.test.ts index 0cb9e974d..5cd761e2e 100644 --- a/libraries/expo-iap/src/modules/__tests__/android.test.ts +++ b/libraries/expo-iap/src/modules/__tests__/android.test.ts @@ -9,6 +9,12 @@ jest.mock('react-native', () => ({ Linking: { openURL: jest.fn(), }, + Platform: { + OS: 'android', + select: jest.fn((spec: {android?: unknown; default?: unknown}) => + spec.android !== undefined ? spec.android : spec.default, + ), + }, })); /* eslint-disable import/first */ @@ -27,6 +33,7 @@ import { showBillingProgramInformationDialogAndroid, showInAppMessagesAndroid, } from '../android'; +import {syncIOS} from '../ios'; /* eslint-enable import/first */ describe('Android Module Functions', () => { @@ -282,8 +289,9 @@ describe('Android Module Functions', () => { playBillingChoiceImageUrl: 'https://play.google.com/image.png', playBillingLoyaltyInfo: null, }; - (ExpoIapModule.getBillingChoiceInfoAndroid as jest.Mock) - .mockResolvedValue(mockResult); + ( + ExpoIapModule.getBillingChoiceInfoAndroid as jest.Mock + ).mockResolvedValue(mockResult); const result = await getBillingChoiceInfoAndroid({}); @@ -300,8 +308,9 @@ describe('Android Module Functions', () => { playBillingChoiceImageUrl: 'https://play.google.com/image.png', playBillingLoyaltyInfo: null, }; - (ExpoIapModule.getBillingChoiceInfoAndroid as jest.Mock) - .mockResolvedValue(mockResult); + ( + ExpoIapModule.getBillingChoiceInfoAndroid as jest.Mock + ).mockResolvedValue(mockResult); const result = await (getBillingChoiceInfoAndroid as any)(); @@ -528,8 +537,9 @@ describe('Android Module Functions', () => { describe('showInAppMessagesAndroid', () => { it('delegates optional message categories', async () => { const mockResult = {responseCode: 'no-action-needed'}; - (ExpoIapModule.showInAppMessagesAndroid as jest.Mock) - .mockResolvedValue(mockResult); + (ExpoIapModule.showInAppMessagesAndroid as jest.Mock).mockResolvedValue( + mockResult, + ); const result = await showInAppMessagesAndroid({ categories: ['transactional'], @@ -543,3 +553,9 @@ describe('Android Module Functions', () => { }); }); }); + +describe('Platform guards (Platform.OS = android)', () => { + it('rejects iOS-only wrappers with the documented platform error', async () => { + await expect(syncIOS()).rejects.toThrow('syncIOS is only available on iOS'); + }); +}); diff --git a/libraries/expo-iap/src/modules/__tests__/ios.test.ts b/libraries/expo-iap/src/modules/__tests__/ios.test.ts index 57eda30c3..be32ae163 100644 --- a/libraries/expo-iap/src/modules/__tests__/ios.test.ts +++ b/libraries/expo-iap/src/modules/__tests__/ios.test.ts @@ -37,6 +37,12 @@ jest.mock('react-native', () => ({ Linking: { openURL: jest.fn(), }, + Platform: { + OS: 'ios', + select: jest.fn((spec: {ios?: unknown; default?: unknown}) => + spec.ios !== undefined ? spec.ios : spec.default, + ), + }, })); /* eslint-disable import/first */ @@ -71,6 +77,7 @@ import { getExternalPurchaseCustomLinkTokenIOS, showExternalPurchaseCustomLinkNoticeIOS, } from '../ios'; +import {checkAlternativeBillingAvailabilityAndroid} from '../android'; /* eslint-enable import/first */ describe('iOS Module Functions', () => { @@ -1017,3 +1024,11 @@ describe('iOS Module Functions', () => { }); }); }); + +describe('Platform guards (Platform.OS = ios)', () => { + it('rejects Android-only wrappers with the documented platform error', async () => { + await expect(checkAlternativeBillingAvailabilityAndroid()).rejects.toThrow( + 'checkAlternativeBillingAvailabilityAndroid is only available on Android', + ); + }); +}); diff --git a/libraries/expo-iap/src/modules/__tests__/tsconfig.json b/libraries/expo-iap/src/modules/__tests__/tsconfig.json new file mode 100644 index 000000000..215877550 --- /dev/null +++ b/libraries/expo-iap/src/modules/__tests__/tsconfig.json @@ -0,0 +1,13 @@ +// Editor-only project for the Jest specs in this directory. The package +// tsconfig excludes __tests__ so expo-module-scripts never emits test files +// into build/, but that leaves VS Code without a project for them and every +// jest global errors with ts(2708). This config re-attaches the directory to +// the package compiler options; ts-jest still type-checks specs at test time. +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["./**/*"], + "exclude": [] +} diff --git a/libraries/expo-iap/src/modules/android.ts b/libraries/expo-iap/src/modules/android.ts index e433c9a0b..ba525979d 100644 --- a/libraries/expo-iap/src/modules/android.ts +++ b/libraries/expo-iap/src/modules/android.ts @@ -1,8 +1,9 @@ // External dependencies -import {Linking} from 'react-native'; +import {Linking, Platform} from 'react-native'; // Internal modules import ExpoIapModule from '../ExpoIapModule'; +import {isVegaOS} from '../vega'; // Types import type { @@ -32,6 +33,20 @@ type NativeAndroidModule = { const nativeAndroidModule = ExpoIapModule as NativeAndroidModule; +/** + * Enforce the documented Android-only contract. Vega OS is treated as an + * Android store runtime (matching `isAndroidStoreRuntime` in src/index.ts), + * so it passes through. Without this guard, calling a suffixed wrapper on + * another platform falls through to the native proxy and surfaces as an + * opaque `TypeError: ExpoIapModule. is not a function` instead of the + * promised platform error. + */ +const requireAndroidPlatform = (methodName: string): void => { + if (Platform.OS !== 'android' && !isVegaOS()) { + throw new Error(`${methodName} is only available on Android and Vega OS`); + } +}; + // Type guards export function isProductAndroid( item: unknown, @@ -148,6 +163,7 @@ export const validateReceiptAndroid = async ({ export const consumePurchaseAndroid: MutationField< 'consumePurchaseAndroid' > = async (purchaseToken) => { + requireAndroidPlatform('consumePurchaseAndroid'); const result = await ExpoIapModule.consumePurchaseAndroid(purchaseToken); if (typeof result === 'boolean') { @@ -172,16 +188,18 @@ export const consumePurchaseAndroid: MutationField< }; /** - * Acknowledge a product (on Android.) No-op on iOS. + * Acknowledge a non-consumable purchase or subscription (Android only). * @param {Object} params - The parameters object * @param {string} params.token - The product's token (on Android) * @returns {Promise} + * @throws Error if called on a non-Android platform * * @see {@link https://openiap.dev/docs/apis/android/acknowledge-purchase-android} */ export const acknowledgePurchaseAndroid: MutationField< 'acknowledgePurchaseAndroid' > = async (purchaseToken) => { + requireAndroidPlatform('acknowledgePurchaseAndroid'); const result = await ExpoIapModule.acknowledgePurchaseAndroid(purchaseToken); if (typeof result === 'boolean') { @@ -234,6 +252,7 @@ export const openRedeemOfferCodeAndroid = async (): Promise => { export const checkAlternativeBillingAvailabilityAndroid: MutationField< 'checkAlternativeBillingAvailabilityAndroid' > = async () => { + requireAndroidPlatform('checkAlternativeBillingAvailabilityAndroid'); return ExpoIapModule.checkAlternativeBillingAvailabilityAndroid(); }; @@ -266,6 +285,7 @@ export const checkAlternativeBillingAvailabilityAndroid: MutationField< export const showAlternativeBillingDialogAndroid: MutationField< 'showAlternativeBillingDialogAndroid' > = async () => { + requireAndroidPlatform('showAlternativeBillingDialogAndroid'); return ExpoIapModule.showAlternativeBillingDialogAndroid(); }; @@ -298,6 +318,7 @@ export const showAlternativeBillingDialogAndroid: MutationField< export const createAlternativeBillingTokenAndroid: MutationField< 'createAlternativeBillingTokenAndroid' > = async (sku?: string) => { + requireAndroidPlatform('createAlternativeBillingTokenAndroid'); return ExpoIapModule.createAlternativeBillingTokenAndroid(sku); }; @@ -326,6 +347,7 @@ export const createAlternativeBillingTokenAndroid: MutationField< export const isBillingProgramAvailableAndroid: MutationField< 'isBillingProgramAvailableAndroid' > = async (program) => { + requireAndroidPlatform('isBillingProgramAvailableAndroid'); return ExpoIapModule.isBillingProgramAvailableAndroid(program); }; @@ -343,6 +365,7 @@ export const getBillingChoiceInfoAndroid: QueryField< > = async ( params: GetBillingChoiceInfoParamsAndroid = {}, ): Promise => { + requireAndroidPlatform('getBillingChoiceInfoAndroid'); return ExpoIapModule.getBillingChoiceInfoAndroid({ billingProgram: params.billingProgram ?? 'billing-choice', playBillingChoiceImageLayout: @@ -375,6 +398,7 @@ export const getBillingChoiceInfoAndroid: QueryField< export const launchExternalLinkAndroid: MutationField< 'launchExternalLinkAndroid' > = async (params) => { + requireAndroidPlatform('launchExternalLinkAndroid'); return ExpoIapModule.launchExternalLinkAndroid(params); }; @@ -401,11 +425,13 @@ const createBillingProgramReportingDetailsAndroidField: MutationField< 'createBillingProgramReportingDetailsAndroid' > = async ( args: MutationCreateBillingProgramReportingDetailsAndroidArgs, -): Promise => - ExpoIapModule.createBillingProgramReportingDetailsAndroid( +): Promise => { + requireAndroidPlatform('createBillingProgramReportingDetailsAndroid'); + return ExpoIapModule.createBillingProgramReportingDetailsAndroid( args.program, args.developerBillingType ?? null, ); +}; export function createBillingProgramReportingDetailsAndroid( args: MutationCreateBillingProgramReportingDetailsAndroidArgs, @@ -442,6 +468,7 @@ export const showBillingProgramInformationDialogAndroid: MutationField< > = async ( params: BillingProgramInformationDialogParamsAndroid, ): Promise => { + requireAndroidPlatform('showBillingProgramInformationDialogAndroid'); return ExpoIapModule.showBillingProgramInformationDialogAndroid({ billingProgram: params.billingProgram ?? 'billing-choice', externalTransactionToken: params.externalTransactionToken, @@ -462,5 +489,6 @@ export const showInAppMessagesAndroid: MutationField< > = async ( params?: InAppMessageParamsAndroid | null, ): Promise => { + requireAndroidPlatform('showInAppMessagesAndroid'); return ExpoIapModule.showInAppMessagesAndroid(params ?? null); }; diff --git a/libraries/expo-iap/src/modules/ios.ts b/libraries/expo-iap/src/modules/ios.ts index 2b17530c9..effddc6e7 100644 --- a/libraries/expo-iap/src/modules/ios.ts +++ b/libraries/expo-iap/src/modules/ios.ts @@ -25,7 +25,19 @@ import { createPurchaseErrorFromNativeException, type PurchaseError, } from '../utils/errorMapping'; -import {Linking} from 'react-native'; +import {Linking, Platform} from 'react-native'; + +/** + * Enforce the documented iOS-only contract. Without this, calling a + * suffixed wrapper on another platform falls through to the native proxy + * and surfaces as an opaque `TypeError: ExpoIapModule. is not a + * function` instead of the promised platform error. + */ +const requireIosPlatform = (methodName: string): void => { + if (Platform.OS !== 'ios') { + throw new Error(`${methodName} is only available on iOS`); + } +}; export type TransactionEvent = { transaction?: Purchase; @@ -60,6 +72,7 @@ export function isProductIOS( * @see {@link https://openiap.dev/docs/apis/ios/sync-ios} */ export const syncIOS: MutationField<'syncIOS'> = async () => { + requireIosPlatform('syncIOS'); return !!(await ExpoIapModule.syncIOS()); }; @@ -77,6 +90,7 @@ export const syncIOS: MutationField<'syncIOS'> = async () => { export const isEligibleForIntroOfferIOS: QueryField< 'isEligibleForIntroOfferIOS' > = async (groupId) => { + requireIosPlatform('isEligibleForIntroOfferIOS'); if (!groupId) { throw new Error('isEligibleForIntroOfferIOS requires a groupId'); } @@ -97,6 +111,7 @@ export const isEligibleForIntroOfferIOS: QueryField< export const subscriptionStatusIOS: QueryField< 'subscriptionStatusIOS' > = async (sku) => { + requireIosPlatform('subscriptionStatusIOS'); if (!sku) { throw new Error('subscriptionStatusIOS requires a SKU'); } @@ -118,6 +133,7 @@ export const subscriptionStatusIOS: QueryField< export const currentEntitlementIOS: QueryField< 'currentEntitlementIOS' > = async (sku) => { + requireIosPlatform('currentEntitlementIOS'); if (!sku) { throw new Error('currentEntitlementIOS requires a SKU'); } @@ -139,6 +155,7 @@ export const currentEntitlementIOS: QueryField< export const latestTransactionIOS: QueryField<'latestTransactionIOS'> = async ( sku, ) => { + requireIosPlatform('latestTransactionIOS'); if (!sku) { throw new Error('latestTransactionIOS requires a SKU'); } @@ -160,6 +177,7 @@ export const latestTransactionIOS: QueryField<'latestTransactionIOS'> = async ( export const beginRefundRequestIOS: MutationField< 'beginRefundRequestIOS' > = async (sku) => { + requireIosPlatform('beginRefundRequestIOS'); if (!sku) { throw new Error('beginRefundRequestIOS requires a SKU'); } @@ -181,6 +199,7 @@ export const beginRefundRequestIOS: MutationField< export const showManageSubscriptionsIOS: MutationField< 'showManageSubscriptionsIOS' > = async () => { + requireIosPlatform('showManageSubscriptionsIOS'); const purchases = await ExpoIapModule.showManageSubscriptionsIOS(); return (purchases ?? []) as PurchaseIOS[]; }; @@ -198,6 +217,7 @@ export const showManageSubscriptionsIOS: MutationField< * @see {@link https://openiap.dev/docs/apis/ios/get-receipt-data-ios} */ export const getReceiptDataIOS: QueryField<'getReceiptDataIOS'> = async () => { + requireIosPlatform('getReceiptDataIOS'); return ExpoIapModule.getReceiptDataIOS(); }; @@ -212,12 +232,15 @@ export const getReceiptIOS = getReceiptDataIOS; * alias so consumers who previously imported `getStorefrontIOS` do not break. * * @returns {Promise} ISO 3166-1 alpha-3 country code (e.g. "USA") + * @throws Error if called on non-iOS platform — Android callers must use the + * cross-platform `getStorefront` from the main index instead * * @platform iOS * * @see {@link https://openiap.dev/docs/apis/ios/get-storefront-ios} */ export const getStorefrontIOS: QueryField<'getStorefrontIOS'> = async () => { + requireIosPlatform('getStorefrontIOS'); if (typeof ExpoIapModule.getStorefront !== 'function') { throw createPurchaseError({ code: ErrorCode.FeatureNotSupported, @@ -259,6 +282,7 @@ export const getStorefrontIOS: QueryField<'getStorefrontIOS'> = async () => { * @platform iOS */ export const requestReceiptRefreshIOS = async (): Promise => { + requireIosPlatform('requestReceiptRefreshIOS'); return ExpoIapModule.requestReceiptRefreshIOS(); }; @@ -277,6 +301,7 @@ export const requestReceiptRefreshIOS = async (): Promise => { export const isTransactionVerifiedIOS: QueryField< 'isTransactionVerifiedIOS' > = async (sku) => { + requireIosPlatform('isTransactionVerifiedIOS'); if (!sku) { throw new Error('isTransactionVerifiedIOS requires a SKU'); } @@ -298,6 +323,7 @@ export const isTransactionVerifiedIOS: QueryField< export const getTransactionJwsIOS: QueryField<'getTransactionJwsIOS'> = async ( sku, ) => { + requireIosPlatform('getTransactionJwsIOS'); if (!sku) { throw new Error('getTransactionJwsIOS requires a SKU'); } @@ -324,6 +350,7 @@ export const getTransactionJwsIOS: QueryField<'getTransactionJwsIOS'> = async ( * @see {@link https://openiap.dev/docs/apis/ios/validate-receipt-ios} */ const validateReceiptIOSImpl = async (props: VerifyPurchaseProps | string) => { + requireIosPlatform('validateReceiptIOS'); const sku = typeof props === 'string' ? props @@ -357,6 +384,7 @@ export const validateReceiptIOS = export const presentCodeRedemptionSheetIOS: MutationField< 'presentCodeRedemptionSheetIOS' > = async () => { + requireIosPlatform('presentCodeRedemptionSheetIOS'); return !!(await ExpoIapModule.presentCodeRedemptionSheetIOS()); }; @@ -379,6 +407,7 @@ export const presentCodeRedemptionSheetIOS: MutationField< export const getAppTransactionIOS: QueryField< 'getAppTransactionIOS' > = async () => { + requireIosPlatform('getAppTransactionIOS'); return (await ExpoIapModule.getAppTransactionIOS()) ?? null; }; @@ -397,6 +426,7 @@ export const getAppTransactionIOS: QueryField< export const getPromotedProductIOS: QueryField< 'getPromotedProductIOS' > = async () => { + requireIosPlatform('getPromotedProductIOS'); const product = await ExpoIapModule.getPromotedProductIOS(); return (product ?? null) as ProductIOS | null; }; @@ -418,6 +448,7 @@ export const getPromotedProductIOS: QueryField< export const requestPurchaseOnPromotedProductIOS: MutationField< 'requestPurchaseOnPromotedProductIOS' > = async () => { + requireIosPlatform('requestPurchaseOnPromotedProductIOS'); const result = await ExpoIapModule.requestPurchaseOnPromotedProductIOS(); return result ?? true; }; @@ -433,6 +464,7 @@ export const requestPurchaseOnPromotedProductIOS: MutationField< export const getPendingTransactionsIOS: QueryField< 'getPendingTransactionsIOS' > = async () => { + requireIosPlatform('getPendingTransactionsIOS'); const transactions = await ExpoIapModule.getPendingTransactionsIOS(); return (transactions ?? []) as PurchaseIOS[]; }; @@ -445,6 +477,7 @@ export const getPendingTransactionsIOS: QueryField< export const getAllTransactionsIOS: QueryField< 'getAllTransactionsIOS' > = async () => { + requireIosPlatform('getAllTransactionsIOS'); const transactions = await ExpoIapModule.getAllTransactionsIOS(); return (transactions ?? []) as PurchaseIOS[]; }; @@ -460,6 +493,7 @@ export const getAllTransactionsIOS: QueryField< export const clearTransactionIOS: MutationField< 'clearTransactionIOS' > = async () => { + requireIosPlatform('clearTransactionIOS'); return !!(await ExpoIapModule.clearTransactionIOS()); }; @@ -487,6 +521,7 @@ export const deepLinkToSubscriptionsIOS = (): Promise => export const canPresentExternalPurchaseNoticeIOS: QueryField< 'canPresentExternalPurchaseNoticeIOS' > = async () => { + requireIosPlatform('canPresentExternalPurchaseNoticeIOS'); return !!(await ExpoIapModule.canPresentExternalPurchaseNoticeIOS()); }; @@ -503,6 +538,7 @@ export const canPresentExternalPurchaseNoticeIOS: QueryField< export const presentExternalPurchaseNoticeSheetIOS: MutationField< 'presentExternalPurchaseNoticeSheetIOS' > = async () => { + requireIosPlatform('presentExternalPurchaseNoticeSheetIOS'); const result = await ExpoIapModule.presentExternalPurchaseNoticeSheetIOS(); return result as ExternalPurchaseNoticeResultIOS; }; @@ -519,6 +555,7 @@ export const presentExternalPurchaseNoticeSheetIOS: MutationField< export const presentExternalPurchaseLinkIOS: MutationField< 'presentExternalPurchaseLinkIOS' > = async (url: string) => { + requireIosPlatform('presentExternalPurchaseLinkIOS'); const result = await ExpoIapModule.presentExternalPurchaseLinkIOS(url); return result as ExternalPurchaseLinkResultIOS; }; @@ -536,6 +573,7 @@ export const presentExternalPurchaseLinkIOS: MutationField< export const isEligibleForExternalPurchaseCustomLinkIOS: QueryField< 'isEligibleForExternalPurchaseCustomLinkIOS' > = async () => { + requireIosPlatform('isEligibleForExternalPurchaseCustomLinkIOS'); return !!(await ExpoIapModule.isEligibleForExternalPurchaseCustomLinkIOS()); }; @@ -553,6 +591,7 @@ export const isEligibleForExternalPurchaseCustomLinkIOS: QueryField< export const getExternalPurchaseCustomLinkTokenIOS: QueryField< 'getExternalPurchaseCustomLinkTokenIOS' > = async (tokenType) => { + requireIosPlatform('getExternalPurchaseCustomLinkTokenIOS'); if (!tokenType) { throw new Error( "getExternalPurchaseCustomLinkTokenIOS requires a tokenType ('acquisition' or 'services')", @@ -579,6 +618,7 @@ export const getExternalPurchaseCustomLinkTokenIOS: QueryField< export const showExternalPurchaseCustomLinkNoticeIOS: MutationField< 'showExternalPurchaseCustomLinkNoticeIOS' > = async (noticeType) => { + requireIosPlatform('showExternalPurchaseCustomLinkNoticeIOS'); if (!noticeType) { throw new Error( "showExternalPurchaseCustomLinkNoticeIOS requires a noticeType ('browser')", diff --git a/libraries/expo-iap/src/utils/__tests__/tsconfig.json b/libraries/expo-iap/src/utils/__tests__/tsconfig.json new file mode 100644 index 000000000..215877550 --- /dev/null +++ b/libraries/expo-iap/src/utils/__tests__/tsconfig.json @@ -0,0 +1,13 @@ +// Editor-only project for the Jest specs in this directory. The package +// tsconfig excludes __tests__ so expo-module-scripts never emits test files +// into build/, but that leaves VS Code without a project for them and every +// jest global errors with ts(2708). This config re-attaches the directory to +// the package compiler options; ts-jest still type-checks specs at test time. +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["./**/*"], + "exclude": [] +} diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 126186ef8..e14ae3446 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -4319,7 +4319,7 @@ class GetBillingChoiceInfoParamsAndroid: ## Parameters for showing Play billing in-app messages (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). class InAppMessageParamsAndroid: ## In-app message categories to show. Defaults to transactional messages. - var categories: Array[InAppMessageCategoryAndroid] = [] + var categories: Array[InAppMessageCategoryAndroid] = [InAppMessageCategoryAndroid.TRANSACTIONAL] static func from_dict(data: Dictionary) -> InAppMessageParamsAndroid: var obj = InAppMessageParamsAndroid.new() diff --git a/libraries/kmp-iap/library/build.gradle.kts b/libraries/kmp-iap/library/build.gradle.kts index c2a096e65..d2989abfc 100644 --- a/libraries/kmp-iap/library/build.gradle.kts +++ b/libraries/kmp-iap/library/build.gradle.kts @@ -349,6 +349,9 @@ dependencies { add("horizonCompileOnly", "com.android.billingclient:billing:$playBillingVersion") add("amazonCompileOnly", "com.android.billingclient:billing:$playBillingVersion") add("androidUnitTestImplementation", "com.android.billingclient:billing:$playBillingVersion") + // openiap-google keeps gson implementation-scoped, so tests that replicate + // its reflective parse of Play Developer API responses need it explicitly. + add("androidUnitTestImplementation", "com.google.code.gson:gson:2.10.1") } // Only configure publishing when we have signing credentials diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index 0f8031547..2c9adaede 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -120,6 +120,9 @@ import io.github.hyochan.kmpiap.openiap.SubscriptionReplacementModeAndroid import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitAmazonProps as AndroidVerifyPurchaseWithIapkitAmazonProps import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitGoogleProps as AndroidVerifyPurchaseWithIapkitGoogleProps import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps as AndroidVerifyPurchaseWithIapkitProps +import dev.hyo.openiap.VerifyPurchaseGoogleOptions as AndroidVerifyPurchaseGoogleOptions +import dev.hyo.openiap.VerifyPurchaseProps as AndroidVerifyPurchaseProps +import dev.hyo.openiap.utils.verifyPurchaseWithGooglePlay as verifyPurchaseWithGooglePlayAndroid import dev.hyo.openiap.utils.verifyPurchaseWithIapkit as verifyPurchaseWithIapkitAndroid import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CompletableDeferred @@ -1379,15 +1382,10 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase { RequestPurchaseResultPurchases(purchases) } - private val validateReceiptHandler: MutationValidateReceiptHandler = { _ -> - // Android doesn't support native receipt validation like iOS - // Use verifyPurchaseWithProvider for server-side verification - failWith( - PurchaseError( - code = ErrorCode.FeatureNotSupported, - message = "validateReceipt is not supported on Android. Use verifyPurchaseWithProvider for server-side verification." - ) - ) + private val validateReceiptHandler: MutationValidateReceiptHandler = { options -> + // Deprecated alias — matches openiap-google, where validateReceipt + // delegates to verifyPurchase. + verifyPurchase(options) } private val deepLinkToSubscriptionsHandler: MutationDeepLinkToSubscriptionsHandler = { options -> @@ -2213,19 +2211,57 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase { override suspend fun validateReceipt(options: ValidationOptions): ValidationResult = validateReceiptHandler(options) /** - * Verify a purchase against your own backend. + * Verify a purchase with the Google Play Developer API. * * @see https://openiap.dev/docs/features/validation#verify-purchase */ override suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult { - // Android doesn't support native receipt verification like iOS - // Use verifyPurchaseWithProvider for server-side verification via IAPKit - failWith( + // Mirrors the other OpenIAP wrappers (flutter/godot/maui): delegate to + // openiap-google's Play Developer API check. The accessToken ships in + // the request, so this is a debugging aid — production apps should + // verify server-side or use verifyPurchaseWithProvider (IAPKit). + val googleOptions = options.google ?: failWith( PurchaseError( - code = ErrorCode.FeatureNotSupported, - message = "verifyPurchase is not supported on Android. Use verifyPurchaseWithProvider for server-side verification via IAPKit." + code = ErrorCode.PurchaseVerificationFailed, + message = "verifyPurchase on Android requires google options " + + "(packageName, purchaseToken, accessToken, sku)" ) ) + + return try { + val androidResult = verifyPurchaseWithGooglePlayAndroid( + AndroidVerifyPurchaseProps( + apple = null, + google = AndroidVerifyPurchaseGoogleOptions( + accessToken = googleOptions.accessToken, + isSub = googleOptions.isSub, + packageName = googleOptions.packageName, + purchaseToken = googleOptions.purchaseToken, + sku = googleOptions.sku + ), + horizon = null + ), + "kmp-iap-android" + ) + + // openiap-google parses the Play Developer API response with + // reflective Gson, so fields declared non-null there (the + // Amazon-RVS-shaped ones Google never returns, e.g. + // parentProductId) can still be null at runtime. A direct + // constructor copy would trip Kotlin's parameter null checks, so + // round-trip through the JSON map boundary where the generated + // fromJson applies its schema defaults. + VerifyPurchaseResultAndroid.fromJson(androidResult.toJson()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + failWith( + PurchaseError( + code = ErrorCode.PurchaseVerificationFailed, + message = e.message ?: "Purchase verification failed" + ) + ) + } } /** diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/VerifyPurchaseResultMappingTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/VerifyPurchaseResultMappingTest.kt new file mode 100644 index 000000000..a146efc73 --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/VerifyPurchaseResultMappingTest.kt @@ -0,0 +1,49 @@ +package io.github.hyochan.kmpiap + +import com.google.gson.Gson +import io.github.hyochan.kmpiap.openiap.VerifyPurchaseResultAndroid +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class VerifyPurchaseResultMappingTest { + // A realistic purchases.products.get response: Google returns none of the + // Amazon-RVS-shaped fields (parentProductId, productType, receiptId, term, + // termSku), so reflective Gson leaves those declared-non-null fields null + // on the openiap-google result — the same parse PurchaseVerificationValidator + // performs. verifyPurchase must absorb that through the JSON map boundary + // instead of tripping constructor null checks. + @Test + fun `gson-parsed google result with absent fields maps through the json boundary`() { + val googleResult = Gson().fromJson( + """ + { + "autoRenewing": false, + "purchaseDate": 1752969600000, + "quantity": 1, + "testTransaction": true, + "productId": "dev.hyo.martie.bulbs10" + } + """.trimIndent(), + dev.hyo.openiap.VerifyPurchaseResultAndroid::class.java, + ) + + // The hazard the boundary absorbs: Gson left this declared-non-null + // field null, so a direct constructor copy would throw NPE. + @Suppress("SENSELESS_COMPARISON") + assertNull(googleResult.parentProductId as String?) + + val mapped = VerifyPurchaseResultAndroid.fromJson(googleResult.toJson()) + + assertEquals("dev.hyo.martie.bulbs10", mapped.productId) + assertEquals(1752969600000.0, mapped.purchaseDate) + assertEquals(1, mapped.quantity) + assertTrue(mapped.testTransaction) + assertEquals("", mapped.parentProductId) + assertEquals("", mapped.productType) + assertEquals("", mapped.receiptId) + assertEquals("", mapped.term) + assertEquals("", mapped.termSku) + } +} diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 19a143432..2699ddd2c 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -1509,8 +1509,12 @@ class HybridRnIap : HybridRnIapSpec() { }, isValid = item.isValid, productId = item.productId?.let { Variant_NullType_String.Second(it) }, - state = mapIapkitPurchaseState(item.state.name), - store = mapIapkitStore(item.store.name) + // Use rawValue ("pending-acknowledgment"), not the Kotlin + // enum constant name ("PendingAcknowledgment") — the + // mappers match separator-delimited spellings, so + // multi-word states would otherwise degrade to UNKNOWN. + state = mapIapkitPurchaseState(item.state.rawValue), + store = mapIapkitStore(item.store.rawValue) ) } @@ -1525,7 +1529,7 @@ class HybridRnIap : HybridRnIapSpec() { NitroVerifyPurchaseWithProviderResult( iapkit = nitroIapkitResult?.let { Variant_NullType_NitroVerifyPurchaseWithIapkitResult.Second(it) }, errors = nitroErrors?.let { Variant_NullType_Array_NitroVerifyPurchaseWithProviderError_.Second(it) }, - provider = mapPurchaseVerificationProvider(result.provider.name) + provider = mapPurchaseVerificationProvider(result.provider.rawValue) ) } catch (e: Exception) { RnIapLog.failure("verifyPurchaseWithProvider", e) diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 1caf1a0a1..5372fbe94 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -1968,7 +1968,7 @@ their `{apiKey}` path segment. - [GET /v1/openapi](https://kit.openiap.dev/v1/openapi) — machine-readable OpenAPI spec - [GET /v1](https://kit.openiap.dev/v1) — Redoc UI for the OpenAPI spec - [GET /health](https://kit.openiap.dev/health) — liveness probe (no Convex round-trip) -- [POST /mcp](https://kit.openiap.dev/docs/ai-assistants/codex-plugin) — MCP Streamable HTTP endpoint for Codex and other MCP clients. Uses an IAPKit project API key, not an OpenAI or ChatGPT API key. +- [POST /mcp](https://kit.openiap.dev/docs/ai-assistants/codex-plugin) — MCP Streamable HTTP endpoint for Codex, Claude Code, and other MCP clients. Uses an IAPKit project API key, not an OpenAI, ChatGPT, Anthropic, or Claude API key. Also mounted at `/api/v1/*` for backwards compatibility. `/v1/verify-purchase` is an alias of `/v1/purchase/verify`. Pick `/v1/purchase/verify` for new code. @@ -2032,9 +2032,10 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - `200` — verification ran; require `isValid`, an operation-appropriate `state`, and an exact store-verified `productId` match - `400 INVALID_INPUT` — malformed body / unknown store / oversized field +- `400 INVALID_API_KEY` — well-formed key that fails project lookup (unknown or rotated) - `413 PAYLOAD_TOO_LARGE` — request body exceeds the 32 KB edge cap - `401 MISSING_API_KEY` — no `Authorization` header -- `403 INVALID_API_KEY` — wrong scheme, malformed, or unrecognized key +- `403 INVALID_API_KEY` — wrong scheme or malformed key (format check only) - `429 RATE_LIMITED` — per-key bucket empty; honor `Retry-After` seconds - `500 UNKNOWN_ERROR` — quote the `X-Correlation-Id` header in a support ticket @@ -2058,6 +2059,7 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - [openiap.dev/docs/webhooks](https://openiap.dev/docs/webhooks) — operator setup steps for the lifecycle webhook URL (Apple ASN v2 + Google RTDN) and SDK code for consuming the SSE stream - [/docs/ai-assistants](https://kit.openiap.dev/docs/ai-assistants) — how to point Codex / Claude / Cursor / etc. at this file - [/docs/ai-assistants/codex-plugin](https://kit.openiap.dev/docs/ai-assistants/codex-plugin) — Codex plugin setup and self-hosted IAPKit MCP server option +- [/docs/ai-assistants/claude-plugin](https://kit.openiap.dev/docs/ai-assistants/claude-plugin) — Claude Code plugin setup (marketplace install or claude mcp add) - [/docs/release-notes](https://kit.openiap.dev/docs/release-notes) — changelog --- diff --git a/packages/docs/src/pages/docs/guides/mcp-server.tsx b/packages/docs/src/pages/docs/guides/mcp-server.tsx index adefc5616..e8d5aee41 100644 --- a/packages/docs/src/pages/docs/guides/mcp-server.tsx +++ b/packages/docs/src/pages/docs/guides/mcp-server.tsx @@ -13,24 +13,24 @@ function MCPServer() {

MCP Server

- OpenIAP ships an IAPKit-backed MCP server so Codex and other MCP clients - can inspect in-app purchase configuration, generate setup snippets, - manage IAPKit catalog rows, run safe store sync previews, and review app - purchase code from the same thread. + OpenIAP ships an IAPKit-backed MCP server so Codex, Claude Code, and + other MCP clients can inspect in-app purchase configuration, generate + setup snippets, manage IAPKit catalog rows, run safe store sync + previews, and review app purchase code from the same thread.

If you only use the OpenIAP SDKs directly in your app, you do not need IAPKit or this MCP server. IAPKit is the optional managed receipt-validation backend for OpenIAP projects: it stores your product catalog, validates App Store / Google Play purchases, tracks - subscriptions, and exposes project tools that Codex can call through - MCP. Create or open an IAPKit project at{' '} + subscriptions, and exposes project tools that AI coding agents can call + through MCP. Create or open an IAPKit project at{' '}

- The IAPKit dashboard keeps a shorter Codex plugin page at{' '} - /docs/ai-assistants/codex-plugin for Kit-local endpoint - and API-key details. That page links back here instead of duplicating + The IAPKit dashboard keeps shorter plugin pages at{' '} + /docs/ai-assistants/codex-plugin and{' '} + /docs/ai-assistants/claude-plugin for Kit-local endpoint + and API-key details. Those pages link back here instead of duplicating the full MCP guide. On a local checkout, Vite may assign different ports to the OpenIAP docs site and the Kit dashboard, so use the page path rather than the port number when opening the guide. @@ -109,9 +110,37 @@ Review my app's in-app purchase flow and list the OpenIAP/IAPKit tools available Do not create products, start sync jobs, or modify files until I confirm.`} +

+ + Claude Code plugin + +

+ The same plugin works in Claude Code. Add the OpenIAP marketplace, + install the plugin, set IAPKIT_API_KEY in the environment + that launches Claude Code, then start a new session. +

+ {`claude plugin marketplace add hyodotdev/openiap +claude plugin install openiap@openiap +export IAPKIT_API_KEY="openiap-kit_your-project-key"`} +

+ Inside an interactive session you can use{' '} + /plugin marketplace add hyodotdev/openiap and{' '} + /plugin install openiap@openiap instead. If you do not + want the plugin bundle, register the hosted MCP server directly: +

+ {`claude mcp add --transport http openiap https://kit.openiap.dev/mcp \\ + --header "Authorization: Bearer \${IAPKIT_API_KEY}"`} +

+ Contributors working inside the OpenIAP monorepo get the same server + from the repo's project-scoped .mcp.json automatically — + approve it once and export IAPKIT_API_KEY before + launching Claude Code. +

+
+
- Manual MCP config + Manual MCP config (Codex)

If you do not install the plugin bundle, configure the hosted MCP @@ -299,14 +328,15 @@ Run typecheck and tests after editing, and summarize exactly what changed.`}

- Codex sees the tools with the iapkit_ prefix. The MCP - server currently exposes tools for setup snippets, status checks, + Your agent sees the tools with the iapkit_ prefix. The + MCP server currently exposes tools for setup snippets, status checks, troubleshooting, product catalog reads and writes, subscription lists, sandbox purchase guidance, synthetic webhook delivery, entitlement inspection, revenue analytics, and App Store / Google Play product sync jobs. Receipt validation still runs in your app or backend - through the OpenIAP SDK and IAPKit API; MCP gives Codex the project - context and tool results it needs to wire and verify that flow. + through the OpenIAP SDK and IAPKit API; MCP gives your agent the + project context and tool results it needs to wire and verify that + flow.

For the lower-level backend architecture and stdio example, see{' '} @@ -321,7 +351,7 @@ Run typecheck and tests after editing, and summarize exactly what changed.`} Product management tools call live IAPKit endpoints. Store sync jobs can write to App Store Connect or Google Play when dryRun{' '} - is false. Ask Codex to inspect first, run store sync as{' '} + is false. Ask your agent to inspect first, run store sync as{' '} dryRun: true, and approve live writes only after reviewing the proposed product id, platform, type, price, and billing period. diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 5b2c0a3a2..8b103e676 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -22,6 +22,13 @@ interface Note { element: React.ReactNode; } +const crossSdkAuditReleases = [ + ['react-native-iap 15.5.3', 'react-native-iap-15.5.3'], + ['expo-iap 4.6.0', 'expo-iap-4.6.0'], + ['godot-iap 2.5.2', 'godot-iap-2.5.2'], + ['kmp-iap 2.6.0', 'kmp-iap-2.6.0'], +] as const; + const productClientPayloadReleases = [ ['openiap-apple 2.4.1', '2.4.1'], ['openiap-google 2.4.1', 'google-2.4.1'], @@ -63,6 +70,165 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // July 20, 2026 - Cross-SDK audit fixes and IAPKit verified-state accuracy + { + id: 'cross-sdk-audit-fixes-2026-07-20', + date: new Date('2026-07-20'), + element: ( +

+ + July 20, 2026 - Cross-SDK audit fixes and IAPKit verified-state + accuracy + + +

+ Publishes coordinated framework releases from a cross-SDK audit ( + + PR #234 + + ): enforced platform guards in Expo, Google Play purchase + verification in KMP, an accurate IAPKit verified-state pipeline end + to end, and a Godot in-app message default fix. The same train ships + Claude Code parity for the repository tooling (dual-manifest plugin, + marketplace, and hosted MCP docs) and IAPKit service-side fixes that + deploy with the dashboard rather than as a package. +

+ +
Expo
+
    +
  • + expo-iap 4.6.0 - behavior change: + platform-suffixed APIs now throw their documented platform error + when called on the wrong OS. Previously iOS-only wrappers such as{' '} + getStorefrontIOS could silently succeed on Android, + and Android-only wrappers surfaced an opaque{' '} + TypeError instead of the promised error. Callers that + relied on silent no-ops should gate calls with{' '} + Platform.OS. +
  • +
+ +
React Native
+
    +
  • + react-native-iap 15.5.3 - fixes the Android{' '} + verifyPurchaseWithProvider bridge degrading + multi-word IAPKit states such as{' '} + pending-acknowledgment and{' '} + ready-to-consume to unknown; Android now + maps enum raw values exactly like iOS. +
  • +
+ +
KMP
+
    +
  • + kmp-iap 2.6.0 - implements Android{' '} + verifyPurchase through the Google Play Developer API, + matching the other OpenIAP wrappers, with{' '} + validateReceipt delegating to it. The result mapping + tolerates fields Google omits from real{' '} + purchases.products responses, so valid purchases no + longer fail with a parameter-null error. +
  • +
+ +
Godot
+
    +
  • + godot-iap 2.5.2 -{' '} + InAppMessageParamsAndroid.categories now defaults to + transactional messages as documented (the GDScript generator + previously dropped list-typed schema defaults, so{' '} + show_in_app_messages showed nothing by default). +
  • +
+ +
IAPKit
+
    +
  • + Verified states - Google verification now + consults the project's synced product catalog, so an + unconsumed consumable records READY_TO_CONSUME{' '} + instead of a PENDING_ACKNOWLEDGMENT that the standard + verify-then-finish client flow could never clear. The purchases + view shows these rows as Ready to consume, matching App Store and + Amazon behavior. +
  • +
  • + Hardening - receipt invalidation is now an + internal-only mutation, and the invalid API key contract is + documented as 400 for malformed keys and 403 for unknown keys. + These service-side changes deploy with IAPKit itself and need no + SDK update. +
  • +
+ +
+
Package Releases
+
    + {crossSdkAuditReleases.map(([label, tag]) => ( +
  • + + {label} + +
  • + ))} +
+
+
+ ), + }, + // July 16, 2026 - IAPKit product client payloads { id: 'iapkit-product-client-payloads-2026-07-16', diff --git a/packages/gql/codegen/plugins/gdscript.ts b/packages/gql/codegen/plugins/gdscript.ts index 61e8dabe5..b8d47caff 100644 --- a/packages/gql/codegen/plugins/gdscript.ts +++ b/packages/gql/codegen/plugins/gdscript.ts @@ -128,20 +128,38 @@ export class GDScriptPlugin extends CodegenPlugin { if (field.defaultValue === undefined || field.defaultValue === null) { return null; } + return this.buildSchemaDefaultForType(field.type, field.defaultValue); + } + + private buildSchemaDefaultForType( + type: IRType, + defaultValue: unknown, + ): string | null { + // Lists recurse per element (e.g. `[TRANSACTIONAL]` in the schema must + // become `[InAppMessageCategoryAndroid.TRANSACTIONAL]`, not `[]`) — + // mirrors buildDefaultValueForType in the Kotlin/Swift/Dart plugins. + if (type.kind === 'list') { + if (!Array.isArray(defaultValue)) return null; + const items = defaultValue.map((value) => + this.buildSchemaDefaultForType(type.elementType!, value), + ); + if (items.some((item) => item === null)) return null; + return `[${items.join(', ')}]`; + } - if (field.type.kind === 'enum' && typeof field.defaultValue === 'string') { - return `${field.type.name}.${this.enumValueCase(field.defaultValue)}`; + if (type.kind === 'enum' && typeof defaultValue === 'string') { + return `${type.name}.${this.enumValueCase(defaultValue)}`; } - if (field.type.kind === 'scalar') { - if (typeof field.defaultValue === 'string') { - return JSON.stringify(field.defaultValue); + if (type.kind === 'scalar') { + if (typeof defaultValue === 'string') { + return JSON.stringify(defaultValue); } if ( - typeof field.defaultValue === 'number' || - typeof field.defaultValue === 'boolean' + typeof defaultValue === 'number' || + typeof defaultValue === 'boolean' ) { - return String(field.defaultValue); + return String(defaultValue); } } diff --git a/packages/gql/src/codegen-defaults.test.ts b/packages/gql/src/codegen-defaults.test.ts index d8c708155..fac2bbb8a 100644 --- a/packages/gql/src/codegen-defaults.test.ts +++ b/packages/gql/src/codegen-defaults.test.ts @@ -146,4 +146,38 @@ describe("codegen defaults", () => { expect(output).toContain("var renderer: Variant = null"); expect(output).toContain("if renderer != null:"); }); + + it("emits GraphQL enum-list defaults as GDScript array initializers", () => { + // Regression: list defaults used to fall through to `[]`, silently + // dropping schema defaults such as `categories: [InAppMessageCategoryAndroid!] + // = [TRANSACTIONAL]` while every other language plugin kept them. + const categoryEnum: IREnum = { + name: "Category", + isErrorCode: false, + values: [ + { + name: "TRANSACTIONAL", + rawValue: "transactional", + legacyAliases: [], + }, + { + name: "PROMOTIONAL", + rawValue: "promotional", + legacyAliases: [], + }, + ], + }; + const listType: IRType = { + kind: "list", + nullable: false, + elementType: { kind: "enum", name: "Category", nullable: false }, + }; + const output = new GDScriptPlugin({ outputPath: "types.gd" }).generate( + schema([field("categories", listType, ["TRANSACTIONAL"])], [categoryEnum]), + ); + + expect(output).toContain( + "var categories: Array[Category] = [Category.TRANSACTIONAL]", + ); + }); }); diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 126186ef8..e14ae3446 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -4319,7 +4319,7 @@ class GetBillingChoiceInfoParamsAndroid: ## Parameters for showing Play billing in-app messages (Android) Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (upstream API available since Play Billing 4.1.0). class InAppMessageParamsAndroid: ## In-app message categories to show. Defaults to transactional messages. - var categories: Array[InAppMessageCategoryAndroid] = [] + var categories: Array[InAppMessageCategoryAndroid] = [InAppMessageCategoryAndroid.TRANSACTIONAL] static func from_dict(data: Dictionary) -> InAppMessageParamsAndroid: var obj = InAppMessageParamsAndroid.new() diff --git a/packages/kit/CONVENTION.md b/packages/kit/CONVENTION.md index e7aca2764..1282d4642 100644 --- a/packages/kit/CONVENTION.md +++ b/packages/kit/CONVENTION.md @@ -72,6 +72,13 @@ convex// └── internal.ts # Internal-only queries/mutations called from actions ``` +One sanctioned exception: an operator-only `internalMutation` may stay +in `mutation.ts` when moving it to `internal.ts` would change its +generated function path (see `purchases/mutation.ts` — +`markReceiptInvalid` is invoked from the Convex dashboard, and the +in-file comment records the rationale). Do not "fix" such functions by +relocating them. + `convex/utils/validation.ts` holds shared `v.*` schemas. Do not edit `convex/_generated/*`; regenerate via `bunx convex dev`. diff --git a/packages/kit/README.md b/packages/kit/README.md index fc80b9e24..fe6db5d68 100644 --- a/packages/kit/README.md +++ b/packages/kit/README.md @@ -41,7 +41,7 @@ One package, one binary, one Fly.io app. - **Free for everyone** — no paywall, no usage caps. Sustained by sponsors at [openiap.dev/sponsors](https://openiap.dev/sponsors) - **Email OTP (Resend) + GitHub OAuth** via `@convex-dev/auth` - **OpenAPI spec** auto-generated by `hono-openapi` -- **Codex / MCP plugin endpoint** at `/mcp` for IAPKit project inspection, revenue questions, product management, and store-sync workflows +- **Codex / Claude Code MCP plugin endpoint** at `/mcp` for IAPKit project inspection, revenue questions, product management, and store-sync workflows - **Per-product client payloads** for public, app-readable TOML, JSON, or text rules without a separate metadata service ## Quick Start @@ -81,9 +81,9 @@ For API-only work: bun run dev:server # Hono on http://localhost:3000 ``` -The dev server also exposes the Codex / MCP plugin endpoint at -`http://localhost:3000/mcp`. Use an IAPKit project key, not an OpenAI or -ChatGPT API key: +The dev server also exposes the Codex / Claude Code MCP plugin endpoint at +`http://localhost:3000/mcp`. Use an IAPKit project key, not an OpenAI, +ChatGPT, Anthropic, or Claude API key: ```bash IAPKIT_API_KEY="openiap-kit_your-project-key" bun run dev:server diff --git a/packages/kit/convex/purchases/android.ts b/packages/kit/convex/purchases/android.ts index d8b4472d2..507d08418 100644 --- a/packages/kit/convex/purchases/android.ts +++ b/packages/kit/convex/purchases/android.ts @@ -110,9 +110,25 @@ export const verifyGooglePlayReceiptInternalV1 = action({ purchaseToken: args.purchaseToken, }); + // The Play API cannot mark an inapp purchase as consumable, so consult + // the project's synced catalog to map unconsumed consumables to + // READY_TO_CONSUME instead of a PENDING_ACKNOWLEDGMENT the standard + // verify-then-finish client flow can never clear. + const catalogProductType = + receiptData.type === "InApp" + ? await ctx.runQuery(internal.products.sync.getExistingProductType, { + projectId: project._id, + platform: "Android", + productId: receiptData.productId, + }) + : null; + // Persist the store-verified purchase state; `expectedProductId` // mismatch is caller-scoped and should not corrupt purchase logs. - const storeReceiptResponse = mapToGooglePlayReceiptResponse(receiptData); + const storeReceiptResponse = mapToGooglePlayReceiptResponse( + receiptData, + catalogProductType, + ); const receiptResponse = applyExpectedProductId( storeReceiptResponse, args.expectedProductId, diff --git a/packages/kit/convex/purchases/mutation.ts b/packages/kit/convex/purchases/mutation.ts index cb058e377..05885c775 100644 --- a/packages/kit/convex/purchases/mutation.ts +++ b/packages/kit/convex/purchases/mutation.ts @@ -1,12 +1,20 @@ -import { mutation } from "../_generated/server"; +import { internalMutation } from "../_generated/server"; import { v } from "convex/values"; import { createError, ErrorCode } from "../utils/errors"; import { HarmonizedPurchaseState } from "./purchaseState"; import { applyPurchaseStatsDelta, deltaForUpdate } from "./stats"; import { getProjectById } from "../projects/helpers"; -// Mark purchase as inauthentic -export const markReceiptInvalid = mutation({ +// Mark purchase as inauthentic. +// +// Internal-only on purpose: nothing in the dashboard or server calls this — +// it is an operator/maintenance function (run it from the Convex dashboard). +// As a public mutation it took only a `purchaseId` and performed no +// identity/membership check, so anyone reaching the deployment could flip +// another tenant's receipt to INAUTHENTIC and skew its purchase stats. +// It stays in this file (rather than internal.ts) to keep the generated +// module graph unchanged; see packages/kit/CONVENTION.md for the CQRS split. +export const markReceiptInvalid = internalMutation({ args: { purchaseId: v.id("purchases"), reason: v.optional(v.string()), diff --git a/packages/kit/convex/purchases/shared.test.ts b/packages/kit/convex/purchases/shared.test.ts index a29ee4f3a..88cd98e82 100644 --- a/packages/kit/convex/purchases/shared.test.ts +++ b/packages/kit/convex/purchases/shared.test.ts @@ -31,6 +31,90 @@ describe("mapGooglePlayPurchaseState", () => { expect(state).toBe(HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT); }); + it("maps unconsumed catalog-known consumables to ready-to-consume", () => { + const state = mapGooglePlayPurchaseState( + { + type: "InApp", + purchaseState: "PURCHASED", + acknowledgementState: "NOT_ACKNOWLEDGED", + consumptionState: "NOT_CONSUMED", + }, + "Consumable", + ); + + expect(state).toBe(HarmonizedPurchaseState.READY_TO_CONSUME); + }); + + it("maps acknowledged-but-unconsumed catalog-known consumables to ready-to-consume", () => { + const state = mapGooglePlayPurchaseState( + { + type: "InApp", + purchaseState: "PURCHASED", + acknowledgementState: "ACKNOWLEDGED", + consumptionState: "NOT_CONSUMED", + }, + "Consumable", + ); + + expect(state).toBe(HarmonizedPurchaseState.READY_TO_CONSUME); + }); + + it("still reports consumed for catalog-known consumables Google marks consumed", () => { + const state = mapGooglePlayPurchaseState( + { + type: "InApp", + purchaseState: "PURCHASED", + acknowledgementState: "ACKNOWLEDGED", + consumptionState: "CONSUMED", + }, + "Consumable", + ); + + expect(state).toBe(HarmonizedPurchaseState.CONSUMED); + }); + + it("keeps pending acknowledgment for catalog-known non-consumables", () => { + const state = mapGooglePlayPurchaseState( + { + type: "InApp", + purchaseState: "PURCHASED", + acknowledgementState: "NOT_ACKNOWLEDGED", + consumptionState: "NOT_CONSUMED", + }, + "NonConsumable", + ); + + expect(state).toBe(HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT); + }); + + it("keeps pending acknowledgment when the product is not in the catalog", () => { + const state = mapGooglePlayPurchaseState( + { + type: "InApp", + purchaseState: "PURCHASED", + acknowledgementState: "NOT_ACKNOWLEDGED", + consumptionState: "NOT_CONSUMED", + }, + null, + ); + + expect(state).toBe(HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT); + }); + + it("ignores the catalog type for subscription receipts", () => { + const state = mapGooglePlayPurchaseState( + { + type: "Subscription", + subscriptionState: "SUBSCRIPTION_STATE_ACTIVE", + acknowledgementState: "ACKNOWLEDGED", + expiryTime: Date.now() + 1000, + }, + "Consumable", + ); + + expect(state).toBe(HarmonizedPurchaseState.ENTITLED); + }); + it("marks subscriptions as expired when past expiry date", () => { const state = mapGooglePlayPurchaseState({ type: "Subscription", diff --git a/packages/kit/convex/purchases/shared.ts b/packages/kit/convex/purchases/shared.ts index 0ca0b2eb0..2dbfbc53c 100644 --- a/packages/kit/convex/purchases/shared.ts +++ b/packages/kit/convex/purchases/shared.ts @@ -174,8 +174,9 @@ export function mapToAppStoreReceiptResponse( export function mapToGooglePlayReceiptResponse( receiptData: GooglePlayReceiptData, + catalogProductType?: CatalogProductType | null, ): ReceiptResponse { - const state = mapGooglePlayPurchaseState(receiptData); + const state = mapGooglePlayPurchaseState(receiptData, catalogProductType); return { isValid: isValidState(state), @@ -254,6 +255,15 @@ function matchesConsumedToken(normalized: string): boolean { return isConsumedToken && !isNotConsumedToken; } +// Project catalog product type, as stored in the `products` table. The Play +// Developer API response alone cannot distinguish consumable from +// non-consumable inapp purchases, so callers that know the project's synced +// catalog can pass the type through to disambiguate. +export type CatalogProductType = + | "Subscription" + | "NonConsumable" + | "Consumable"; + export function mapGooglePlayPurchaseState( receipt: Pick< GooglePlayReceiptData, @@ -264,6 +274,7 @@ export function mapGooglePlayPurchaseState( | "consumptionState" | "expiryTime" >, + catalogProductType?: CatalogProductType | null, ): HarmonizedPurchaseState { if (receipt.type === "Subscription") { if (receipt.expiryTime && receipt.expiryTime < Date.now()) { @@ -307,6 +318,14 @@ export function mapGooglePlayPurchaseState( return HarmonizedPurchaseState.CONSUMED; } + // A consumable "finishes" by being consumed, not acknowledged, so an + // unconsumed catalog-known consumable is awaiting fulfillment — mirror + // the App Store consumable mapping instead of reporting a state the + // client flow (verify before finishTransaction) can never clear. + if (catalogProductType === "Consumable") { + return HarmonizedPurchaseState.READY_TO_CONSUME; + } + return isAcknowledged(receipt.acknowledgementState) ? HarmonizedPurchaseState.ENTITLED : HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT; diff --git a/packages/kit/public/llms-full.txt b/packages/kit/public/llms-full.txt index 49c81bf9d..d30a5b5c3 100644 --- a/packages/kit/public/llms-full.txt +++ b/packages/kit/public/llms-full.txt @@ -12,8 +12,9 @@ as one Fly.io app: - `src/` — React 19 SPA (dashboard, auth, usage, projects, API keys, docs) - `server/` — Hono + Bun server for `/api/v1/*` + `/v1/*` + SPA fallback -- `packages/mcp-server/` — MCP server used by `/mcp`, the Codex plugin, - MCP-compatible assistant clients, and self-hosted IAPKit assistant workflows +- `packages/mcp-server/` — MCP server used by `/mcp`, the Codex and Claude + Code plugins, MCP-compatible assistant clients, and self-hosted IAPKit + assistant workflows - `convex/` — Convex functions (auth, orgs, projects, receipts) - `public/` — static assets (served by Vite in dev, Hono in prod) @@ -37,7 +38,7 @@ or email-OTP (Resend). The MCP endpoint at `/mcp` uses the same IAPKit project key. MCP clients may send it as `Authorization: Bearer `. A self-hosted MCP server can instead set `IAPKIT_API_KEY` so the project key stays in that private process. -This is not an OpenAI or ChatGPT API key. +This is not an OpenAI, ChatGPT, Anthropic, or Claude API key. ## Endpoints @@ -177,11 +178,12 @@ probe traffic doesn't dominate trace quota. ### POST /mcp -MCP Streamable HTTP endpoint for Codex and other MCP clients. Exposes tools -with the `iapkit_` prefix: setup snippets, revenue analytics, status checks, -diagnostics, product catalog reads/writes, store-sync jobs, subscriber views, -sandbox guidance, webhook simulation, and project inspection. Full setup guide: -`/docs/ai-assistants/codex-plugin`. +MCP Streamable HTTP endpoint for Codex, Claude Code, and other MCP clients. +Exposes tools with the `iapkit_` prefix: setup snippets, revenue analytics, +status checks, diagnostics, product catalog reads/writes, store-sync jobs, +subscriber views, sandbox guidance, webhook simulation, and project +inspection. Setup guides: `/docs/ai-assistants/codex-plugin` and +`/docs/ai-assistants/claude-plugin`. ## Harmonized purchase states @@ -203,9 +205,10 @@ sandbox guidance, webhook simulation, and project inspection. Full setup guide: | ---- | ------------------------------------------------------- | ----------------------------------------------- | | 200 | `{ store, isValid, state, productId?, clientPayload? }` | Verification ran | | 400 | `INVALID_INPUT` | Malformed body, unknown store, oversized field | +| 400 | `INVALID_API_KEY` | Well-formed key that fails project lookup | | 413 | `PAYLOAD_TOO_LARGE` | Request body exceeds the 32 KB edge cap | | 401 | `MISSING_API_KEY` | No `Authorization` header | -| 403 | `INVALID_API_KEY` | Wrong scheme, malformed, or unknown key | +| 403 | `INVALID_API_KEY` | Wrong scheme or malformed key (format only) | | 429 | `RATE_LIMITED` | Per-key bucket empty; honor `Retry-After` | | 500 | `UNKNOWN_ERROR` | Server-side failure; include `X-Correlation-Id` | diff --git a/packages/kit/public/llms.txt b/packages/kit/public/llms.txt index 405bac3ce..3c6d41fb5 100644 --- a/packages/kit/public/llms.txt +++ b/packages/kit/public/llms.txt @@ -23,7 +23,7 @@ their `{apiKey}` path segment. - [GET /v1/openapi](https://kit.openiap.dev/v1/openapi) — machine-readable OpenAPI spec - [GET /v1](https://kit.openiap.dev/v1) — Redoc UI for the OpenAPI spec - [GET /health](https://kit.openiap.dev/health) — liveness probe (no Convex round-trip) -- [POST /mcp](https://kit.openiap.dev/docs/ai-assistants/codex-plugin) — MCP Streamable HTTP endpoint for Codex and other MCP clients. Uses an IAPKit project API key, not an OpenAI or ChatGPT API key. +- [POST /mcp](https://kit.openiap.dev/docs/ai-assistants/codex-plugin) — MCP Streamable HTTP endpoint for Codex, Claude Code, and other MCP clients. Uses an IAPKit project API key, not an OpenAI, ChatGPT, Anthropic, or Claude API key. Also mounted at `/api/v1/*` for backwards compatibility. `/v1/verify-purchase` is an alias of `/v1/purchase/verify`. Pick `/v1/purchase/verify` for new code. @@ -87,9 +87,10 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - `200` — verification ran; require `isValid`, an operation-appropriate `state`, and an exact store-verified `productId` match - `400 INVALID_INPUT` — malformed body / unknown store / oversized field +- `400 INVALID_API_KEY` — well-formed key that fails project lookup (unknown or rotated) - `413 PAYLOAD_TOO_LARGE` — request body exceeds the 32 KB edge cap - `401 MISSING_API_KEY` — no `Authorization` header -- `403 INVALID_API_KEY` — wrong scheme, malformed, or unrecognized key +- `403 INVALID_API_KEY` — wrong scheme or malformed key (format check only) - `429 RATE_LIMITED` — per-key bucket empty; honor `Retry-After` seconds - `500 UNKNOWN_ERROR` — quote the `X-Correlation-Id` header in a support ticket @@ -113,4 +114,5 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - [openiap.dev/docs/webhooks](https://openiap.dev/docs/webhooks) — operator setup steps for the lifecycle webhook URL (Apple ASN v2 + Google RTDN) and SDK code for consuming the SSE stream - [/docs/ai-assistants](https://kit.openiap.dev/docs/ai-assistants) — how to point Codex / Claude / Cursor / etc. at this file - [/docs/ai-assistants/codex-plugin](https://kit.openiap.dev/docs/ai-assistants/codex-plugin) — Codex plugin setup and self-hosted IAPKit MCP server option +- [/docs/ai-assistants/claude-plugin](https://kit.openiap.dev/docs/ai-assistants/claude-plugin) — Claude Code plugin setup (marketplace install or claude mcp add) - [/docs/release-notes](https://kit.openiap.dev/docs/release-notes) — changelog diff --git a/packages/kit/public/sitemap.xml b/packages/kit/public/sitemap.xml index 6eec56d11..092832dee 100644 --- a/packages/kit/public/sitemap.xml +++ b/packages/kit/public/sitemap.xml @@ -80,6 +80,12 @@ monthly 0.6 + + https://kit.openiap.dev/docs/ai-assistants/claude-plugin + 2026-07-19 + monthly + 0.6 + https://kit.openiap.dev/docs/release-notes 2026-04-22 diff --git a/packages/kit/server/api/v1/route-response-schemas.ts b/packages/kit/server/api/v1/route-response-schemas.ts index 83186421a..c332b25ac 100644 --- a/packages/kit/server/api/v1/route-response-schemas.ts +++ b/packages/kit/server/api/v1/route-response-schemas.ts @@ -34,7 +34,7 @@ const unifiedPurchaseStates = [ { name: "READY_TO_CONSUME", description: - "Consumable purchase is ready to be fulfilled. Note: This state is only applicable to App Store and does not reflect the actual consumable state of the item.", + "Consumable purchase is ready to be fulfilled. Reported for App Store and Amazon consumables, and for Google Play purchases the project catalog identifies as consumable. Reflects the state at verification time, not later consumption.", }, { name: "CONSUMED", diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index 908cbb650..01d600519 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -261,7 +261,7 @@ const verifyPurchaseRouteDescription = describeRoute({ }, 400: { description: - "Verification failed — malformed body, unknown store, or input exceeds size cap (`INVALID_INPUT`).", + "Verification failed — malformed body, unknown store, or input exceeds size cap (`INVALID_INPUT`), or a well-formed API key that fails project lookup (`INVALID_API_KEY`).", headers: commonResponseHeaders, content: { "application/json": { @@ -288,7 +288,7 @@ const verifyPurchaseRouteDescription = describeRoute({ }, }, 403: { - description: "Invalid bearer token", + description: "Malformed bearer token or wrong scheme (format check only)", content: { "application/json": { schema: resolver(apiErrorResponseSchema), diff --git a/packages/kit/src/pages/docs/nav.ts b/packages/kit/src/pages/docs/nav.ts index ecafa278a..59e60dd56 100644 --- a/packages/kit/src/pages/docs/nav.ts +++ b/packages/kit/src/pages/docs/nav.ts @@ -75,7 +75,7 @@ export const DOCS_NAV: DocsNavEntry[] = [ { slug: "ai-assistants", title: "AI assistants", - summary: "llms.txt, MCP, and Codex plugin setup.", + summary: "llms.txt, MCP, and AI agent plugin setup.", children: [ { slug: "ai-assistants/codex-plugin", @@ -83,6 +83,12 @@ export const DOCS_NAV: DocsNavEntry[] = [ summary: "Kit endpoint and key reference; full MCP guide lives in OpenIAP docs.", }, + { + slug: "ai-assistants/claude-plugin", + title: "Claude Code plugin", + summary: + "Kit endpoint and key reference; full MCP guide lives in OpenIAP docs.", + }, ], }, { diff --git a/packages/kit/src/pages/docs/routes.tsx b/packages/kit/src/pages/docs/routes.tsx index 6830b063c..46c65797d 100644 --- a/packages/kit/src/pages/docs/routes.tsx +++ b/packages/kit/src/pages/docs/routes.tsx @@ -12,6 +12,7 @@ import AnalyticsPage from "./sections/analytics"; import OperationsPage from "./sections/operations"; import AiAssistantsPage from "./sections/ai-assistants"; import CodexPluginPage from "./sections/codex-plugin"; +import ClaudePluginPage from "./sections/claude-plugin"; import ReleaseNotesPage from "./sections/release-notes"; /** @@ -49,6 +50,7 @@ export const docsChildRoutes = ( } /> } /> } /> + } /> } /> {/* Unknown sub-paths bounce back to the docs index so the user never ends up in the authed organization routes by accident. */} diff --git a/packages/kit/src/pages/docs/sections/ai-assistants.tsx b/packages/kit/src/pages/docs/sections/ai-assistants.tsx index 643123770..cd0b410bd 100644 --- a/packages/kit/src/pages/docs/sections/ai-assistants.tsx +++ b/packages/kit/src/pages/docs/sections/ai-assistants.tsx @@ -84,17 +84,27 @@ export default function AiAssistantsPage() {

-

Codex plugin

+

+ Codex & Claude Code plugins +

- Codex can use IAPKit as an MCP-backed plugin through{" "} + Codex and Claude Code can use IAPKit as an MCP-backed plugin through{" "} https://kit.openiap.dev/mcp. The plugin uses your IAPKit - project API key, not an OpenAI or ChatGPT API key. See the{" "} + project API key, not an OpenAI, ChatGPT, Anthropic, or Claude API key. + See the{" "} Codex plugin guide {" "} + or the{" "} + + Claude Code plugin guide + {" "} for the setup flow, self-hosted option, and tool list.

diff --git a/packages/kit/src/pages/docs/sections/api.tsx b/packages/kit/src/pages/docs/sections/api.tsx index 261ec79e2..990c36be4 100644 --- a/packages/kit/src/pages/docs/sections/api.tsx +++ b/packages/kit/src/pages/docs/sections/api.tsx @@ -243,7 +243,8 @@ export default function ApiReferencePage() { READY_TO_CONSUME - Apple or Amazon consumable ready for durable fulfillment. + Apple, Amazon, or catalog-known Google consumable ready for + durable fulfillment. true @@ -335,6 +336,13 @@ export default function ApiReferencePage() { Malformed body / unknown store / oversized field. + + 400 + INVALID_API_KEY + + Well-formed key that fails project lookup (unknown or rotated). + + 413 PAYLOAD_TOO_LARGE @@ -351,7 +359,7 @@ export default function ApiReferencePage() { 403 INVALID_API_KEY - Wrong scheme, malformed key, or key not recognized. + Wrong scheme or malformed key (format check only). diff --git a/packages/kit/src/pages/docs/sections/claude-plugin.tsx b/packages/kit/src/pages/docs/sections/claude-plugin.tsx new file mode 100644 index 000000000..4c65926d7 --- /dev/null +++ b/packages/kit/src/pages/docs/sections/claude-plugin.tsx @@ -0,0 +1,112 @@ +import { DOCS_URL } from "../../../config/env"; +import { Callout } from "../components/Callout"; +import { CodeBlock } from "../components/CodeBlock"; +import { DocsPage } from "../components/DocsPage"; + +const MCP_SERVER_GUIDE_URL = `${DOCS_URL}/docs/guides/mcp-server`; + +export default function ClaudePluginPage() { + return ( + +

+ The OpenIAP plugin connects Claude Code to this IAPKit project through + the hosted /mcp endpoint. Use this page for the Kit-local + 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. +

+ + +

+ For the full setup guide, local PR testing steps, tool list, safety + notes, and Example App walkthrough, open{" "} + + /docs/guides/mcp-server + + . +

+
+ + +

+ This OpenIAP plugin is experimental. The MCP endpoint, tool names, and + setup flow are available for early testing and may continue to evolve. +

+
+ + +

+ Do not use an Anthropic or Claude API key for this plugin. + Authentication is an IAPKit project API key sent as{" "} + Authorization: Bearer <IAPKit project key>, read + from the IAPKIT_API_KEY environment variable. +

+
+ +
+

Plugin settings

+
+
+
+ Remote MCP URL +
+ + https://kit.openiap.dev/mcp + +
+
+
+ Authentication +
+ + Bearer token = IAPKit project API key + +
+
+
+ Tool prefix +
+ + iapkit_* tools through the OpenIAP plugin + +
+
+
+ +

Install

+

+ Add the OpenIAP marketplace, install the plugin, and set the project key + before launching Claude Code: +

+ + {`claude plugin marketplace add hyodotdev/openiap +claude plugin install openiap@openiap +export IAPKIT_API_KEY="openiap-kit_your-project-key"`} + +

Without the plugin bundle, register the hosted MCP server directly:

+ + {`claude mcp add --transport http openiap https://kit.openiap.dev/mcp \\ + --header "Authorization: Bearer \${IAPKIT_API_KEY}"`} + + +

+ Start with a read-only prompt and keep product writes behind review. + Store sync jobs should begin with dryRun: true; approve + live writes only after checking the proposed platform, product id, + price, and billing period in the OpenIAP MCP Server guide. +

+
+ ); +} diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 000000000..307f10ef4 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,64 @@ +# @hyodotdev/openiap-mcp-server + +Model Context Protocol server for IAPKit. It exposes `iapkit_*` tools +(setup snippets, catalog reads/writes, subscription lists, revenue +analytics, webhook simulation, store sync jobs) to Codex, Claude Code, +and any other MCP client. + +The hosted deployment lives at `https://kit.openiap.dev/mcp`. This +package is what you run for local development and unreleased PR +testing. Full user-facing setup docs: +[openiap.dev/docs/guides/mcp-server](https://openiap.dev/docs/guides/mcp-server). + +## Authentication + +Every transport authenticates with an **IAPKit project API key** — not +an OpenAI, ChatGPT, Anthropic, or Claude API key. The key is resolved +from, in order: a tool's explicit `apiKey` argument, then the MCP +`Authorization: Bearer` token, then the `IAPKIT_API_KEY` environment +variable. + +## Transports + +```bash +# stdio (bin: iapkit-mcp / openiap-mcp) +IAPKIT_API_KEY="openiap-kit_your-project-key" bun run start + +# Streamable HTTP on http://127.0.0.1:3939/mcp (bin: iapkit-mcp-http) +IAPKIT_API_KEY="openiap-kit_your-project-key" bun run start:http +``` + +`PORT` / `IAPKIT_MCP_PORT` override the HTTP port and +`IAPKIT_MCP_ALLOWED_ORIGINS` overrides the CORS allow-list. + +## Client configuration + +Codex (`~/.codex/config.toml`): + +```toml +[mcp_servers.openiap] +url = "https://kit.openiap.dev/mcp" +bearer_token_env_var = "IAPKIT_API_KEY" +default_tools_approval_mode = "prompt" +``` + +Claude Code: + +```bash +claude mcp add --transport http openiap https://kit.openiap.dev/mcp \ + --header "Authorization: Bearer ${IAPKIT_API_KEY}" +``` + +Both agents can instead install the `plugins/openiap` plugin — see the +Codex marketplace entry in `.agents/plugins/marketplace.json` and the +Claude Code marketplace in `.claude-plugin/marketplace.json` at the +repo root. For local PR testing, point either client at +`http://127.0.0.1:3939/mcp`. + +## Development + +```bash +bun run lint # tsc --noEmit +bun run test # vitest +bun run build # emit dist/ +``` diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 9d3da036c..059364d11 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,7 +1,7 @@ { "name": "@hyodotdev/openiap-mcp-server", "version": "0.1.0", - "description": "Model Context Protocol server for IAPKit — wires Codex and other MCP clients into IAPKit's product, subscription, revenue, and webhook surfaces.", + "description": "Model Context Protocol server for IAPKit — wires Codex, Claude Code, and other MCP clients into IAPKit's product, subscription, revenue, and webhook surfaces.", "type": "module", "private": true, "bin": { diff --git a/plugins/openiap/.claude-plugin/plugin.json b/plugins/openiap/.claude-plugin/plugin.json new file mode 100644 index 000000000..5c3eb4b49 --- /dev/null +++ b/plugins/openiap/.claude-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "openiap", + "displayName": "OpenIAP", + "version": "0.1.0", + "description": "Inspect and implement in-app purchase flows with OpenIAP and IAPKit.", + "author": { + "name": "OpenIAP", + "url": "https://openiap.dev" + }, + "homepage": "https://openiap.dev/docs/guides/mcp-server", + "repository": "https://github.com/hyodotdev/openiap", + "license": "MIT", + "keywords": ["openiap", "iapkit", "in-app-purchases", "mcp", "claude"], + "mcpServers": { + "openiap": { + "type": "http", + "url": "https://kit.openiap.dev/mcp", + "headers": { + "Authorization": "Bearer ${IAPKIT_API_KEY:-}" + } + } + } +} diff --git a/plugins/openiap/.mcp.json b/plugins/openiap/.codex-plugin/mcp.json similarity index 100% rename from plugins/openiap/.mcp.json rename to plugins/openiap/.codex-plugin/mcp.json diff --git a/plugins/openiap/.codex-plugin/plugin.json b/plugins/openiap/.codex-plugin/plugin.json index ca54209d6..772b6c59a 100644 --- a/plugins/openiap/.codex-plugin/plugin.json +++ b/plugins/openiap/.codex-plugin/plugin.json @@ -28,5 +28,5 @@ ], "brandColor": "#2563EB" }, - "mcpServers": "./.mcp.json" + "mcpServers": "./.codex-plugin/mcp.json" } diff --git a/plugins/openiap/skills/openiap/SKILL.md b/plugins/openiap/skills/openiap/SKILL.md index acd1275a6..d09a79d61 100644 --- a/plugins/openiap/skills/openiap/SKILL.md +++ b/plugins/openiap/skills/openiap/SKILL.md @@ -1,6 +1,6 @@ --- name: openiap -description: Use when the user wants Codex to inspect, implement, or troubleshoot app in-app purchase flows with OpenIAP, including SDK setup, product catalog checks, subscription analytics, IAPKit receipt validation, store sync jobs, and webhook simulation. +description: Use when the user wants their AI coding agent (Codex, Claude Code, etc.) to inspect, implement, or troubleshoot app in-app purchase flows with OpenIAP, including SDK setup, product catalog checks, subscription analytics, IAPKit receipt validation, store sync jobs, and webhook simulation. --- # OpenIAP @@ -11,9 +11,14 @@ and exposes `iapkit_*` tools for live project operations. ## Authentication -The server expects an IAPKit project API key, not an OpenAI or ChatGPT API key. -Users should set `IAPKIT_API_KEY` in the environment that launches Codex before -using the plugin. +The server expects an IAPKit project API key, not an OpenAI, ChatGPT, +Anthropic, or Claude API key. Set `IAPKIT_API_KEY` in the environment that +launches the agent before using the plugin: + +- **Codex**: export `IAPKIT_API_KEY` before starting Codex; the plugin's MCP + config reads it through `bearer_token_env_var`. +- **Claude Code**: export `IAPKIT_API_KEY` before starting Claude Code; the + plugin's MCP config expands it into the `Authorization` header. ## Operating Rules diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 12facbd73..51cb9d55b 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -796,6 +796,148 @@ function checkOperationRegistry() { } } +// packages/google dispatches through hand-written Mutation/Query/Subscription +// handler bundles in each flavor's OpenIapModule.kt — NOT through the +// generated resolver interfaces that checkGeneratedTypeSync() compares. A new +// Android operation regenerates into Types.kt (keeping that check green) while +// its nullable bundle field silently defaults to null, which is exactly the +// "declared but not implemented" gap this audit exists to catch. So compare +// every flavor's bundle argument names against the registry directly. +const GOOGLE_FLAVOR_MODULES = [ + "packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt", + "packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt", + "packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt", +]; + +// Kotlin comments and string literals may contain brackets or commas (e.g. +// `// (optional)` or `"a, b"`) that must not affect the structural +// bracket-depth and argument-split scans below. Blank their contents out with +// spaces, preserving indices, so only real code characters are scanned. +function maskKotlinCommentsAndStrings(text) { + let masked = ""; + let state = "code"; + let index = 0; + while (index < text.length) { + const char = text[index]; + const next = text[index + 1]; + if (state === "code") { + if (char === "/" && next === "/") { + state = "line"; + masked += " "; + index += 2; + continue; + } + if (char === "/" && next === "*") { + state = "block"; + masked += " "; + index += 2; + continue; + } + if (char === '"') state = "string"; + masked += char; + index += 1; + continue; + } + if (state === "line") { + if (char === "\n") state = "code"; + masked += char === "\n" ? "\n" : " "; + index += 1; + continue; + } + if (state === "block") { + if (char === "*" && next === "/") { + state = "code"; + masked += " "; + index += 2; + continue; + } + masked += char === "\n" ? "\n" : " "; + index += 1; + continue; + } + if (char === "\\") { + masked += " "; + index += 2; + continue; + } + if (char === '"') state = "code"; + masked += char === '"' ? '"' : " "; + index += 1; + } + return masked; +} + +function parseHandlerBundleArgumentNames(text, bundleType, relativePath) { + const source = maskKotlinCommentsAndStrings(text); + const marker = new RegExp( + `override val \\w+Handlers: ${bundleType} = ${bundleType}\\(`, + ); + const match = marker.exec(source); + if (!match) { + fail(`${relativePath} is missing an override val ${bundleType} bundle`); + return []; + } + + // Walk the balanced bracket span of the constructor call, then collect the + // top-level `name =` argument names inside it. + let index = match.index + match[0].length; + const start = index; + let depth = 1; + while (index < source.length && depth > 0) { + const char = source[index]; + if (char === "(" || char === "{" || char === "[") depth += 1; + else if (char === ")" || char === "}" || char === "]") depth -= 1; + index += 1; + } + if (depth !== 0) { + fail(`${relativePath} has an unbalanced ${bundleType} bundle`); + return []; + } + const block = source.slice(start, index - 1); + + const names = []; + const pushName = (segment) => { + const nameMatch = segment.match(/^\s*([A-Za-z][A-Za-z0-9_]*)\s*=/); + if (nameMatch) names.push(nameMatch[1]); + }; + let level = 0; + let argStart = 0; + for (let cursor = 0; cursor < block.length; cursor += 1) { + const char = block[cursor]; + if (char === "(" || char === "{" || char === "[") level += 1; + else if (char === ")" || char === "}" || char === "]") level -= 1; + else if (char === "," && level === 0) { + pushName(block.slice(argStart, cursor)); + argStart = cursor + 1; + } + } + pushName(block.slice(argStart)); + return names.sort(); +} + +function checkGoogleFlavorHandlerWiring() { + const androidRelevantOperations = Object.fromEntries( + Object.entries(operationParityRegistry).map(([kind, operations]) => [ + kind, + operations.filter((operation) => !operation.endsWith("IOS")), + ]), + ); + for (const relativePath of GOOGLE_FLAVOR_MODULES) { + expectFile(relativePath); + if (!exists(relativePath)) continue; + const text = read(relativePath); + for (const [kind, expectedOperations] of Object.entries( + androidRelevantOperations, + )) { + expectSameSet( + `${relativePath} ${kind}Handlers wiring`, + expectedOperations, + parseHandlerBundleArgumentNames(text, `${kind}Handlers`, relativePath), + ); + } + } +} + function walk(dir, acc = []) { if (!fs.existsSync(dir)) return acc; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -5964,6 +6106,7 @@ checkE2eExampleIds(); checkGeneratedTypeSync(); checkGqlRuntimeExports(); checkOperationRegistry(); +checkGoogleFlavorHandlerWiring(); checkFrameworkOperationBindings(); checkExpoRouterExample("libraries/expo-iap/example", "src/utils/constants.ts"); checkReactNativeClassic();