Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b358200
feat: audit iapkit response enums against the spec
hyochan Aug 13, 2026
3d99d5b
fix(kit): enforce the verify response contract at runtime
hyochan Aug 13, 2026
0324eca
test(kit): pin the entitlement decision and state mapping
hyochan Aug 13, 2026
6f973a3
fix: close gaps self-review found in the kit contract guards
hyochan Aug 13, 2026
5a79967
fix: stop SDKs failing receipts over IAPKit metadata
hyochan Aug 13, 2026
f663bcf
feat: report the client spec version on IAPKit verify
hyochan Aug 13, 2026
3cc1969
fix: correct what CI and the second review round caught
hyochan Aug 13, 2026
6f810ad
fix: pin the guards the second review round found unpinned
hyochan Aug 13, 2026
60df30f
fix: trim comments and close the third review round
hyochan Aug 13, 2026
f181ad9
fix(kmp): catch every failure on the Amazon verify path
hyochan Aug 13, 2026
bf06984
docs: state the IAPKit version compatibility policy
hyochan Aug 13, 2026
f81d06d
fix(apple): resolve the spec version at compile time
hyochan Aug 13, 2026
66a781f
fix(mcp): forward an unrecognized client payload format
hyochan Aug 13, 2026
968a14f
docs(kit): add the release note for this deploy
hyochan Aug 13, 2026
bdf9a21
docs(kit): backfill the release notes for shipped deploys
hyochan Aug 13, 2026
ea532b3
fix: address CodeRabbit review on #321
hyochan Aug 13, 2026
0b5d3fd
fix(kit): audit the write path and stop casting Apple's environment
hyochan Aug 13, 2026
ed8674c
fix: close remaining kit compatibility gaps
hyochan Aug 13, 2026
d47b1b9
test: restore expo coverage gate
hyochan Aug 13, 2026
bd01c9d
test: cover mapped ipv6 kit origins
hyochan Aug 13, 2026
9e5d36a
fix: align remaining contract review notes
hyochan Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,13 @@ jobs:
- name: Run non-Godot SDK parity audit
run: node scripts/audit-non-godot-parity.mjs

# Unconditional: kit and the spec deploy on separate workflows.
- name: Test IAPKit spec contract audit
run: node --test scripts/audit-kit-spec-contract.test.mjs

- name: Run IAPKit spec contract audit
run: node scripts/audit-kit-spec-contract.mjs

test-gql:
name: Test GQL Types
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/deploy-kit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ jobs:
exit 1
fi

# ci.yml runs independently, so the guard has to gate the deploy here too.
- name: Run IAPKit spec contract audit
working-directory: ${{ github.workspace }}
run: node scripts/audit-kit-spec-contract.mjs

- name: Lint (app + Convex typecheck + eslint)
run: bun run lint

Expand Down
5 changes: 5 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ fi
echo "🔎 SDK parity audit — running CI mirror…"
node scripts/audit-non-godot-parity.mjs

# Unconditional: either side of the kit/spec contract can move.
echo "🔎 IAPKit spec contract audit — running CI mirror…"
node --test scripts/audit-kit-spec-contract.test.mjs
node scripts/audit-kit-spec-contract.mjs

# Paths-aware kit pre-commit gate. Only runs when staged changes touch
# packages/kit/**, so unrelated edits to apple/google/gql/docs/libraries
# aren't blocked.
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ openiap/
- [`packages/google/CONVENTION.md`](packages/google/CONVENTION.md)
- [`packages/apple/CONVENTION.md`](packages/apple/CONVENTION.md)
- [`packages/docs/CONVENTION.md`](packages/docs/CONVENTION.md)
- [`packages/kit/CONVENTION.md`](packages/kit/CONVENTION.md) — kit is a deployable SaaS (not a library); has its own Convex schema and isn't part of the GQL type-sync chain
- [`packages/kit/CONVENTION.md`](packages/kit/CONVENTION.md) — kit is a deployable SaaS (not a library); has its own Convex schema and isn't part of the GQL type-sync chain. Its `/v1` responses are still a published contract that shipped SDKs decode: read the `/v1` response contract section and run `bun audit:kit-contract` before changing a response enum, `isValidState`, or a purchase-state mapping
3. **For framework libraries, read the library-specific CLAUDE.md**:
- [`libraries/react-native-iap/CLAUDE.md`](libraries/react-native-iap/CLAUDE.md) — Yarn 3, Nitro Modules, useIAP hook semantics, error handling
- [`libraries/expo-iap/CLAUDE.md`](libraries/expo-iap/CLAUDE.md) — Bun, Expo Modules, iOS podspec 13.4 workaround, tvOS 16.0 requirement
Expand Down Expand Up @@ -121,6 +121,7 @@ including its stricter release-note limits.
### Auto-Generated Files (DO NOT EDIT)

