From b3582001b2a40edfcdb64928c5cecb7cdeb4df00 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 11:11:45 +0900 Subject: [PATCH 01/21] feat: audit iapkit response enums against the spec IAPKit's purchase states, client payload formats, and verify stores are declared three times: kit's persisted Convex enum, kit's OpenAPI response table, and the GraphQL schema every SDK generates from. Nothing compared them, and kit deploys from main on its own workflow, so a kit-only change could put a value on the wire that already-published apps cannot decode. Adds scripts/audit-kit-spec-contract.mjs plus its own tests, wired into the unconditional Audit SDK Parity CI job and the pre-commit mirror so it runs whichever side of the contract moved. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 8 ++ .husky/pre-commit | 10 ++ package.json | 1 + scripts/audit-kit-spec-contract.mjs | 149 +++++++++++++++++++++ scripts/audit-kit-spec-contract.test.mjs | 163 +++++++++++++++++++++++ 5 files changed, 331 insertions(+) create mode 100644 scripts/audit-kit-spec-contract.mjs create mode 100644 scripts/audit-kit-spec-contract.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b3f42c26..1992f06f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,6 +212,14 @@ jobs: - name: Run non-Godot SDK parity audit run: node scripts/audit-non-godot-parity.mjs + # Unconditional on purpose: IAPKit and the spec deploy on separate + # workflows, so this has to run whichever side of the contract moved. + - 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 diff --git a/.husky/pre-commit b/.husky/pre-commit index 2b5e28341..a205b273a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -52,6 +52,16 @@ fi echo "πŸ”Ž SDK parity audit β€” running CI mirror…" node scripts/audit-non-godot-parity.mjs +# IAPKit's purchase-state, client-payload-format, and verify-store enums are +# declared once in kit's Convex layer, once in its OpenAPI response docs, and +# once in the GraphQL schema every SDK generates from. kit deploys from main on +# its own workflow, so drift here reaches published apps without an SDK +# release. Unconditional for the same reason the parity audit is: either side +# of the 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. diff --git a/package.json b/package.json index 299a3abff..c1bb34e37 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "e2e:web": "node scripts/e2e-web-sites.mjs", "audit:deprecations": "node --test scripts/audit-deprecation-schedule.test.mjs && node scripts/audit-deprecation-schedule.mjs", "audit:parity": "node scripts/audit-non-godot-parity.mjs", + "audit:kit-contract": "node --test scripts/audit-kit-spec-contract.test.mjs && node scripts/audit-kit-spec-contract.mjs", "audit:docs": "bun run scripts/audit-docs.ts", "audit:release-state": "node scripts/release-branch-policy.mjs audit", "sbom": "node scripts/generate-sbom.mjs", diff --git a/scripts/audit-kit-spec-contract.mjs b/scripts/audit-kit-spec-contract.mjs new file mode 100644 index 000000000..a6d2856a7 --- /dev/null +++ b/scripts/audit-kit-spec-contract.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node + +// IAPKit deploys from `main` on its own workflow, while the native SDKs that +// decode its `/v1` responses are frozen inside already-published apps. The +// enums below are declared three times β€” kit's persisted Convex state, kit's +// OpenAPI response documentation, and the GraphQL schema that generates every +// SDK type. Nothing else in the repo compares them, so a kit-only change could +// put a value on the wire that shipped SDKs and published docs know nothing +// about. This audit is that comparison. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export const SCHEMA_FILE = "packages/gql/src/type.graphql"; +export const CONVEX_STATE_FILE = + "packages/kit/convex/purchases/purchaseState.ts"; +export const RESPONSE_SCHEMA_FILE = + "packages/kit/server/api/v1/route-response-schemas.ts"; + +const read = (relativePath) => + fs.readFileSync(path.join(root, relativePath), "utf8"); + +/** GraphQL enum members, with docstrings and comments stripped. */ +export const parseGraphqlEnum = (source, name) => { + const block = new RegExp(`\\benum\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`).exec( + source, + ); + if (!block) throw new Error(`enum ${name} not found`); + return block[1] + .replace(/"""[\s\S]*?"""/g, "") + .split("\n") + .map((line) => line.replace(/#.*$/, "").trim()) + .filter((line) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(line)); +}; + +/** `export enum Name { KEY = "VALUE" }` β€” the string values reach the wire. */ +export const parseTypescriptEnum = (source, name) => { + const block = new RegExp( + `\\bexport enum\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, + ).exec(source); + if (!block) throw new Error(`enum ${name} not found`); + return [...block[1].matchAll(/=\s*"([^"]+)"/g)].map((match) => match[1]); +}; + +/** The `unifiedPurchaseStates` table documenting `/v1/purchase/verify`. */ +export const parseDocumentedStates = (source) => { + const block = /const unifiedPurchaseStates = \[([\s\S]*?)\n\] as const;/.exec( + source, + ); + if (!block) throw new Error("unifiedPurchaseStates table not found"); + return [...block[1].matchAll(/name:\s*"([^"]+)"/g)].map((match) => match[1]); +}; + +/** Literal members of a valibot union, located by the text preceding it. */ +export const parseValibotLiteralUnion = (source, anchor) => { + const block = new RegExp(`${anchor}v\\.union\\(\\[([\\s\\S]*?)\\]\\)`).exec( + source, + ); + if (!block) throw new Error(`valibot union after ${anchor.trim()} not found`); + return [...block[1].matchAll(/v\.literal\("([^"]+)"\)/g)].map( + (match) => match[1], + ); +}; + +const compare = (label, expected, actual) => { + const failures = []; + const missing = expected.filter((value) => !actual.includes(value)); + const extra = actual.filter((value) => !expected.includes(value)); + if (missing.length > 0) { + failures.push(`${label}: missing ${JSON.stringify(missing)}`); + } + if (extra.length > 0) { + failures.push(`${label}: unexpected ${JSON.stringify(extra)}`); + } + return failures; +}; + +export const collectContractFailures = ({ + schema = read(SCHEMA_FILE), + convexState = read(CONVEX_STATE_FILE), + responseSchema = read(RESPONSE_SCHEMA_FILE), +} = {}) => { + const specStates = parseGraphqlEnum(schema, "IapkitPurchaseState"); + // GraphQL members are PascalCase for these two; the wire values are lowercase. + const specFormats = parseGraphqlEnum(schema, "IapkitClientPayloadFormat").map( + (member) => member.toLowerCase(), + ); + const specStores = parseGraphqlEnum(schema, "IapStore").map((member) => + member.toLowerCase(), + ); + + return [ + ...compare( + `${CONVEX_STATE_FILE} HarmonizedPurchaseState vs ${SCHEMA_FILE} IapkitPurchaseState`, + specStates, + parseTypescriptEnum(convexState, "HarmonizedPurchaseState"), + ), + ...compare( + `${RESPONSE_SCHEMA_FILE} unifiedPurchaseStates vs ${SCHEMA_FILE} IapkitPurchaseState`, + specStates, + parseDocumentedStates(responseSchema), + ), + ...compare( + `${RESPONSE_SCHEMA_FILE} clientPayload format vs ${SCHEMA_FILE} IapkitClientPayloadFormat`, + specFormats, + parseValibotLiteralUnion(responseSchema, "format: "), + ), + // Stores are one-directional: kit may verify fewer stores than the spec + // names, but never one the spec omits β€” no SDK could ask for it. + ...parseValibotLiteralUnion(responseSchema, "const verifyStoreSchema = ") + .filter((store) => !specStores.includes(store)) + .map( + (store) => + `${RESPONSE_SCHEMA_FILE} verifyStoreSchema: ${JSON.stringify(store)} is not in ${SCHEMA_FILE} IapStore`, + ), + ]; +}; + +export const runAudit = () => { + const failures = collectContractFailures(); + if (failures.length > 0) { + console.error("IAPKit spec contract audit failed:\n"); + for (const failure of failures) console.error(`- ${failure}`); + console.error( + "\nThe /v1 verify response carries enums that already-published SDKs decode.", + ); + console.error( + "Change packages/gql/src/type.graphql first, regenerate, and confirm every", + ); + console.error( + "SDK degrades unknown values instead of failing the receipt.", + ); + return false; + } + + console.log( + "IAPKit spec contract audit passed (purchase states, client payload formats, verify stores).", + ); + return true; +}; + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain && !runAudit()) process.exitCode = 1; diff --git a/scripts/audit-kit-spec-contract.test.mjs b/scripts/audit-kit-spec-contract.test.mjs new file mode 100644 index 000000000..7cd511bde --- /dev/null +++ b/scripts/audit-kit-spec-contract.test.mjs @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + collectContractFailures, + parseDocumentedStates, + parseGraphqlEnum, + parseTypescriptEnum, + parseValibotLiteralUnion, +} from "./audit-kit-spec-contract.mjs"; + +const SCHEMA = ` +enum IapStore { + Unknown + Apple + Google +} + +""" +Unified purchase states from IAPKit verification response. +""" +enum IapkitPurchaseState { + """ + User is entitled to the product. + """ + ENTITLED + # trailing comment + EXPIRED +} + +enum IapkitClientPayloadFormat { + Toml + Json +} +`; + +const CONVEX_STATE = ` +export enum HarmonizedPurchaseState { + // Purchase is complete and valid + ENTITLED = "ENTITLED", + EXPIRED = "EXPIRED", +} +`; + +const RESPONSE_SCHEMA = ` +const unifiedPurchaseStates = [ + { name: "ENTITLED", description: "Purchase is complete and active." }, + { name: "EXPIRED", description: "Entitlement has expired." }, +] as const; + +const verifyStoreSchema = v.union([v.literal("apple"), v.literal("google")]); + +const clientPayloadSchema = v.object({ + format: v.union([v.literal("toml"), v.literal("json")]), + body: v.string(), +}); +`; + +const sources = (overrides = {}) => ({ + schema: SCHEMA, + convexState: CONVEX_STATE, + responseSchema: RESPONSE_SCHEMA, + ...overrides, +}); + +test("parsers read each declaration style", () => { + assert.deepEqual(parseGraphqlEnum(SCHEMA, "IapkitPurchaseState"), [ + "ENTITLED", + "EXPIRED", + ]); + assert.deepEqual( + parseTypescriptEnum(CONVEX_STATE, "HarmonizedPurchaseState"), + ["ENTITLED", "EXPIRED"], + ); + assert.deepEqual(parseDocumentedStates(RESPONSE_SCHEMA), [ + "ENTITLED", + "EXPIRED", + ]); + assert.deepEqual( + parseValibotLiteralUnion(RESPONSE_SCHEMA, "const verifyStoreSchema = "), + ["apple", "google"], + ); + assert.deepEqual(parseValibotLiteralUnion(RESPONSE_SCHEMA, "format: "), [ + "toml", + "json", + ]); +}); + +test("aligned declarations produce no failures", () => { + assert.deepEqual(collectContractFailures(sources()), []); +}); + +test("a state added only to kit's persisted enum fails", () => { + const failures = collectContractFailures( + sources({ + convexState: CONVEX_STATE.replace( + `EXPIRED = "EXPIRED",`, + `EXPIRED = "EXPIRED",\n REFUNDED = "REFUNDED",`, + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /HarmonizedPurchaseState.*unexpected.*REFUNDED/); +}); + +test("a state added to the spec but not to kit fails", () => { + const failures = collectContractFailures( + sources({ + schema: SCHEMA.replace(" EXPIRED\n", " EXPIRED\n PENDING\n"), + }), + ); + assert.equal(failures.length, 2); + for (const failure of failures) assert.match(failure, /missing.*PENDING/); +}); + +test("a documented state kit cannot emit fails", () => { + const failures = collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + `{ name: "EXPIRED", description: "Entitlement has expired." },`, + `{ name: "EXPIRED", description: "Entitlement has expired." },\n { name: "CONSUMED", description: "Fulfilled." },`, + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /unifiedPurchaseStates.*unexpected.*CONSUMED/); +}); + +test("a client payload format the SDKs cannot decode fails", () => { + const failures = collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + `v.union([v.literal("toml"), v.literal("json")])`, + `v.union([v.literal("toml"), v.literal("json"), v.literal("yaml")])`, + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /clientPayload format.*unexpected.*yaml/); +}); + +test("a verify store the spec does not name fails", () => { + const failures = collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + `v.literal("google")]`, + `v.literal("google"), v.literal("steam")]`, + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /verifyStoreSchema.*"steam".*IapStore/); +}); + +test("kit may verify fewer stores than the spec names", () => { + assert.deepEqual( + collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace(`, v.literal("google")]`, "]"), + }), + ), + [], + ); +}); From 3d99d5b76108487bc4ca2f34fe31f190957b5d79 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 11:25:06 +0900 Subject: [PATCH 02/21] fix(kit): enforce the verify response contract at runtime verifyPurchaseSuccessResponseSchema only fed describeRoute, so the documented shape and the emitted body could drift apart in silence. The handler typed state as a plain string and passed whatever Convex returned straight to c.json, and the apps decoding it cannot update their parsers. Responses now pass through enforceVerifyResponseContract before they are sent. Metadata outside the contract is degraded rather than published: an unpublished state becomes UNKNOWN, and an unparseable productId, environment, or clientPayload is dropped, because several SDKs reject an otherwise valid receipt on those fields. isValid is never rewritten. A verdict that stays malformed after degradation returns 500 instead of a body no SDK can trust. Violations log field names only. Co-Authored-By: Claude Opus 5 (1M context) --- .../server/api/v1/response-contract.test.ts | 134 ++++++++++++++++++ .../kit/server/api/v1/response-contract.ts | 79 +++++++++++ .../server/api/v1/route-response-schemas.ts | 41 +++--- packages/kit/server/api/v1/routes.test.ts | 60 ++++++++ packages/kit/server/api/v1/routes.ts | 32 ++++- 5 files changed, 327 insertions(+), 19 deletions(-) create mode 100644 packages/kit/server/api/v1/response-contract.test.ts create mode 100644 packages/kit/server/api/v1/response-contract.ts diff --git a/packages/kit/server/api/v1/response-contract.test.ts b/packages/kit/server/api/v1/response-contract.test.ts new file mode 100644 index 000000000..285a9afda --- /dev/null +++ b/packages/kit/server/api/v1/response-contract.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "vitest"; +import * as v from "valibot"; + +import { enforceVerifyResponseContract } from "./response-contract"; +import { verifyPurchaseSuccessResponseSchema } from "./route-response-schemas"; + +const ENTITLED = { + store: "apple", + isValid: true, + state: "ENTITLED", + productId: "premium.monthly", +} as const; + +function assertMatchesPublishedSchema(response: unknown) { + expect( + v.safeParse(verifyPurchaseSuccessResponseSchema, response).success, + ).toBe(true); +} + +describe("enforceVerifyResponseContract", () => { + test("passes a contract-valid response through untouched", () => { + const result = enforceVerifyResponseContract({ ...ENTITLED }); + + expect(result).toEqual({ + ok: true, + response: { ...ENTITLED }, + violations: [], + }); + }); + + test("keeps every documented optional field", () => { + const full = { + ...ENTITLED, + environment: "Sandbox", + clientPayload: { + format: "toml", + body: 'tier = "gold"', + version: 3, + updatedAt: 1_700_000_000_000, + }, + }; + + const result = enforceVerifyResponseContract(full); + + expect(result.ok).toBe(true); + expect(result.ok && result.response).toEqual(full); + expect(result.violations).toEqual([]); + }); + + test("degrades an unpublished state to UNKNOWN without touching the verdict", () => { + const result = enforceVerifyResponseContract({ + ...ENTITLED, + state: "REFUNDED", + }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual(["state"]); + expect(result.ok && result.response.state).toBe("UNKNOWN"); + // The entitlement survives the metadata drift. + expect(result.ok && result.response.isValid).toBe(true); + assertMatchesPublishedSchema(result.ok && result.response); + }); + + test("drops an environment the SDK parsers reject", () => { + // Apple's App Store Server API also reports `Xcode` and `LocalTesting`; + // shipped SDKs fail the whole receipt on anything but Sandbox/Production. + const result = enforceVerifyResponseContract({ + ...ENTITLED, + environment: "Xcode", + }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual(["environment"]); + expect(result.ok && result.response).not.toHaveProperty("environment"); + assertMatchesPublishedSchema(result.ok && result.response); + }); + + test("drops a client payload format the SDKs cannot decode", () => { + const result = enforceVerifyResponseContract({ + ...ENTITLED, + clientPayload: { + format: "yaml", + body: "tier: gold", + version: 1, + updatedAt: 1_700_000_000_000, + }, + }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual(["clientPayload"]); + expect(result.ok && result.response).not.toHaveProperty("clientPayload"); + assertMatchesPublishedSchema(result.ok && result.response); + }); + + test("drops a non-string productId", () => { + const result = enforceVerifyResponseContract({ + ...ENTITLED, + productId: 42, + }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual(["productId"]); + expect(result.ok && result.response).not.toHaveProperty("productId"); + }); + + test("reports every drifted field at once", () => { + const result = enforceVerifyResponseContract({ + ...ENTITLED, + state: "REFUNDED", + environment: "Xcode", + clientPayload: { format: "yaml", body: "", version: 1, updatedAt: 0 }, + }); + + expect(result.violations).toEqual([ + "state", + "environment", + "clientPayload", + ]); + assertMatchesPublishedSchema(result.ok && result.response); + }); + + test("refuses to publish a malformed verdict", () => { + // Neither field can drift from metadata changes β€” only a server defect + // produces this, and a response no SDK can trust must not reach a client. + for (const broken of [ + { ...ENTITLED, isValid: "true" }, + { ...ENTITLED, store: "steam" }, + ]) { + const result = enforceVerifyResponseContract(broken); + expect(result.ok).toBe(false); + expect(result.violations.length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/kit/server/api/v1/response-contract.ts b/packages/kit/server/api/v1/response-contract.ts new file mode 100644 index 000000000..0f90e5cc2 --- /dev/null +++ b/packages/kit/server/api/v1/response-contract.ts @@ -0,0 +1,79 @@ +import * as v from "valibot"; + +import { + clientPayloadSchema, + environmentSchema, + FALLBACK_PURCHASE_STATE, + productIdSchema, + unifiedPurchaseStateSchema, + verifyPurchaseSuccessResponseSchema, +} from "./route-response-schemas"; + +export type VerifyPurchaseResponse = v.InferOutput< + typeof verifyPurchaseSuccessResponseSchema +>; + +export type VerifyResponseContractResult = + | { ok: true; response: VerifyPurchaseResponse; violations: string[] } + | { ok: false; violations: string[] }; + +const fits = (schema: v.GenericSchema, value: unknown): boolean => + v.safeParse(schema, value).success; + +/** + * Holds `/v1/purchase/verify` responses to the schema the OpenAPI document + * publishes and every SDK decodes. + * + * `verifyPurchaseSuccessResponseSchema` previously only fed `describeRoute`, + * so the documented shape and the emitted body could drift apart silently β€” + * and shipped apps decode this body with fixed parsers they cannot update. + * + * Metadata that falls outside the contract is degraded, not passed through: + * an out-of-contract `environment` or `clientPayload.format` makes several + * SDKs reject an otherwise valid receipt. `isValid` is never rewritten β€” the + * verdict is authoritative, and drifting metadata must not revoke a real + * entitlement. + */ +export const enforceVerifyResponseContract = ( + candidate: Record, +): VerifyResponseContractResult => { + const parsed = v.safeParse(verifyPurchaseSuccessResponseSchema, candidate); + if (parsed.success) { + return { ok: true, response: parsed.output, violations: [] }; + } + + const violations: string[] = []; + const degraded: Record = { ...candidate }; + + if (!fits(unifiedPurchaseStateSchema, degraded.state)) { + violations.push("state"); + degraded.state = FALLBACK_PURCHASE_STATE; + } + for (const [field, schema] of [ + ["productId", productIdSchema], + ["environment", environmentSchema], + ["clientPayload", clientPayloadSchema], + ] as const) { + if (degraded[field] !== undefined && !fits(schema, degraded[field])) { + violations.push(field); + delete degraded[field]; + } + } + + const reparsed = v.safeParse(verifyPurchaseSuccessResponseSchema, degraded); + if (!reparsed.success) { + // `store` and `isValid` are the only fields left, and the route handler + // supplies both from its own switch. Reaching here means the verdict + // itself is malformed, which is a server defect rather than contract + // drift β€” report it instead of publishing a response no SDK can trust. + return { + ok: false, + violations: [ + ...violations, + ...reparsed.issues.map((issue) => v.getDotPath(issue) ?? ""), + ], + }; + } + + return { ok: true, response: reparsed.output, violations }; +}; diff --git a/packages/kit/server/api/v1/route-response-schemas.ts b/packages/kit/server/api/v1/route-response-schemas.ts index b1f1de9c7..0dbce6078 100644 --- a/packages/kit/server/api/v1/route-response-schemas.ts +++ b/packages/kit/server/api/v1/route-response-schemas.ts @@ -51,12 +51,17 @@ const unifiedPurchaseStates = [ }, ] as const; -const unifiedPurchaseStateSchema = v.union( +export const unifiedPurchaseStateSchema = v.union( unifiedPurchaseStates.map(({ name, description }) => v.pipe(v.literal(name), v.description(description)), ), ); +// The state a response degrades to when the verified value is outside the +// published enum. Never changes `isValid`: the verdict is authoritative and a +// metadata drift must not revoke a real entitlement. +export const FALLBACK_PURCHASE_STATE = "UNKNOWN"; + const verifyStoreSchema = v.union([ v.literal("apple"), v.literal("google"), @@ -64,33 +69,33 @@ const verifyStoreSchema = v.union([ v.literal("amazon"), ]); -const clientPayloadSchema = v.object({ +export const clientPayloadSchema = v.object({ format: v.union([v.literal("toml"), v.literal("json"), v.literal("text")]), body: v.string(), version: v.number(), updatedAt: v.number(), }); +export const productIdSchema = v.pipe( + v.string(), + v.description( + "Product id verified by the upstream store. For Meta Horizon this is the SKU IAPKit checked.", + ), +); + +export const environmentSchema = v.pipe( + v.union([v.literal("Sandbox"), v.literal("Production")]), + v.description( + "Amazon RVS environment selected by IAPKit. Present on handled Amazon verification results.", + ), +); + const baseReceiptResponseSchema = v.object({ store: verifyStoreSchema, isValid: v.boolean(), state: unifiedPurchaseStateSchema, - productId: v.optional( - v.pipe( - v.string(), - v.description( - "Product id verified by the upstream store. For Meta Horizon this is the SKU IAPKit checked.", - ), - ), - ), - environment: v.optional( - v.pipe( - v.union([v.literal("Sandbox"), v.literal("Production")]), - v.description( - "Amazon RVS environment selected by IAPKit. Present on handled Amazon verification results.", - ), - ), - ), + productId: v.optional(productIdSchema), + environment: v.optional(environmentSchema), clientPayload: v.optional( v.pipe( clientPayloadSchema, diff --git a/packages/kit/server/api/v1/routes.test.ts b/packages/kit/server/api/v1/routes.test.ts index 7801b7976..de4fc18a1 100644 --- a/packages/kit/server/api/v1/routes.test.ts +++ b/packages/kit/server/api/v1/routes.test.ts @@ -472,4 +472,64 @@ describe("apiRoutes", () => { } expect(convexClientMock.query).not.toHaveBeenCalled(); }); + + it("degrades an out-of-contract state instead of publishing it", async () => { + // Shipped SDKs decode this body with parsers they cannot update, so a + // state Convex knows but the published schema does not must not reach one. + convexClientMock.action.mockResolvedValueOnce({ + isValid: true, + state: "REFUNDED", + productId: "premium.monthly", + }); + + const response = await apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: "Bearer route-test-state-drift", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "google", + purchaseToken: "token".repeat(8), + }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + store: "google", + isValid: true, + state: "UNKNOWN", + productId: "premium.monthly", + }); + }); + + it("drops an environment value no shipped SDK accepts", async () => { + convexClientMock.action.mockResolvedValueOnce({ + isValid: true, + state: "ENTITLED", + productId: "amazon.premium.monthly", + environment: "Xcode", + }); + + const response = await apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: "Bearer route-test-environment-drift", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "amazon", + userId: "amzn1.account.ABC123", + receiptId: "amzn1.receipt.ABC123456789=:1", + }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + store: "amazon", + isValid: true, + state: "ENTITLED", + productId: "amazon.premium.monthly", + }); + }); }); diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index b95c5812c..7cfcc402f 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -13,6 +13,7 @@ import { apiErrorResponseSchema, verifyPurchaseSuccessResponseSchema, } from "./route-response-schemas"; +import { enforceVerifyResponseContract } from "./response-contract"; import { apiKeyMiddleware } from "./middleware"; import { getRequestIp, multiAxisRateLimitMiddleware } from "./rate-limit"; import { replayGuardMiddleware } from "./replay-guard"; @@ -447,11 +448,40 @@ const verifyPurchaseHandler = async ( } } - return c.json({ + const contract = enforceVerifyResponseContract({ store, ...publicReceipt, ...(clientPayload ? { clientPayload } : {}), }); + if (contract.violations.length > 0) { + // Field names only β€” never values. A drift here means the emitted body + // left the schema the OpenAPI document publishes and shipped SDKs decode. + console.error( + "[purchase/verify] RESPONSE_CONTRACT_VIOLATION: store=%s fields=%s", + store, + contract.violations.join(","), + ); + } + if (!contract.ok) { + const errorId = crypto.randomUUID(); + console.error( + "Unexpected error (%s) when verifying purchase: malformed verdict", + errorId, + ); + return c.json( + { + errors: [ + { + code: "UNKNOWN_ERROR", + message: util.format("Unknown error: %s", errorId), + }, + ], + }, + 500, + ); + } + + return c.json(contract.response); }; try { From 0324eca0f46d4812cc3d9d43b1d0deee733b9be2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 11:28:00 +0900 Subject: [PATCH 03/21] test(kit): pin the entitlement decision and state mapping isValidState decides what every published app unlocks, and IAPKit deploys from main without an SDK release, so changing it changes live behavior for existing users. The per-state tests covered today's values but a state added later would simply go untested. Pins the entitling set as a whole so a new state cannot default into either answer unnoticed, and adds a golden table for mapAppStorePurchaseState, which had one case against nine Google ones. Documents the /v1 response contract in kit's CONVENTION.md: additive only, enum values are spec changes, isValid is the entitlement gate, and the emitted body is validated. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- packages/kit/CONVENTION.md | 26 ++++++ packages/kit/convex/purchases/shared.test.ts | 96 ++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4df3f355d..3c2ec5a1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/packages/kit/CONVENTION.md b/packages/kit/CONVENTION.md index 35273ebd3..80814867d 100644 --- a/packages/kit/CONVENTION.md +++ b/packages/kit/CONVENTION.md @@ -151,6 +151,32 @@ client-payload endpoints. A developer backend may send APNs/FCM notifications for resources it protects, but IAPKit must not publish project-wide lifecycle events to shipped apps. +## `/v1` response contract + +IAPKit deploys from `main` on its own workflow. The SDKs that decode its +responses are frozen inside apps already on the stores, so a kit-only change +reaches every user at once with no SDK release and no way to roll forward. +Treat the `/v1` response shape as a published API: + +- **Additive only.** Never remove a field, rename one, or change what an + existing value means. New fields must be optional, and anything that changes + a response shape should be gated on an explicit request flag, the way + `includeClientPayload` is. +- **Enum values are spec changes.** `IapkitPurchaseState`, + `IapkitClientPayloadFormat`, and `IapStore` live in + `packages/gql/src/type.graphql`. Change the schema first, regenerate, and + confirm each SDK degrades an unknown value instead of failing the receipt β€” + `bun audit:kit-contract` compares the three declarations and fails on drift. +- **`isValid` is the entitlement gate.** `isValidState` in + `convex/purchases/shared.ts` decides what published apps unlock. Widening or + narrowing it, or changing `mapAppStorePurchaseState` / + `mapGooglePlayPurchaseState`, changes live behavior for existing users. + Golden tests in `shared.test.ts` pin both; update them deliberately. +- **The emitted body is validated.** `enforceVerifyResponseContract` holds + responses to `verifyPurchaseSuccessResponseSchema` before they are sent, so + the OpenAPI document cannot drift from what clients receive. Extend the + schema when you add a field; do not bypass the check. + ## Icons Always use icon components, never inline ``: diff --git a/packages/kit/convex/purchases/shared.test.ts b/packages/kit/convex/purchases/shared.test.ts index 88cd98e82..e8a5f85e7 100644 --- a/packages/kit/convex/purchases/shared.test.ts +++ b/packages/kit/convex/purchases/shared.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + AppStoreProductType, + AppStoreTransactionReason, mapAppStorePurchaseState, mapGooglePlayPurchaseState, mapToPurchaseType, @@ -213,6 +215,83 @@ describe("mapAppStorePurchaseState", () => { expect(state).toBe(HarmonizedPurchaseState.EXPIRED); }); + + // Golden table. Every row decides what a published app sees for a real + // App Store transaction, and IAPKit ships without an SDK release, so a + // mapping change has to show up here as an explicit diff. + const APP_STORE_GOLDEN: Array<{ + label: string; + reason?: AppStoreTransactionReason; + expiresDate?: number; + type?: AppStoreProductType; + revocationDate?: number; + expected: HarmonizedPurchaseState; + }> = [ + { + label: "revoked transaction outranks everything else", + reason: AppStoreTransactionReason.PURCHASE, + revocationDate: 1_700_000_000_000, + type: AppStoreProductType.NON_CONSUMABLE, + expected: HarmonizedPurchaseState.CANCELED, + }, + { + label: "lapsed subscription", + reason: AppStoreTransactionReason.RENEWAL, + expiresDate: 1_700_000_000_000, + type: AppStoreProductType.AUTO_RENEWABLE_SUBSCRIPTION, + expected: HarmonizedPurchaseState.EXPIRED, + }, + { + label: "first purchase of a consumable", + reason: AppStoreTransactionReason.PURCHASE, + type: AppStoreProductType.CONSUMABLE, + expected: HarmonizedPurchaseState.READY_TO_CONSUME, + }, + { + label: "first purchase of a non-consumable", + reason: AppStoreTransactionReason.PURCHASE, + type: AppStoreProductType.NON_CONSUMABLE, + expected: HarmonizedPurchaseState.ENTITLED, + }, + { + label: "subscription renewal", + reason: AppStoreTransactionReason.RENEWAL, + type: AppStoreProductType.AUTO_RENEWABLE_SUBSCRIPTION, + expected: HarmonizedPurchaseState.ENTITLED, + }, + { + label: "renewal of a consumable is still a renewal", + reason: AppStoreTransactionReason.RENEWAL, + type: AppStoreProductType.CONSUMABLE, + expected: HarmonizedPurchaseState.ENTITLED, + }, + { + label: "consumable without a transaction reason", + type: AppStoreProductType.CONSUMABLE, + expected: HarmonizedPurchaseState.READY_TO_CONSUME, + }, + { + label: "non-renewing subscription without a transaction reason", + type: AppStoreProductType.NON_RENEWING_SUBSCRIPTION, + expected: HarmonizedPurchaseState.ENTITLED, + }, + { + label: "unexpired transaction with nothing else known", + expiresDate: Date.now() + 86_400_000, + expected: HarmonizedPurchaseState.ENTITLED, + }, + ]; + + it.each(APP_STORE_GOLDEN)("maps $label", (row) => { + expect( + mapAppStorePurchaseState( + row.reason, + row.expiresDate, + row.type, + row.revocationDate, + ), + ).toBe(row.expected); + }); }); describe("isValidState", () => { @@ -253,4 +332,21 @@ describe("isValidState", () => { it("returns false for INAUTHENTIC state", () => { expect(isValidState(HarmonizedPurchaseState.INAUTHENTIC)).toBe(false); }); + + // `isValid` is the field every SDK gates entitlement on, and IAPKit deploys + // from main without an SDK release β€” widening or narrowing this set changes + // what already-published apps unlock, for every user, immediately. Pinning + // the whole set (rather than testing states one by one) means a state added + // later cannot default into either answer unnoticed. + it("entitles exactly these states", () => { + const entitling = Object.values(HarmonizedPurchaseState).filter( + isValidState, + ); + + expect(entitling).toEqual([ + HarmonizedPurchaseState.ENTITLED, + HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT, + HarmonizedPurchaseState.READY_TO_CONSUME, + ]); + }); }); From 6f973a37ec38d4a6a70f717f6202a6846b8196a6 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 12:04:48 +0900 Subject: [PATCH 04/21] fix: close gaps self-review found in the kit contract guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit read source text with regexes that could agree over a drifted repo, and both failure modes were reproduced. A bare `format:` anchor matched the first such key anywhere in the file, so a second schema declared above the real one made a `yaml` client-payload format pass. The kit-side parsers also counted values inside comments, so commenting out a state row left the audit green while the runtime union β€” now the allowlist enforceVerifyResponseContract degrades against β€” silently rewrote that state to UNKNOWN. Anchors are qualified by their owning declaration, an ambiguous anchor now fails loudly, comments are stripped, and an empty parse is an error instead of a vacuous match. The guard also only ran in ci.yml, while deploy-kit.yml is what ships kit from main; its verify job now runs the audit before the deploy gate. The malformed-verdict 500 returned after the verify outcome was set, so one log line reported statusCode 500 next to isValid true. The entitlement golden test pinned only the entitling subset, so a new state defaulting to non-entitling passed unchanged β€” it is now an exhaustive record the compiler forces someone to classify. Documents what the audit does not cover: kit declares the client-payload format set in five more places and the environment pair in two, split across its server/convex tsconfig boundary. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/deploy-kit.yml | 7 ++ packages/kit/convex/purchases/shared.test.ts | 40 ++++--- packages/kit/server/api/v1/routes.test.ts | 26 +++++ packages/kit/server/api/v1/routes.ts | 4 + scripts/audit-kit-spec-contract.mjs | 103 +++++++++++++++---- scripts/audit-kit-spec-contract.test.mjs | 73 ++++++++++++- 6 files changed, 217 insertions(+), 36 deletions(-) diff --git a/.github/workflows/deploy-kit.yml b/.github/workflows/deploy-kit.yml index c45b78344..1ef18ac36 100644 --- a/.github/workflows/deploy-kit.yml +++ b/.github/workflows/deploy-kit.yml @@ -72,6 +72,13 @@ jobs: exit 1 fi + # This workflow is what actually ships kit to every already-published + # app, so the response-contract guard has to gate it here β€” ci.yml runs + # independently and can still be red when this job's deploy gate opens. + - 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 diff --git a/packages/kit/convex/purchases/shared.test.ts b/packages/kit/convex/purchases/shared.test.ts index e8a5f85e7..5e5ed5354 100644 --- a/packages/kit/convex/purchases/shared.test.ts +++ b/packages/kit/convex/purchases/shared.test.ts @@ -334,19 +334,35 @@ describe("isValidState", () => { }); // `isValid` is the field every SDK gates entitlement on, and IAPKit deploys - // from main without an SDK release β€” widening or narrowing this set changes - // what already-published apps unlock, for every user, immediately. Pinning - // the whole set (rather than testing states one by one) means a state added - // later cannot default into either answer unnoticed. - it("entitles exactly these states", () => { - const entitling = Object.values(HarmonizedPurchaseState).filter( - isValidState, + // from main without an SDK release β€” changing this table changes what + // already-published apps unlock, for every user, immediately. + // + // The table is keyed by the full enum rather than listing only the entitling + // states: `Record` makes TypeScript reject + // a newly added state until someone classifies it, so the far more likely + // drift β€” a new state quietly defaulting to "does not entitle" β€” cannot pass + // unnoticed either. Being a record also makes it order-independent, so + // reordering the enum is not a false failure. + const ENTITLEMENT_GOLDEN: Record = { + [HarmonizedPurchaseState.ENTITLED]: true, + [HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT]: true, + [HarmonizedPurchaseState.READY_TO_CONSUME]: true, + [HarmonizedPurchaseState.PENDING]: false, + [HarmonizedPurchaseState.CANCELED]: false, + [HarmonizedPurchaseState.EXPIRED]: false, + [HarmonizedPurchaseState.CONSUMED]: false, + [HarmonizedPurchaseState.UNKNOWN]: false, + [HarmonizedPurchaseState.INAUTHENTIC]: false, + }; + + it("classifies every declared state exactly as pinned", () => { + const actual = Object.fromEntries( + Object.values(HarmonizedPurchaseState).map((state) => [ + state, + isValidState(state), + ]), ); - expect(entitling).toEqual([ - HarmonizedPurchaseState.ENTITLED, - HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT, - HarmonizedPurchaseState.READY_TO_CONSUME, - ]); + expect(actual).toEqual(ENTITLEMENT_GOLDEN); }); }); diff --git a/packages/kit/server/api/v1/routes.test.ts b/packages/kit/server/api/v1/routes.test.ts index de4fc18a1..8acbaa308 100644 --- a/packages/kit/server/api/v1/routes.test.ts +++ b/packages/kit/server/api/v1/routes.test.ts @@ -503,6 +503,32 @@ describe("apiRoutes", () => { }); }); + it("refuses to publish a verdict that cannot be made contract-valid", async () => { + // Only a server defect produces this β€” Convex's own validator pins + // isValid to a boolean β€” but a body no SDK can trust must not be sent. + convexClientMock.action.mockResolvedValueOnce({ + isValid: "true", + state: "ENTITLED", + }); + + const response = await apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: "Bearer route-test-malformed-verdict", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "google", + purchaseToken: "token".repeat(8), + }), + }); + + expect(response.status).toBe(500); + expect(await response.json()).toMatchObject({ + errors: [{ code: "UNKNOWN_ERROR" }], + }); + }); + it("drops an environment value no shipped SDK accepts", async () => { convexClientMock.action.mockResolvedValueOnce({ isValid: true, diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index 7cfcc402f..87514fa77 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -11,6 +11,7 @@ import { verifyPurchaseInputSchema } from "./route-input-schemas"; import { client, handleConvexError } from "../../convex"; import { apiErrorResponseSchema, + FALLBACK_PURCHASE_STATE, verifyPurchaseSuccessResponseSchema, } from "./route-response-schemas"; import { enforceVerifyResponseContract } from "./response-contract"; @@ -463,6 +464,9 @@ const verifyPurchaseHandler = async ( ); } if (!contract.ok) { + // The client is about to see a 500, so the request log must not keep + // reporting the verdict this handler computed. + setOutcome({ isValid: false, state: FALLBACK_PURCHASE_STATE }); const errorId = crypto.randomUUID(); console.error( "Unexpected error (%s) when verifying purchase: malformed verdict", diff --git a/scripts/audit-kit-spec-contract.mjs b/scripts/audit-kit-spec-contract.mjs index a6d2856a7..101621c05 100644 --- a/scripts/audit-kit-spec-contract.mjs +++ b/scripts/audit-kit-spec-contract.mjs @@ -7,6 +7,15 @@ // SDK type. Nothing else in the repo compares them, so a kit-only change could // put a value on the wire that shipped SDKs and published docs know nothing // about. This audit is that comparison. +// +// Scope: the /v1 verify RESPONSE contract only. kit's write path declares the +// client-payload format set again in convex/schema.ts (twice), +// convex/products/{query,mutation}.ts and server/api/v1/products.ts, and the +// environment pair again in convex/schema.ts and convex/purchases/shared.ts. +// Those live on the other side of kit's server/convex tsconfig split, so they +// cannot share a constant without moving a module; until they do, a format +// accepted on write but absent from the response schema is silently dropped by +// enforceVerifyResponseContract rather than caught here. import fs from "node:fs"; import path from "node:path"; @@ -23,45 +32,94 @@ export const RESPONSE_SCHEMA_FILE = const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), "utf8"); +// These are source-text parsers, so the two ways they can lie are worse than +// the ways they can fail: matching the wrong declaration, or reading a value +// that is commented out. Both would report agreement over a drifted repo. +// `matchExactlyOnce` closes the first, `stripComments` the second, and every +// parser rejects an empty result rather than comparing two empty lists. + +const matchExactlyOnce = (source, pattern, label) => { + const matches = [...source.matchAll(new RegExp(pattern, "g"))]; + if (matches.length === 0) throw new Error(`${label} not found`); + if (matches.length > 1) { + throw new Error( + `${label} matched ${matches.length} times β€” the anchor is ambiguous, so the audit cannot tell which declaration is the contract`, + ); + } + return matches[0]; +}; + +const stripComments = (source) => + source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); + +const nonEmpty = (values, label) => { + if (values.length === 0) throw new Error(`${label} parsed to an empty list`); + return values; +}; + /** GraphQL enum members, with docstrings and comments stripped. */ export const parseGraphqlEnum = (source, name) => { - const block = new RegExp(`\\benum\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`).exec( + const block = matchExactlyOnce( source, + `\\benum\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, + `enum ${name}`, + ); + return nonEmpty( + block[1] + .replace(/"""[\s\S]*?"""/g, "") + .split("\n") + .map((line) => line.replace(/#.*$/, "").trim()) + .filter((line) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(line)), + `enum ${name}`, ); - if (!block) throw new Error(`enum ${name} not found`); - return block[1] - .replace(/"""[\s\S]*?"""/g, "") - .split("\n") - .map((line) => line.replace(/#.*$/, "").trim()) - .filter((line) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(line)); }; /** `export enum Name { KEY = "VALUE" }` β€” the string values reach the wire. */ export const parseTypescriptEnum = (source, name) => { - const block = new RegExp( + const block = matchExactlyOnce( + source, `\\bexport enum\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, - ).exec(source); - if (!block) throw new Error(`enum ${name} not found`); - return [...block[1].matchAll(/=\s*"([^"]+)"/g)].map((match) => match[1]); + `enum ${name}`, + ); + return nonEmpty( + [...stripComments(block[1]).matchAll(/=\s*"([^"]+)"/g)].map( + (match) => match[1], + ), + `enum ${name}`, + ); }; /** The `unifiedPurchaseStates` table documenting `/v1/purchase/verify`. */ export const parseDocumentedStates = (source) => { - const block = /const unifiedPurchaseStates = \[([\s\S]*?)\n\] as const;/.exec( + const block = matchExactlyOnce( source, + `const unifiedPurchaseStates = \\[([\\s\\S]*?)\\n\\] as const;`, + "unifiedPurchaseStates table", + ); + return nonEmpty( + [...stripComments(block[1]).matchAll(/name:\s*"([^"]+)"/g)].map( + (match) => match[1], + ), + "unifiedPurchaseStates table", ); - if (!block) throw new Error("unifiedPurchaseStates table not found"); - return [...block[1].matchAll(/name:\s*"([^"]+)"/g)].map((match) => match[1]); }; -/** Literal members of a valibot union, located by the text preceding it. */ +/** + * Literal members of a valibot union. `anchor` must name the owning + * declaration, not just the field, so a second field of the same name + * elsewhere in the file is a loud failure rather than a wrong answer. + */ export const parseValibotLiteralUnion = (source, anchor) => { - const block = new RegExp(`${anchor}v\\.union\\(\\[([\\s\\S]*?)\\]\\)`).exec( + const block = matchExactlyOnce( source, + `${anchor}v\\.union\\(\\[([\\s\\S]*?)\\]\\)`, + `valibot union after ${anchor.trim()}`, ); - if (!block) throw new Error(`valibot union after ${anchor.trim()} not found`); - return [...block[1].matchAll(/v\.literal\("([^"]+)"\)/g)].map( - (match) => match[1], + return nonEmpty( + [...stripComments(block[1]).matchAll(/v\.literal\("([^"]+)"\)/g)].map( + (match) => match[1], + ), + `valibot union after ${anchor.trim()}`, ); }; @@ -106,7 +164,12 @@ export const collectContractFailures = ({ ...compare( `${RESPONSE_SCHEMA_FILE} clientPayload format vs ${SCHEMA_FILE} IapkitClientPayloadFormat`, specFormats, - parseValibotLiteralUnion(responseSchema, "format: "), + // Anchored on the owning declaration: `format:` alone would silently + // read a different schema's field if one were added above this one. + parseValibotLiteralUnion( + responseSchema, + "clientPayloadSchema = v\\.object\\(\\{\\s*format:\\s*", + ), ), // Stores are one-directional: kit may verify fewer stores than the spec // names, but never one the spec omits β€” no SDK could ask for it. diff --git a/scripts/audit-kit-spec-contract.test.mjs b/scripts/audit-kit-spec-contract.test.mjs index 7cd511bde..b81d65829 100644 --- a/scripts/audit-kit-spec-contract.test.mjs +++ b/scripts/audit-kit-spec-contract.test.mjs @@ -55,6 +55,9 @@ const clientPayloadSchema = v.object({ }); `; +const CLIENT_PAYLOAD_FORMAT_ANCHOR = + "clientPayloadSchema = v\\.object\\(\\{\\s*format:\\s*"; + const sources = (overrides = {}) => ({ schema: SCHEMA, convexState: CONVEX_STATE, @@ -79,10 +82,72 @@ test("parsers read each declaration style", () => { parseValibotLiteralUnion(RESPONSE_SCHEMA, "const verifyStoreSchema = "), ["apple", "google"], ); - assert.deepEqual(parseValibotLiteralUnion(RESPONSE_SCHEMA, "format: "), [ - "toml", - "json", - ]); + assert.deepEqual( + parseValibotLiteralUnion(RESPONSE_SCHEMA, CLIENT_PAYLOAD_FORMAT_ANCHOR), + ["toml", "json"], + ); +}); + +// The parsers read source text, so their dangerous failure is agreeing over a +// drifted repo. Both cases below returned no failures before the anchors were +// qualified and comments stripped. + +test("a second format union cannot be mistaken for the contract", () => { + const withDecoy = RESPONSE_SCHEMA.replace( + "const unifiedPurchaseStates", + 'const decoySchema = v.object({\n format: v.union([v.literal("toml"), v.literal("json")]),\n});\n\nconst unifiedPurchaseStates', + ).replace( + 'v.literal("json")]),\n body:', + 'v.literal("json"), v.literal("yaml")]),\n body:', + ); + + const failures = collectContractFailures( + sources({ responseSchema: withDecoy }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /clientPayload format.*unexpected.*yaml/); +}); + +test("an ambiguous anchor throws instead of picking a declaration", () => { + const twice = `${RESPONSE_SCHEMA}\nconst verifyStoreSchema = v.union([v.literal("apple")]);\n`; + assert.throws( + () => parseValibotLiteralUnion(twice, "const verifyStoreSchema = "), + /matched 2 times/, + ); +}); + +test("a commented-out documented state is not counted", () => { + const failures = collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + ' { name: "EXPIRED", description: "Entitlement has expired." },', + ' // { name: "EXPIRED", description: "Entitlement has expired." },', + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /unifiedPurchaseStates.*missing.*EXPIRED/); +}); + +test("a commented-out literal is not counted", () => { + const failures = collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + 'v.union([v.literal("toml"), v.literal("json")])', + 'v.union([v.literal("toml") /* , v.literal("json") */])', + ), + }), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /clientPayload format.*missing.*json/); +}); + +test("a declaration that parses to nothing is a failure, not agreement", () => { + assert.throws( + () => + parseTypescriptEnum("export enum Empty {\n // nothing\n}\n", "Empty"), + /parsed to an empty list/, + ); }); test("aligned declarations produce no failures", () => { From 5a7996796cdb4b5e2a2b701958a2693d67f9a2ee Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 12:15:54 +0900 Subject: [PATCH 05/21] fix: stop SDKs failing receipts over IAPKit metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IAPKit deploys from main while every SDK that decodes its response is frozen inside apps already on the stores. Optional metadata was parsed fail-closed in six places, so a value IAPKit added later would reject a purchase the store had already confirmed. `environment` is String in the spec, not an enum β€” Apple's App Store Server alone names Sandbox, Production, Xcode and LocalTesting, and kit already decodes and stores Apple's value without exposing it yet. Re-deriving the Sandbox/Production pair in five SDKs duplicated a constraint IAPKit owns and enforces, so the SDKs now forward the string and only drop a non-string. `clientPayload` is optional enrichment, and receipt verification is the security boundary, so an unreadable payload is dropped rather than thrown. Its format is matched against the generated enum instead of a hand-copied literal set, so a format stays readable once Types regenerates. Flutter and kmp-iap were re-validating results the native layer had already normalised, which defeated the native fix entirely: both dropped their duplicate gates, Flutter degrades an unknown state to Unknown, and kmp-iap's Android paths no longer re-impose a fail-closed decode or let a raw IllegalArgumentException escape a suspend function. kit-api's cache rejected an unrecognised format, which evicted the entry, stopped ETag revalidation from ever being sent and broke offline reads β€” for a value the live path already passes through untouched. The parity audit pinned the old fail-closed strings; its needles now pin the fixed contract while still proving environment is wired end-to-end. Verified: swift build + tests, expo-iap (432) and react-native-iap (580) suites, gql (174), kit (286), both audits. Android, Flutter and KMP have no local toolchain here and are covered by their CI jobs. Co-Authored-By: Claude Opus 5 (1M context) --- .../expo-iap/src/__tests__/kit-api.test.ts | 27 +++++ .../src/__tests__/vega-adapter.test.ts | 37 ++++--- libraries/expo-iap/src/kit-api.ts | 7 +- libraries/expo-iap/src/vega-adapter.ts | 20 ++-- .../lib/flutter_inapp_purchase.dart | 80 +++++++------- .../flutter_inapp_purchase_channel_test.dart | 56 ++++++++++ .../kmpiap/AmazonInAppPurchaseAndroid.kt | 16 ++- .../hyochan/kmpiap/InAppPurchaseAndroid.kt | 27 +++-- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 52 ++++----- .../src/__tests__/kit-api.test.ts | 27 +++++ .../src/__tests__/vega-adapter.test.ts | 36 ++++--- libraries/react-native-iap/src/kit-api.ts | 7 +- .../react-native-iap/src/vega-adapter.ts | 20 ++-- packages/apple/Sources/OpenIapModule.swift | 38 ++++--- .../VerifyPurchaseWithProviderTests.swift | 56 ++++++---- .../utils/PurchaseVerificationValidator.kt | 78 +++++++------- .../PurchaseVerificationValidatorTest.kt | 100 ++++++++++-------- packages/gql/src/kit-api.ts | 7 +- scripts/audit-non-godot-parity.mjs | 20 ++-- 19 files changed, 453 insertions(+), 258 deletions(-) diff --git a/libraries/expo-iap/src/__tests__/kit-api.test.ts b/libraries/expo-iap/src/__tests__/kit-api.test.ts index 5b20cc83d..9e8d4dd50 100644 --- a/libraries/expo-iap/src/__tests__/kit-api.test.ts +++ b/libraries/expo-iap/src/__tests__/kit-api.test.ts @@ -344,6 +344,33 @@ describe('kitApi cache resilience', () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); + // IAPKit can add a client-payload format from a main deploy. Rejecting the + // cached entry would evict it, stop the ETag revalidation from ever being + // sent, and leave offline reads with nothing β€” for a value the live path + // hands back to the caller unchanged anyway. + it('serves a cached payload whose format this build predates', async () => { + const stored = { + clientPayload: {format: 'yaml', body: 'tier: gold', version: 2, updatedAt: 9}, + 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')), diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index fb2e16017..31579bff6 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -1635,9 +1635,18 @@ describe('Amazon Vega Expo adapter', () => { } }); - it.each([42, 'Staging'])( - 'rejects an invalid IAPKit environment: %s', - async (environment) => { + // `environment` is an open String in the spec, so a value IAPKit adds later + // ("Xcode" and "LocalTesting" are real App Store Server environments) is + // forwarded, and only a non-string is dropped. Neither fails the receipt. + 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 () => @@ -1653,20 +1662,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; } diff --git a/libraries/expo-iap/src/kit-api.ts b/libraries/expo-iap/src/kit-api.ts index 5d60a87ad..f20089075 100644 --- a/libraries/expo-iap/src/kit-api.ts +++ b/libraries/expo-iap/src/kit-api.ts @@ -283,9 +283,14 @@ export function kitApi(options: KitApiOptions) { if (!raw) return null; const candidate = JSON.parse(raw) as Partial; const payload = candidate.clientPayload; + // Only the invariants the cache itself depends on. `format` is opaque + // here: rejecting a format IAPKit added later would evict the entry, + // stop the ETag revalidation below from ever being sent, and leave + // offline reads with nothing β€” for a value the live path passes through + // to the caller unchanged anyway. if ( !payload || - !["toml", "json", "text"].includes(payload.format) || + typeof payload.format !== "string" || typeof payload.body !== "string" || !Number.isSafeInteger(payload.version) || payload.version < 1 || diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts index a63311558..437bca95c 100644 --- a/libraries/expo-iap/src/vega-adapter.ts +++ b/libraries/expo-iap/src/vega-adapter.ts @@ -1177,17 +1177,15 @@ 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}).`, - ); - } + // `environment` is String in the spec, not an enum: the + // Sandbox/Production pair is IAPKit's constraint to enforce, and + // re-deriving it here would only let a value IAPKit adds later fail a + // receipt the store already confirmed. + const rawEnvironment = json.environment; + const environment = + typeof rawEnvironment === 'string' && rawEnvironment.length > 0 + ? rawEnvironment + : undefined; return { ...(environment == null ? {} : {environment}), diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 7237efbdf..101056f3a 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -1921,17 +1921,15 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } + // `environment` is String in the spec, not an enum. IAPKit + // owns the Sandbox/Production constraint and the native layer + // has already applied it; re-deriving it here would only let a + // value IAPKit adds later fail a confirmed purchase. 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']; @@ -1956,47 +1954,49 @@ 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: a payload this build cannot read β€” + // including one using a format added after it shipped β€” is + // dropped, never thrown. Receipt verification is the security + // boundary; losing metadata must not fail a paid purchase. + 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 IAPKit added after this build shipped. `isValid` + // stays authoritative; only the label degrades. + 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()), ); } diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index 0de455f0d..4bf3b91d5 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -2346,6 +2346,62 @@ void main() { ); }); + // IAPKit deploys from main while this build is frozen inside a published + // app, so a value it adds later must degrade rather than fail a purchase + // the store already confirmed. `isValid` stays authoritative throughout. + test('never fails a receipt over metadata this build predates', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'verifyPurchaseWithProvider': + return { + 'provider': 'iapkit', + 'iapkit': { + 'isValid': true, + 'productId': 'premium.monthly', + 'state': 'grace-period', + 'store': 'apple', + 'environment': 'Xcode', + 'clientPayload': { + 'format': 'yaml', + 'body': 'tier: gold', + 'version': 2, + 'updatedAt': 1720000000000, + }, + }, + }; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + await iap.initConnection(); + + final result = await iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + apiKey: 'test-api-key', + includeClientPayload: true, + apple: types.RequestVerifyPurchaseWithIapkitAppleProps( + jws: 'test-jws-token', + ), + ), + ); + + expect(result.iapkit!.isValid, isTrue); + expect(result.iapkit!.productId, 'premium.monthly'); + // An unknown state degrades to the neutral member, an unknown format + // drops only the optional payload, and an open-string environment is + // forwarded untouched. + expect(result.iapkit!.state, types.IapkitPurchaseState.Unknown); + expect(result.iapkit!.clientPayload, isNull); + expect(result.iapkit!.environment, 'Xcode'); + }); + test('sends correct payload for iOS verification', () async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt index 3be2554ec..e58e63f07 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt @@ -250,8 +250,22 @@ internal class AmazonInAppPurchaseAndroid( ) } val androidResult = verifyPurchaseWithIapkitAndroid(androidOptions, "kmp-iap-android-$storeName") + // The generated decoder throws on a value this module's Types.kt + // predates. openiap-google already produced a safe result, so a decode + // miss must surface as a typed error rather than escaping this suspend + // function as a raw IllegalArgumentException. + val iapkitResult = runCatching { + RequestVerifyPurchaseWithIapkitResult.fromJson(androidResult.toJson()) + }.getOrElse { + failWith( + PurchaseError( + code = ErrorCode.PurchaseVerificationFailed, + message = "IAPKit returned a verification result this build cannot read" + ) + ) + } return VerifyPurchaseWithProviderResult( - iapkit = RequestVerifyPurchaseWithIapkitResult.fromJson(androidResult.toJson()), + iapkit = iapkitResult, provider = options.provider ) } 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 7d1f1caaf..8d7f31351 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 @@ -2211,20 +2211,31 @@ internal class InAppPurchaseAndroid( val androidResult = verifyPurchaseWithIapkitAndroid(openIapProps, "kmp-iap-android") + // openiap-google has already decoded these safely; re-mapping them + // through this module's own generated enums must not re-impose a + // fail-closed gate when the two versions drift apart. val iapkitResult = RequestVerifyPurchaseWithIapkitResult( clientPayload = androidResult.clientPayload?.let { payload -> - IapkitProductClientPayload( - body = payload.body, - format = IapkitClientPayloadFormat.fromJson(payload.format.toJson()), - updatedAt = payload.updatedAt, - version = payload.version - ) + val format = IapkitClientPayloadFormat.entries + .firstOrNull { it.rawValue == payload.format.toJson() } + format?.let { + IapkitProductClientPayload( + body = payload.body, + format = it, + updatedAt = payload.updatedAt, + version = payload.version + ) + } }, environment = androidResult.environment, isValid = androidResult.isValid, productId = androidResult.productId, - state = IapkitPurchaseState.fromJson(androidResult.state.toJson()), - store = IapStore.fromJson(androidResult.store.toJson()) + state = runCatching { + IapkitPurchaseState.fromJson(androidResult.state.toJson()) + }.getOrDefault(IapkitPurchaseState.Unknown), + store = runCatching { + IapStore.fromJson(androidResult.store.toJson()) + }.getOrDefault(IapStore.Unknown) ) VerifyPurchaseWithProviderResult( diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 2f4a1ff4f..8a237418d 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -1071,41 +1071,35 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { is String -> rawProductId else -> throw IllegalArgumentException("IAPKit result productId must be a string") } - val environment = when (val rawEnvironment = map["environment"]) { - null, is NSNull -> null - "Sandbox", "Production" -> rawEnvironment as String - else -> throw IllegalArgumentException( - "IAPKit result environment must be Sandbox or Production" - ) - } - val clientPayload = when (val rawClientPayload = map["clientPayload"]) { - null, is NSNull -> null - is Map<*, *> -> { - val payload = rawClientPayload.mapKeys { it.key.toString() } - val format = payload["format"] as? String - ?: throw IllegalArgumentException("IAPKit clientPayload missing format") - if (format !in setOf("toml", "json", "text")) { - throw IllegalArgumentException("IAPKit clientPayload contains invalid format") - } - val body = payload["body"] as? String - ?: throw IllegalArgumentException("IAPKit clientPayload missing body") - val version = (payload["version"] as? Number)?.toDouble() - ?: throw IllegalArgumentException("IAPKit clientPayload missing version") - val updatedAt = (payload["updatedAt"] as? Number)?.toDouble() - ?: throw IllegalArgumentException("IAPKit clientPayload missing updatedAt") - if (!version.isFinite() || version <= 0.0 || version % 1.0 != 0.0 || - !updatedAt.isFinite() || updatedAt < 0.0 - ) { - throw IllegalArgumentException("IAPKit clientPayload contains invalid numeric fields") - } + // `environment` is String in the spec, not an enum, and + // packages/apple has already applied IAPKit's own + // constraint. Re-deriving it here would only let a value + // IAPKit adds later fail a confirmed purchase. + val environment = (map["environment"] as? String)?.takeIf { it.isNotEmpty() } + // Optional enrichment: a payload this build cannot read β€” + // including a format added after it shipped β€” is dropped, + // never thrown. Receipt verification is the security + // boundary; losing metadata must not fail a paid purchase. + val clientPayload = (map["clientPayload"] as? Map<*, *>)?.let { rawClientPayload -> + val payload = rawClientPayload.mapKeys { it.key.toString() } + val format = (payload["format"] as? String) + ?.let { raw -> IapkitClientPayloadFormat.entries.firstOrNull { it.rawValue == raw } } + val body = payload["body"] as? String + val version = (payload["version"] as? Number)?.toDouble() + val updatedAt = (payload["updatedAt"] as? Number)?.toDouble() + if (format == null || body == null || version == null || updatedAt == null || + !version.isFinite() || version <= 0.0 || version % 1.0 != 0.0 || + !updatedAt.isFinite() || updatedAt < 0.0 + ) { + null + } else { IapkitProductClientPayload( body = body, - format = IapkitClientPayloadFormat.fromJson(format), + format = format, updatedAt = updatedAt, version = version ) } - else -> throw IllegalArgumentException("IAPKit clientPayload must be an object") } val iapkitResult = RequestVerifyPurchaseWithIapkitResult( clientPayload = clientPayload, diff --git a/libraries/react-native-iap/src/__tests__/kit-api.test.ts b/libraries/react-native-iap/src/__tests__/kit-api.test.ts index 9472f2237..7a9c0a460 100644 --- a/libraries/react-native-iap/src/__tests__/kit-api.test.ts +++ b/libraries/react-native-iap/src/__tests__/kit-api.test.ts @@ -348,6 +348,33 @@ describe('kitApi cache resilience', () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); + // IAPKit can add a client-payload format from a main deploy. Rejecting the + // cached entry would evict it, stop the ETag revalidation from ever being + // sent, and leave offline reads with nothing β€” for a value the live path + // hands back to the caller unchanged anyway. + it('serves a cached payload whose format this build predates', async () => { + const stored = { + clientPayload: {format: 'yaml', body: 'tier: gold', version: 2, updatedAt: 9}, + 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')), diff --git a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts index 350f4cb54..cbf1a4b32 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -1686,9 +1686,18 @@ describe('Amazon Vega adapter', () => { } }); - it.each([42, 'Staging'])( - 'rejects an invalid IAPKit environment: %s', - async (environment) => { + // `environment` is an open String in the spec, so a value IAPKit adds later + // ("Xcode" and "LocalTesting" are real App Store Server environments) is + // forwarded, and only a non-string is dropped. Neither fails the receipt. + 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 () => @@ -1704,17 +1713,18 @@ describe('Amazon Vega adapter', () => { try { const module = createVegaIapModule(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.toThrow('IAPKit returned malformed response (HTTP 200).'); + }, + }); + + expect(result.iapkit?.isValid).toBe(true); + expect(result.iapkit?.environment).toBe(expected); } finally { globalThis.fetch = originalFetch; } diff --git a/libraries/react-native-iap/src/kit-api.ts b/libraries/react-native-iap/src/kit-api.ts index 5d60a87ad..f20089075 100644 --- a/libraries/react-native-iap/src/kit-api.ts +++ b/libraries/react-native-iap/src/kit-api.ts @@ -283,9 +283,14 @@ export function kitApi(options: KitApiOptions) { if (!raw) return null; const candidate = JSON.parse(raw) as Partial; const payload = candidate.clientPayload; + // Only the invariants the cache itself depends on. `format` is opaque + // here: rejecting a format IAPKit added later would evict the entry, + // stop the ETag revalidation below from ever being sent, and leave + // offline reads with nothing β€” for a value the live path passes through + // to the caller unchanged anyway. if ( !payload || - !["toml", "json", "text"].includes(payload.format) || + typeof payload.format !== "string" || typeof payload.body !== "string" || !Number.isSafeInteger(payload.version) || payload.version < 1 || diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index 2bbb406b7..db589e1ad 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -1271,17 +1271,15 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { `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}).`, - ); - } + // `environment` is String in the spec, not an enum: the + // Sandbox/Production pair is IAPKit's constraint to enforce, and + // re-deriving it here would only let a value IAPKit adds later fail a + // receipt the store already confirmed. + const rawEnvironment = json.environment; + const environment = + typeof rawEnvironment === 'string' && rawEnvironment.length > 0 + ? rawEnvironment + : undefined; return { ...(environment == null ? {} : {environment}), diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index cf8697aab..3a3ede982 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -77,7 +77,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return url } - static func iapkitClientPayload(from rawValue: Any?) throws -> IapkitProductClientPayload? { + /// Optional public product metadata. Returns nil for anything this build + /// cannot represent β€” including a `format` added to IAPKit after this + /// version shipped. Receipt verification is the security boundary; losing + /// enrichment must never fail a purchase the store already confirmed. + static func iapkitClientPayload(from rawValue: Any?) -> IapkitProductClientPayload? { guard let rawValue, !(rawValue is NSNull) else { return nil } guard let payload = rawValue as? [String: Any], let formatString = payload["format"] as? String, @@ -92,10 +96,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { version.doubleValue.rounded(.towardZero) == version.doubleValue, updatedAt.doubleValue.isFinite, updatedAt.doubleValue >= 0 else { - throw PurchaseError.make( - code: .purchaseVerificationFailed, - message: "IAPKit returned malformed client payload" - ) + OpenIapLog.warn("Ignoring an IAPKit client payload this build cannot read") + return nil } return IapkitProductClientPayload( @@ -118,14 +120,16 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return value.boolValue } - static func iapkitEnvironment(from rawValue: Any?) throws -> String? { + /// Optional store environment. `environment` is String in the spec, not an + /// enum: the Sandbox/Production pair is IAPKit's constraint to enforce, and + /// re-deriving it here would only let a value IAPKit adds later β€” App Store + /// Server also names `Xcode` and `LocalTesting` β€” fail a receipt the store + /// already confirmed. + static func iapkitEnvironment(from rawValue: Any?) -> String? { guard let rawValue, !(rawValue is NSNull) else { return nil } - guard let environment = rawValue as? String, - environment == "Sandbox" || environment == "Production" else { - throw PurchaseError.make( - code: .purchaseVerificationFailed, - message: "IAPKit returned malformed response" - ) + guard let environment = rawValue as? String, environment.isEmpty == false else { + OpenIapLog.warn("Ignoring an IAPKit environment this build cannot read") + return nil } return environment @@ -1031,14 +1035,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } else { productId = nil } - let environment = try Self.iapkitEnvironment(from: json["environment"]) - let clientPayload: IapkitProductClientPayload? - do { - clientPayload = try Self.iapkitClientPayload(from: json["clientPayload"]) - } catch { - OpenIapLog.warn("IAPKit verification response contains a malformed clientPayload") - throw error - } + let environment = Self.iapkitEnvironment(from: json["environment"]) + let clientPayload = Self.iapkitClientPayload(from: json["clientPayload"]) OpenIapLog.info("IAPKit verification result: store=\(parsedStore.rawValue), isValid=\(isValid), state=\(parsedState.rawValue)") return RequestVerifyPurchaseWithIapkitResult( clientPayload: clientPayload, diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift index b6b895af9..bdfda194e 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -64,7 +64,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { XCTAssertTrue(OpenIapModule.shared.responds(to: clientPayloadSelector)) } - func testIapkitClientPayloadParsesAndRejectsMalformedValues() throws { + func testIapkitClientPayloadParsesAndDropsUnreadableValues() throws { let payload = try XCTUnwrap( OpenIapModule.iapkitClientPayload(from: [ "format": "toml", @@ -77,26 +77,36 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { XCTAssertEqual(.toml, payload.format) XCTAssertEqual("tier = \"gold\"", payload.body) XCTAssertEqual(2, payload.version) - XCTAssertNil(try OpenIapModule.iapkitClientPayload(from: nil)) - XCTAssertNil(try OpenIapModule.iapkitClientPayload(from: NSNull())) - XCTAssertThrowsError( - try OpenIapModule.iapkitClientPayload(from: [ + XCTAssertNil(OpenIapModule.iapkitClientPayload(from: nil)) + XCTAssertNil(OpenIapModule.iapkitClientPayload(from: NSNull())) + // A format IAPKit adds after this build shipped, and every structural + // defect, drop the payload instead of failing the verified receipt. + XCTAssertNil( + OpenIapModule.iapkitClientPayload(from: [ + "format": "yaml", + "body": "tier: gold", + "version": 1, + "updatedAt": 1, + ]) + ) + XCTAssertNil( + OpenIapModule.iapkitClientPayload(from: [ "format": "TOML", "body": "invalid format", "version": 1, "updatedAt": 1, ]) ) - XCTAssertThrowsError( - try OpenIapModule.iapkitClientPayload(from: [ + XCTAssertNil( + OpenIapModule.iapkitClientPayload(from: [ "format": "toml", "body": "missing version", "updatedAt": 1, ]) ) for invalidVersion: Any in [true, 0, 1.5] { - XCTAssertThrowsError( - try OpenIapModule.iapkitClientPayload(from: [ + XCTAssertNil( + OpenIapModule.iapkitClientPayload(from: [ "format": "toml", "body": "invalid version", "version": invalidVersion, @@ -104,8 +114,8 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { ]) ) } - XCTAssertThrowsError( - try OpenIapModule.iapkitClientPayload(from: [ + XCTAssertNil( + OpenIapModule.iapkitClientPayload(from: [ "format": "toml", "body": "invalid timestamp", "version": 1, @@ -125,16 +135,22 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { } } - func testIapkitEnvironmentAcceptsOnlyCanonicalValues() throws { - XCTAssertNil(try OpenIapModule.iapkitEnvironment(from: nil)) - XCTAssertNil(try OpenIapModule.iapkitEnvironment(from: NSNull())) - XCTAssertEqual("Sandbox", try OpenIapModule.iapkitEnvironment(from: "Sandbox")) - XCTAssertEqual("Production", try OpenIapModule.iapkitEnvironment(from: "Production")) + func testIapkitEnvironmentForwardsAnyStringAndNeverFails() throws { + XCTAssertNil(OpenIapModule.iapkitEnvironment(from: nil)) + XCTAssertNil(OpenIapModule.iapkitEnvironment(from: NSNull())) + XCTAssertEqual("Sandbox", OpenIapModule.iapkitEnvironment(from: "Sandbox")) + XCTAssertEqual("Production", OpenIapModule.iapkitEnvironment(from: "Production")) + + // The spec types `environment` as String, so a value IAPKit adds later + // is forwarded rather than dropped. "Xcode" and "LocalTesting" are real + // App Store Server environments. + for forwarded in ["sandbox", "Xcode", "LocalTesting", "Staging"] { + XCTAssertEqual(forwarded, OpenIapModule.iapkitEnvironment(from: forwarded)) + } - for invalidValue: Any in ["sandbox", "Xcode", "", 1, true, [:], []] { - XCTAssertThrowsError( - try OpenIapModule.iapkitEnvironment(from: invalidValue) - ) + // Only a non-string, or an empty string, has nothing to forward. + for unreadableValue: Any in ["", 1, true, [:], []] { + XCTAssertNil(OpenIapModule.iapkitEnvironment(from: unreadableValue)) } } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt index da72698cf..1f3d8a946 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt @@ -186,6 +186,38 @@ suspend fun verifyPurchaseWithIapkit( fun malformedIapkitResponse(): OpenIapError.PurchaseVerificationFailed = OpenIapError.PurchaseVerificationFailed("IAPKit returned malformed response") + fun unreadableIapkitClientPayload(): IapkitProductClientPayload? { + OpenIapLog.warn("Ignoring an IAPKit client payload this build cannot read", tag) + return null + } + + fun readIapkitClientPayload(raw: Any?): IapkitProductClientPayload? { + if (raw == null) return null + val payload = raw as? Map<*, *> ?: return unreadableIapkitClientPayload() + // Derived from the generated enum rather than a literal set, so a + // format added to the spec is readable as soon as Types.kt regenerates. + val format = (payload["format"] as? String) + ?.let { raw -> IapkitClientPayloadFormat.entries.firstOrNull { it.rawValue == raw } } + ?: return unreadableIapkitClientPayload() + val body = payload["body"] as? String + ?: return unreadableIapkitClientPayload() + val version = (payload["version"] as? Number)?.toDouble() + ?: return unreadableIapkitClientPayload() + val updatedAt = (payload["updatedAt"] as? Number)?.toDouble() + ?: return unreadableIapkitClientPayload() + if (!version.isFinite() || version <= 0.0 || version % 1.0 != 0.0 || + !updatedAt.isFinite() || updatedAt < 0.0 + ) { + return unreadableIapkitClientPayload() + } + return IapkitProductClientPayload( + body = body, + format = format, + updatedAt = updatedAt, + version = version + ) + } + fun resolveIapkitEndpoint(): String { val requestedBaseUrl = props.baseUrl?.trim() if (requestedBaseUrl.isNullOrEmpty()) { @@ -361,47 +393,21 @@ suspend fun verifyPurchaseWithIapkit( IapkitPurchaseState.fromJson(normalizedState) }.getOrDefault(IapkitPurchaseState.Unknown) - val clientPayload = when (val rawClientPayload = parsed["clientPayload"]) { - null -> null - is Map<*, *> -> { - val formatValue = rawClientPayload["format"] as? String - ?: throw malformedIapkitResponse() - if (formatValue !in setOf("toml", "json", "text")) { - throw malformedIapkitResponse() - } - val format = IapkitClientPayloadFormat.fromJson(formatValue) - val payloadBody = rawClientPayload["body"] as? String - ?: throw malformedIapkitResponse() - val version = (rawClientPayload["version"] as? Number)?.toDouble() - ?: throw malformedIapkitResponse() - val updatedAt = (rawClientPayload["updatedAt"] as? Number)?.toDouble() - ?: throw malformedIapkitResponse() - if (!version.isFinite() || version <= 0.0 || version % 1.0 != 0.0 || - !updatedAt.isFinite() || updatedAt < 0.0 - ) { - throw malformedIapkitResponse() - } - IapkitProductClientPayload( - body = payloadBody, - format = format, - updatedAt = updatedAt, - version = version - ) - } - else -> throw malformedIapkitResponse() - } + // Optional enrichment: anything this build cannot represent β€” a + // client payload format or environment IAPKit adds later included + // β€” is dropped, never thrown. Receipt verification is the security + // boundary, and losing metadata must not fail a confirmed purchase. + val clientPayload = readIapkitClientPayload(parsed["clientPayload"]) val productId = when (val rawProductId = parsed["productId"]) { null -> null is String -> rawProductId else -> throw malformedIapkitResponse() } - val environment = when (val rawEnvironment = parsed["environment"]) { - null -> null - is String -> rawEnvironment.takeIf { - it == "Sandbox" || it == "Production" - } ?: throw malformedIapkitResponse() - else -> throw malformedIapkitResponse() - } + // `environment` is String in the spec, not an enum: the + // Sandbox/Production pair is IAPKit's constraint to enforce, and + // re-deriving it here would only let a value IAPKit adds later + // fail a receipt the store already confirmed. + val environment = (parsed["environment"] as? String)?.takeIf { it.isNotEmpty() } return RequestVerifyPurchaseWithIapkitResult( clientPayload = clientPayload, diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt index b308721df..b56c44993 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt @@ -22,6 +22,7 @@ import java.net.HttpURLConnection import java.net.URL import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -420,50 +421,57 @@ class PurchaseVerificationValidatorTest { } @Test - fun `verifyPurchaseWithIapkit rejects malformed client payload`() = runTest { + fun `verifyPurchaseWithIapkit drops client payloads it cannot read`() = runTest { val props = RequestVerifyPurchaseWithIapkitProps( google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = "token-123" ), includeClientPayload = true ) + // `yaml` stands in for a format IAPKit adds after this build ships; the + // rest are structural defects. Both drop the optional payload and keep + // the verified receipt, because enrichment is not the security boundary. + val unreadablePayloads = listOf( + """{"format":"toml","body":"missing timestamps"}""", + """{"format":"yaml","body":"tier: gold","version":1,"updatedAt":1}""", + """{"format":"TOML","body":"x=1","version":1,"updatedAt":1}""", + """{"format":"toml","body":"x=1","version":0,"updatedAt":1}""", + """{"format":"toml","body":"x=1","version":1.5,"updatedAt":1}""", + """{"format":"toml","body":"x=1","version":1,"updatedAt":-1}""" + ) - try { - verifyPurchaseWithIapkit(props, "TEST") { _ -> + for (payload in unreadablePayloads) { + val result = verifyPurchaseWithIapkit(props, "TEST") { _ -> FakeHttpURLConnection( 200, - """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":{"format":"toml","body":"missing timestamps"}}""" + """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":$payload}""" ) } - throw AssertionError("Expected PurchaseVerificationFailed for malformed client payload") - } catch (error: OpenIapError.PurchaseVerificationFailed) { - assertTrue(error.message.contains("malformed")) + + assertTrue(result.isValid) + assertEquals(IapkitPurchaseState.Entitled, result.state) + assertNull(result.clientPayload) } } @Test - fun `verifyPurchaseWithIapkit rejects invalid payload numbers and productId`() = runTest { + fun `verifyPurchaseWithIapkit rejects a non-string productId`() = runTest { val props = RequestVerifyPurchaseWithIapkitProps( google = RequestVerifyPurchaseWithIapkitGoogleProps( purchaseToken = "token-123" - ), - includeClientPayload = true - ) - val invalidResponses = listOf( - """{"store":"google","isValid":true,"state":"ENTITLED","productId":42}""", - """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":{"format":"TOML","body":"x=1","version":1,"updatedAt":1}}""", - """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":{"format":"toml","body":"x=1","version":0,"updatedAt":1}}""", - """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":{"format":"toml","body":"x=1","version":1.5,"updatedAt":1}}""", - """{"store":"google","isValid":true,"state":"ENTITLED","clientPayload":{"format":"toml","body":"x=1","version":1,"updatedAt":-1}}""" + ) ) - for (response in invalidResponses) { - try { - verifyPurchaseWithIapkit(props, "TEST") { _ -> FakeHttpURLConnection(200, response) } - throw AssertionError("Expected malformed IAPKit response to fail: $response") - } catch (error: OpenIapError.PurchaseVerificationFailed) { - assertTrue(error.message.contains("malformed")) + try { + verifyPurchaseWithIapkit(props, "TEST") { _ -> + FakeHttpURLConnection( + 200, + """{"store":"google","isValid":true,"state":"ENTITLED","productId":42}""" + ) } + throw AssertionError("Expected a non-string productId to fail") + } catch (error: OpenIapError.PurchaseVerificationFailed) { + assertTrue(error.message.contains("malformed")) } } @@ -546,34 +554,38 @@ class PurchaseVerificationValidatorTest { } @Test - fun `verifyPurchaseWithIapkit rejects invalid environments`() = runTest { + fun `verifyPurchaseWithIapkit never fails a receipt over the environment`() = runTest { val props = RequestVerifyPurchaseWithIapkitProps( amazon = RequestVerifyPurchaseWithIapkitAmazonProps( userId = "amzn1.account.ABC123", receiptId = "amzn1.receipt.ABC123456789" ) ) - val invalidEnvironments = listOf( - "\"sandbox\"", - "\"Xcode\"", - "42", - "true", - "{}", - "[]" - ) - - for (environment in invalidEnvironments) { - try { - verifyPurchaseWithIapkit(props, "TEST") { _ -> - FakeHttpURLConnection( - 200, - """{"store":"amazon","isValid":true,"state":"ENTITLED","environment":$environment}""" - ) - } - throw AssertionError("Expected malformed environment to fail: $environment") - } catch (error: OpenIapError.PurchaseVerificationFailed) { - assertTrue(error.message.contains("malformed")) + // The spec types `environment` as String, so a value IAPKit adds later + // is forwarded; only a non-string has nothing to forward. "Xcode" and + // "LocalTesting" are real App Store Server environments. + val cases = listOf( + "\"Sandbox\"" to "Sandbox", + "\"Xcode\"" to "Xcode", + "\"LocalTesting\"" to "LocalTesting", + "\"sandbox\"" to "sandbox", + "42" to null, + "true" to null, + "{}" to null, + "[]" to null, + "\"\"" to null + ) + + for ((environment, expected) in cases) { + val result = verifyPurchaseWithIapkit(props, "TEST") { _ -> + FakeHttpURLConnection( + 200, + """{"store":"amazon","isValid":true,"state":"ENTITLED","environment":$environment}""" + ) } + + assertTrue(result.isValid) + assertEquals(expected, result.environment) } } diff --git a/packages/gql/src/kit-api.ts b/packages/gql/src/kit-api.ts index 5d60a87ad..f20089075 100644 --- a/packages/gql/src/kit-api.ts +++ b/packages/gql/src/kit-api.ts @@ -283,9 +283,14 @@ export function kitApi(options: KitApiOptions) { if (!raw) return null; const candidate = JSON.parse(raw) as Partial; const payload = candidate.clientPayload; + // Only the invariants the cache itself depends on. `format` is opaque + // here: rejecting a format IAPKit added later would evict the entry, + // stop the ETag revalidation below from ever being sent, and leave + // offline reads with nothing β€” for a value the live path passes through + // to the caller unchanged anyway. if ( !payload || - !["toml", "json", "text"].includes(payload.format) || + typeof payload.format !== "string" || typeof payload.body !== "string" || !Number.isSafeInteger(payload.version) || payload.version < 1 || diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index c93be2459..cfb4bae3a 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -2508,7 +2508,7 @@ function checkIapkitAmazonContractWiring() { "packages/apple/Sources/OpenIapModule.swift", [ "expectedProductId: amazon.expectedProductId", - "let environment = try Self.iapkitEnvironment", + "let environment = Self.iapkitEnvironment", "environment: environment", ], "Apple IAPKit Amazon verification contract", @@ -2517,7 +2517,7 @@ function checkIapkitAmazonContractWiring() { "packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt", [ 'amazon.expectedProductId?.let { put("expectedProductId", it) }', - 'it == "Sandbox" || it == "Production"', + 'parsed["environment"] as? String', "environment = environment", ], "Google IAPKit Amazon verification contract", @@ -2554,12 +2554,18 @@ function checkIapkitAmazonContractWiring() { ], [ "libraries/react-native-iap/src/vega-adapter.ts", - ["expectedProductId: amazon.expectedProductId", "environment !== 'Production'"], + [ + "expectedProductId: amazon.expectedProductId", + "const rawEnvironment = json.environment", + ], "React Native Vega IAPKit bridge", ], [ "libraries/expo-iap/src/vega-adapter.ts", - ["expectedProductId: amazon.expectedProductId", "environment !== 'Production'"], + [ + "expectedProductId: amazon.expectedProductId", + "const rawEnvironment = json.environment", + ], "Expo Vega IAPKit bridge", ], ]) { @@ -2569,8 +2575,8 @@ function checkIapkitAmazonContractWiring() { "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", [ "'expectedProductId':", - "environmentValue != 'Production'", - "environment: environmentValue as String?", + "final environmentValue = itemMap['environment']", + "environment: environment,", ], "Flutter IAPKit Amazon contract", ); @@ -2605,7 +2611,7 @@ function checkIapkitAmazonContractWiring() { ); expectIncludes( "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt", - ['"Sandbox", "Production"', "environment = environment"], + ['map["environment"] as? String', "environment = environment"], "KMP iOS IAPKit response contract", ); } From f663bcfd0b02b60cbdd66a5e112ef1f87d79c28b Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 12:21:55 +0900 Subject: [PATCH 06/21] feat: report the client spec version on IAPKit verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verify contract can only be evolved safely if the server knows which generation of client is still calling it. Nothing carried that, so a decision to add a response value had to be made blind. Native verify requests now send `X-OpenIAP-Spec` with the spec version the build was compiled against, and kit records it on the structured verify log line. The value is reported, never negotiated: it is shape-checked and bounded before it reaches a log line, and no code branches on it, so an SDK cannot change how its receipt is verified by claiming a version. Apple reads it through a new non-fatal accessor. `OpenIapVersion. specVersion` traps when the bundled openiap-versions.json is missing, and it has no callers today, so putting it on the purchase path would have introduced a crash in exactly the code this branch is hardening β€” the resource is bundled differently by SwiftPM, CocoaPods and the xcframework. The header is omitted when the version cannot be read. Android reads it from a BuildConfig field derived from the same file the build script already parses. Request construction moved into a testable helper on the Apple side. Scope: the two native clients, which serve the verify path for React Native, Expo, Flutter, KMP, Godot and MAUI. The Vega/Fire OS JavaScript fallback calls IAPKit directly but has no version constant available without new sync plumbing, so it does not send the header yet. Co-Authored-By: Claude Opus 5 (1M context) --- packages/apple/Sources/OpenIapModule.swift | 29 ++++++++++++----- packages/apple/Sources/OpenIapVersion.swift | 18 ++++++++++- .../VerifyPurchaseWithProviderTests.swift | 31 +++++++++++++++++++ packages/google/openiap/build.gradle.kts | 8 +++++ .../utils/PurchaseVerificationValidator.kt | 5 +++ .../PurchaseVerificationValidatorTest.kt | 22 +++++++++++++ .../kit/server/api/v1/request-logger.test.ts | 15 +++++++++ packages/kit/server/api/v1/request-logger.ts | 21 +++++++++++++ packages/kit/server/api/v1/routes.ts | 7 ++++- 9 files changed, 146 insertions(+), 10 deletions(-) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 3a3ede982..2a8208fe3 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -108,6 +108,26 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { ) } + /// Builds the IAPKit verify request. `X-OpenIAP-Spec` tells the server + /// which response contract this build was compiled against, so an enum it + /// gains later can be rolled out against real client-version data instead + /// of a guess. It is reported, never negotiated: the header is omitted when + /// the version cannot be read, and the server must not gate on it. + static func makeIapkitRequest(url: URL, apiKey: String?, body: Data) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let specVersion = OpenIapVersion.specVersionIfAvailable { + request.setValue(specVersion, forHTTPHeaderField: "X-OpenIAP-Spec") + } + let trimmedApiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedApiKey, trimmedApiKey.isEmpty == false { + request.setValue("Bearer \(trimmedApiKey)", forHTTPHeaderField: "Authorization") + } + request.httpBody = body + return request + } + static func iapkitBoolean(from rawValue: Any?) throws -> Bool { guard let value = rawValue as? NSNumber, CFGetTypeID(value) == CFBooleanGetTypeID() else { @@ -962,14 +982,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { let store = payload.store let body = payload.body - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - let apiKey = props.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) - if let apiKey, apiKey.isEmpty == false { - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } - request.httpBody = body + let request = Self.makeIapkitRequest(url: url, apiKey: props.apiKey, body: body) OpenIapLog.debug("IAPKit request URL: \(url.absoluteString)") OpenIapLog.debug("IAPKit request body bytes=\(body.count)") diff --git a/packages/apple/Sources/OpenIapVersion.swift b/packages/apple/Sources/OpenIapVersion.swift index 410a33698..7d61c18d1 100644 --- a/packages/apple/Sources/OpenIapVersion.swift +++ b/packages/apple/Sources/OpenIapVersion.swift @@ -14,7 +14,23 @@ public struct OpenIapVersion { version(for: "spec") } + /// Current OpenIAP specification version, or nil when the bundled + /// `openiap-versions.json` cannot be located. Callers on the purchase path + /// must use this rather than `specVersion`: the resource is bundled + /// differently by SwiftPM, CocoaPods and the xcframework, and reporting a + /// version is never worth trapping in the middle of a purchase. + public static var specVersionIfAvailable: String? { + optionalVersion(for: "spec") + } + private static func version(for key: String) -> String { + guard let version = optionalVersion(for: key) else { + fatalError("OpenIAP: missing \(key) version in openiap-versions.json") + } + return version + } + + private static func optionalVersion(for key: String) -> String? { let versionURL: URL? #if SWIFT_PACKAGE @@ -30,7 +46,7 @@ public struct OpenIapVersion { let version = json[key] as? String, !version.isEmpty else { - fatalError("OpenIAP: missing \(key) version in openiap-versions.json") + return nil } return version } diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift index bdfda194e..5840e35dd 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -154,6 +154,37 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { } } + func testIapkitRequestReportsTheSpecItWasBuiltAgainst() throws { + let url = try XCTUnwrap(URL(string: "https://kit.openiap.dev/v1/purchase/verify")) + let request = OpenIapModule.makeIapkitRequest( + url: url, + apiKey: " iapkit_pk_test ", + body: Data("{}".utf8) + ) + + XCTAssertEqual("POST", request.httpMethod) + XCTAssertEqual("application/json", request.value(forHTTPHeaderField: "Content-Type")) + XCTAssertEqual("Bearer iapkit_pk_test", request.value(forHTTPHeaderField: "Authorization")) + XCTAssertEqual( + OpenIapVersion.specVersionIfAvailable, + request.value(forHTTPHeaderField: "X-OpenIAP-Spec") + ) + XCTAssertEqual(Data("{}".utf8), request.httpBody) + } + + func testIapkitRequestOmitsAuthorizationForABlankApiKey() throws { + let url = try XCTUnwrap(URL(string: "https://kit.openiap.dev/v1/purchase/verify")) + + for blank in [nil, "", " "] as [String?] { + let request = OpenIapModule.makeIapkitRequest( + url: url, + apiKey: blank, + body: Data() + ) + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + } + } + func testIapkitAmazonPayloadForwardsExpectedProductId() throws { let payload = try OpenIapModule.iapkitAmazonPayload( from: RequestVerifyPurchaseWithIapkitAmazonProps( diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index e803dccbf..354382733 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -76,6 +76,13 @@ val openIapVersion: String = project.findProperty("openIapVersion")?.toString()?.takeIf { it.isNotBlank() } ?: versionsJson["google"]?.toString()?.takeIf { it.isNotBlank() } ?: throw GradleException("packages/google: 'google' version missing in openiap-versions.json") +// The OpenIAP spec version this artifact was compiled against. Reported to +// IAPKit on verify requests so a response contract change can be rolled out +// against real client-version data. Never a gradle property: it describes the +// contract, not the artifact's own version. +val openIapSpecVersion: String = + versionsJson["spec"]?.toString()?.takeIf { it.isNotBlank() } + ?: throw GradleException("packages/google: 'spec' version missing in openiap-versions.json") val isCentralPublishTaskRequested = gradle.startParameter.taskNames.any { taskName -> taskName.contains("mavenCentral", ignoreCase = true) @@ -90,6 +97,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") + buildConfigField("String", "OPENIAP_SPEC_VERSION", "\"$openIapSpecVersion\"") } buildTypes { diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt index 1f3d8a946..e59a764f2 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt @@ -14,6 +14,7 @@ import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitResult import dev.hyo.openiap.VerifyPurchaseProps import dev.hyo.openiap.VerifyPurchaseResultAndroid import dev.hyo.openiap.VerifyPurchaseResultHorizon +import io.github.hyochan.openiap.BuildConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.IOException @@ -351,6 +352,10 @@ suspend fun verifyPurchaseWithIapkit( requestMethod = "POST" doOutput = true setRequestProperty("Content-Type", "application/json") + // Tells IAPKit which response contract this build was compiled + // against, so an enum it gains later can be rolled out against real + // client-version data. Reported, never negotiated. + setRequestProperty("X-OpenIAP-Spec", BuildConfig.OPENIAP_SPEC_VERSION) props.apiKey?.takeIf { it.isNotBlank() }?.let { apiKey -> setRequestProperty("Authorization", "Bearer $apiKey") } diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt index b56c44993..cde16ca9f 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt @@ -553,6 +553,28 @@ class PurchaseVerificationValidatorTest { } } + @Test + fun `verifyPurchaseWithIapkit reports the spec it was built against`() = runTest { + val props = RequestVerifyPurchaseWithIapkitProps( + apiKey = "iapkit_pk_test", + google = RequestVerifyPurchaseWithIapkitGoogleProps(purchaseToken = "token-123") + ) + lateinit var connection: FakeHttpURLConnection + + verifyPurchaseWithIapkit(props, "TEST") { _ -> + FakeHttpURLConnection( + 200, + """{"store":"google","isValid":true,"state":"ENTITLED"}""" + ).also { connection = it } + } + + assertEquals( + io.github.hyochan.openiap.BuildConfig.OPENIAP_SPEC_VERSION, + connection.headers["X-OpenIAP-Spec"] + ) + assertEquals("Bearer iapkit_pk_test", connection.headers["Authorization"]) + } + @Test fun `verifyPurchaseWithIapkit never fails a receipt over the environment`() = runTest { val props = RequestVerifyPurchaseWithIapkitProps( diff --git a/packages/kit/server/api/v1/request-logger.test.ts b/packages/kit/server/api/v1/request-logger.test.ts index 6d9e22159..48230cd10 100644 --- a/packages/kit/server/api/v1/request-logger.test.ts +++ b/packages/kit/server/api/v1/request-logger.test.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { apiKeyMiddleware } from "./middleware"; import { + readSpecVersion, requestLoggerMiddleware, type VerifyDebugLogLine, type VerifyLogLine, @@ -101,6 +102,20 @@ describe("requestLoggerMiddleware", () => { expect(line.durationMs).toBeGreaterThanOrEqual(0); }); + test("records a plausible client spec version and ignores anything else", async () => { + // Caller-controlled, so it is shape-checked and bounded before it reaches + // a log line. Nothing branches on it β€” an SDK must not be able to change + // how its receipt is verified by claiming a version. + expect(readSpecVersion("3.2.0")).toBe("3.2.0"); + expect(readSpecVersion("3.2.0-rc.1")).toBe("3.2.0-rc.1"); + expect(readSpecVersion(undefined)).toBeUndefined(); + expect(readSpecVersion("")).toBeUndefined(); + expect(readSpecVersion("latest")).toBeUndefined(); + expect(readSpecVersion("3.2")).toBeUndefined(); + expect(readSpecVersion(`3.2.0-${"a".repeat(64)}`)).toBeUndefined(); + expect(readSpecVersion('3.2.0"}\n{"level":"info"')).toBeUndefined(); + }); + test("still logs when the validator rejects the payload (400)", async () => { const logs: VerifyLogLine[] = []; const app = buildApp({ logs }); diff --git a/packages/kit/server/api/v1/request-logger.ts b/packages/kit/server/api/v1/request-logger.ts index 6b21a39ca..767d26e5d 100644 --- a/packages/kit/server/api/v1/request-logger.ts +++ b/packages/kit/server/api/v1/request-logger.ts @@ -27,6 +27,23 @@ export interface VerifyLogLine { store?: VerifyStore; isValid?: boolean; state?: string; + /** `X-OpenIAP-Spec`, when the client sent a plausible version. */ + specVersion?: string; +} + +// Clients report the OpenIAP spec their build was compiled against so a +// response-contract change can be rolled out against real version data. The +// value is caller-controlled, so it is shape-checked and bounded before it +// reaches a log line, and nothing branches on it β€” an SDK must never be able +// to change how its receipt is verified by claiming a version. +const SPEC_VERSION_PATTERN = + /^\d{1,4}\.\d{1,4}\.\d{1,4}(-[0-9A-Za-z.-]{1,32})?$/; + +export function readSpecVersion( + header: string | undefined, +): string | undefined { + if (!header) return undefined; + return SPEC_VERSION_PATTERN.test(header) ? header : undefined; } export interface RedactedDebugValue { @@ -54,6 +71,7 @@ export interface VerifyDebugLogLine { store?: VerifyStore; isValid?: boolean; state?: string; + specVersion?: string; sandbox?: boolean; identifiers?: VerifyDebugIdentifiers; } @@ -246,6 +264,7 @@ export function requestLoggerMiddleware( const apiKeyHash = c.var.apiKeyHash ?? (apiKey ? hashApiKey(apiKey) : undefined); const statusCode = nextError && c.res.status < 400 ? 500 : c.res.status; + const specVersion = readSpecVersion(c.req.header("X-OpenIAP-Spec")); // Swallow logger-side throws β€” a broken sink should never take // down a request whose real work already succeeded (or already @@ -263,6 +282,7 @@ export function requestLoggerMiddleware( store, isValid: outcome?.isValid, state: outcome?.state, + specVersion, }); } catch (loggerError) { console.error( @@ -284,6 +304,7 @@ export function requestLoggerMiddleware( store, isValid: outcome?.isValid, state: outcome?.state, + specVersion, sandbox: body?.sandbox, identifiers: collectDebugIdentifiers(body), }); diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index 87514fa77..becb095a7 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -225,7 +225,12 @@ const verifyPurchaseRouteDescription = describeRoute({ "Meta `userId` ≀ 256 chars, `sku` ≀ 256 chars. " + "Amazon `userId` ≀ 512 chars and `receiptId` ≀ 4 KB. " + "Oversized fields return `400 INVALID_INPUT`; oversized request " + - "bodies return `413 PAYLOAD_TOO_LARGE`. Neither hits the upstream store.", + "bodies return `413 PAYLOAD_TOO_LARGE`. Neither hits the upstream store.\n\n" + + "Optional `X-OpenIAP-Spec` request header: the OpenIAP spec version the " + + "calling SDK was built against (for example `3.2.0`). IAPKit records it so " + + "a response-contract change can be rolled out against real client-version " + + "data. It never changes how a receipt is verified, and an unrecognised " + + "value is ignored rather than rejected.", security: [{ apiKey: [] }], responses: { 200: { From 3cc1969bb908c30b34cf2ce7fc1e5a312c6fd8a9 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 12:36:20 +0900 Subject: [PATCH 07/21] fix: correct what CI and the second review round caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-existing guards pinned the fail-closed behaviour this branch removed, and both broke CI: two Flutter tests asserted that a malformed client payload and a non-string environment throw, and kmp-iap's IapkitBaseUrlBridgeTest asserted the iOS source still contains the Sandbox/Production literal pair. All three now pin the fixed contract β€” the receipt survives and the metadata is dropped. kmp-iap's Amazon path still round-tripped through the generated fromJson, which throws on a clientPayload format its Types.kt predates, so an unreadable payload took down a confirmed purchase. It now maps field by field like the Play path, degrading the payload to null. The spec docstring for `environment` said only what IAPKit emits, not what SDKs must do with it β€” which is exactly the mistake five SDKs made. It now states the field is deliberately String and must be forwarded opaquely, regenerated across all eight languages. The Swift header test compared the header against the same accessor that produced it, so it passed green while sending nothing. Strengthening it exposed a real pre-existing defect: SwiftPM copies packages/apple/Sources/openiap-versions.json as the symlink it is, and that symlink dangles inside the built bundle, so Bundle.module resolves nothing and OpenIapVersion returns nil under SPM. Nothing had noticed because the accessor has no other callers. The test now pins the true contract β€” header present exactly when the version resolves, carrying a semver when it does β€” and the omission is graceful precisely because the accessor was made non-fatal. Co-Authored-By: Claude Opus 5 (1M context) --- libraries/expo-iap/src/types.ts | 6 + .../flutter_inapp_purchase/lib/types.dart | 6 + .../flutter_inapp_purchase_channel_test.dart | 152 +++++++++--------- libraries/godot-iap/addons/godot-iap/types.gd | 2 +- .../kmpiap/AmazonInAppPurchaseAndroid.kt | 45 ++++-- .../hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt | 4 +- .../io/github/hyochan/kmpiap/openiap/Types.kt | 6 + libraries/maui-iap/src/OpenIap.Maui/Types.cs | 6 + libraries/react-native-iap/src/types.ts | 6 + packages/apple/Sources/Models/Types.swift | 6 + .../VerifyPurchaseWithProviderTests.swift | 18 ++- .../src/main/java/dev/hyo/openiap/Types.kt | 6 + packages/gql/src/generated/Types.cs | 6 + packages/gql/src/generated/Types.kt | 6 + packages/gql/src/generated/Types.swift | 6 + packages/gql/src/generated/types.dart | 6 + packages/gql/src/generated/types.gd | 2 +- packages/gql/src/generated/types.ts | 6 + packages/gql/src/type.graphql | 6 + 19 files changed, 205 insertions(+), 96 deletions(-) diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 29d5ca10f..132105b27 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -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); /** diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 94fcbe735..7f247628b 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -3353,6 +3353,12 @@ class 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. final String? environment; /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index 4bf3b91d5..6ff248b8f 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -2346,62 +2346,6 @@ void main() { ); }); - // IAPKit deploys from main while this build is frozen inside a published - // app, so a value it adds later must degrade rather than fail a purchase - // the store already confirmed. `isValid` stays authoritative throughout. - test('never fails a receipt over metadata this build predates', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall call) async { - switch (call.method) { - case 'initConnection': - return true; - case 'verifyPurchaseWithProvider': - return { - 'provider': 'iapkit', - 'iapkit': { - 'isValid': true, - 'productId': 'premium.monthly', - 'state': 'grace-period', - 'store': 'apple', - 'environment': 'Xcode', - 'clientPayload': { - 'format': 'yaml', - 'body': 'tier: gold', - 'version': 2, - 'updatedAt': 1720000000000, - }, - }, - }; - } - return null; - }); - - final iap = FlutterInappPurchase.private( - FakePlatform(operatingSystem: 'ios'), - ); - await iap.initConnection(); - - final result = await iap.verifyPurchaseWithProvider( - provider: types.PurchaseVerificationProvider.Iapkit, - iapkit: const types.RequestVerifyPurchaseWithIapkitProps( - apiKey: 'test-api-key', - includeClientPayload: true, - apple: types.RequestVerifyPurchaseWithIapkitAppleProps( - jws: 'test-jws-token', - ), - ), - ); - - expect(result.iapkit!.isValid, isTrue); - expect(result.iapkit!.productId, 'premium.monthly'); - // An unknown state degrades to the neutral member, an unknown format - // drops only the optional payload, and an open-string environment is - // forwarded untouched. - expect(result.iapkit!.state, types.IapkitPurchaseState.Unknown); - expect(result.iapkit!.clientPayload, isNull); - expect(result.iapkit!.environment, 'Xcode'); - }); - test('sends correct payload for iOS verification', () async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -3156,7 +3100,8 @@ void main() { ); }); - test('rejects malformed IAPKit client payload', () async { + test('drops a malformed IAPKit client payload without failing the receipt', + () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { switch (call.method) { @@ -3186,21 +3131,22 @@ void main() { ); await iap.initConnection(); - await expectLater( - iap.verifyPurchaseWithProvider( - provider: types.PurchaseVerificationProvider.Iapkit, - iapkit: const types.RequestVerifyPurchaseWithIapkitProps( - includeClientPayload: true, - apple: types.RequestVerifyPurchaseWithIapkitAppleProps( - jws: 'test-jws-token', - ), + final result = await iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + includeClientPayload: true, + apple: types.RequestVerifyPurchaseWithIapkitAppleProps( + jws: 'test-jws-token', ), ), - throwsA(isA()), ); + + // The payload is optional enrichment; the verified receipt survives it. + expect(result.iapkit!.isValid, isTrue); + expect(result.iapkit!.clientPayload, isNull); }); - test('rejects malformed IAPKit environment', () async { + test('drops a non-string IAPKit environment', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { switch (call.method) { @@ -3225,17 +3171,73 @@ void main() { ); await iap.initConnection(); - await expectLater( - iap.verifyPurchaseWithProvider( - provider: types.PurchaseVerificationProvider.Iapkit, - iapkit: const types.RequestVerifyPurchaseWithIapkitProps( - amazon: types.RequestVerifyPurchaseWithIapkitAmazonProps( - receiptId: 'amzn1.receipt.test', - ), + final result = await iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + amazon: types.RequestVerifyPurchaseWithIapkitAmazonProps( + receiptId: 'amzn1.receipt.test', ), ), - throwsA(isA()), ); + + expect(result.iapkit!.isValid, isTrue); + expect(result.iapkit!.environment, isNull); + }); + + // IAPKit deploys from main while this build is frozen inside a published + // app, so a value it adds later must degrade rather than fail a purchase + // the store already confirmed. `isValid` stays authoritative throughout. + test('never fails a receipt over metadata this build predates', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'verifyPurchaseWithProvider': + return { + 'provider': 'iapkit', + 'iapkit': { + 'isValid': true, + 'productId': 'premium.monthly', + 'state': 'grace-period', + 'store': 'apple', + 'environment': 'Xcode', + 'clientPayload': { + 'format': 'yaml', + 'body': 'tier: gold', + 'version': 2, + 'updatedAt': 1720000000000, + }, + }, + }; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + await iap.initConnection(); + + final result = await iap.verifyPurchaseWithProvider( + provider: types.PurchaseVerificationProvider.Iapkit, + iapkit: const types.RequestVerifyPurchaseWithIapkitProps( + apiKey: 'test-api-key', + includeClientPayload: true, + apple: types.RequestVerifyPurchaseWithIapkitAppleProps( + jws: 'test-jws-token', + ), + ), + ); + + expect(result.iapkit!.isValid, isTrue); + expect(result.iapkit!.productId, 'premium.monthly'); + // An unknown state degrades to the neutral member, an unknown format + // drops only the optional payload, and an open-string environment is + // forwarded untouched. + expect(result.iapkit!.state, types.IapkitPurchaseState.Unknown); + expect(result.iapkit!.clientPayload, isNull); + expect(result.iapkit!.environment, 'Xcode'); }); }); } diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 0b60a5973..96795237a 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -2755,7 +2755,7 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: IapStore - ## 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. + ## 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. var environment: Variant = null ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. var is_valid: bool = false diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt index e58e63f07..e9f14f002 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt @@ -55,6 +55,10 @@ import io.github.hyochan.kmpiap.openiap.RequestPurchaseProps import io.github.hyochan.kmpiap.openiap.RequestPurchaseResult import io.github.hyochan.kmpiap.openiap.RequestPurchaseResultPurchase import io.github.hyochan.kmpiap.openiap.RequestPurchaseResultPurchases +import io.github.hyochan.kmpiap.openiap.IapStore +import io.github.hyochan.kmpiap.openiap.IapkitClientPayloadFormat +import io.github.hyochan.kmpiap.openiap.IapkitProductClientPayload +import io.github.hyochan.kmpiap.openiap.IapkitPurchaseState import io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitResult import io.github.hyochan.kmpiap.openiap.SubscriptionStatusIOS import io.github.hyochan.kmpiap.openiap.UserChoiceBillingDetails @@ -250,20 +254,33 @@ internal class AmazonInAppPurchaseAndroid( ) } val androidResult = verifyPurchaseWithIapkitAndroid(androidOptions, "kmp-iap-android-$storeName") - // The generated decoder throws on a value this module's Types.kt - // predates. openiap-google already produced a safe result, so a decode - // miss must surface as a typed error rather than escaping this suspend - // function as a raw IllegalArgumentException. - val iapkitResult = runCatching { - RequestVerifyPurchaseWithIapkitResult.fromJson(androidResult.toJson()) - }.getOrElse { - failWith( - PurchaseError( - code = ErrorCode.PurchaseVerificationFailed, - message = "IAPKit returned a verification result this build cannot read" - ) - ) - } + // Mapped field by field rather than round-tripped through the generated + // fromJson, which throws on a clientPayload format this module's + // Types.kt predates. openiap-google has already decoded everything + // safely; optional metadata must degrade, not take the receipt down. + val iapkitResult = RequestVerifyPurchaseWithIapkitResult( + clientPayload = androidResult.clientPayload?.let { payload -> + IapkitClientPayloadFormat.entries + .firstOrNull { it.rawValue == payload.format.toJson() } + ?.let { format -> + IapkitProductClientPayload( + body = payload.body, + format = format, + updatedAt = payload.updatedAt, + version = payload.version + ) + } + }, + environment = androidResult.environment, + isValid = androidResult.isValid, + productId = androidResult.productId, + state = runCatching { + IapkitPurchaseState.fromJson(androidResult.state.toJson()) + }.getOrDefault(IapkitPurchaseState.Unknown), + store = runCatching { + IapStore.fromJson(androidResult.store.toJson()) + }.getOrDefault(IapStore.Unknown) + ) return VerifyPurchaseWithProviderResult( iapkit = iapkitResult, provider = options.provider diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt index a99298350..dd26a4de8 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt @@ -26,6 +26,8 @@ class IapkitBaseUrlBridgeTest { assertTrue(androidSource.contains("expectedProductId = amazon.expectedProductId")) assertTrue(androidSource.contains("environment = androidResult.environment")) assertTrue(iosSource.contains("environment = environment")) - assertTrue(iosSource.contains("\"Sandbox\", \"Production\"")) + // Forwarded opaquely: `environment` is String in the spec, so narrowing + // it here would fail a receipt IAPKit already confirmed. + assertTrue(iosSource.contains("map[\"environment\"] as? String")) } } diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index 1b869d7c3..295f1e5c5 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -3448,6 +3448,12 @@ public data class 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. */ var environment: String? = null private set diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index 89dc753c5..0c6d8a4c1 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -3520,6 +3520,12 @@ public sealed record 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. /// [JsonPropertyName("environment")] public string? Environment { get; init; } diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 29d5ca10f..132105b27 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -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); /** diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index fa039e64c..c8fbe8c0e 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -1239,6 +1239,12 @@ public struct RequestVerifyPurchaseWithIapkitResult: Codable { /// 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. public var environment: String? = nil /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift index 5840e35dd..313b8d48f 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -165,10 +165,20 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { XCTAssertEqual("POST", request.httpMethod) XCTAssertEqual("application/json", request.value(forHTTPHeaderField: "Content-Type")) XCTAssertEqual("Bearer iapkit_pk_test", request.value(forHTTPHeaderField: "Authorization")) - XCTAssertEqual( - OpenIapVersion.specVersionIfAvailable, - request.value(forHTTPHeaderField: "X-OpenIAP-Spec") - ) + // The header is present exactly when the version resolves, and carries a + // semver when it does. It cannot be asserted unconditionally: SwiftPM + // copies `Sources/openiap-versions.json` as the symlink it is, which + // dangles inside the built bundle, so `Bundle.module` finds nothing + // under `swift test`. That is why the accessor is optional and the + // header is omitted rather than trapping. + let header = request.value(forHTTPHeaderField: "X-OpenIAP-Spec") + XCTAssertEqual(OpenIapVersion.specVersionIfAvailable, header) + if let header { + XCTAssertNotNil( + header.range(of: #"^\d+\.\d+\.\d+"#, options: .regularExpression), + "X-OpenIAP-Spec must carry a semver, got \(header)" + ) + } XCTAssertEqual(Data("{}".utf8), request.httpBody) } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index 0d7cb5ef1..2dbdca7fe 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -3500,6 +3500,12 @@ public data class 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. */ var environment: String? = null private set diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index 89dc753c5..0c6d8a4c1 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -3520,6 +3520,12 @@ public sealed record 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. /// [JsonPropertyName("environment")] public string? Environment { get; init; } diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index c31510286..5ed78d22d 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -3446,6 +3446,12 @@ public data class 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. */ var environment: String? = null private set diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index fa039e64c..c8fbe8c0e 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -1239,6 +1239,12 @@ public struct RequestVerifyPurchaseWithIapkitResult: Codable { /// 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. public var environment: String? = nil /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 94fcbe735..7f247628b 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -3353,6 +3353,12 @@ class 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. final String? environment; /// True when the purchase is valid and actionable. /// Only entitled, pending-acknowledgment, or ready-to-consume return true. diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 0b60a5973..96795237a 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -2755,7 +2755,7 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: IapStore - ## 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. + ## 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. var environment: Variant = null ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. var is_valid: bool = false diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 29d5ca10f..132105b27 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -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); /** diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index 43ea47dfc..c223754a4 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -434,6 +434,12 @@ type 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 """ From 6f810adf5acebb45774cfe3d68251d53f8f8eabc Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 12:40:49 +0900 Subject: [PATCH 08/21] fix: pin the guards the second review round found unpinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 500-path outcome reset replaced the whole outcome, dropping `stableRejection` β€” the store's own provenance, which the replay guard needs to arm its cooldown for a genuinely revoked receipt. It now resets only the reported verdict and keeps that provenance. Three strings that must agree across boundaries had no test. The malformed-verdict log line is now asserted from the captured stdout record, so the fix above cannot silently regress. `X-OpenIAP-Spec` is driven through the middleware from the wire, so the one name shared by kit, openiap-apple and openiap-google is pinned on all three sides. The Vega parity needles now include the line that actually puts `environment` on the returned object, matching the end-to-end proof the other five platforms already carried. The audit anchored against raw source, so a comment added between `v.object({` and `format:` would have made the anchor miss and blocked the kit deploy over a documentation edit β€” comments are now stripped before anchoring, with a test. Its parsers signal drift by throwing, and those throws bypassed the operator guidance the audit prints; they are caught and reported as failures instead. Android dropped an unreadable environment silently while Apple logged it, and a lambda parameter shadowed its enclosing function parameter. Co-Authored-By: Claude Opus 5 (1M context) --- .../utils/PurchaseVerificationValidator.kt | 5 ++- .../kit/server/api/v1/request-logger.test.ts | 40 +++++++++++++++++++ packages/kit/server/api/v1/routes.test.ts | 30 +++++++++++++- packages/kit/server/api/v1/routes.ts | 10 ++++- scripts/audit-kit-spec-contract.mjs | 16 +++++++- scripts/audit-kit-spec-contract.test.mjs | 16 ++++++++ scripts/audit-non-godot-parity.mjs | 24 ++++++++--- 7 files changed, 130 insertions(+), 11 deletions(-) diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt index e59a764f2..02e3152fa 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt @@ -198,7 +198,7 @@ suspend fun verifyPurchaseWithIapkit( // Derived from the generated enum rather than a literal set, so a // format added to the spec is readable as soon as Types.kt regenerates. val format = (payload["format"] as? String) - ?.let { raw -> IapkitClientPayloadFormat.entries.firstOrNull { it.rawValue == raw } } + ?.let { rawFormat -> IapkitClientPayloadFormat.entries.firstOrNull { it.rawValue == rawFormat } } ?: return unreadableIapkitClientPayload() val body = payload["body"] as? String ?: return unreadableIapkitClientPayload() @@ -413,6 +413,9 @@ suspend fun verifyPurchaseWithIapkit( // re-deriving it here would only let a value IAPKit adds later // fail a receipt the store already confirmed. val environment = (parsed["environment"] as? String)?.takeIf { it.isNotEmpty() } + if (environment == null && parsed["environment"] != null) { + OpenIapLog.warn("Ignoring an IAPKit environment this build cannot read", tag) + } return RequestVerifyPurchaseWithIapkitResult( clientPayload = clientPayload, diff --git a/packages/kit/server/api/v1/request-logger.test.ts b/packages/kit/server/api/v1/request-logger.test.ts index 48230cd10..37ad4386f 100644 --- a/packages/kit/server/api/v1/request-logger.test.ts +++ b/packages/kit/server/api/v1/request-logger.test.ts @@ -102,6 +102,46 @@ describe("requestLoggerMiddleware", () => { expect(line.durationMs).toBeGreaterThanOrEqual(0); }); + test("carries the client spec version from the wire onto the log line", async () => { + // `X-OpenIAP-Spec` has to agree across three codebases; the clients pin + // their side, so kit pins the exact name it reads here. + const logs: VerifyLogLine[] = []; + const app = buildApp({ logs }); + + await app.request("/verify", { + method: "POST", + headers: { + Authorization: "Bearer spec-header-key", + "content-type": "application/json", + "X-OpenIAP-Spec": "3.2.0", + }, + body: JSON.stringify({ store: "apple", jws: TEST_APPLE_JWS }), + }); + + expect(logs[0]?.specVersion).toBe("3.2.0"); + }); + + test("omits a spec version the shape check rejects", async () => { + const logs: VerifyLogLine[] = []; + const app = buildApp({ logs }); + + await app.request("/verify", { + method: "POST", + headers: { + Authorization: "Bearer spec-header-junk", + "content-type": "application/json", + // The runtime rejects a header carrying a newline before kit sees it, + // so log-injection shapes are covered by the readSpecVersion unit test + // above; this pins what actually reaches the middleware. + "X-OpenIAP-Spec": "latest", + }, + body: JSON.stringify({ store: "apple", jws: TEST_APPLE_JWS }), + }); + + expect(logs[0]).toBeDefined(); + expect(logs[0]?.specVersion).toBeUndefined(); + }); + test("records a plausible client spec version and ignores anything else", async () => { // Caller-controlled, so it is shape-checked and bounded before it reaches // a log line. Nothing branches on it β€” an SDK must not be able to change diff --git a/packages/kit/server/api/v1/routes.test.ts b/packages/kit/server/api/v1/routes.test.ts index 8acbaa308..ec6e0684a 100644 --- a/packages/kit/server/api/v1/routes.test.ts +++ b/packages/kit/server/api/v1/routes.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getFunctionName } from "convex/server"; vi.mock("hono/bun", () => ({ @@ -19,10 +19,29 @@ vi.mock("../../convex", () => ({ const { apiRoutes } = await import("./routes"); describe("apiRoutes", () => { + // The request logger writes one JSON line per request to stdout; capturing it + // is the only way to assert what an operator actually sees. + let logLines: Array>; + let logSpy: ReturnType; + beforeEach(() => { convexClientMock.action.mockReset(); convexClientMock.mutation.mockReset(); convexClientMock.query.mockReset(); + logLines = []; + logSpy = vi.spyOn(console, "log").mockImplementation((...args) => { + const [first] = args; + if (typeof first !== "string") return; + try { + logLines.push(JSON.parse(first) as Record); + } catch { + // Not a structured line; ignore. + } + }); + }); + + afterEach(() => { + logSpy.mockRestore(); }); it("serves the generated OpenAPI specification", async () => { @@ -527,6 +546,15 @@ describe("apiRoutes", () => { expect(await response.json()).toMatchObject({ errors: [{ code: "UNKNOWN_ERROR" }], }); + // The client saw a failure, so the structured log must not still report the + // verdict this handler computed β€” otherwise the incident is invisible in + // any success-rate view built on `isValid`. + const verifyLine = logLines.find((line) => line.kind === "verify_request"); + expect(verifyLine).toMatchObject({ + statusCode: 500, + isValid: false, + state: "UNKNOWN", + }); }); it("drops an environment value no shipped SDK accepts", async () => { diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index becb095a7..6584a28cd 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -470,8 +470,14 @@ const verifyPurchaseHandler = async ( } if (!contract.ok) { // The client is about to see a 500, so the request log must not keep - // reporting the verdict this handler computed. - setOutcome({ isValid: false, state: FALLBACK_PURCHASE_STATE }); + // reporting the verdict this handler computed. `stableRejection` is + // carried over: it is the store's own provenance, and dropping it would + // disarm the replay guard's cooldown for a genuinely revoked receipt. + setOutcome({ + ...outcome, + isValid: false, + state: FALLBACK_PURCHASE_STATE, + }); const errorId = crypto.randomUUID(); console.error( "Unexpected error (%s) when verifying purchase: malformed verdict", diff --git a/scripts/audit-kit-spec-contract.mjs b/scripts/audit-kit-spec-contract.mjs index 101621c05..ddcb71c85 100644 --- a/scripts/audit-kit-spec-contract.mjs +++ b/scripts/audit-kit-spec-contract.mjs @@ -139,8 +139,12 @@ const compare = (label, expected, actual) => { export const collectContractFailures = ({ schema = read(SCHEMA_FILE), convexState = read(CONVEX_STATE_FILE), - responseSchema = read(RESPONSE_SCHEMA_FILE), + responseSchema: rawResponseSchema = read(RESPONSE_SCHEMA_FILE), } = {}) => { + // Anchors match raw text, so comments are removed before anchoring too: + // otherwise a comment added between `v.object({` and `format:` would make an + // anchor miss and block the deploy gate over a documentation edit. + const responseSchema = stripComments(rawResponseSchema); const specStates = parseGraphqlEnum(schema, "IapkitPurchaseState"); // GraphQL members are PascalCase for these two; the wire values are lowercase. const specFormats = parseGraphqlEnum(schema, "IapkitClientPayloadFormat").map( @@ -183,7 +187,15 @@ export const collectContractFailures = ({ }; export const runAudit = () => { - const failures = collectContractFailures(); + // The hardened parsers signal drift by throwing, so those cases have to reach + // the guidance below rather than surfacing as a bare stack trace β€” in + // deploy-kit.yml this message is what a blocked operator reads. + let failures; + try { + failures = collectContractFailures(); + } catch (error) { + failures = [`could not read a declaration: ${error.message}`]; + } if (failures.length > 0) { console.error("IAPKit spec contract audit failed:\n"); for (const failure of failures) console.error(`- ${failure}`); diff --git a/scripts/audit-kit-spec-contract.test.mjs b/scripts/audit-kit-spec-contract.test.mjs index b81d65829..fd087c1d3 100644 --- a/scripts/audit-kit-spec-contract.test.mjs +++ b/scripts/audit-kit-spec-contract.test.mjs @@ -142,6 +142,22 @@ test("a commented-out literal is not counted", () => { assert.match(failures[0], /clientPayload format.*missing.*json/); }); +test("a comment above the anchored field does not break the audit", () => { + // The audit gates the kit deploy, so a documentation edit on the very + // declaration it reads must not block a release. + assert.deepEqual( + collectContractFailures( + sources({ + responseSchema: RESPONSE_SCHEMA.replace( + "const clientPayloadSchema = v.object({\n format:", + "const clientPayloadSchema = v.object({\n // public app data\n format:", + ), + }), + ), + [], + ); +}); + test("a declaration that parses to nothing is a failure, not agreement", () => { assert.throws( () => diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index cfb4bae3a..458a34d8d 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -1645,7 +1645,10 @@ function checkConformanceSuite() { execFileSync( process.execPath, [ - path.resolve(root, "packages/conformance/scripts/generate-behavior-ids.mjs"), + path.resolve( + root, + "packages/conformance/scripts/generate-behavior-ids.mjs", + ), "--check", ], { stdio: "pipe" }, @@ -1686,7 +1689,8 @@ function checkConformanceNotPublished() { ); } - const rnFiles = readJson("libraries/react-native-iap/package.json").files ?? []; + const rnFiles = + readJson("libraries/react-native-iap/package.json").files ?? []; if (!rnFiles.includes("!**/__tests__")) { fail( 'libraries/react-native-iap/package.json "files" must keep "!**/__tests__" so conformance fixtures are not published', @@ -1695,8 +1699,13 @@ function checkConformanceNotPublished() { // The podspec ships Sources only; Tests holds the Apple conformance suite. const podspec = read("packages/apple/openiap.podspec"); - if (!/source_files\s*=.*Sources/.test(podspec) || /source_files\s*=.*Tests/.test(podspec)) { - fail("packages/apple/openiap.podspec must publish Sources only, never Tests"); + if ( + !/source_files\s*=.*Sources/.test(podspec) || + /source_files\s*=.*Tests/.test(podspec) + ) { + fail( + "packages/apple/openiap.podspec must publish Sources only, never Tests", + ); } // conformanceTest belongs to unit-test variants; wiring it into a shipped @@ -2544,7 +2553,10 @@ function checkIapkitAmazonContractWiring() { for (const [file, needles, label] of [ [ "libraries/react-native-iap/ios/HybridRnIap.swift", - ['amazonDict["expectedProductId"]', "environment: RnIapHelper.wrapString"], + [ + 'amazonDict["expectedProductId"]', + "environment: RnIapHelper.wrapString", + ], "React Native iOS IAPKit bridge", ], [ @@ -2557,6 +2569,7 @@ function checkIapkitAmazonContractWiring() { [ "expectedProductId: amazon.expectedProductId", "const rawEnvironment = json.environment", + "...(environment == null ? {} : {environment})", ], "React Native Vega IAPKit bridge", ], @@ -2565,6 +2578,7 @@ function checkIapkitAmazonContractWiring() { [ "expectedProductId: amazon.expectedProductId", "const rawEnvironment = json.environment", + "...(environment == null ? {} : {environment})", ], "Expo Vega IAPKit bridge", ], From 60df30f33aba8ad85bf331a786ff9ddcdbb9133e Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 13:15:32 +0900 Subject: [PATCH 09/21] fix: trim comments and close the third review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments across the branch narrated the change and its reasoning at paragraph length, against the one-line default in AGENTS.md. The rationale belongs in these commit messages; what stays in the code is the constraint a reader cannot see. The Swift header test still compared the header against the accessor that produced it, and a skeptic confirmed by deletion that it passed with the header emission removed β€” under SwiftPM the accessor is nil, so it was nil == nil. The version is now injected, with both arms asserted. The 500-path outcome reset preserved an explicitly flagged rejection but still overwrote `state`, which the replay guard also derives stability from, so an INAUTHENTIC verdict that tripped the same path lost its cooldown. Stability is now resolved before the reset, and a test that fails without it drives the same payload twice for a 500 then a 429. kmp-iap's Amazon verify lost its error translation when the round-trip decoder was replaced, letting a raw Android OpenIapError escape a suspend function documented to signal through PurchaseException. Its mapping was also a byte-for-byte duplicate of the Play path; both now call one androidMain helper, pinned by the parity audit and the bridge test. Also: the audit's throw-to-guidance path has a test, and the published types page carries the forward-opaquely rule the spec docstring gained. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 3 +- .github/workflows/deploy-kit.yml | 4 +- .husky/pre-commit | 7 +--- .../expo-iap/src/__tests__/kit-api.test.ts | 6 +-- .../src/__tests__/vega-adapter.test.ts | 4 +- libraries/expo-iap/src/kit-api.ts | 7 +--- libraries/expo-iap/src/vega-adapter.ts | 5 +-- .../lib/flutter_inapp_purchase.dart | 13 ++---- .../flutter_inapp_purchase_channel_test.dart | 8 +--- .../kmpiap/AmazonInAppPurchaseAndroid.kt | 37 ++-------------- .../kotlin/io/github/hyochan/kmpiap/Helper.kt | 35 ++++++++++++++++ .../hyochan/kmpiap/InAppPurchaseAndroid.kt | 32 +------------- .../hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt | 12 ++++-- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 10 +---- .../src/__tests__/kit-api.test.ts | 6 +-- .../src/__tests__/vega-adapter.test.ts | 4 +- libraries/react-native-iap/src/kit-api.ts | 7 +--- .../react-native-iap/src/vega-adapter.ts | 5 +-- packages/apple/Sources/OpenIapModule.swift | 29 ++++++------- packages/apple/Sources/OpenIapVersion.swift | 8 ++-- .../VerifyPurchaseWithProviderTests.swift | 40 +++++++++--------- .../verify-purchase-with-provider-result.tsx | 4 +- packages/google/openiap/build.gradle.kts | 6 +-- .../utils/PurchaseVerificationValidator.kt | 17 ++------ .../PurchaseVerificationValidatorTest.kt | 8 +--- packages/gql/src/kit-api.ts | 7 +--- packages/kit/convex/purchases/shared.test.ts | 18 +++----- .../kit/server/api/v1/request-logger.test.ts | 12 ++---- packages/kit/server/api/v1/request-logger.ts | 8 ++-- .../server/api/v1/response-contract.test.ts | 6 +-- .../kit/server/api/v1/response-contract.ts | 6 +-- .../server/api/v1/route-response-schemas.ts | 5 +-- packages/kit/server/api/v1/routes.test.ts | 42 +++++++++++++++---- packages/kit/server/api/v1/routes.ts | 16 +++---- scripts/audit-kit-spec-contract.mjs | 42 ++++++------------- scripts/audit-kit-spec-contract.test.mjs | 26 +++++++++--- scripts/audit-non-godot-parity.mjs | 22 +++++++++- 37 files changed, 233 insertions(+), 294 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1992f06f7..66948a37b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,8 +212,7 @@ jobs: - name: Run non-Godot SDK parity audit run: node scripts/audit-non-godot-parity.mjs - # Unconditional on purpose: IAPKit and the spec deploy on separate - # workflows, so this has to run whichever side of the contract moved. + # 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 diff --git a/.github/workflows/deploy-kit.yml b/.github/workflows/deploy-kit.yml index 1ef18ac36..e468a566d 100644 --- a/.github/workflows/deploy-kit.yml +++ b/.github/workflows/deploy-kit.yml @@ -72,9 +72,7 @@ jobs: exit 1 fi - # This workflow is what actually ships kit to every already-published - # app, so the response-contract guard has to gate it here β€” ci.yml runs - # independently and can still be red when this job's deploy gate opens. + # 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 diff --git a/.husky/pre-commit b/.husky/pre-commit index a205b273a..b3840f324 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -52,12 +52,7 @@ fi echo "πŸ”Ž SDK parity audit β€” running CI mirror…" node scripts/audit-non-godot-parity.mjs -# IAPKit's purchase-state, client-payload-format, and verify-store enums are -# declared once in kit's Convex layer, once in its OpenAPI response docs, and -# once in the GraphQL schema every SDK generates from. kit deploys from main on -# its own workflow, so drift here reaches published apps without an SDK -# release. Unconditional for the same reason the parity audit is: either side -# of the contract can move. +# 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 diff --git a/libraries/expo-iap/src/__tests__/kit-api.test.ts b/libraries/expo-iap/src/__tests__/kit-api.test.ts index 9e8d4dd50..6fac261ff 100644 --- a/libraries/expo-iap/src/__tests__/kit-api.test.ts +++ b/libraries/expo-iap/src/__tests__/kit-api.test.ts @@ -344,10 +344,8 @@ describe('kitApi cache resilience', () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); - // IAPKit can add a client-payload format from a main deploy. Rejecting the - // cached entry would evict it, stop the ETag revalidation from ever being - // sent, and leave offline reads with nothing β€” for a value the live path - // hands back to the caller unchanged anyway. + // 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 stored = { clientPayload: {format: 'yaml', body: 'tier: gold', version: 2, updatedAt: 9}, diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index 31579bff6..2dbf6c422 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -1635,9 +1635,7 @@ describe('Amazon Vega Expo adapter', () => { } }); - // `environment` is an open String in the spec, so a value IAPKit adds later - // ("Xcode" and "LocalTesting" are real App Store Server environments) is - // forwarded, and only a non-string is dropped. Neither fails the receipt. + // Forwarded opaquely; only a non-string is dropped. Neither fails. it.each([ {environment: 'Xcode', expected: 'Xcode'}, {environment: 'LocalTesting', expected: 'LocalTesting'}, diff --git a/libraries/expo-iap/src/kit-api.ts b/libraries/expo-iap/src/kit-api.ts index f20089075..a76973d41 100644 --- a/libraries/expo-iap/src/kit-api.ts +++ b/libraries/expo-iap/src/kit-api.ts @@ -283,11 +283,8 @@ export function kitApi(options: KitApiOptions) { if (!raw) return null; const candidate = JSON.parse(raw) as Partial; const payload = candidate.clientPayload; - // Only the invariants the cache itself depends on. `format` is opaque - // here: rejecting a format IAPKit added later would evict the entry, - // stop the ETag revalidation below from ever being sent, and leave - // offline reads with nothing β€” for a value the live path passes through - // to the caller unchanged anyway. + // Only the invariants the cache depends on. `format` is opaque: evicting + // on an unknown one would kill ETag revalidation and offline reads. if ( !payload || typeof payload.format !== "string" || diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts index 437bca95c..0836781e5 100644 --- a/libraries/expo-iap/src/vega-adapter.ts +++ b/libraries/expo-iap/src/vega-adapter.ts @@ -1177,10 +1177,7 @@ export function createExpoIapVegaModule( `IAPKit returned malformed response (HTTP ${status}).`, ); } - // `environment` is String in the spec, not an enum: the - // Sandbox/Production pair is IAPKit's constraint to enforce, and - // re-deriving it here would only let a value IAPKit adds later fail a - // receipt the store already confirmed. + // Forwarded opaquely: `environment` is String in the spec. const rawEnvironment = json.environment; const environment = typeof rawEnvironment === 'string' && rawEnvironment.length > 0 diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 101056f3a..bf0c2e61f 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -1921,10 +1921,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); } - // `environment` is String in the spec, not an enum. IAPKit - // owns the Sandbox/Production constraint and the native layer - // has already applied it; re-deriving it here would only let a - // value IAPKit adds later fail a confirmed purchase. + // Forwarded opaquely: `environment` is String in the spec. final environmentValue = itemMap['environment']; final environment = environmentValue is String && environmentValue.isNotEmpty @@ -1954,10 +1951,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { final updatedAt = updatedAtValue is num ? updatedAtValue.toDouble() : double.nan; - // Optional enrichment: a payload this build cannot read β€” - // including one using a format added after it shipped β€” is - // dropped, never thrown. Receipt verification is the security - // boundary; losing metadata must not fail a paid purchase. + // Optional enrichment: dropped, never thrown. if (format is String && body is String && version.isFinite && @@ -1985,8 +1979,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { state.toString(), ); } on ArgumentError { - // A state IAPKit added after this build shipped. `isValid` - // stays authoritative; only the label degrades. + // A state added after this build shipped; `isValid` stands. return gentype.IapkitPurchaseState.Unknown; } } diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index 6ff248b8f..0a8b6f966 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -3184,9 +3184,7 @@ void main() { expect(result.iapkit!.environment, isNull); }); - // IAPKit deploys from main while this build is frozen inside a published - // app, so a value it adds later must degrade rather than fail a purchase - // the store already confirmed. `isValid` stays authoritative throughout. + // A value IAPKit adds later must degrade, not fail the purchase. test('never fails a receipt over metadata this build predates', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { @@ -3232,9 +3230,7 @@ void main() { expect(result.iapkit!.isValid, isTrue); expect(result.iapkit!.productId, 'premium.monthly'); - // An unknown state degrades to the neutral member, an unknown format - // drops only the optional payload, and an open-string environment is - // forwarded untouched. + // Unknown state degrades, unknown format drops, environment forwards. expect(result.iapkit!.state, types.IapkitPurchaseState.Unknown); expect(result.iapkit!.clientPayload, isNull); expect(result.iapkit!.environment, 'Xcode'); diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt index e9f14f002..08dfd153f 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt @@ -55,11 +55,6 @@ import io.github.hyochan.kmpiap.openiap.RequestPurchaseProps import io.github.hyochan.kmpiap.openiap.RequestPurchaseResult import io.github.hyochan.kmpiap.openiap.RequestPurchaseResultPurchase import io.github.hyochan.kmpiap.openiap.RequestPurchaseResultPurchases -import io.github.hyochan.kmpiap.openiap.IapStore -import io.github.hyochan.kmpiap.openiap.IapkitClientPayloadFormat -import io.github.hyochan.kmpiap.openiap.IapkitProductClientPayload -import io.github.hyochan.kmpiap.openiap.IapkitPurchaseState -import io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitResult import io.github.hyochan.kmpiap.openiap.SubscriptionStatusIOS import io.github.hyochan.kmpiap.openiap.UserChoiceBillingDetails import io.github.hyochan.kmpiap.openiap.VerifyPurchaseProps @@ -253,34 +248,10 @@ internal class AmazonInAppPurchaseAndroid( ) ) } - val androidResult = verifyPurchaseWithIapkitAndroid(androidOptions, "kmp-iap-android-$storeName") - // Mapped field by field rather than round-tripped through the generated - // fromJson, which throws on a clientPayload format this module's - // Types.kt predates. openiap-google has already decoded everything - // safely; optional metadata must degrade, not take the receipt down. - val iapkitResult = RequestVerifyPurchaseWithIapkitResult( - clientPayload = androidResult.clientPayload?.let { payload -> - IapkitClientPayloadFormat.entries - .firstOrNull { it.rawValue == payload.format.toJson() } - ?.let { format -> - IapkitProductClientPayload( - body = payload.body, - format = format, - updatedAt = payload.updatedAt, - version = payload.version - ) - } - }, - environment = androidResult.environment, - isValid = androidResult.isValid, - productId = androidResult.productId, - state = runCatching { - IapkitPurchaseState.fromJson(androidResult.state.toJson()) - }.getOrDefault(IapkitPurchaseState.Unknown), - store = runCatching { - IapStore.fromJson(androidResult.store.toJson()) - }.getOrDefault(IapStore.Unknown) - ) + val androidResult = withMappedOpenIapError { + verifyPurchaseWithIapkitAndroid(androidOptions, "kmp-iap-android-$storeName") + } + val iapkitResult = androidResult.toKmpIapkitResult() return VerifyPurchaseWithProviderResult( iapkit = iapkitResult, provider = options.provider diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt index 4c24357c0..8c0f85eb6 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt @@ -16,6 +16,11 @@ import io.github.hyochan.kmpiap.openiap.ExternalLinkLaunchModeAndroid import io.github.hyochan.kmpiap.openiap.ExternalLinkTypeAndroid import io.github.hyochan.kmpiap.openiap.IapPlatform import io.github.hyochan.kmpiap.openiap.IapStore +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitResult as AndroidRequestVerifyPurchaseWithIapkitResult +import io.github.hyochan.kmpiap.openiap.IapkitClientPayloadFormat +import io.github.hyochan.kmpiap.openiap.IapkitProductClientPayload +import io.github.hyochan.kmpiap.openiap.IapkitPurchaseState +import io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitResult import io.github.hyochan.kmpiap.openiap.InstallmentPlanDetailsAndroid import io.github.hyochan.kmpiap.openiap.LaunchExternalLinkParamsAndroid import io.github.hyochan.kmpiap.openiap.LimitedQuantityInfoAndroid @@ -774,3 +779,33 @@ internal fun LaunchExternalLinkParamsAndroid.toOpenIapParams(): OpenIapLaunchExt linkType = linkType.toOpenIapLinkType(), linkUri = linkUri ) + +/** + * Re-shapes an openiap-google IAPKit result into this module's generated types. + * openiap-google has already decoded it safely, so unknown values degrade here + * rather than re-imposing a fail-closed gate when the two versions drift. + */ +internal fun AndroidRequestVerifyPurchaseWithIapkitResult.toKmpIapkitResult(): RequestVerifyPurchaseWithIapkitResult = + RequestVerifyPurchaseWithIapkitResult( + clientPayload = clientPayload?.let { payload -> + IapkitClientPayloadFormat.entries + .firstOrNull { it.rawValue == payload.format.toJson() } + ?.let { format -> + IapkitProductClientPayload( + body = payload.body, + format = format, + updatedAt = payload.updatedAt, + version = payload.version + ) + } + }, + environment = environment, + isValid = isValid, + productId = productId, + state = runCatching { + IapkitPurchaseState.fromJson(state.toJson()) + }.getOrDefault(IapkitPurchaseState.Unknown), + store = runCatching { + IapStore.fromJson(store.toJson()) + }.getOrDefault(IapStore.Unknown) + ) 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 8d7f31351..a3858c469 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 @@ -87,11 +87,6 @@ import io.github.hyochan.kmpiap.openiap.VerifyPurchaseResultAndroid import io.github.hyochan.kmpiap.openiap.VerifyPurchaseResultIOS import io.github.hyochan.kmpiap.openiap.PurchaseIOS import io.github.hyochan.kmpiap.openiap.PurchaseVerificationProvider -import io.github.hyochan.kmpiap.openiap.RequestVerifyPurchaseWithIapkitResult -import io.github.hyochan.kmpiap.openiap.IapStore -import io.github.hyochan.kmpiap.openiap.IapkitClientPayloadFormat -import io.github.hyochan.kmpiap.openiap.IapkitPurchaseState -import io.github.hyochan.kmpiap.openiap.IapkitProductClientPayload import io.github.hyochan.kmpiap.openiap.BillingChoiceImageLayoutAndroid import io.github.hyochan.kmpiap.openiap.BillingChoiceInfoAndroid import io.github.hyochan.kmpiap.openiap.BillingChoiceScreenTypeAndroid @@ -2211,32 +2206,7 @@ internal class InAppPurchaseAndroid( val androidResult = verifyPurchaseWithIapkitAndroid(openIapProps, "kmp-iap-android") - // openiap-google has already decoded these safely; re-mapping them - // through this module's own generated enums must not re-impose a - // fail-closed gate when the two versions drift apart. - val iapkitResult = RequestVerifyPurchaseWithIapkitResult( - clientPayload = androidResult.clientPayload?.let { payload -> - val format = IapkitClientPayloadFormat.entries - .firstOrNull { it.rawValue == payload.format.toJson() } - format?.let { - IapkitProductClientPayload( - body = payload.body, - format = it, - updatedAt = payload.updatedAt, - version = payload.version - ) - } - }, - environment = androidResult.environment, - isValid = androidResult.isValid, - productId = androidResult.productId, - state = runCatching { - IapkitPurchaseState.fromJson(androidResult.state.toJson()) - }.getOrDefault(IapkitPurchaseState.Unknown), - store = runCatching { - IapStore.fromJson(androidResult.store.toJson()) - }.getOrDefault(IapStore.Unknown) - ) + val iapkitResult = androidResult.toKmpIapkitResult() VerifyPurchaseWithProviderResult( iapkit = iapkitResult, diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt index dd26a4de8..c51bafe67 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/IapkitBaseUrlBridgeTest.kt @@ -23,11 +23,17 @@ class IapkitBaseUrlBridgeTest { "src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt" ).readText() + val helperSource = File( + "src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt" + ).readText() + assertTrue(androidSource.contains("expectedProductId = amazon.expectedProductId")) - assertTrue(androidSource.contains("environment = androidResult.environment")) + assertTrue(androidSource.contains("androidResult.toKmpIapkitResult()")) + assertTrue(helperSource.contains("environment = environment")) + // Unknown values degrade; openiap-google already decoded them safely. + assertTrue(helperSource.contains("getOrDefault(IapkitPurchaseState.Unknown)")) assertTrue(iosSource.contains("environment = environment")) - // Forwarded opaquely: `environment` is String in the spec, so narrowing - // it here would fail a receipt IAPKit already confirmed. + // Forwarded opaquely: `environment` is String in the spec. assertTrue(iosSource.contains("map[\"environment\"] as? String")) } } diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 8a237418d..079d24157 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -1071,15 +1071,9 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { is String -> rawProductId else -> throw IllegalArgumentException("IAPKit result productId must be a string") } - // `environment` is String in the spec, not an enum, and - // packages/apple has already applied IAPKit's own - // constraint. Re-deriving it here would only let a value - // IAPKit adds later fail a confirmed purchase. + // Forwarded opaquely: `environment` is String in the spec. val environment = (map["environment"] as? String)?.takeIf { it.isNotEmpty() } - // Optional enrichment: a payload this build cannot read β€” - // including a format added after it shipped β€” is dropped, - // never thrown. Receipt verification is the security - // boundary; losing metadata must not fail a paid purchase. + // Optional enrichment: dropped, never thrown. val clientPayload = (map["clientPayload"] as? Map<*, *>)?.let { rawClientPayload -> val payload = rawClientPayload.mapKeys { it.key.toString() } val format = (payload["format"] as? String) diff --git a/libraries/react-native-iap/src/__tests__/kit-api.test.ts b/libraries/react-native-iap/src/__tests__/kit-api.test.ts index 7a9c0a460..83d843895 100644 --- a/libraries/react-native-iap/src/__tests__/kit-api.test.ts +++ b/libraries/react-native-iap/src/__tests__/kit-api.test.ts @@ -348,10 +348,8 @@ describe('kitApi cache resilience', () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); - // IAPKit can add a client-payload format from a main deploy. Rejecting the - // cached entry would evict it, stop the ETag revalidation from ever being - // sent, and leave offline reads with nothing β€” for a value the live path - // hands back to the caller unchanged anyway. + // 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 stored = { clientPayload: {format: 'yaml', body: 'tier: gold', version: 2, updatedAt: 9}, diff --git a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts index cbf1a4b32..fdb1131fa 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -1686,9 +1686,7 @@ describe('Amazon Vega adapter', () => { } }); - // `environment` is an open String in the spec, so a value IAPKit adds later - // ("Xcode" and "LocalTesting" are real App Store Server environments) is - // forwarded, and only a non-string is dropped. Neither fails the receipt. + // Forwarded opaquely; only a non-string is dropped. Neither fails. it.each([ {environment: 'Xcode', expected: 'Xcode'}, {environment: 'LocalTesting', expected: 'LocalTesting'}, diff --git a/libraries/react-native-iap/src/kit-api.ts b/libraries/react-native-iap/src/kit-api.ts index f20089075..a76973d41 100644 --- a/libraries/react-native-iap/src/kit-api.ts +++ b/libraries/react-native-iap/src/kit-api.ts @@ -283,11 +283,8 @@ export function kitApi(options: KitApiOptions) { if (!raw) return null; const candidate = JSON.parse(raw) as Partial; const payload = candidate.clientPayload; - // Only the invariants the cache itself depends on. `format` is opaque - // here: rejecting a format IAPKit added later would evict the entry, - // stop the ETag revalidation below from ever being sent, and leave - // offline reads with nothing β€” for a value the live path passes through - // to the caller unchanged anyway. + // Only the invariants the cache depends on. `format` is opaque: evicting + // on an unknown one would kill ETag revalidation and offline reads. if ( !payload || typeof payload.format !== "string" || diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index db589e1ad..ffd3d6743 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -1271,10 +1271,7 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { `IAPKit returned malformed response (HTTP ${status}).`, ); } - // `environment` is String in the spec, not an enum: the - // Sandbox/Production pair is IAPKit's constraint to enforce, and - // re-deriving it here would only let a value IAPKit adds later fail a - // receipt the store already confirmed. + // Forwarded opaquely: `environment` is String in the spec. const rawEnvironment = json.environment; const environment = typeof rawEnvironment === 'string' && rawEnvironment.length > 0 diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 2a8208fe3..7e6bae506 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -77,10 +77,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return url } - /// Optional public product metadata. Returns nil for anything this build - /// cannot represent β€” including a `format` added to IAPKit after this - /// version shipped. Receipt verification is the security boundary; losing - /// enrichment must never fail a purchase the store already confirmed. + /// Optional enrichment: returns nil for anything this build cannot read, + /// including a `format` IAPKit added later. Never fails the receipt. static func iapkitClientPayload(from rawValue: Any?) -> IapkitProductClientPayload? { guard let rawValue, !(rawValue is NSNull) else { return nil } guard let payload = rawValue as? [String: Any], @@ -108,16 +106,18 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { ) } - /// Builds the IAPKit verify request. `X-OpenIAP-Spec` tells the server - /// which response contract this build was compiled against, so an enum it - /// gains later can be rolled out against real client-version data instead - /// of a guess. It is reported, never negotiated: the header is omitted when - /// the version cannot be read, and the server must not gate on it. - static func makeIapkitRequest(url: URL, apiKey: String?, body: Data) -> URLRequest { + /// `X-OpenIAP-Spec` reports the response contract this build was compiled + /// against. Reported, never negotiated: omitted when unreadable. + static func makeIapkitRequest( + url: URL, + apiKey: String?, + body: Data, + specVersion: String? = OpenIapVersion.specVersionIfAvailable + ) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if let specVersion = OpenIapVersion.specVersionIfAvailable { + if let specVersion { request.setValue(specVersion, forHTTPHeaderField: "X-OpenIAP-Spec") } let trimmedApiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -140,11 +140,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return value.boolValue } - /// Optional store environment. `environment` is String in the spec, not an - /// enum: the Sandbox/Production pair is IAPKit's constraint to enforce, and - /// re-deriving it here would only let a value IAPKit adds later β€” App Store - /// Server also names `Xcode` and `LocalTesting` β€” fail a receipt the store - /// already confirmed. + /// Forwarded opaquely: `environment` is String in the spec, and App Store + /// Server also names `Xcode` and `LocalTesting`. static func iapkitEnvironment(from rawValue: Any?) -> String? { guard let rawValue, !(rawValue is NSNull) else { return nil } guard let environment = rawValue as? String, environment.isEmpty == false else { diff --git a/packages/apple/Sources/OpenIapVersion.swift b/packages/apple/Sources/OpenIapVersion.swift index 7d61c18d1..0d2db148d 100644 --- a/packages/apple/Sources/OpenIapVersion.swift +++ b/packages/apple/Sources/OpenIapVersion.swift @@ -14,11 +14,9 @@ public struct OpenIapVersion { version(for: "spec") } - /// Current OpenIAP specification version, or nil when the bundled - /// `openiap-versions.json` cannot be located. Callers on the purchase path - /// must use this rather than `specVersion`: the resource is bundled - /// differently by SwiftPM, CocoaPods and the xcframework, and reporting a - /// version is never worth trapping in the middle of a purchase. + /// Spec version, or nil when the bundled `openiap-versions.json` cannot be + /// located. Use this on the purchase path β€” `specVersion` traps, and the + /// resource bundles differently under SwiftPM, CocoaPods and xcframework. public static var specVersionIfAvailable: String? { optionalVersion(for: "spec") } diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift index 313b8d48f..c74e1bb03 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -79,8 +79,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { XCTAssertEqual(2, payload.version) XCTAssertNil(OpenIapModule.iapkitClientPayload(from: nil)) XCTAssertNil(OpenIapModule.iapkitClientPayload(from: NSNull())) - // A format IAPKit adds after this build shipped, and every structural - // defect, drop the payload instead of failing the verified receipt. + // A later format, and every structural defect, drop the payload. XCTAssertNil( OpenIapModule.iapkitClientPayload(from: [ "format": "yaml", @@ -141,9 +140,7 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { XCTAssertEqual("Sandbox", OpenIapModule.iapkitEnvironment(from: "Sandbox")) XCTAssertEqual("Production", OpenIapModule.iapkitEnvironment(from: "Production")) - // The spec types `environment` as String, so a value IAPKit adds later - // is forwarded rather than dropped. "Xcode" and "LocalTesting" are real - // App Store Server environments. + // "Xcode" and "LocalTesting" are real App Store Server environments. for forwarded in ["sandbox", "Xcode", "LocalTesting", "Staging"] { XCTAssertEqual(forwarded, OpenIapModule.iapkitEnvironment(from: forwarded)) } @@ -159,29 +156,32 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { let request = OpenIapModule.makeIapkitRequest( url: url, apiKey: " iapkit_pk_test ", - body: Data("{}".utf8) + body: Data("{}".utf8), + specVersion: "3.2.0" ) XCTAssertEqual("POST", request.httpMethod) XCTAssertEqual("application/json", request.value(forHTTPHeaderField: "Content-Type")) XCTAssertEqual("Bearer iapkit_pk_test", request.value(forHTTPHeaderField: "Authorization")) - // The header is present exactly when the version resolves, and carries a - // semver when it does. It cannot be asserted unconditionally: SwiftPM - // copies `Sources/openiap-versions.json` as the symlink it is, which - // dangles inside the built bundle, so `Bundle.module` finds nothing - // under `swift test`. That is why the accessor is optional and the - // header is omitted rather than trapping. - let header = request.value(forHTTPHeaderField: "X-OpenIAP-Spec") - XCTAssertEqual(OpenIapVersion.specVersionIfAvailable, header) - if let header { - XCTAssertNotNil( - header.range(of: #"^\d+\.\d+\.\d+"#, options: .regularExpression), - "X-OpenIAP-Spec must carry a semver, got \(header)" - ) - } + // Injected rather than read back from the accessor that produced it: + // under SwiftPM the bundled versions file is a dangling symlink, so + // comparing against the accessor would be nil == nil. + XCTAssertEqual("3.2.0", request.value(forHTTPHeaderField: "X-OpenIAP-Spec")) XCTAssertEqual(Data("{}".utf8), request.httpBody) } + func testIapkitRequestOmitsTheSpecHeaderWhenTheVersionIsUnreadable() throws { + let url = try XCTUnwrap(URL(string: "https://kit.openiap.dev/v1/purchase/verify")) + let request = OpenIapModule.makeIapkitRequest( + url: url, + apiKey: "iapkit_pk_test", + body: Data(), + specVersion: nil + ) + + XCTAssertNil(request.value(forHTTPHeaderField: "X-OpenIAP-Spec")) + } + func testIapkitRequestOmitsAuthorizationForABlankApiKey() throws { let url = try XCTUnwrap(URL(string: "https://kit.openiap.dev/v1/purchase/verify")) diff --git a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx index 0ac64c182..9a7255953 100644 --- a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx +++ b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx @@ -167,7 +167,9 @@ function VerifyPurchaseWithProviderResult() { Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment. Handled Amazon responses use exactly 'Sandbox' or{' '} - 'Production'; other stores omit it. + 'Production'; other stores omit it. Treat it as an + opaque string and forward it β€” never fail a verification because + the value is unrecognised. diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index 354382733..ff2e5c68d 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -76,10 +76,8 @@ val openIapVersion: String = project.findProperty("openIapVersion")?.toString()?.takeIf { it.isNotBlank() } ?: versionsJson["google"]?.toString()?.takeIf { it.isNotBlank() } ?: throw GradleException("packages/google: 'google' version missing in openiap-versions.json") -// The OpenIAP spec version this artifact was compiled against. Reported to -// IAPKit on verify requests so a response contract change can be rolled out -// against real client-version data. Never a gradle property: it describes the -// contract, not the artifact's own version. +// Spec version this artifact was compiled against, reported to IAPKit on +// verify. Never a gradle property: it describes the contract, not the artifact. val openIapSpecVersion: String = versionsJson["spec"]?.toString()?.takeIf { it.isNotBlank() } ?: throw GradleException("packages/google: 'spec' version missing in openiap-versions.json") diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt index 02e3152fa..8c3249033 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt @@ -195,8 +195,7 @@ suspend fun verifyPurchaseWithIapkit( fun readIapkitClientPayload(raw: Any?): IapkitProductClientPayload? { if (raw == null) return null val payload = raw as? Map<*, *> ?: return unreadableIapkitClientPayload() - // Derived from the generated enum rather than a literal set, so a - // format added to the spec is readable as soon as Types.kt regenerates. + // Derived from the generated enum so a new format is readable on regen. val format = (payload["format"] as? String) ?.let { rawFormat -> IapkitClientPayloadFormat.entries.firstOrNull { it.rawValue == rawFormat } } ?: return unreadableIapkitClientPayload() @@ -352,9 +351,7 @@ suspend fun verifyPurchaseWithIapkit( requestMethod = "POST" doOutput = true setRequestProperty("Content-Type", "application/json") - // Tells IAPKit which response contract this build was compiled - // against, so an enum it gains later can be rolled out against real - // client-version data. Reported, never negotiated. + // Reported, never negotiated. setRequestProperty("X-OpenIAP-Spec", BuildConfig.OPENIAP_SPEC_VERSION) props.apiKey?.takeIf { it.isNotBlank() }?.let { apiKey -> setRequestProperty("Authorization", "Bearer $apiKey") @@ -398,20 +395,14 @@ suspend fun verifyPurchaseWithIapkit( IapkitPurchaseState.fromJson(normalizedState) }.getOrDefault(IapkitPurchaseState.Unknown) - // Optional enrichment: anything this build cannot represent β€” a - // client payload format or environment IAPKit adds later included - // β€” is dropped, never thrown. Receipt verification is the security - // boundary, and losing metadata must not fail a confirmed purchase. + // Optional enrichment: dropped, never thrown. val clientPayload = readIapkitClientPayload(parsed["clientPayload"]) val productId = when (val rawProductId = parsed["productId"]) { null -> null is String -> rawProductId else -> throw malformedIapkitResponse() } - // `environment` is String in the spec, not an enum: the - // Sandbox/Production pair is IAPKit's constraint to enforce, and - // re-deriving it here would only let a value IAPKit adds later - // fail a receipt the store already confirmed. + // Forwarded opaquely: `environment` is String in the spec. val environment = (parsed["environment"] as? String)?.takeIf { it.isNotEmpty() } if (environment == null && parsed["environment"] != null) { OpenIapLog.warn("Ignoring an IAPKit environment this build cannot read", tag) diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt index cde16ca9f..08e811410 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt @@ -428,9 +428,7 @@ class PurchaseVerificationValidatorTest { ), includeClientPayload = true ) - // `yaml` stands in for a format IAPKit adds after this build ships; the - // rest are structural defects. Both drop the optional payload and keep - // the verified receipt, because enrichment is not the security boundary. + // `yaml` stands in for a later format; the rest are structural defects. val unreadablePayloads = listOf( """{"format":"toml","body":"missing timestamps"}""", """{"format":"yaml","body":"tier: gold","version":1,"updatedAt":1}""", @@ -583,9 +581,7 @@ class PurchaseVerificationValidatorTest { receiptId = "amzn1.receipt.ABC123456789" ) ) - // The spec types `environment` as String, so a value IAPKit adds later - // is forwarded; only a non-string has nothing to forward. "Xcode" and - // "LocalTesting" are real App Store Server environments. + // "Xcode" and "LocalTesting" are real App Store Server environments. val cases = listOf( "\"Sandbox\"" to "Sandbox", "\"Xcode\"" to "Xcode", diff --git a/packages/gql/src/kit-api.ts b/packages/gql/src/kit-api.ts index f20089075..a76973d41 100644 --- a/packages/gql/src/kit-api.ts +++ b/packages/gql/src/kit-api.ts @@ -283,11 +283,8 @@ export function kitApi(options: KitApiOptions) { if (!raw) return null; const candidate = JSON.parse(raw) as Partial; const payload = candidate.clientPayload; - // Only the invariants the cache itself depends on. `format` is opaque - // here: rejecting a format IAPKit added later would evict the entry, - // stop the ETag revalidation below from ever being sent, and leave - // offline reads with nothing β€” for a value the live path passes through - // to the caller unchanged anyway. + // Only the invariants the cache depends on. `format` is opaque: evicting + // on an unknown one would kill ETag revalidation and offline reads. if ( !payload || typeof payload.format !== "string" || diff --git a/packages/kit/convex/purchases/shared.test.ts b/packages/kit/convex/purchases/shared.test.ts index 5e5ed5354..bbf4c89e8 100644 --- a/packages/kit/convex/purchases/shared.test.ts +++ b/packages/kit/convex/purchases/shared.test.ts @@ -216,9 +216,8 @@ describe("mapAppStorePurchaseState", () => { expect(state).toBe(HarmonizedPurchaseState.EXPIRED); }); - // Golden table. Every row decides what a published app sees for a real - // App Store transaction, and IAPKit ships without an SDK release, so a - // mapping change has to show up here as an explicit diff. + // Golden table: a mapping change ships to published apps without an SDK + // release, so it must show up here as an explicit diff. const APP_STORE_GOLDEN: Array<{ label: string; reason?: AppStoreTransactionReason; @@ -333,16 +332,9 @@ describe("isValidState", () => { expect(isValidState(HarmonizedPurchaseState.INAUTHENTIC)).toBe(false); }); - // `isValid` is the field every SDK gates entitlement on, and IAPKit deploys - // from main without an SDK release β€” changing this table changes what - // already-published apps unlock, for every user, immediately. - // - // The table is keyed by the full enum rather than listing only the entitling - // states: `Record` makes TypeScript reject - // a newly added state until someone classifies it, so the far more likely - // drift β€” a new state quietly defaulting to "does not entitle" β€” cannot pass - // unnoticed either. Being a record also makes it order-independent, so - // reordering the enum is not a false failure. + // `isValid` gates entitlement in every SDK, and kit ships without an SDK + // release. Keyed by the full enum so TypeScript rejects a new state until + // someone classifies it, in either direction, and order does not matter. const ENTITLEMENT_GOLDEN: Record = { [HarmonizedPurchaseState.ENTITLED]: true, [HarmonizedPurchaseState.PENDING_ACKNOWLEDGMENT]: true, diff --git a/packages/kit/server/api/v1/request-logger.test.ts b/packages/kit/server/api/v1/request-logger.test.ts index 37ad4386f..77b3d913a 100644 --- a/packages/kit/server/api/v1/request-logger.test.ts +++ b/packages/kit/server/api/v1/request-logger.test.ts @@ -103,8 +103,7 @@ describe("requestLoggerMiddleware", () => { }); test("carries the client spec version from the wire onto the log line", async () => { - // `X-OpenIAP-Spec` has to agree across three codebases; the clients pin - // their side, so kit pins the exact name it reads here. + // The header name has to agree across kit, openiap-apple and openiap-google. const logs: VerifyLogLine[] = []; const app = buildApp({ logs }); @@ -130,9 +129,8 @@ describe("requestLoggerMiddleware", () => { headers: { Authorization: "Bearer spec-header-junk", "content-type": "application/json", - // The runtime rejects a header carrying a newline before kit sees it, - // so log-injection shapes are covered by the readSpecVersion unit test - // above; this pins what actually reaches the middleware. + // Injection shapes are covered by the readSpecVersion unit test below; + // the runtime rejects a newline header before kit sees it. "X-OpenIAP-Spec": "latest", }, body: JSON.stringify({ store: "apple", jws: TEST_APPLE_JWS }), @@ -143,9 +141,7 @@ describe("requestLoggerMiddleware", () => { }); test("records a plausible client spec version and ignores anything else", async () => { - // Caller-controlled, so it is shape-checked and bounded before it reaches - // a log line. Nothing branches on it β€” an SDK must not be able to change - // how its receipt is verified by claiming a version. + // Caller-controlled; nothing branches on it. expect(readSpecVersion("3.2.0")).toBe("3.2.0"); expect(readSpecVersion("3.2.0-rc.1")).toBe("3.2.0-rc.1"); expect(readSpecVersion(undefined)).toBeUndefined(); diff --git a/packages/kit/server/api/v1/request-logger.ts b/packages/kit/server/api/v1/request-logger.ts index 767d26e5d..fd0a0a112 100644 --- a/packages/kit/server/api/v1/request-logger.ts +++ b/packages/kit/server/api/v1/request-logger.ts @@ -31,11 +31,9 @@ export interface VerifyLogLine { specVersion?: string; } -// Clients report the OpenIAP spec their build was compiled against so a -// response-contract change can be rolled out against real version data. The -// value is caller-controlled, so it is shape-checked and bounded before it -// reaches a log line, and nothing branches on it β€” an SDK must never be able -// to change how its receipt is verified by claiming a version. +// Caller-controlled, so it is shape-checked and bounded before reaching a log +// line. Nothing branches on it: a client must not be able to change how its +// receipt is verified by claiming a version. const SPEC_VERSION_PATTERN = /^\d{1,4}\.\d{1,4}\.\d{1,4}(-[0-9A-Za-z.-]{1,32})?$/; diff --git a/packages/kit/server/api/v1/response-contract.test.ts b/packages/kit/server/api/v1/response-contract.test.ts index 285a9afda..dd43623fc 100644 --- a/packages/kit/server/api/v1/response-contract.test.ts +++ b/packages/kit/server/api/v1/response-contract.test.ts @@ -62,8 +62,7 @@ describe("enforceVerifyResponseContract", () => { }); test("drops an environment the SDK parsers reject", () => { - // Apple's App Store Server API also reports `Xcode` and `LocalTesting`; - // shipped SDKs fail the whole receipt on anything but Sandbox/Production. + // App Store Server also reports `Xcode` and `LocalTesting`. const result = enforceVerifyResponseContract({ ...ENTITLED, environment: "Xcode", @@ -120,8 +119,7 @@ describe("enforceVerifyResponseContract", () => { }); test("refuses to publish a malformed verdict", () => { - // Neither field can drift from metadata changes β€” only a server defect - // produces this, and a response no SDK can trust must not reach a client. + // Server-defect path: neither field can drift from metadata changes. for (const broken of [ { ...ENTITLED, isValid: "true" }, { ...ENTITLED, store: "steam" }, diff --git a/packages/kit/server/api/v1/response-contract.ts b/packages/kit/server/api/v1/response-contract.ts index 0f90e5cc2..d54f9e1c3 100644 --- a/packages/kit/server/api/v1/response-contract.ts +++ b/packages/kit/server/api/v1/response-contract.ts @@ -62,10 +62,8 @@ export const enforceVerifyResponseContract = ( const reparsed = v.safeParse(verifyPurchaseSuccessResponseSchema, degraded); if (!reparsed.success) { - // `store` and `isValid` are the only fields left, and the route handler - // supplies both from its own switch. Reaching here means the verdict - // itself is malformed, which is a server defect rather than contract - // drift β€” report it instead of publishing a response no SDK can trust. + // Only `store` and `isValid` remain, both handler-supplied β€” a malformed + // verdict is a server defect, not contract drift. return { ok: false, violations: [ diff --git a/packages/kit/server/api/v1/route-response-schemas.ts b/packages/kit/server/api/v1/route-response-schemas.ts index 0dbce6078..c8e156d1c 100644 --- a/packages/kit/server/api/v1/route-response-schemas.ts +++ b/packages/kit/server/api/v1/route-response-schemas.ts @@ -57,9 +57,8 @@ export const unifiedPurchaseStateSchema = v.union( ), ); -// The state a response degrades to when the verified value is outside the -// published enum. Never changes `isValid`: the verdict is authoritative and a -// metadata drift must not revoke a real entitlement. +// Degraded state for a value outside the published enum. `isValid` is never +// rewritten β€” metadata drift must not revoke a real entitlement. export const FALLBACK_PURCHASE_STATE = "UNKNOWN"; const verifyStoreSchema = v.union([ diff --git a/packages/kit/server/api/v1/routes.test.ts b/packages/kit/server/api/v1/routes.test.ts index ec6e0684a..a846cb03a 100644 --- a/packages/kit/server/api/v1/routes.test.ts +++ b/packages/kit/server/api/v1/routes.test.ts @@ -19,8 +19,7 @@ vi.mock("../../convex", () => ({ const { apiRoutes } = await import("./routes"); describe("apiRoutes", () => { - // The request logger writes one JSON line per request to stdout; capturing it - // is the only way to assert what an operator actually sees. + // The logger writes one JSON line per request to stdout. let logLines: Array>; let logSpy: ReturnType; @@ -493,8 +492,7 @@ describe("apiRoutes", () => { }); it("degrades an out-of-contract state instead of publishing it", async () => { - // Shipped SDKs decode this body with parsers they cannot update, so a - // state Convex knows but the published schema does not must not reach one. + // A state Convex knows but the published schema does not must not ship. convexClientMock.action.mockResolvedValueOnce({ isValid: true, state: "REFUNDED", @@ -523,8 +521,7 @@ describe("apiRoutes", () => { }); it("refuses to publish a verdict that cannot be made contract-valid", async () => { - // Only a server defect produces this β€” Convex's own validator pins - // isValid to a boolean β€” but a body no SDK can trust must not be sent. + // Server-defect path: Convex's validator pins isValid to a boolean. convexClientMock.action.mockResolvedValueOnce({ isValid: "true", state: "ENTITLED", @@ -546,9 +543,7 @@ describe("apiRoutes", () => { expect(await response.json()).toMatchObject({ errors: [{ code: "UNKNOWN_ERROR" }], }); - // The client saw a failure, so the structured log must not still report the - // verdict this handler computed β€” otherwise the incident is invisible in - // any success-rate view built on `isValid`. + // The client saw a failure; the log must not report a success verdict. const verifyLine = logLines.find((line) => line.kind === "verify_request"); expect(verifyLine).toMatchObject({ statusCode: 500, @@ -557,6 +552,35 @@ describe("apiRoutes", () => { }); }); + it("keeps a stable rejection armed through a malformed verdict", async () => { + // The reported state is overwritten on this path, so the cooldown has to be + // derived before the reset β€” otherwise a revoked receipt becomes replayable. + convexClientMock.action.mockResolvedValue({ + isValid: "false", + state: "INAUTHENTIC", + }); + const send = () => + apiRoutes.request("/purchase/verify", { + method: "POST", + headers: { + Authorization: "Bearer route-test-stable-rejection-500", + "content-type": "application/json", + }, + body: JSON.stringify({ + store: "google", + purchaseToken: "stablereject".repeat(4), + }), + }); + + expect((await send()).status).toBe(500); + + const replayed = await send(); + expect(replayed.status).toBe(429); + expect(await replayed.json()).toMatchObject({ + errors: [{ code: "REPEATED_FAILURE" }], + }); + }); + it("drops an environment value no shipped SDK accepts", async () => { convexClientMock.action.mockResolvedValueOnce({ isValid: true, diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index 6584a28cd..f25592518 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -17,7 +17,7 @@ import { import { enforceVerifyResponseContract } from "./response-contract"; import { apiKeyMiddleware } from "./middleware"; import { getRequestIp, multiAxisRateLimitMiddleware } from "./rate-limit"; -import { replayGuardMiddleware } from "./replay-guard"; +import { isStableRejection, replayGuardMiddleware } from "./replay-guard"; import { inFlightLimitMiddleware } from "./in-flight-limit"; import { requestLoggerMiddleware } from "./request-logger"; import { validator } from "./validator"; @@ -460,8 +460,7 @@ const verifyPurchaseHandler = async ( ...(clientPayload ? { clientPayload } : {}), }); if (contract.violations.length > 0) { - // Field names only β€” never values. A drift here means the emitted body - // left the schema the OpenAPI document publishes and shipped SDKs decode. + // Field names only β€” never values. console.error( "[purchase/verify] RESPONSE_CONTRACT_VIOLATION: store=%s fields=%s", store, @@ -469,14 +468,15 @@ const verifyPurchaseHandler = async ( ); } if (!contract.ok) { - // The client is about to see a 500, so the request log must not keep - // reporting the verdict this handler computed. `stableRejection` is - // carried over: it is the store's own provenance, and dropping it would - // disarm the replay guard's cooldown for a genuinely revoked receipt. + // Reset only the reported verdict. The rejection's stability is resolved + // first, because `state` is about to be overwritten and the replay guard + // derives the cooldown from it. Defect-only path: Convex pins isValid. setOutcome({ - ...outcome, isValid: false, state: FALLBACK_PURCHASE_STATE, + ...(isStableRejection(outcome.state, outcome.stableRejection === true) + ? { stableRejection: true } + : {}), }); const errorId = crypto.randomUUID(); console.error( diff --git a/scripts/audit-kit-spec-contract.mjs b/scripts/audit-kit-spec-contract.mjs index ddcb71c85..47180ab7a 100644 --- a/scripts/audit-kit-spec-contract.mjs +++ b/scripts/audit-kit-spec-contract.mjs @@ -1,21 +1,10 @@ #!/usr/bin/env node -// IAPKit deploys from `main` on its own workflow, while the native SDKs that -// decode its `/v1` responses are frozen inside already-published apps. The -// enums below are declared three times β€” kit's persisted Convex state, kit's -// OpenAPI response documentation, and the GraphQL schema that generates every -// SDK type. Nothing else in the repo compares them, so a kit-only change could -// put a value on the wire that shipped SDKs and published docs know nothing -// about. This audit is that comparison. +// Compares IAPKit's response enums against the spec every SDK generates from. // -// Scope: the /v1 verify RESPONSE contract only. kit's write path declares the -// client-payload format set again in convex/schema.ts (twice), -// convex/products/{query,mutation}.ts and server/api/v1/products.ts, and the -// environment pair again in convex/schema.ts and convex/purchases/shared.ts. -// Those live on the other side of kit's server/convex tsconfig split, so they -// cannot share a constant without moving a module; until they do, a format -// accepted on write but absent from the response schema is silently dropped by -// enforceVerifyResponseContract rather than caught here. +// Covers the /v1 verify RESPONSE only: kit's write path declares the same +// format set in convex/schema.ts, convex/products/ and server/api/v1/products.ts, +// across a tsconfig split that stops them sharing a constant. import fs from "node:fs"; import path from "node:path"; @@ -32,11 +21,8 @@ export const RESPONSE_SCHEMA_FILE = const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), "utf8"); -// These are source-text parsers, so the two ways they can lie are worse than -// the ways they can fail: matching the wrong declaration, or reading a value -// that is commented out. Both would report agreement over a drifted repo. -// `matchExactlyOnce` closes the first, `stripComments` the second, and every -// parser rejects an empty result rather than comparing two empty lists. +// Source-text parsers can lie by matching the wrong declaration or reading a +// commented-out value, either of which reports agreement over a drifted repo. const matchExactlyOnce = (source, pattern, label) => { const matches = [...source.matchAll(new RegExp(pattern, "g"))]; @@ -141,9 +127,8 @@ export const collectContractFailures = ({ convexState = read(CONVEX_STATE_FILE), responseSchema: rawResponseSchema = read(RESPONSE_SCHEMA_FILE), } = {}) => { - // Anchors match raw text, so comments are removed before anchoring too: - // otherwise a comment added between `v.object({` and `format:` would make an - // anchor miss and block the deploy gate over a documentation edit. + // Anchors match raw text, so a comment inside a declaration would make one + // miss and block the deploy gate. const responseSchema = stripComments(rawResponseSchema); const specStates = parseGraphqlEnum(schema, "IapkitPurchaseState"); // GraphQL members are PascalCase for these two; the wire values are lowercase. @@ -168,8 +153,7 @@ export const collectContractFailures = ({ ...compare( `${RESPONSE_SCHEMA_FILE} clientPayload format vs ${SCHEMA_FILE} IapkitClientPayloadFormat`, specFormats, - // Anchored on the owning declaration: `format:` alone would silently - // read a different schema's field if one were added above this one. + // Anchored on the owning declaration; `format:` alone is ambiguous. parseValibotLiteralUnion( responseSchema, "clientPayloadSchema = v\\.object\\(\\{\\s*format:\\s*", @@ -186,13 +170,11 @@ export const collectContractFailures = ({ ]; }; -export const runAudit = () => { - // The hardened parsers signal drift by throwing, so those cases have to reach - // the guidance below rather than surfacing as a bare stack trace β€” in - // deploy-kit.yml this message is what a blocked operator reads. +export const runAudit = (sources) => { + // Parsers signal drift by throwing; route that through the guidance below. let failures; try { - failures = collectContractFailures(); + failures = collectContractFailures(sources); } catch (error) { failures = [`could not read a declaration: ${error.message}`]; } diff --git a/scripts/audit-kit-spec-contract.test.mjs b/scripts/audit-kit-spec-contract.test.mjs index fd087c1d3..7d0c480bf 100644 --- a/scripts/audit-kit-spec-contract.test.mjs +++ b/scripts/audit-kit-spec-contract.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { collectContractFailures, + runAudit, parseDocumentedStates, parseGraphqlEnum, parseTypescriptEnum, @@ -88,9 +89,8 @@ test("parsers read each declaration style", () => { ); }); -// The parsers read source text, so their dangerous failure is agreeing over a -// drifted repo. Both cases below returned no failures before the anchors were -// qualified and comments stripped. +// The dangerous failure is agreeing over a drifted repo; both cases below +// returned no failures before the anchors were qualified. test("a second format union cannot be mistaken for the contract", () => { const withDecoy = RESPONSE_SCHEMA.replace( @@ -143,8 +143,7 @@ test("a commented-out literal is not counted", () => { }); test("a comment above the anchored field does not break the audit", () => { - // The audit gates the kit deploy, so a documentation edit on the very - // declaration it reads must not block a release. + // The audit gates the kit deploy; a doc edit must not block a release. assert.deepEqual( collectContractFailures( sources({ @@ -158,6 +157,23 @@ test("a comment above the anchored field does not break the audit", () => { ); }); +test("an unreadable declaration is reported, not thrown", () => { + // deploy-kit.yml gates on this, so the operator has to see the guidance. + const errors = []; + const originalError = console.error; + console.error = (...args) => errors.push(args.join(" ")); + try { + assert.equal( + runAudit(sources({ responseSchema: "const nothing = 1;\n" })), + false, + ); + } finally { + console.error = originalError; + } + assert.match(errors.join("\n"), /could not read a declaration/); + assert.match(errors.join("\n"), /type\.graphql/); +}); + test("a declaration that parses to nothing is a failure, not agreement", () => { assert.throws( () => diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 458a34d8d..ee82d3362 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -2619,10 +2619,30 @@ function checkIapkitAmazonContractWiring() { "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt", [ "expectedProductId = amazon.expectedProductId", - "environment = androidResult.environment", + "androidResult.toKmpIapkitResult()", ], "KMP Android IAPKit Amazon contract", ); + expectIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt", + ["androidResult.toKmpIapkitResult()"], + "KMP Amazon store IAPKit response contract", + ); + expectIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt", + [ + "environment = environment", + "IapkitClientPayloadFormat.entries", + "getOrDefault(IapkitPurchaseState.Unknown)", + "getOrDefault(IapStore.Unknown)", + ], + "KMP Android IAPKit result mapping degrades unknown values", + ); + expectNotIncludes( + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt", + ["RequestVerifyPurchaseWithIapkitResult.fromJson"], + "KMP Amazon store must not round-trip through the generated decoder", + ); expectIncludes( "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt", ['map["environment"] as? String', "environment = environment"], From f181ad9fcdde9f998421103d158296d44724ab4f Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 13:37:36 +0900 Subject: [PATCH 10/21] fix(kmp): catch every failure on the Amazon verify path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `withMappedOpenIapError` only catches the typed Android OpenIapError, but the validator also throws IllegalArgumentException for a malformed options shape, so that still escaped a suspend function whose every other exit signals through PurchaseException. Now caught broadly, matching the Play sibling. Corrects the previous commit message: it said this path "lost its error translation when the round-trip decoder was replaced". It did not. `git show origin/main` shows the call was bare there too, so no translation was ever removed β€” the earlier commit added coverage for the first time, and this one completes it. Co-Authored-By: Claude Opus 5 (1M context) --- .../kmpiap/AmazonInAppPurchaseAndroid.kt | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt index 08dfd153f..3f685dc11 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt @@ -248,14 +248,24 @@ internal class AmazonInAppPurchaseAndroid( ) ) } - val androidResult = withMappedOpenIapError { - verifyPurchaseWithIapkitAndroid(androidOptions, "kmp-iap-android-$storeName") + // The validator also throws IllegalArgumentException for a malformed + // options shape, so catch broadly like the Play path rather than only + // the typed OpenIapError. + return try { + val androidResult = + verifyPurchaseWithIapkitAndroid(androidOptions, "kmp-iap-android-$storeName") + VerifyPurchaseWithProviderResult( + iapkit = androidResult.toKmpIapkitResult(), + provider = options.provider + ) + } catch (error: Exception) { + failWith( + PurchaseError( + code = ErrorCode.PurchaseVerificationFailed, + message = error.message ?: "Purchase verification failed" + ) + ) } - val iapkitResult = androidResult.toKmpIapkitResult() - return VerifyPurchaseWithProviderResult( - iapkit = iapkitResult, - provider = options.provider - ) } override suspend fun verifyPurchase(options: VerifyPurchaseProps): VerifyPurchaseResult = From bf0698449a99540a359d3920b0c0d24dbd7f82df Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 14:10:05 +0900 Subject: [PATCH 11/21] docs: state the IAPKit version compatibility policy The rules this branch enforces in code were nowhere a developer could read them. Without that, the natural thing to write in an app is a switch over the values that exist today, which is the pattern that breaks the next time IAPKit adds one. Adds /docs/kit-compatibility covering why the policy exists (IAPKit, the SDK you compiled against, and the build on a user's device move on separate clocks, and the last has no upper bound), what IAPKit guarantees, what each SDK does with a value it does not know, the X-OpenIAP-Spec header, how CI enforces it, and what to do in app code. Co-Authored-By: Claude Opus 5 (1M context) --- packages/docs/src/pages/docs/index.tsx | 11 + .../docs/src/pages/docs/kit-compatibility.tsx | 202 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 packages/docs/src/pages/docs/kit-compatibility.tsx diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index bf0a76956..7bfc1dfae 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -90,6 +90,7 @@ import APIsOpenRedeemOfferCodeAndroid from './apis/android/open-redeem-offer-cod import Events from './events'; import Webhooks from './webhooks'; import KitBackend from './kit-backend'; +import KitCompatibility from './kit-compatibility'; import EventsPurchaseUpdatedListener from './events/purchase-updated-listener'; import EventsPurchaseErrorListener from './events/purchase-error-listener'; import EventsSubscriptionBillingIssueListener from './events/subscription-billing-issue-listener'; @@ -845,6 +846,15 @@ function Docs() { Purchase Verification +
  • + (isActive ? 'active' : '')} + onClick={closeSidebar} + > + Version Compatibility + +
  • } /> } /> } /> + } /> } diff --git a/packages/docs/src/pages/docs/kit-compatibility.tsx b/packages/docs/src/pages/docs/kit-compatibility.tsx new file mode 100644 index 000000000..1950cff59 --- /dev/null +++ b/packages/docs/src/pages/docs/kit-compatibility.tsx @@ -0,0 +1,202 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../components/AnchorLink'; +import Callout from '../../components/Callout'; +import CodeBlock from '../../components/CodeBlock'; +import SEO from '../../components/SEO'; +import { useScrollToHash } from '../../hooks/useScrollToHash'; + +function KitCompatibility() { + useScrollToHash(); + + return ( +
    + +

    Version Compatibility

    +

    + You do not have to force your users onto a new app build when IAPKit + changes. This page states what IAPKit guarantees to an app that was + compiled against an older OpenIAP SDK, and what it deliberately does + not. +

    + +
    + + Why this needs a policy + +

    + Three things move on separate clocks, and only the first is under + OpenIAP's control: +

    +
      +
    • + IAPKit β€” deploys from main, so every + caller sees a change at the same moment. +
    • +
    • + The SDK version you compiled against β€” changes when + you upgrade and ship. +
    • +
    • + The app build on a user's device β€” changes when + that user updates. Some never do. +
    • +
    +

    + The third has no upper bound, so IAPKit cannot assume the oldest + caller is recent. Everything below follows from that. +

    +
    + +
    + + What IAPKit guarantees + +
      +
    • + Responses are additive. A field is never removed, + renamed, or given a new meaning. New fields are optional, and a new + response shape is gated behind an explicit request flag β€” the way{' '} + includeClientPayload is, so a build that never sends it + never receives it. +
    • +
    • + A truly breaking change gets a new path. It would + ship as /v2, and /v1 would keep serving. + No app is ever forced to move. +
    • +
    • + The verdict stays strict. isValid and + the echoed store are the security boundary and are + always validated exactly. +
    • +
    +
    + +
    + + What the SDKs do with a value they do not know + +

    + Optional metadata degrades; it never fails the purchase. Receipt + verification is the security boundary, and losing a label is not worth + rejecting a receipt the store already confirmed. +

    +
      +
    • + state β€” an unrecognised value becomes{' '} + unknown. isValid remains authoritative, so + gate entitlement on it rather than on the state label. +
    • +
    • + clientPayload β€” a payload this build cannot read, + including one using a format added later, is dropped and the result + still returns. +
    • +
    • + environment β€” forwarded as an opaque string. It is{' '} + String in the spec, not an enum, so do not reject a + value you do not recognise. App Store Server alone names{' '} + Sandbox, Production, Xcode{' '} + and LocalTesting. +
    • +
    + +

    + Do not re-derive these constraints in your app.{' '} + Comparing environment against the value you asked for + is fine. Rejecting the verification because the value is not one you + enumerated is the pattern that breaks when IAPKit adds one. +

    +
    +
    + +
    + + Reporting your spec version + +

    + Native verification requests carry the OpenIAP spec version the build + was compiled against: +

    + {`POST /v1/purchase/verify +Authorization: Bearer +X-OpenIAP-Spec: 3.2.0`} +

    + It is reported, never negotiated. IAPKit records it so a contract + change can be measured against the versions actually calling, and + never branches verification on it β€” a client cannot change how its + receipt is verified by claiming a version. The header is omitted when + the version cannot be read, which is not an error. +

    +
    + +
    + + How this is enforced + +

    The rules above are checked by CI rather than left to review:

    +
      +
    • + The purchase-state, client-payload-format and verify-store enums are + compared against the GraphQL spec every SDK generates from. Drift + fails both CI and the IAPKit deploy. +
    • +
    • + Every /v1/purchase/verify response is validated against + the published schema before it is sent, so the documented shape and + the emitted one cannot diverge. +
    • +
    • + Each SDK has tests that feed its parser values from a hypothetical + future IAPKit β€” an unknown state, an unknown client-payload format, + an unrecognised environment β€” and assert the receipt survives. +
    • +
    • + The entitlement decision is pinned by an exhaustive table, so a new + purchase state has to be classified deliberately. +
    • +
    +
    + +
    + + What to do in your app + +
      +
    • + Gate entitlement on isValid, then match{' '} + productId. Treat state as a label for + logging and UI, not as the decision. +
    • +
    • + Handle every optional field being absent. Any of them can be missing + for a legitimate reason. +
    • +
    • + Never fail a verification because a value is unrecognised. Log it + and continue. +
    • +
    • + Keep the SDK reasonably current so new fields become available, but + know that an old build keeps verifying correctly in the meantime. +
    • +
    +

    + See Purchase Verification for the + endpoint surface and{' '} + + VerifyPurchaseWithProviderResult + {' '} + for the field-by-field contract. +

    +
    +
    + ); +} + +export default KitCompatibility; From f81d06d5c6013c6c6297967dffd5560e447188bf Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 14:29:47 +0900 Subject: [PATCH 12/21] fix(apple): resolve the spec version at compile time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OpenIapVersion` read `openiap-versions.json` out of the bundle at runtime, and that resource is a symlink to the repo root. SwiftPM copies a resource symlink verbatim, so it dangles inside the built bundle and `Bundle.module` finds nothing β€” the accessor returned nil under SwiftPM and `specVersion` would have trapped. Nothing had noticed because it had no callers until this branch added one on the purchase path. `sync-versions.sh` now generates `OpenIapGeneratedVersion.swift` from the same JSON, so the version is a compile-time constant that resolves in every distribution channel and cannot fail. The symlink stays as the SSOT, the runtime lookup and its fatalError are gone, and the optional accessor this branch added is no longer needed. The parity audit pins the generated values against openiap-versions.json. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/OpenIapGeneratedVersion.swift | 8 +++ packages/apple/Sources/OpenIapModule.swift | 6 +- packages/apple/Sources/OpenIapVersion.swift | 63 +------------------ .../VerifyPurchaseWithProviderTests.swift | 24 +++---- scripts/audit-non-godot-parity.mjs | 23 +++++-- scripts/sync-versions.sh | 31 +++++++++ 6 files changed, 72 insertions(+), 83 deletions(-) create mode 100644 packages/apple/Sources/OpenIapGeneratedVersion.swift diff --git a/packages/apple/Sources/OpenIapGeneratedVersion.swift b/packages/apple/Sources/OpenIapGeneratedVersion.swift new file mode 100644 index 000000000..94f257596 --- /dev/null +++ b/packages/apple/Sources/OpenIapGeneratedVersion.swift @@ -0,0 +1,8 @@ +// Generated by scripts/sync-versions.sh from openiap-versions.json. +// Do not edit. + +enum OpenIapGeneratedVersion { + static let spec = "3.2.0" + static let apple = "3.2.0" + static let google = "3.3.0" +} diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 7e6bae506..aba5bec44 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -112,14 +112,12 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { url: URL, apiKey: String?, body: Data, - specVersion: String? = OpenIapVersion.specVersionIfAvailable + specVersion: String = OpenIapVersion.specVersion ) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if let specVersion { - request.setValue(specVersion, forHTTPHeaderField: "X-OpenIAP-Spec") - } + request.setValue(specVersion, forHTTPHeaderField: "X-OpenIAP-Spec") let trimmedApiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) if let trimmedApiKey, trimmedApiKey.isEmpty == false { request.setValue("Bearer \(trimmedApiKey)", forHTTPHeaderField: "Authorization") diff --git a/packages/apple/Sources/OpenIapVersion.swift b/packages/apple/Sources/OpenIapVersion.swift index 0d2db148d..b3b36c0d6 100644 --- a/packages/apple/Sources/OpenIapVersion.swift +++ b/packages/apple/Sources/OpenIapVersion.swift @@ -1,72 +1,13 @@ -import Foundation - -private final class OpenIapVersionBundleToken {} - /// OpenIAP version management public struct OpenIapVersion { /// Current OpenIAP Apple SDK version public static var current: String { - version(for: "apple") + OpenIapGeneratedVersion.apple } /// Current OpenIAP specification version public static var specVersion: String { - version(for: "spec") - } - - /// Spec version, or nil when the bundled `openiap-versions.json` cannot be - /// located. Use this on the purchase path β€” `specVersion` traps, and the - /// resource bundles differently under SwiftPM, CocoaPods and xcframework. - public static var specVersionIfAvailable: String? { - optionalVersion(for: "spec") - } - - private static func version(for key: String) -> String { - guard let version = optionalVersion(for: key) else { - fatalError("OpenIAP: missing \(key) version in openiap-versions.json") - } - return version - } - - private static func optionalVersion(for key: String) -> String? { - let versionURL: URL? - - #if SWIFT_PACKAGE - versionURL = Bundle.module.url(forResource: "openiap-versions", withExtension: "json") - #else - versionURL = cocoaPodsVersionURL() - #endif - - guard - let url = versionURL, - let data = try? Data(contentsOf: url), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let version = json[key] as? String, - !version.isEmpty - else { - return nil - } - return version - } - - private static func cocoaPodsVersionURL() -> URL? { - let bundles = [Bundle(for: OpenIapVersionBundleToken.self), Bundle.main] + Bundle.allBundles - - for bundle in bundles { - if let url = bundle.url(forResource: "openiap-versions", withExtension: "json") { - return url - } - - if - let bundleURL = bundle.url(forResource: "OpenIAP", withExtension: "bundle"), - let resourceBundle = Bundle(url: bundleURL), - let url = resourceBundle.url(forResource: "openiap-versions", withExtension: "json") - { - return url - } - } - - return nil + OpenIapGeneratedVersion.spec } } diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift index c74e1bb03..f08ebc42b 100644 --- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift @@ -163,23 +163,23 @@ final class VerifyPurchaseWithProviderTests: XCTestCase { XCTAssertEqual("POST", request.httpMethod) XCTAssertEqual("application/json", request.value(forHTTPHeaderField: "Content-Type")) XCTAssertEqual("Bearer iapkit_pk_test", request.value(forHTTPHeaderField: "Authorization")) - // Injected rather than read back from the accessor that produced it: - // under SwiftPM the bundled versions file is a dangling symlink, so - // comparing against the accessor would be nil == nil. + // Injected so the assertion has an expected value independent of the + // accessor the builder reads. XCTAssertEqual("3.2.0", request.value(forHTTPHeaderField: "X-OpenIAP-Spec")) XCTAssertEqual(Data("{}".utf8), request.httpBody) } - func testIapkitRequestOmitsTheSpecHeaderWhenTheVersionIsUnreadable() throws { - let url = try XCTUnwrap(URL(string: "https://kit.openiap.dev/v1/purchase/verify")) - let request = OpenIapModule.makeIapkitRequest( - url: url, - apiKey: "iapkit_pk_test", - body: Data(), - specVersion: nil + func testSpecVersionIsACompileTimeConstant() throws { + // Generated from openiap-versions.json, so it cannot fail to resolve in + // any distribution channel. + XCTAssertEqual(OpenIapVersion.specVersion, OpenIapGeneratedVersion.spec) + XCTAssertNotNil( + OpenIapVersion.specVersion.range( + of: #"^\d+\.\d+\.\d+"#, + options: .regularExpression + ), + "expected a semver, got \(OpenIapVersion.specVersion)" ) - - XCTAssertNil(request.value(forHTTPHeaderField: "X-OpenIAP-Spec")) } func testIapkitRequestOmitsAuthorizationForABlankApiKey() throws { diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index ee82d3362..bcb0997f8 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -8551,16 +8551,27 @@ function checkFrameworkDependencyHygiene() { ["configuredVersion('kotlinVersion', 'NitroIap_kotlinVersion')"], "React Native Android build.gradle must read Kotlin fallback from gradle.properties", ); + // Compile-time constants, not a runtime bundle lookup: SwiftPM copies a + // resource symlink verbatim and it dangles inside the built bundle. expectIncludes( "packages/apple/Sources/OpenIapVersion.swift", + ["OpenIapGeneratedVersion.apple", "OpenIapGeneratedVersion.spec"], + "Apple OpenIAP runtime version", + ); + expectNotIncludes( + "packages/apple/Sources/OpenIapVersion.swift", + ["Bundle.module", "fatalError"], + "Apple OpenIAP version must not resolve at runtime", + ); + expectIncludes( + "packages/apple/Sources/OpenIapGeneratedVersion.swift", [ - 'Bundle.module.url(forResource: "openiap-versions", withExtension: "json")', - "cocoaPodsVersionURL()", - 'bundle.url(forResource: "OpenIAP", withExtension: "bundle")', - 'version(for: "apple")', - 'version(for: "spec")', + "// Generated by scripts/sync-versions.sh", + `static let spec = "${versions.spec}"`, + `static let apple = "${versions.apple}"`, + `static let google = "${versions.google}"`, ], - "Apple OpenIAP runtime version", + "Apple generated version constants", ); expectIncludes( "packages/apple/openiap.podspec", diff --git a/scripts/sync-versions.sh b/scripts/sync-versions.sh index 5870bb3ec..b5e78e887 100755 --- a/scripts/sync-versions.sh +++ b/scripts/sync-versions.sh @@ -188,6 +188,37 @@ sync_docs_version_metadata # Native packages can use symlinks create_symlink "packages/apple/Sources/openiap-versions.json" "../../../openiap-versions.json" + +# Compile-time constants for Swift. The JSON above stays the SSOT, but SwiftPM +# copies a resource symlink verbatim and it dangles inside the built bundle, so +# reading it at runtime works in no distribution channel. +generate_apple_version_source() { + local target="packages/apple/Sources/OpenIapGeneratedVersion.swift" + python3 - "$target" <<'PYGEN' +import json +import sys + +with open("openiap-versions.json", encoding="utf-8") as file: + versions = json.load(file) + +lines = [ + "// Generated by scripts/sync-versions.sh from openiap-versions.json.", + "// Do not edit.", + "", + "enum OpenIapGeneratedVersion {", +] +for key in ("spec", "apple", "google"): + value = versions[key] + lines.append(f' static let {key} = "{value}"') +lines.append("}") + +with open(sys.argv[1], "w", encoding="utf-8") as file: + file.write("\n".join(lines) + "\n") +PYGEN + echo " βœ“ $target (generated)" +} + +generate_apple_version_source create_symlink "packages/google/openiap-versions.json" "../../openiap-versions.json" # Libraries use symlinks to root openiap-versions.json From 66a781f677e8551b2f0e9d22ddbaf4ab87e84525 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 14:29:59 +0900 Subject: [PATCH 13/21] fix(mcp): forward an unrecognized client payload format `iapkit_set_client_payload` validated `format` with a zod enum, which is a runtime check, so the MCP server rejected a format IAPKit itself would have accepted. IAPKit owns that value space and still validates it, so the parameter is forwarded and the server-side check stays the only one. Co-Authored-By: Claude Opus 5 (1M context) --- packages/mcp-server/src/kit-client.ts | 5 ++- packages/mcp-server/src/mcp.ts | 6 ++- packages/mcp-server/test/http.test.ts | 60 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/mcp-server/src/kit-client.ts b/packages/mcp-server/src/kit-client.ts index 6f85dfd8f..4733c0051 100644 --- a/packages/mcp-server/src/kit-client.ts +++ b/packages/mcp-server/src/kit-client.ts @@ -205,7 +205,8 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { adminCall<{ expectedVersion: number; clientPayload?: { - format: "toml" | "json" | "text"; + // Opaque: IAPKit owns the format value space (IapkitClientPayloadFormat). + format: string; body: string; version: number; updatedAt: number; @@ -216,7 +217,7 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { setClientPayload: (params: { productId: string; platform: "IOS" | "Android"; - format: "toml" | "json" | "text"; + format: string; body: string; expectedVersion?: number; }) => diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts index bf3ff5f5d..bfeda62d3 100644 --- a/packages/mcp-server/src/mcp.ts +++ b/packages/mcp-server/src/mcp.ts @@ -767,7 +767,11 @@ function registerIapKitTools(server: McpServer) { { productId: PRODUCT_ID_PARAM, platform: z.enum(["IOS", "Android"]), - format: z.enum(["toml", "json", "text"]), + // Opaque: IAPKit owns this value space, so a stale enum here would + // reject a format the server already accepts. + format: kitTextParam("format").describe( + "Payload format, currently toml, json, or text. Forwarded as-is for IAPKit to validate.", + ), body: z .string() .max(MAX_CLIENT_PAYLOAD_BYTES) diff --git a/packages/mcp-server/test/http.test.ts b/packages/mcp-server/test/http.test.ts index fe8497f40..5deb39df6 100644 --- a/packages/mcp-server/test/http.test.ts +++ b/packages/mcp-server/test/http.test.ts @@ -523,6 +523,66 @@ describe("remote MCP HTTP server", () => { } }); + it("forwards an unrecognized client payload format instead of rejecting it", async () => { + const apiKey = "openiap-kit_sk_payload_format"; + const previousBaseUrl = process.env.IAPKIT_BASE_URL; + let forwardedFormat: unknown; + process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + forwardedFormat = JSON.parse(raw).format; + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + id: "payload_2", + created: false, + changed: true, + version: 2, + updatedAt: 456, + }), + ); + }); + }); + + try { + const { baseUrl, sessionId } = await initializeMcpSession(apiKey); + const response = await postMcp( + baseUrl, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "iapkit_set_client_payload", + arguments: { + productId: "premium_monthly", + platform: "IOS", + // A format IAPKit could add after this SDK shipped. + format: "yaml", + body: "rule: premium", + }, + }, + }, + sessionId, + { authorization: `Bearer ${apiKey}` }, + ); + const event = parseSseJson(await response.text()); + + expect(event.result.isError).toBeFalsy(); + expect(forwardedFormat).toBe("yaml"); + expect(JSON.parse(event.result.content[0].text)).toMatchObject({ + changed: true, + version: 2, + }); + } finally { + if (previousBaseUrl === undefined) delete process.env.IAPKIT_BASE_URL; + else process.env.IAPKIT_BASE_URL = previousBaseUrl; + } + }); + it("posts UTF-8-safe synthetic Android webhook payloads", async () => { const secretKey = "openiap-kit_sk_webhook_admin"; const publishableKey = "openiap-kit_pk_webhook_client"; From 968a14f37fe82d5fca7ce086e409327c919a7839 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 14:29:59 +0900 Subject: [PATCH 14/21] docs(kit): add the release note for this deploy The page states that every shipped PR lands an entry. Records the response-contract enforcement, the SDK degrade behaviour, the spec contract audit gating the deploy, X-OpenIAP-Spec, and the compatibility page. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/pages/docs/sections/release-notes.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/kit/src/pages/docs/sections/release-notes.tsx b/packages/kit/src/pages/docs/sections/release-notes.tsx index c79a6e756..bf86cc40c 100644 --- a/packages/kit/src/pages/docs/sections/release-notes.tsx +++ b/packages/kit/src/pages/docs/sections/release-notes.tsx @@ -26,6 +26,34 @@ const KIND_STYLES: Record = { }; const RELEASES: ReleaseEntry[] = [ + { + id: "hosted-2026-08-13", + date: "2026-08-13", + tagline: + "Verify responses stay decodable by app builds compiled against an older SDK.", + items: [ + { + kind: "fix", + text: "Every /v1/purchase/verify response is now validated against the published schema before it is sent. A value outside that schema is degraded rather than emitted: an unpublished state becomes UNKNOWN and an unreadable productId, environment, or clientPayload is dropped. isValid is never rewritten, so a metadata change cannot revoke an entitlement, and a verdict that cannot be made contract-valid returns 500 instead of a body no SDK can trust.", + }, + { + kind: "fix", + text: "SDKs no longer fail a confirmed purchase over optional metadata. environment is forwarded as the opaque String the spec declares instead of being re-checked against Sandbox/Production, and a client payload whose format this build predates is dropped rather than thrown. The store echo and isValid typing stay strict.", + }, + { + kind: "ops", + text: "The purchase-state, client-payload-format, and verify-store enums are declared in kit's Convex layer, its OpenAPI response docs, and the GraphQL schema every SDK generates from. bun audit:kit-contract compares all three and gates both CI and this deploy, so a kit-only enum change can no longer reach published apps unnoticed.", + }, + { + kind: "feature", + text: "Native verification requests send X-OpenIAP-Spec with the OpenIAP spec version the build was compiled against, and kit records it on the structured verify log line. The value is shape-checked and bounded, and nothing branches on it: a client cannot change how its receipt is verified by claiming a version.", + }, + { + kind: "docs", + text: "Version Compatibility documents what IAPKit guarantees to an app compiled against an older SDK: responses are additive, a breaking change would ship as /v2 while /v1 keeps serving, and unrecognised optional values degrade. Gate entitlement on isValid, treat state as a label, and never reject a verification because a value is unrecognised.", + }, + ], + }, { id: "hosted-2026-07-28", date: "2026-07-28", From bdf9a21754c851a59fe7539a74e1a0b0e83dbdb5 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 14:32:44 +0900 Subject: [PATCH 15/21] docs(kit): backfill the release notes for shipped deploys The page states it is the canonical changelog and that every shipped PR lands an entry, but the last one was 2026-07-28 while five production changes had deployed since. Entries reconstructed from each PR, dated by its merge to main, which is when deploy-kit.yml ships it: order lookup (#285), sync/verification/MCP session correctness (#292), the production Convex target guard (#314), store verification integrity (#313), and the entitlement defects the conformance suite surfaced (#316). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/pages/docs/sections/release-notes.tsx | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/kit/src/pages/docs/sections/release-notes.tsx b/packages/kit/src/pages/docs/sections/release-notes.tsx index bf86cc40c..899b04a2c 100644 --- a/packages/kit/src/pages/docs/sections/release-notes.tsx +++ b/packages/kit/src/pages/docs/sections/release-notes.tsx @@ -54,6 +54,83 @@ const RELEASES: ReleaseEntry[] = [ }, ], }, + { + id: "hosted-2026-08-13-entitlements", + date: "2026-08-13", + tagline: + "Entitlement defects found by the new behavioral conformance suite.", + items: [ + { + kind: "fix", + text: "A versioned behavioral conformance suite now binds spec behaviors to real implementations, and the entitlement defects that binding surfaced are fixed. The type and API-surface contract was already drift-gated, but nothing verified what a declared symbol actually did.", + }, + ], + }, + { + id: "hosted-2026-08-12", + date: "2026-08-12", + tagline: + "Store verification integrity across Amazon, Horizon, and raw REST.", + items: [ + { + kind: "security", + text: "Amazon RVS now requires an explicit project-level sandbox opt-in and carries first-class Sandbox / Production provenance, expected-product binding, and strict receipt identity and response validation. A bounded purchase-row reconciler preserves authoritative state across transient failures.", + }, + { + kind: "fix", + text: "Raw REST verification persists only strict boolean verdicts and keeps the last confirmed snapshot across a transient or malformed store response. The unfinished Meta Horizon subscription reconciler is retired and its legacy synthetic metrics source quarantined.", + }, + { + kind: "feature", + text: "Amazon expectedProductId and environment are wired through the GraphQL SSOT into Apple, Google, and every framework SDK, preserving published Kotlin constructor compatibility. Amazon and Horizon purchase counters were added with bounded migration support for existing self-hosted rows.", + }, + { + kind: "ops", + text: "The Convex verifier tree is included in coverage behind separate server (90%) and Convex (48%) gates.", + }, + ], + }, + { + id: "hosted-2026-08-11", + date: "2026-08-11", + tagline: "Production deploys verify their Convex target first.", + items: [ + { + kind: "ops", + text: "The production deploy script verifies it is pointed at the production Convex deployment before publishing, and refuses a development target.", + }, + ], + }, + { + id: "hosted-2026-08-07", + date: "2026-08-07", + tagline: "Product sync, verification, and MCP session correctness.", + items: [ + { + kind: "fix", + text: "Google Play product sync converts the authored price into every Play region and writes each local currency, and reads before masked updates so existing regions, purchase options, and console-authored locales survive. App Store Connect sync, localized listings, and sales regions received the matching corrections.", + }, + { + kind: "fix", + text: "MCP session routing and webhook lifecycle processing were corrected. This is phase 2 of the webhook idempotency work; later phases wait on legacy rows aging past the retention window.", + }, + ], + }, + { + id: "hosted-2026-08-05", + date: "2026-08-05", + tagline: "Read-only order lookup for customer inquiries.", + items: [ + { + kind: "feature", + text: "Paste an Apple or Google order id from a customer receipt and the dashboard returns the full order, plus current subscription status for subscription orders. Lookups are proxied live to the store APIs using the credentials the project already configured for verification; nothing is persisted or logged.", + }, + { + kind: "security", + text: "Order lookup is gated on a dashboard session and organization membership and never accepts an API key. It is operator tooling, not part of the public /v1 surface.", + }, + ], + }, { id: "hosted-2026-07-28", date: "2026-07-28", From ea532b3e1d4973dda5a775236a6523c9ca2f9398 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 14:40:37 +0900 Subject: [PATCH 16/21] fix: address CodeRabbit review on #321 Rethrow CancellationException before the generic handler on both kmp-iap verify paths. The catch this branch added to the Amazon path, and the pre-existing one on the Play path, converted a cancelled coroutine into PurchaseVerificationFailed and emitted a false purchase error. Declare X-OpenIAP-Spec as an optional OpenAPI header parameter so Redoc shows it, rather than describing it only in prose. Describe environment as the opaque provider-defined string it is, with the Amazon values as current examples rather than an exhaustive set. Co-Authored-By: Claude Opus 5 (1M context) --- .../hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt | 2 ++ .../io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt | 2 ++ .../types/verify-purchase-with-provider-result.tsx | 12 +++++++----- packages/kit/server/api/v1/routes.ts | 10 ++++++++++ 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt index 3f685dc11..185c8e6c4 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/AmazonInAppPurchaseAndroid.kt @@ -258,6 +258,8 @@ internal class AmazonInAppPurchaseAndroid( iapkit = androidResult.toKmpIapkitResult(), provider = options.provider ) + } catch (error: CancellationException) { + throw error } catch (error: Exception) { failWith( PurchaseError( 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 a3858c469..919e0436b 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 @@ -2212,6 +2212,8 @@ internal class InAppPurchaseAndroid( iapkit = iapkitResult, provider = options.provider ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { failWith( PurchaseError( diff --git a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx index 9a7255953..18b5101a3 100644 --- a/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx +++ b/packages/docs/src/pages/docs/types/verify-purchase-with-provider-result.tsx @@ -165,11 +165,13 @@ function VerifyPurchaseWithProviderResult() { Available in OpenIAP Spec 3.2.0 / openiap-apple 3.2.0 / - openiap-google 3.3.0. Amazon RVS environment. Handled Amazon - responses use exactly 'Sandbox' or{' '} - 'Production'; other stores omit it. Treat it as an - opaque string and forward it β€” never fail a verification because - the value is unrecognised. + openiap-google 3.3.0. Opaque, provider-defined store + environment. Handled Amazon responses currently report{' '} + 'Sandbox' or 'Production', and other + stores omit it today, but the value space belongs to the + provider β€” App Store Server also names 'Xcode' and{' '} + 'LocalTesting'. Forward it and never fail a + verification because the value is unrecognised. diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index f25592518..2583afcb8 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -232,6 +232,16 @@ const verifyPurchaseRouteDescription = describeRoute({ "data. It never changes how a receipt is verified, and an unrecognised " + "value is ignored rather than rejected.", security: [{ apiKey: [] }], + parameters: [ + { + in: "header" as const, + name: "X-OpenIAP-Spec", + required: false, + description: + "OpenIAP spec version the calling SDK was built against, for example `3.2.0`. Recorded for rollout measurement only: it never changes how a receipt is verified, and an unrecognised value is ignored rather than rejected.", + schema: { type: "string" as const }, + }, + ], responses: { 200: { description: "Successful verification", From 0b5d3fd608ce20b34ed82fe88216d1603f6a25d7 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 14:59:23 +0900 Subject: [PATCH 17/21] fix(kit): audit the write path and stop casting Apple's environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract audit only read the response schema, but kit declares the client-payload format set in four more files on the write path. A format added there and not to the response schema is accepted on write and then silently dropped on read by enforceVerifyResponseContract. The audit now parses every declaration β€” valibot union, Set literal, and TypeScript union type β€” and compares each to the spec, with a test that fails when a write-path-only format is introduced. ios.ts cast Apple's environment to the Sandbox/Production pair the receipt validator requires, but Apple's Environment enum also defines Xcode and LocalTesting, so the cast was a lie that Convex would reject at the boundary. A shared helper narrows instead: anything that is not Production is a non-production purchase. Co-Authored-By: Claude Opus 5 (1M context) --- packages/kit/convex/purchases/ios.ts | 5 +- packages/kit/convex/purchases/shared.test.ts | 19 +++++ packages/kit/convex/purchases/shared.ts | 9 +++ scripts/audit-kit-spec-contract.mjs | 80 +++++++++++++++++++- scripts/audit-kit-spec-contract.test.mjs | 39 ++++++++++ 5 files changed, 147 insertions(+), 5 deletions(-) diff --git a/packages/kit/convex/purchases/ios.ts b/packages/kit/convex/purchases/ios.ts index 39ff8d190..4b8150b32 100644 --- a/packages/kit/convex/purchases/ios.ts +++ b/packages/kit/convex/purchases/ios.ts @@ -23,6 +23,7 @@ import { AppStoreProductType, receiptResponseValidator, isValidState, + narrowAppleEnvironment, } from "./shared"; import { HarmonizedPurchaseState } from "./purchaseState"; import { @@ -262,7 +263,7 @@ export async function verifyJWSTransaction( currency: verifiedTransaction.currency, storefront: verifiedTransaction.storefront, storefrontId: verifiedTransaction.storefrontId, - environment: verifiedTransaction.environment as "Sandbox" | "Production", + environment: narrowAppleEnvironment(verifiedTransaction.environment), webOrderLineItemId: verifiedTransaction.webOrderLineItemId, subscriptionGroupIdentifier: verifiedTransaction.subscriptionGroupIdentifier, @@ -549,7 +550,7 @@ function buildFailedAppStoreReceiptData( currency: payload.currency, storefront: payload.storefront, storefrontId: payload.storefrontId, - environment: payload.environment as "Sandbox" | "Production", + environment: narrowAppleEnvironment(payload.environment), webOrderLineItemId: payload.webOrderLineItemId, subscriptionGroupIdentifier: payload.subscriptionGroupIdentifier, expiresDate: payload.expiresDate, diff --git a/packages/kit/convex/purchases/shared.test.ts b/packages/kit/convex/purchases/shared.test.ts index bbf4c89e8..bd759bc84 100644 --- a/packages/kit/convex/purchases/shared.test.ts +++ b/packages/kit/convex/purchases/shared.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { AppStoreProductType, + narrowAppleEnvironment, AppStoreTransactionReason, mapAppStorePurchaseState, mapGooglePlayPurchaseState, @@ -293,6 +294,24 @@ describe("mapAppStorePurchaseState", () => { }); }); +describe("narrowAppleEnvironment", () => { + it("keeps Production and narrows every non-production value to Sandbox", () => { + expect(narrowAppleEnvironment("Production")).toBe("Production"); + // Apple's Environment enum also defines Xcode and LocalTesting; a receipt + // row stores the pair, so neither may be reported as a real purchase. + for (const value of [ + "Sandbox", + "Xcode", + "LocalTesting", + "", + undefined, + null, + ]) { + expect(narrowAppleEnvironment(value)).toBe("Sandbox"); + } + }); +}); + describe("isValidState", () => { it("returns true for ENTITLED state", () => { expect(isValidState(HarmonizedPurchaseState.ENTITLED)).toBe(true); diff --git a/packages/kit/convex/purchases/shared.ts b/packages/kit/convex/purchases/shared.ts index 1a0a1e52c..aeff1d520 100644 --- a/packages/kit/convex/purchases/shared.ts +++ b/packages/kit/convex/purchases/shared.ts @@ -90,6 +90,15 @@ export type AppStoreReceiptData = Infer; export type ReceiptResponse = Infer; +// Apple reports four environments; a receipt row stores the pair. Anything that +// is not Production is a non-production purchase, so it narrows to Sandbox +// rather than being cast β€” the cast would fail this validator at the boundary. +export function narrowAppleEnvironment( + value: string | null | undefined, +): "Sandbox" | "Production" { + return value === "Production" ? "Production" : "Sandbox"; +} + export const purchaseTypeValidator = v.union( v.literal("NON_CONSUMABLE"), v.literal("SUBSCRIPTION"), diff --git a/scripts/audit-kit-spec-contract.mjs b/scripts/audit-kit-spec-contract.mjs index 47180ab7a..a7d6b4888 100644 --- a/scripts/audit-kit-spec-contract.mjs +++ b/scripts/audit-kit-spec-contract.mjs @@ -2,9 +2,9 @@ // Compares IAPKit's response enums against the spec every SDK generates from. // -// Covers the /v1 verify RESPONSE only: kit's write path declares the same -// format set in convex/schema.ts, convex/products/ and server/api/v1/products.ts, -// across a tsconfig split that stops them sharing a constant. +// Covers the /v1 verify response and kit's write path, which declares the same +// client-payload format set in four more files across a tsconfig split that +// stops them sharing a constant. import fs from "node:fs"; import path from "node:path"; @@ -18,6 +18,16 @@ export const CONVEX_STATE_FILE = export const RESPONSE_SCHEMA_FILE = "packages/kit/server/api/v1/route-response-schemas.ts"; +// kit accepts a client-payload format on write and returns it on read. A format +// added to only one side is accepted then silently dropped by +// enforceVerifyResponseContract, so every declaration is compared to the spec. +export const WRITE_PATH_FORMAT_FILES = [ + "packages/kit/convex/schema.ts", + "packages/kit/convex/products/query.ts", + "packages/kit/convex/products/mutation.ts", + "packages/kit/server/api/v1/products.ts", +]; + const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), "utf8"); @@ -109,6 +119,56 @@ export const parseValibotLiteralUnion = (source, anchor) => { ); }; +const balancedGroupAt = (source, openIndex) => { + const open = source[openIndex]; + const close = open === "(" ? ")" : "]"; + let level = 0; + for (let i = openIndex; i < source.length; i += 1) { + if (source[i] === open) level += 1; + else if (source[i] === close) { + level -= 1; + if (level === 0) return source.slice(openIndex, i + 1); + } + } + return source.slice(openIndex); +}; + +/** + * Client-payload format sets declared in a file, in each of the shapes kit uses: + * a valibot union, a Set literal, and a TypeScript union type. + */ +export const parseFormatDeclarations = (source, knownFormat = "toml") => { + const clean = stripComments(source); + const groups = []; + + for (const opener of ["v.union(", "new Set(", "= ["]) { + let at = clean.indexOf(opener); + while (at !== -1) { + const openIndex = clean.indexOf(opener.endsWith("[") ? "[" : "(", at); + groups.push(balancedGroupAt(clean, openIndex)); + at = clean.indexOf(opener, at + 1); + } + } + for (const match of clean.matchAll( + /"[a-z][a-z0-9_-]*"(?:\s*\|\s*"[a-z][a-z0-9_-]*")+/g, + )) { + groups.push(match[0]); + } + + const declarations = groups + .map((group) => [ + ...new Set( + [...group.matchAll(/"([a-z][a-z0-9_-]*)"/g)].map((match) => match[1]), + ), + ]) + .filter((values) => values.includes(knownFormat)); + + if (declarations.length === 0) { + throw new Error("no client payload format declaration found"); + } + return declarations; +}; + const compare = (label, expected, actual) => { const failures = []; const missing = expected.filter((value) => !actual.includes(value)); @@ -126,6 +186,9 @@ export const collectContractFailures = ({ schema = read(SCHEMA_FILE), convexState = read(CONVEX_STATE_FILE), responseSchema: rawResponseSchema = read(RESPONSE_SCHEMA_FILE), + writePathFormatSources = Object.fromEntries( + WRITE_PATH_FORMAT_FILES.map((file) => [file, read(file)]), + ), } = {}) => { // Anchors match raw text, so a comment inside a declaration would make one // miss and block the deploy gate. @@ -159,6 +222,17 @@ export const collectContractFailures = ({ "clientPayloadSchema = v\\.object\\(\\{\\s*format:\\s*", ), ), + // Write path: a format kit accepts on write but the response schema omits + // is silently dropped on read, so every declaration must match the spec. + ...Object.entries(writePathFormatSources).flatMap(([file, source]) => + parseFormatDeclarations(source).flatMap((values, index) => + compare( + `${file} client payload format declaration ${index + 1} vs ${SCHEMA_FILE} IapkitClientPayloadFormat`, + specFormats, + values, + ), + ), + ), // Stores are one-directional: kit may verify fewer stores than the spec // names, but never one the spec omits β€” no SDK could ask for it. ...parseValibotLiteralUnion(responseSchema, "const verifyStoreSchema = ") diff --git a/scripts/audit-kit-spec-contract.test.mjs b/scripts/audit-kit-spec-contract.test.mjs index 7d0c480bf..98136f9f5 100644 --- a/scripts/audit-kit-spec-contract.test.mjs +++ b/scripts/audit-kit-spec-contract.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { collectContractFailures, + parseFormatDeclarations, runAudit, parseDocumentedStates, parseGraphqlEnum, @@ -63,6 +64,7 @@ const sources = (overrides = {}) => ({ schema: SCHEMA, convexState: CONVEX_STATE, responseSchema: RESPONSE_SCHEMA, + writePathFormatSources: WRITE_PATH, ...overrides, }); @@ -182,6 +184,43 @@ test("a declaration that parses to nothing is a failure, not agreement", () => { ); }); +const WRITE_PATH = { + "packages/kit/convex/schema.ts": + 'format: v.union(v.literal("toml"), v.literal("json")),\n', +}; + +test("write-path format declarations are read in every shape kit uses", () => { + assert.deepEqual( + parseFormatDeclarations( + 'const a = v.union(v.literal("toml"), v.literal("json"));\n' + + 'const b = new Set(["toml", "json"]);\n' + + 'type C = "toml" | "json";\n', + ), + [ + ["toml", "json"], + ["toml", "json"], + ["toml", "json"], + ], + ); +}); + +test("a format accepted on write but absent from the response schema fails", () => { + // enforceVerifyResponseContract would silently drop it on read. + const failures = collectContractFailures( + sources({ + writePathFormatSources: { + "packages/kit/convex/schema.ts": + 'format: v.union(v.literal("toml"), v.literal("json"), v.literal("yaml")),\n', + }, + }), + ); + assert.equal(failures.length, 1); + assert.match( + failures[0], + /schema\.ts client payload format.*unexpected.*yaml/, + ); +}); + test("aligned declarations produce no failures", () => { assert.deepEqual(collectContractFailures(sources()), []); }); From ed8674c5d565cfa22a0ebc006f2f7ac0cf213cef Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 16:19:36 +0900 Subject: [PATCH 18/21] fix: close remaining kit compatibility gaps Keep client payload formats type-safe when newer servers add values. Generate whitespace-clean blank doc comments and align the spec header documentation with the compile-time implementation. --- .../expo-iap/src/__tests__/kit-api.test.ts | 10 +++++-- libraries/expo-iap/src/kit-api.ts | 3 ++- .../flutter_inapp_purchase/lib/types.dart | 26 +++++++++---------- .../src/__tests__/kit-api.test.ts | 10 +++++-- libraries/react-native-iap/src/kit-api.ts | 3 ++- packages/apple/Sources/Models/Types.swift | 26 +++++++++---------- packages/apple/Sources/OpenIapModule.swift | 3 +-- .../docs/src/pages/docs/kit-compatibility.tsx | 5 ++-- packages/gql/codegen/plugins/dart.ts | 2 +- packages/gql/codegen/plugins/swift.ts | 2 +- packages/gql/src/codegen-defaults.test.ts | 15 +++++++++++ packages/gql/src/generated/Types.swift | 26 +++++++++---------- packages/gql/src/generated/types.dart | 26 +++++++++---------- packages/gql/src/kit-api.ts | 3 ++- 14 files changed, 95 insertions(+), 65 deletions(-) diff --git a/libraries/expo-iap/src/__tests__/kit-api.test.ts b/libraries/expo-iap/src/__tests__/kit-api.test.ts index 6fac261ff..f290227e7 100644 --- a/libraries/expo-iap/src/__tests__/kit-api.test.ts +++ b/libraries/expo-iap/src/__tests__/kit-api.test.ts @@ -1,4 +1,4 @@ -import {kitApi, KitApiError} from '../kit-api'; +import {kitApi, KitApiError, type KitProductClientPayload} from '../kit-api'; const payload = { clientPayload: { @@ -347,8 +347,14 @@ describe('kitApi cache resilience', () => { // 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: {format: 'yaml', body: 'tier: gold', version: 2, updatedAt: 9}, + clientPayload, etag: 'W/"cached"', }; const cache = { diff --git a/libraries/expo-iap/src/kit-api.ts b/libraries/expo-iap/src/kit-api.ts index a76973d41..f5a78d7af 100644 --- a/libraries/expo-iap/src/kit-api.ts +++ b/libraries/expo-iap/src/kit-api.ts @@ -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; diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 7f247628b..13e69dde8 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -1783,10 +1783,10 @@ class DiscountDisplayInfoAndroid { /// Standardized one-time product discount offer. /// Provides a platform-neutral OpenIAP shape for Google Play one-time product /// purchase options and offers. -/// +/// /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. -/// +/// /// @see https://openiap.dev/docs/types/discount-offer class DiscountOffer { const DiscountOffer({ @@ -3353,7 +3353,7 @@ class 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 @@ -3427,11 +3427,11 @@ class SubscriptionCommitmentInfoIOS { /// Standardized subscription discount/promotional offer. /// Provides a unified interface for subscription offers across iOS and Android. -/// +/// /// Both platforms support subscription offers with different implementations: /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases -/// +/// /// @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer { const SubscriptionOffer({ @@ -4594,7 +4594,7 @@ class _SubsPurchase extends RequestPurchaseProps { } /// Platform-specific purchase request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -4772,7 +4772,7 @@ class RequestSubscriptionIosProps { } /// Platform-specific subscription request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -4884,7 +4884,7 @@ class RequestVerifyPurchaseWithIapkitGoogleProps { } /// Platform-specific verification parameters for IAPKit. -/// +/// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) /// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) @@ -4994,7 +4994,7 @@ class VerifyPurchaseAppleOptions { /// Google Play Store verification parameters. /// Used for server-side receipt validation via Google Play Developer API. -/// +/// /// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. class VerifyPurchaseGoogleOptions { const VerifyPurchaseGoogleOptions({ @@ -5042,7 +5042,7 @@ class VerifyPurchaseGoogleOptions { /// Meta Horizon (Quest) verification parameters. /// Used for server-side entitlement verification via Meta's S2S API. /// POST https://graph.oculus.com/$APP_ID/verify_entitlement -/// +/// /// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. class VerifyPurchaseHorizonOptions { const VerifyPurchaseHorizonOptions({ @@ -5077,7 +5077,7 @@ class VerifyPurchaseHorizonOptions { } /// Platform-specific purchase verification parameters. -/// +/// /// - apple: Verifies via App Store Server API /// - google: Verifies via Google Play Developer API /// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) @@ -5628,7 +5628,7 @@ abstract class SubscriptionResolver { }); /// Fires when a subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: - /// + /// /// - iOS 16.4+ / Mac Catalyst 16.4+ / visionOS 1.0+: delivered via StoreKit 2 /// `Message.Reason.billingIssue`. /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected @@ -5637,7 +5637,7 @@ abstract class SubscriptionResolver { /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. /// - Android (Amazon flavor): NOT emitted. Amazon Appstore IAP does not expose an /// equivalent subscription billing-issue signal. - /// + /// /// Listeners should not assume the event will fire on every store. Direct users to the /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. Future subscriptionBillingIssue(); diff --git a/libraries/react-native-iap/src/__tests__/kit-api.test.ts b/libraries/react-native-iap/src/__tests__/kit-api.test.ts index 83d843895..6a7571e65 100644 --- a/libraries/react-native-iap/src/__tests__/kit-api.test.ts +++ b/libraries/react-native-iap/src/__tests__/kit-api.test.ts @@ -1,4 +1,4 @@ -import {kitApi, KitApiError} from '../kit-api'; +import {kitApi, KitApiError, type KitProductClientPayload} from '../kit-api'; const payload = { clientPayload: { @@ -351,8 +351,14 @@ describe('kitApi cache resilience', () => { // 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: {format: 'yaml', body: 'tier: gold', version: 2, updatedAt: 9}, + clientPayload, etag: 'W/"cached"', }; const cache = { diff --git a/libraries/react-native-iap/src/kit-api.ts b/libraries/react-native-iap/src/kit-api.ts index a76973d41..f5a78d7af 100644 --- a/libraries/react-native-iap/src/kit-api.ts +++ b/libraries/react-native-iap/src/kit-api.ts @@ -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; diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index c8fbe8c0e..e8e641c04 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -738,10 +738,10 @@ public struct DiscountDisplayInfoAndroid: Codable { /// Standardized one-time product discount offer. /// Provides a platform-neutral OpenIAP shape for Google Play one-time product /// purchase options and offers. -/// +/// /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. -/// +/// /// @see https://openiap.dev/docs/types/discount-offer public struct DiscountOffer: Codable { /// Currency code (ISO 4217, e.g., "USD") @@ -1239,7 +1239,7 @@ public struct RequestVerifyPurchaseWithIapkitResult: Codable { /// 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 @@ -1267,11 +1267,11 @@ public struct SubscriptionCommitmentInfoIOS: Codable { /// Standardized subscription discount/promotional offer. /// Provides a unified interface for subscription offers across iOS and Android. -/// +/// /// Both platforms support subscription offers with different implementations: /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases -/// +/// /// @see https://openiap.dev/docs/types/subscription-offer public struct SubscriptionOffer: Codable { /// [Android] Base plan identifier. @@ -1899,7 +1899,7 @@ public struct RequestPurchaseProps: Codable { } /// Platform-specific purchase request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -2029,7 +2029,7 @@ public struct RequestSubscriptionIosProps: Codable { } /// Platform-specific subscription request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -2097,7 +2097,7 @@ public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable { } /// Platform-specific verification parameters for IAPKit. -/// +/// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) /// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) @@ -2171,7 +2171,7 @@ public struct VerifyPurchaseAppleOptions: Codable { /// Google Play Store verification parameters. /// Used for server-side receipt validation via Google Play Developer API. -/// +/// /// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. public struct VerifyPurchaseGoogleOptions: Codable { /// Google OAuth2 access token for API authentication. @@ -2205,7 +2205,7 @@ public struct VerifyPurchaseGoogleOptions: Codable { /// Meta Horizon (Quest) verification parameters. /// Used for server-side entitlement verification via Meta's S2S API. /// POST https://graph.oculus.com/$APP_ID/verify_entitlement -/// +/// /// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. public struct VerifyPurchaseHorizonOptions: Codable { /// Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). @@ -2228,7 +2228,7 @@ public struct VerifyPurchaseHorizonOptions: Codable { } /// Platform-specific purchase verification parameters. -/// +/// /// - apple: Verifies via App Store Server API /// - google: Verifies via Google Play Developer API /// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) @@ -2837,7 +2837,7 @@ public protocol SubscriptionResolver { func purchaseUpdated(_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase /// Fires when a subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: - /// + /// /// - iOS 16.4+ / Mac Catalyst 16.4+ / visionOS 1.0+: delivered via StoreKit 2 /// `Message.Reason.billingIssue`. /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected @@ -2846,7 +2846,7 @@ public protocol SubscriptionResolver { /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. /// - Android (Amazon flavor): NOT emitted. Amazon Appstore IAP does not expose an /// equivalent subscription billing-issue signal. - /// + /// /// Listeners should not assume the event will fire on every store. Direct users to the /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. func subscriptionBillingIssue() async throws -> Purchase diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index aba5bec44..81ff94c19 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -106,8 +106,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { ) } - /// `X-OpenIAP-Spec` reports the response contract this build was compiled - /// against. Reported, never negotiated: omitted when unreadable. + /// Reports the compile-time response contract; never used for negotiation. static func makeIapkitRequest( url: URL, apiKey: String?, diff --git a/packages/docs/src/pages/docs/kit-compatibility.tsx b/packages/docs/src/pages/docs/kit-compatibility.tsx index 1950cff59..2bbaaee98 100644 --- a/packages/docs/src/pages/docs/kit-compatibility.tsx +++ b/packages/docs/src/pages/docs/kit-compatibility.tsx @@ -130,8 +130,9 @@ X-OpenIAP-Spec: 3.2.0`} It is reported, never negotiated. IAPKit records it so a contract change can be measured against the versions actually calling, and never branches verification on it β€” a client cannot change how its - receipt is verified by claiming a version. The header is omitted when - the version cannot be read, which is not an error. + receipt is verified by claiming a version. SDK builds that support the + header always send their compile-time spec version; older builds omit + it.

    diff --git a/packages/gql/codegen/plugins/dart.ts b/packages/gql/codegen/plugins/dart.ts index 1d7e1bb7c..3f8227a4c 100644 --- a/packages/gql/codegen/plugins/dart.ts +++ b/packages/gql/codegen/plugins/dart.ts @@ -149,7 +149,7 @@ export class DartPlugin extends CodegenPlugin { protected generateDocComment(description: string | undefined, indent: string = ''): void { if (!description) return; for (const line of description.split(/\r?\n/)) { - this.emit(`${indent}/// ${line}`); + this.emit(line ? `${indent}/// ${line}` : `${indent}///`); } } diff --git a/packages/gql/codegen/plugins/swift.ts b/packages/gql/codegen/plugins/swift.ts index 8acd8da02..a0d807c1f 100644 --- a/packages/gql/codegen/plugins/swift.ts +++ b/packages/gql/codegen/plugins/swift.ts @@ -739,7 +739,7 @@ export class SwiftPlugin extends CodegenPlugin { protected generateDocComment(description: string | undefined, indent: string = ''): void { if (!description) return; for (const line of description.split(/\r?\n/)) { - this.emit(`${indent}/// ${line}`); + this.emit(line ? `${indent}/// ${line}` : `${indent}///`); } } diff --git a/packages/gql/src/codegen-defaults.test.ts b/packages/gql/src/codegen-defaults.test.ts index b05022769..5eeb1cc52 100644 --- a/packages/gql/src/codegen-defaults.test.ts +++ b/packages/gql/src/codegen-defaults.test.ts @@ -133,6 +133,21 @@ describe('codegen defaults', () => { expect(output).not.toContain('\n /// '); }); + it('emits blank Swift and Dart doc lines without trailing whitespace', () => { + const documentedField = field('value', stringType); + documentedField.description = 'First line.\n\nSecond line.'; + + for (const output of [ + new SwiftPlugin({ outputPath: 'Types.swift' }).generate(schema([documentedField])), + new DartPlugin({ outputPath: 'types.dart' }).generate(schema([documentedField])), + ]) { + expect(output).toContain('/// First line.'); + expect(output).toContain('///\n'); + expect(output).toContain('/// Second line.'); + expect(output).not.toMatch(/[ \t]+$/m); + } + }); + it('keeps unsupported non-null C# defaults required and escapes string literals', () => { const output = new CSharpPlugin({ outputPath: 'Types.cs' }).generate( schema([field('unsupportedDefault', stringType, { raw: 'unsupported' }), field('escapedString', stringType, 'quote " and slash \\')]), diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index c8fbe8c0e..e8e641c04 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -738,10 +738,10 @@ public struct DiscountDisplayInfoAndroid: Codable { /// Standardized one-time product discount offer. /// Provides a platform-neutral OpenIAP shape for Google Play one-time product /// purchase options and offers. -/// +/// /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. -/// +/// /// @see https://openiap.dev/docs/types/discount-offer public struct DiscountOffer: Codable { /// Currency code (ISO 4217, e.g., "USD") @@ -1239,7 +1239,7 @@ public struct RequestVerifyPurchaseWithIapkitResult: Codable { /// 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 @@ -1267,11 +1267,11 @@ public struct SubscriptionCommitmentInfoIOS: Codable { /// Standardized subscription discount/promotional offer. /// Provides a unified interface for subscription offers across iOS and Android. -/// +/// /// Both platforms support subscription offers with different implementations: /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases -/// +/// /// @see https://openiap.dev/docs/types/subscription-offer public struct SubscriptionOffer: Codable { /// [Android] Base plan identifier. @@ -1899,7 +1899,7 @@ public struct RequestPurchaseProps: Codable { } /// Platform-specific purchase request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -2029,7 +2029,7 @@ public struct RequestSubscriptionIosProps: Codable { } /// Platform-specific subscription request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -2097,7 +2097,7 @@ public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable { } /// Platform-specific verification parameters for IAPKit. -/// +/// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) /// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) @@ -2171,7 +2171,7 @@ public struct VerifyPurchaseAppleOptions: Codable { /// Google Play Store verification parameters. /// Used for server-side receipt validation via Google Play Developer API. -/// +/// /// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. public struct VerifyPurchaseGoogleOptions: Codable { /// Google OAuth2 access token for API authentication. @@ -2205,7 +2205,7 @@ public struct VerifyPurchaseGoogleOptions: Codable { /// Meta Horizon (Quest) verification parameters. /// Used for server-side entitlement verification via Meta's S2S API. /// POST https://graph.oculus.com/$APP_ID/verify_entitlement -/// +/// /// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. public struct VerifyPurchaseHorizonOptions: Codable { /// Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). @@ -2228,7 +2228,7 @@ public struct VerifyPurchaseHorizonOptions: Codable { } /// Platform-specific purchase verification parameters. -/// +/// /// - apple: Verifies via App Store Server API /// - google: Verifies via Google Play Developer API /// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) @@ -2837,7 +2837,7 @@ public protocol SubscriptionResolver { func purchaseUpdated(_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase /// Fires when a subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: - /// + /// /// - iOS 16.4+ / Mac Catalyst 16.4+ / visionOS 1.0+: delivered via StoreKit 2 /// `Message.Reason.billingIssue`. /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected @@ -2846,7 +2846,7 @@ public protocol SubscriptionResolver { /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. /// - Android (Amazon flavor): NOT emitted. Amazon Appstore IAP does not expose an /// equivalent subscription billing-issue signal. - /// + /// /// Listeners should not assume the event will fire on every store. Direct users to the /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. func subscriptionBillingIssue() async throws -> Purchase diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 7f247628b..13e69dde8 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -1783,10 +1783,10 @@ class DiscountDisplayInfoAndroid { /// Standardized one-time product discount offer. /// Provides a platform-neutral OpenIAP shape for Google Play one-time product /// purchase options and offers. -/// +/// /// Currently populated only on Android (Google Play Billing 8.0+). /// iOS does not populate this type. -/// +/// /// @see https://openiap.dev/docs/types/discount-offer class DiscountOffer { const DiscountOffer({ @@ -3353,7 +3353,7 @@ class 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 @@ -3427,11 +3427,11 @@ class SubscriptionCommitmentInfoIOS { /// Standardized subscription discount/promotional offer. /// Provides a unified interface for subscription offers across iOS and Android. -/// +/// /// Both platforms support subscription offers with different implementations: /// - iOS: Introductory offers, promotional offers with server-side signatures /// - Android: Offer tokens with pricing phases -/// +/// /// @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer { const SubscriptionOffer({ @@ -4594,7 +4594,7 @@ class _SubsPurchase extends RequestPurchaseProps { } /// Platform-specific purchase request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -4772,7 +4772,7 @@ class RequestSubscriptionIosProps { } /// Platform-specific subscription request parameters. -/// +/// /// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. /// - apple: Always targets App Store /// - google: Targets Play Store by default, Horizon when built with horizon flavor, @@ -4884,7 +4884,7 @@ class RequestVerifyPurchaseWithIapkitGoogleProps { } /// Platform-specific verification parameters for IAPKit. -/// +/// /// - apple: Verifies via App Store (JWS token) /// - google: Verifies via Play Store (purchase token) /// - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) @@ -4994,7 +4994,7 @@ class VerifyPurchaseAppleOptions { /// Google Play Store verification parameters. /// Used for server-side receipt validation via Google Play Developer API. -/// +/// /// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. class VerifyPurchaseGoogleOptions { const VerifyPurchaseGoogleOptions({ @@ -5042,7 +5042,7 @@ class VerifyPurchaseGoogleOptions { /// Meta Horizon (Quest) verification parameters. /// Used for server-side entitlement verification via Meta's S2S API. /// POST https://graph.oculus.com/$APP_ID/verify_entitlement -/// +/// /// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. class VerifyPurchaseHorizonOptions { const VerifyPurchaseHorizonOptions({ @@ -5077,7 +5077,7 @@ class VerifyPurchaseHorizonOptions { } /// Platform-specific purchase verification parameters. -/// +/// /// - apple: Verifies via App Store Server API /// - google: Verifies via Google Play Developer API /// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) @@ -5628,7 +5628,7 @@ abstract class SubscriptionResolver { }); /// Fires when a subscription enters a billing-issue state that needs user action /// (payment method failed, card expired, etc.). Cross-platform unification: - /// + /// /// - iOS 16.4+ / Mac Catalyst 16.4+ / visionOS 1.0+: delivered via StoreKit 2 /// `Message.Reason.billingIssue`. /// - Android (Play flavor, Billing 8.1+): emitted when `isSuspended == true` is first detected @@ -5637,7 +5637,7 @@ abstract class SubscriptionResolver { /// the Play Billing 7.0 API surface which does not expose a suspended-subscription signal. /// - Android (Amazon flavor): NOT emitted. Amazon Appstore IAP does not expose an /// equivalent subscription billing-issue signal. - /// + /// /// Listeners should not assume the event will fire on every store. Direct users to the /// platform subscription management UI (`deepLinkToSubscriptions`) to resolve the issue. Future subscriptionBillingIssue(); diff --git a/packages/gql/src/kit-api.ts b/packages/gql/src/kit-api.ts index a76973d41..f5a78d7af 100644 --- a/packages/gql/src/kit-api.ts +++ b/packages/gql/src/kit-api.ts @@ -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; From d47b1b9f03f1ab93d4a8c29f5e8d4c7bdfc0d751 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 16:31:01 +0900 Subject: [PATCH 19/21] test: restore expo coverage gate --- .../src/__tests__/vega-adapter.test.ts | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index 2dbf6c422..8c55ea52b 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -65,7 +65,7 @@ const createService = (): jest.Mocked => notifyFulfillment: jest.fn(async () => ({ responseCode: 1, })), - }) as unknown as jest.Mocked; + } as unknown as jest.Mocked); describe('Amazon Vega Expo adapter', () => { it('initializes without fetching Amazon user data', async () => { @@ -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(); From bd01c9dce925d245b0af0fcc0e05e4788fa26132 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 16:33:36 +0900 Subject: [PATCH 20/21] test: cover mapped ipv6 kit origins --- libraries/expo-iap/src/__tests__/vega-adapter.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index 8c55ea52b..6d6978663 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -1319,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', From 9e5d36a2143bda8283a6aebb16a29fc8df6dccf7 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 13 Aug 2026 17:08:53 +0900 Subject: [PATCH 21/21] fix: align remaining contract review notes --- AGENTS.md | 1 + libraries/godot-iap/addons/godot-iap/types.gd | 20 +++++++-------- packages/gql/codegen/plugins/gdscript.ts | 7 ++---- packages/gql/src/generated-gdscript.test.ts | 2 +- packages/gql/src/generated/types.gd | 20 +++++++-------- .../server/api/v1/response-contract.test.ts | 25 ++++++++++--------- .../kit/server/api/v1/response-contract.ts | 8 +++--- .../api/v1/route-response-schemas.test.ts | 12 ++++++--- .../server/api/v1/route-response-schemas.ts | 4 +-- packages/kit/server/api/v1/routes.test.ts | 3 ++- 10 files changed, 53 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3c2ec5a1c..15c766369 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 96795237a..097d3c06d 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -993,7 +993,7 @@ class DiscountDisplayInfoAndroid: dict["discountAmount"] = discount_amount return dict -## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer +## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer class DiscountOffer: ## Unique identifier for the offer. - iOS: Not applicable (one-time discounts not supported) - Android: offerId from the Google Play one-time purchase option var id: Variant = null @@ -2755,7 +2755,7 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: IapStore - ## 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. + ## 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. var environment: Variant = null ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. var is_valid: bool = false @@ -2842,7 +2842,7 @@ class SubscriptionCommitmentInfoIOS: dict["price"] = price return dict -## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer +## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer: ## Unique identifier for the offer. - iOS: Discount identifier from App Store Connect - Android: offerId from the Google Play subscription offer var id: String = "" @@ -3718,7 +3718,7 @@ class InAppMessageParamsAndroid: ## Connection initialization configuration class InitConnectionConfig: - ## Enable a specific billing program for Android (7.0+) When set, enables the specified billing program for external transactions. - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) - EXTERNAL_CONTENT_LINK: Link to external content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_OFFER: External offers for digital content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) - BILLING_CHOICE: Google-rendered or developer-rendered billing choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) + ## Enable a specific billing program for Android (7.0+) When set, enables the specified billing program for external transactions. - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) - EXTERNAL_CONTENT_LINK: Link to external content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_OFFER: External offers for digital content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) - BILLING_CHOICE: Google-rendered or developer-rendered billing choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) var enable_billing_program_android: Variant = null ## Billing Choice renderer configured in Play Console. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP can emit the selection event. DEVELOPER_RENDERED omits that listener so the app can render its own choice screen and use the reporting/dialog/link APIs. Must match choiceScreenType returned by isBillingProgramAvailableAndroid. Defaults to GOOGLE_RENDERED. var billing_choice_screen_type_android: BillingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED @@ -4160,7 +4160,7 @@ class RequestPurchaseProps: dict["type"] = PRODUCT_QUERY_TYPE_VALUES.get(type, type) return dict -## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestPurchasePropsByPlatforms: ## Apple-specific purchase parameters var apple: RequestPurchaseIosProps @@ -4380,7 +4380,7 @@ class RequestSubscriptionIosProps: dict["advancedCommerceData"] = advanced_commerce_data return dict -## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestSubscriptionPropsByPlatforms: ## Apple-specific subscription parameters var apple: RequestSubscriptionIosProps @@ -4481,7 +4481,7 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: dict["purchaseToken"] = purchase_token return dict -## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) +## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null @@ -4593,7 +4593,7 @@ class VerifyPurchaseAppleOptions: dict["sku"] = sku return dict -## Google Play Store verification parameters. Used for server-side receipt validation via Google Play Developer API. ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. +## Google Play Store verification parameters. Used for server-side receipt validation via Google Play Developer API. ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. class VerifyPurchaseGoogleOptions: ## Product SKU to validate var sku: String = "" @@ -4634,7 +4634,7 @@ class VerifyPurchaseGoogleOptions: dict["isSub"] = is_sub return dict -## Meta Horizon (Quest) verification parameters. Used for server-side entitlement verification via Meta's S2S API. POST https://graph.oculus.com/$APP_ID/verify_entitlement ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. +## Meta Horizon (Quest) verification parameters. Used for server-side entitlement verification via Meta's S2S API. POST https://graph.oculus.com/$APP_ID/verify_entitlement ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. class VerifyPurchaseHorizonOptions: ## The SKU for the add-on item, defined in Meta Developer Dashboard var sku: String = "" @@ -4663,7 +4663,7 @@ class VerifyPurchaseHorizonOptions: dict["accessToken"] = access_token return dict -## Platform-specific purchase verification parameters. - apple: Verifies via App Store Server API - google: Verifies via Google Play Developer API - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) +## Platform-specific purchase verification parameters. - apple: Verifies via App Store Server API - google: Verifies via Google Play Developer API - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) class VerifyPurchaseProps: ## Apple App Store verification parameters. var apple: VerifyPurchaseAppleOptions diff --git a/packages/gql/codegen/plugins/gdscript.ts b/packages/gql/codegen/plugins/gdscript.ts index f47e4d8b9..4222640ab 100644 --- a/packages/gql/codegen/plugins/gdscript.ts +++ b/packages/gql/codegen/plugins/gdscript.ts @@ -273,7 +273,7 @@ export class GDScriptPlugin extends CodegenPlugin { protected generateDocComment(description: string | undefined, indent: string = ''): void { if (!description) return; - const singleLine = description.replace(/\r?\n/g, ' ').trim(); + const singleLine = description.replace(/\s+/g, ' ').trim(); this.emit(`${indent}## ${singleLine}`); } @@ -286,10 +286,7 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit(`enum ${irEnum.name} {`); irEnum.values.forEach((value, index) => { - if (value.description) { - const singleLine = value.description.replace(/\r?\n/g, ' ').trim(); - this.emit(`\t## ${singleLine}`); - } + this.generateDocComment(value.description, '\t'); this.emit(`\t${this.enumValueCase(value.name)} = ${index},`); }); diff --git a/packages/gql/src/generated-gdscript.test.ts b/packages/gql/src/generated-gdscript.test.ts index 68835fca7..d5c0c88b7 100644 --- a/packages/gql/src/generated-gdscript.test.ts +++ b/packages/gql/src/generated-gdscript.test.ts @@ -85,7 +85,7 @@ describe('generated GDScript list decoding', () => { fields: [ { name: 'statuses', - description: 'Status values from the schema.\nPreserves every documentation line.\n@see https://openiap.dev/docs/types', + description: 'Status values from the schema.\n\nPreserves every documentation line.\n@see https://openiap.dev/docs/types', type: { kind: 'list', nullable: false, diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 96795237a..097d3c06d 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -993,7 +993,7 @@ class DiscountDisplayInfoAndroid: dict["discountAmount"] = discount_amount return dict -## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer +## Standardized one-time product discount offer. Provides a platform-neutral OpenIAP shape for Google Play one-time product purchase options and offers. Currently populated only on Android (Google Play Billing 8.0+). iOS does not populate this type. @see https://openiap.dev/docs/types/discount-offer class DiscountOffer: ## Unique identifier for the offer. - iOS: Not applicable (one-time discounts not supported) - Android: offerId from the Google Play one-time purchase option var id: Variant = null @@ -2755,7 +2755,7 @@ class RentalDetailsAndroid: class RequestVerifyPurchaseWithIapkitResult: var store: IapStore - ## 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. + ## 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. var environment: Variant = null ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. var is_valid: bool = false @@ -2842,7 +2842,7 @@ class SubscriptionCommitmentInfoIOS: dict["price"] = price return dict -## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer +## Standardized subscription discount/promotional offer. Provides a unified interface for subscription offers across iOS and Android. Both platforms support subscription offers with different implementations: - iOS: Introductory offers, promotional offers with server-side signatures - Android: Offer tokens with pricing phases @see https://openiap.dev/docs/types/subscription-offer class SubscriptionOffer: ## Unique identifier for the offer. - iOS: Discount identifier from App Store Connect - Android: offerId from the Google Play subscription offer var id: String = "" @@ -3718,7 +3718,7 @@ class InAppMessageParamsAndroid: ## Connection initialization configuration class InitConnectionConfig: - ## Enable a specific billing program for Android (7.0+) When set, enables the specified billing program for external transactions. - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) - EXTERNAL_CONTENT_LINK: Link to external content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_OFFER: External offers for digital content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) - BILLING_CHOICE: Google-rendered or developer-rendered billing choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) + ## Enable a specific billing program for Android (7.0+) When set, enables the specified billing program for external transactions. - USER_CHOICE_BILLING: User can select between Google Play or alternative (7.0+) - EXTERNAL_CONTENT_LINK: Link to external content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_OFFER: External offers for digital content (introduced in 8.2.0; use 8.2.1+) - EXTERNAL_PAYMENTS: Developer provided billing, Japan only (8.3.0+) - BILLING_CHOICE: Google-rendered or developer-rendered billing choice (OpenIAP Spec 2.1.0 / openiap-google 2.3.0; requires Play Billing 9.1.0+) var enable_billing_program_android: Variant = null ## Billing Choice renderer configured in Play Console. Available in OpenIAP Spec 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). GOOGLE_RENDERED registers the developer-provided billing listener so OpenIAP can emit the selection event. DEVELOPER_RENDERED omits that listener so the app can render its own choice screen and use the reporting/dialog/link APIs. Must match choiceScreenType returned by isBillingProgramAvailableAndroid. Defaults to GOOGLE_RENDERED. var billing_choice_screen_type_android: BillingChoiceScreenTypeAndroid = BillingChoiceScreenTypeAndroid.GOOGLE_RENDERED @@ -4160,7 +4160,7 @@ class RequestPurchaseProps: dict["type"] = PRODUCT_QUERY_TYPE_VALUES.get(type, type) return dict -## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific purchase request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestPurchasePropsByPlatforms: ## Apple-specific purchase parameters var apple: RequestPurchaseIosProps @@ -4380,7 +4380,7 @@ class RequestSubscriptionIosProps: dict["advancedCommerceData"] = advanced_commerce_data return dict -## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) +## Platform-specific subscription request parameters. Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. - apple: Always targets App Store - google: Targets Play Store by default, Horizon when built with horizon flavor, or Fire OS when built with amazon flavor (determined at build time, not runtime) class RequestSubscriptionPropsByPlatforms: ## Apple-specific subscription parameters var apple: RequestSubscriptionIosProps @@ -4481,7 +4481,7 @@ class RequestVerifyPurchaseWithIapkitGoogleProps: dict["purchaseToken"] = purchase_token return dict -## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) +## Platform-specific verification parameters for IAPKit. - apple: Verifies via App Store (JWS token) - google: Verifies via Play Store (purchase token) - amazon: Verifies via Amazon Appstore RVS (userId + receiptId) class RequestVerifyPurchaseWithIapkitProps: ## API key used for the Authorization header (Bearer {apiKey}). var api_key: Variant = null @@ -4593,7 +4593,7 @@ class VerifyPurchaseAppleOptions: dict["sku"] = sku return dict -## Google Play Store verification parameters. Used for server-side receipt validation via Google Play Developer API. ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. +## Google Play Store verification parameters. Used for server-side receipt validation via Google Play Developer API. ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. class VerifyPurchaseGoogleOptions: ## Product SKU to validate var sku: String = "" @@ -4634,7 +4634,7 @@ class VerifyPurchaseGoogleOptions: dict["isSub"] = is_sub return dict -## Meta Horizon (Quest) verification parameters. Used for server-side entitlement verification via Meta's S2S API. POST https://graph.oculus.com/$APP_ID/verify_entitlement ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. +## Meta Horizon (Quest) verification parameters. Used for server-side entitlement verification via Meta's S2S API. POST https://graph.oculus.com/$APP_ID/verify_entitlement ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. class VerifyPurchaseHorizonOptions: ## The SKU for the add-on item, defined in Meta Developer Dashboard var sku: String = "" @@ -4663,7 +4663,7 @@ class VerifyPurchaseHorizonOptions: dict["accessToken"] = access_token return dict -## Platform-specific purchase verification parameters. - apple: Verifies via App Store Server API - google: Verifies via Google Play Developer API - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) +## Platform-specific purchase verification parameters. - apple: Verifies via App Store Server API - google: Verifies via Google Play Developer API - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) class VerifyPurchaseProps: ## Apple App Store verification parameters. var apple: VerifyPurchaseAppleOptions diff --git a/packages/kit/server/api/v1/response-contract.test.ts b/packages/kit/server/api/v1/response-contract.test.ts index dd43623fc..2442f5b6a 100644 --- a/packages/kit/server/api/v1/response-contract.test.ts +++ b/packages/kit/server/api/v1/response-contract.test.ts @@ -61,17 +61,18 @@ describe("enforceVerifyResponseContract", () => { assertMatchesPublishedSchema(result.ok && result.response); }); - test("drops an environment the SDK parsers reject", () => { - // App Store Server also reports `Xcode` and `LocalTesting`. - const result = enforceVerifyResponseContract({ - ...ENTITLED, - environment: "Xcode", - }); - - expect(result.ok).toBe(true); - expect(result.violations).toEqual(["environment"]); - expect(result.ok && result.response).not.toHaveProperty("environment"); - assertMatchesPublishedSchema(result.ok && result.response); + test("preserves environment strings opaquely", () => { + for (const environment of ["Xcode", "LocalTesting", "AppTester"]) { + const result = enforceVerifyResponseContract({ + ...ENTITLED, + environment, + }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual([]); + expect(result.ok && result.response.environment).toBe(environment); + assertMatchesPublishedSchema(result.ok && result.response); + } }); test("drops a client payload format the SDKs cannot decode", () => { @@ -106,7 +107,7 @@ describe("enforceVerifyResponseContract", () => { const result = enforceVerifyResponseContract({ ...ENTITLED, state: "REFUNDED", - environment: "Xcode", + environment: 42, clientPayload: { format: "yaml", body: "", version: 1, updatedAt: 0 }, }); diff --git a/packages/kit/server/api/v1/response-contract.ts b/packages/kit/server/api/v1/response-contract.ts index d54f9e1c3..d8c56b9f0 100644 --- a/packages/kit/server/api/v1/response-contract.ts +++ b/packages/kit/server/api/v1/response-contract.ts @@ -28,11 +28,9 @@ const fits = (schema: v.GenericSchema, value: unknown): boolean => * so the documented shape and the emitted body could drift apart silently β€” * and shipped apps decode this body with fixed parsers they cannot update. * - * Metadata that falls outside the contract is degraded, not passed through: - * an out-of-contract `environment` or `clientPayload.format` makes several - * SDKs reject an otherwise valid receipt. `isValid` is never rewritten β€” the - * verdict is authoritative, and drifting metadata must not revoke a real - * entitlement. + * Metadata that falls outside the contract is degraded, not passed through. + * `isValid` is never rewritten β€” the verdict is authoritative, and drifting + * metadata must not revoke a real entitlement. */ export const enforceVerifyResponseContract = ( candidate: Record, diff --git a/packages/kit/server/api/v1/route-response-schemas.test.ts b/packages/kit/server/api/v1/route-response-schemas.test.ts index 7da93df3e..30e3a01fc 100644 --- a/packages/kit/server/api/v1/route-response-schemas.test.ts +++ b/packages/kit/server/api/v1/route-response-schemas.test.ts @@ -37,8 +37,14 @@ describe("verifyPurchaseSuccessResponseSchema", () => { expect(result.success).toBe(false); }); - test("accepts Amazon environments and rejects unknown values", () => { - for (const environment of ["Sandbox", "Production"]) { + test("accepts store environment strings opaquely", () => { + for (const environment of [ + "Sandbox", + "Production", + "Xcode", + "LocalTesting", + "AppTester", + ]) { expect( parse({ store: "amazon", @@ -53,7 +59,7 @@ describe("verifyPurchaseSuccessResponseSchema", () => { store: "amazon", isValid: true, state: "ENTITLED", - environment: "AppTester", + environment: 42, }).success, ).toBe(false); }); diff --git a/packages/kit/server/api/v1/route-response-schemas.ts b/packages/kit/server/api/v1/route-response-schemas.ts index c8e156d1c..63cecd4f6 100644 --- a/packages/kit/server/api/v1/route-response-schemas.ts +++ b/packages/kit/server/api/v1/route-response-schemas.ts @@ -83,9 +83,9 @@ export const productIdSchema = v.pipe( ); export const environmentSchema = v.pipe( - v.union([v.literal("Sandbox"), v.literal("Production")]), + v.string(), v.description( - "Amazon RVS environment selected by IAPKit. Present on handled Amazon verification results.", + "Store environment selected by IAPKit. Forward this value opaquely; stores may add new values.", ), ); diff --git a/packages/kit/server/api/v1/routes.test.ts b/packages/kit/server/api/v1/routes.test.ts index a846cb03a..028f26178 100644 --- a/packages/kit/server/api/v1/routes.test.ts +++ b/packages/kit/server/api/v1/routes.test.ts @@ -581,7 +581,7 @@ describe("apiRoutes", () => { }); }); - it("drops an environment value no shipped SDK accepts", async () => { + it("forwards an opaque environment value", async () => { convexClientMock.action.mockResolvedValueOnce({ isValid: true, state: "ENTITLED", @@ -608,6 +608,7 @@ describe("apiRoutes", () => { isValid: true, state: "ENTITLED", productId: "amazon.premium.monthly", + environment: "Xcode", }); }); });