- `packages/gql/src/generated/*` - All generated type files (SSOT)
- `packages/apple/Sources/OpenIapGeneratedVersion.swift` - Synced from `openiap-versions.json`
- `packages/apple/Sources/Models/Types.swift` - Synced from GQL
- `packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt` - Synced from GQL
- `libraries/react-native-iap/src/types.ts` - Synced from GQL
Expand Down
33 changes: 32 additions & 1 deletion libraries/expo-iap/src/__tests__/kit-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {kitApi, KitApiError} from '../kit-api';
import {kitApi, KitApiError, type KitProductClientPayload} from '../kit-api';

const payload = {
clientPayload: {
Expand Down Expand Up @@ -344,6 +344,37 @@ describe('kitApi cache resilience', () => {
expect(fetchImpl).toHaveBeenCalledTimes(1);
});

// Evicting on an unknown format would kill ETag revalidation and offline
// reads, for a value the live path forwards unchanged.
it('serves a cached payload whose format this build predates', async () => {
const clientPayload: KitProductClientPayload = {
format: 'yaml',
body: 'tier: gold',
version: 2,
updatedAt: 9,
};
const stored = {
clientPayload,
etag: 'W/"cached"',
};
const cache = {
getItem: jest.fn().mockResolvedValue(JSON.stringify(stored)),
setItem: jest.fn(),
removeItem: jest.fn(),
};
const fetchImpl = jest.fn();

await expect(
kitApi({
apiKey: 'key',
fetchImpl,
clientPayloadCache: cache,
}).clientPayload('premium', 'IOS'),
).resolves.toEqual({clientPayload: stored.clientPayload});
expect(fetchImpl).not.toHaveBeenCalled();
expect(cache.removeItem).not.toHaveBeenCalled();
});

it('keeps successful reads when cache operations fail', async () => {
const cache = {
getItem: jest.fn().mockRejectedValue(new Error('read failed')),
Expand Down
96 changes: 80 additions & 16 deletions libraries/expo-iap/src/__tests__/vega-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const createService = (): jest.Mocked<VegaPurchasingService> =>
notifyFulfillment: jest.fn(async () => ({
responseCode: 1,
})),
}) as unknown as jest.Mocked<VegaPurchasingService>;
} as unknown as jest.Mocked<VegaPurchasingService>);

describe('Amazon Vega Expo adapter', () => {
it('initializes without fetching Amazon user data', async () => {
Expand Down Expand Up @@ -269,6 +269,61 @@ describe('Amazon Vega Expo adapter', () => {
});
});

it('supports subscription checks and consumption', async () => {
const service = createService();
const module = createExpoIapVegaModule(service);

await expect(module.hasActiveSubscriptions()).resolves.toBe(true);
await expect(
module.consumePurchaseAndroid('sub-receipt'),
).resolves.toBeUndefined();

expect(service.notifyFulfillment).toHaveBeenCalledWith({
fulfillmentResult: 1,
receiptId: 'sub-receipt',
});
});

it('clears cached state and removed listeners on disconnect', async () => {
const service = createService();
const module = createExpoIapVegaModule(service);
const subscriptionListener = jest.fn();
const directListener = jest.fn();
const subscription = module.addListener(
'purchase-updated',
subscriptionListener,
);
module.addListener('purchase-updated', directListener);

await module.getStorefront();
subscription.remove();
module.removeListener('purchase-updated', directListener);
await module.requestPurchase({skus: ['coins_100'], type: 'in-app'});
await expect(module.endConnection()).resolves.toBe(true);
await module.getStorefront();

expect(subscriptionListener).not.toHaveBeenCalled();
expect(directListener).not.toHaveBeenCalled();
expect(service.getUserData).toHaveBeenCalledTimes(2);
});

it('normalizes non-error purchase failures for listeners', async () => {
const service = createService();
service.purchase.mockRejectedValueOnce('purchase failed');
const module = createExpoIapVegaModule(service);
const listener = jest.fn();
module.addListener('purchase-error', listener);

await expect(
module.requestPurchase({skus: ['coins_100'], type: 'in-app'}),
).rejects.toBe('purchase failed');
expect(listener).toHaveBeenCalledWith({
code: ErrorCode.PurchaseError,
message: 'Failed to complete Amazon Vega purchase',
productId: 'coins_100',
});
});

it('retries transient Amazon Vega fulfillment failures', async () => {
jest.useFakeTimers();
const service = createService();
Expand Down Expand Up @@ -1264,6 +1319,10 @@ describe('Amazon Vega Expo adapter', () => {
['http://localhost:3100/', 'http://localhost:3100/v1/purchase/verify'],
['http://192.168.0.4:3100', 'http://192.168.0.4:3100/v1/purchase/verify'],
['http://[::1]:3100', 'http://[::1]:3100/v1/purchase/verify'],
[
'http://[::ffff:192.168.0.1]:3100',
'http://[::ffff:192.168.0.1]:3100/v1/purchase/verify',
],
[
'https://[2001:db8::1]:65535///',
'https://[2001:db8::1]:65535/v1/purchase/verify',
Expand Down Expand Up @@ -1635,9 +1694,16 @@ describe('Amazon Vega Expo adapter', () => {
}
});

it.each([42, 'Staging'])(
'rejects an invalid IAPKit environment: %s',
async (environment) => {
// Forwarded opaquely; only a non-string is dropped. Neither fails.
it.each([
{environment: 'Xcode', expected: 'Xcode'},
{environment: 'LocalTesting', expected: 'LocalTesting'},
{environment: 'Staging', expected: 'Staging'},
{environment: 42, expected: undefined},
{environment: '', expected: undefined},
])(
'never fails a receipt over the IAPKit environment: $environment',
async ({environment, expected}) => {
const service = createService();
const originalFetch = globalThis.fetch;
const fetchMock = jest.fn(async () =>
Expand All @@ -1653,20 +1719,18 @@ describe('Amazon Vega Expo adapter', () => {
try {
const module = createExpoIapVegaModule(service);

await expect(
module.verifyPurchaseWithProvider({
provider: 'iapkit',
iapkit: {
amazon: {
userId: 'amazon-user',
receiptId: 'receipt-vega-1',
},
const result = await module.verifyPurchaseWithProvider({
provider: 'iapkit',
iapkit: {
amazon: {
userId: 'amazon-user',
receiptId: 'receipt-vega-1',
},
}),
).rejects.toMatchObject({
code: ErrorCode.PurchaseVerificationFailed,
message: 'IAPKit returned malformed response (HTTP 200).',
},
});

expect(result.iapkit?.isValid).toBe(true);
expect(result.iapkit?.environment).toBe(expected);
} finally {
globalThis.fetch = originalFetch;
}
Expand Down
7 changes: 5 additions & 2 deletions libraries/expo-iap/src/kit-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ export type StatusResponse = {
export type KitProductPlatform = "IOS" | "Android";

export type KitProductClientPayload = {
format: "toml" | "json" | "text";
/** Current values are toml, json, and text; preserve unknown values. */
format: string;
body: string;
version: number;
updatedAt: number;
Expand Down Expand Up @@ -283,9 +284,11 @@ export function kitApi(options: KitApiOptions) {
if (!raw) return null;
const candidate = JSON.parse(raw) as Partial<CachedClientPayload>;
const payload = candidate.clientPayload;
// Only the invariants the cache depends on. `format` is opaque: evicting
// on an unknown one would kill ETag revalidation and offline reads.
if (
!payload ||
!["toml", "json", "text"].includes(payload.format) ||
typeof payload.format !== "string" ||
typeof payload.body !== "string" ||
!Number.isSafeInteger(payload.version) ||
payload.version < 1 ||
Expand Down
6 changes: 6 additions & 0 deletions libraries/expo-iap/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1857,6 +1857,12 @@ export interface RequestVerifyPurchaseWithIapkitResult {
* Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0.
* Amazon RVS environment selected by IAPKit. Present as `Sandbox` or
* `Production` on handled Amazon verification results.
*
* Deliberately String, not an enum: the value space belongs to IAPKit and the
* stores behind it, and Apple's App Store Server alone also names `Xcode` and
* `LocalTesting`. SDKs must forward this value opaquely. Never reject a
* verification because the environment is unrecognised — that fails a purchase
* the store already confirmed.
*/
environment?: (string | null);
/**
Expand Down
17 changes: 6 additions & 11 deletions libraries/expo-iap/src/vega-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1177,17 +1177,12 @@ export function createExpoIapVegaModule(
`IAPKit returned malformed response (HTTP ${status}).`,
);
}
const environment = json.environment;
if (
environment != null &&
(typeof environment !== 'string' ||
(environment !== 'Sandbox' && environment !== 'Production'))
) {
throw createVegaError(
ErrorCode.PurchaseVerificationFailed,
`IAPKit returned malformed response (HTTP ${status}).`,
);
}
// Forwarded opaquely: `environment` is String in the spec.
const rawEnvironment = json.environment;
const environment =
typeof rawEnvironment === 'string' && rawEnvironment.length > 0
? rawEnvironment
: undefined;

return {
...(environment == null ? {} : {environment}),
Expand Down
73 changes: 33 additions & 40 deletions libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1921,17 +1921,12 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
);
}

// Forwarded opaquely: `environment` is String in the spec.
final environmentValue = itemMap['environment'];
if (environmentValue != null &&
(environmentValue is! String ||
(environmentValue != 'Sandbox' &&
environmentValue != 'Production'))) {
throw PurchaseError(
code: gentype.ErrorCode.PurchaseVerificationFailed,
message:
'Malformed IAPKit verification result: environment must be Sandbox or Production',
);
}
final environment =
environmentValue is String && environmentValue.isNotEmpty
? environmentValue
: null;

gentype.IapkitProductClientPayload? clientPayload;
final clientPayloadValue = itemMap['clientPayload'];
Expand All @@ -1956,47 +1951,45 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
final updatedAt = updatedAtValue is num
? updatedAtValue.toDouble()
: double.nan;
if (format is! String ||
(format != 'toml' &&
format != 'json' &&
format != 'text') ||
body is! String ||
!version.isFinite ||
version <= 0 ||
version.truncateToDouble() != version ||
!updatedAt.isFinite ||
updatedAt < 0) {
throw PurchaseError(
code: gentype.ErrorCode.PurchaseVerificationFailed,
message:
'Malformed IAPKit verification result: invalid clientPayload',
);
// Optional enrichment: dropped, never thrown.
if (format is String &&
body is String &&
version.isFinite &&
version > 0 &&
version.truncateToDouble() == version &&
updatedAt.isFinite &&
updatedAt >= 0) {
try {
clientPayload = gentype.IapkitProductClientPayload(
body: body,
format:
gentype.IapkitClientPayloadFormat.fromJson(format),
updatedAt: updatedAt,
version: version,
);
} on ArgumentError {
clientPayload = null;
}
}
}

gentype.IapkitPurchaseState parseState() {
try {
clientPayload = gentype.IapkitProductClientPayload(
body: body,
format:
gentype.IapkitClientPayloadFormat.fromJson(format),
updatedAt: updatedAt,
version: version,
return gentype.IapkitPurchaseState.fromJson(
state.toString(),
);
} on ArgumentError {
throw PurchaseError(
code: gentype.ErrorCode.PurchaseVerificationFailed,
message:
'Malformed IAPKit verification result: invalid clientPayload format',
);
// A state added after this build shipped; `isValid` stands.
return gentype.IapkitPurchaseState.Unknown;
}
}

return gentype.RequestVerifyPurchaseWithIapkitResult(
clientPayload: clientPayload,
environment: environmentValue as String?,
environment: environment,
isValid: isValid,
productId: productIdValue as String?,
state: gentype.IapkitPurchaseState.fromJson(
state.toString(),
),
state: parseState(),
store: gentype.IapStore.fromJson(store.toString()),
);
}
Expand Down
Loading
Loading