From 52c2605ce9dc4d3acea428b5868ca95efc6f6e86 Mon Sep 17 00:00:00 2001 From: eyalizhaki Date: Sun, 6 Sep 2026 11:25:47 +0300 Subject: [PATCH 1/4] feat(iap): POC Apple in-app purchase module for backend functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base44's iOS shell speaks StoreKit 2 on the device, but nothing on the server proved a purchase happened or tracked a subscription over time. Renewals, grace periods, billing retry, refunds and plan changes had no server side at all. Adds an `iap` module implementing v1 of the frozen spec: certificate and signature verification of Apple's signed tokens, the App Store Server Notifications webhook, the two device paths, entitlement reads derived from stored tokens, and three App Store Server API methods. Two decisions worth knowing. Verification is hand-rolled against native WebCrypto rather than using Apple's own library, which is Node-only and so cannot ship to a browser — this adds zero production dependencies. And the runtime sits behind a new `@base44/sdk/iap` subpath export, so browsers and React Native never download certificate code; the types are re-exported from the main entry as types only, at no runtime cost. The `exports` map keeps deep `dist/` paths resolving, which app templates rely on, verified by packing and importing from a throwaway consumer. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 265 ++++++- package.json | 29 + .../appended-articles.json | 3 + .../method-order.json | 24 + .../types-to-expose.json | 68 +- src/iap/account-token.ts | 46 ++ src/iap/config.ts | 135 ++++ src/iap/errors.ts | 148 ++++ src/iap/errors.types.ts | 79 ++ src/iap/events/emitter.ts | 74 ++ src/iap/events/events.types.ts | 136 ++++ src/iap/iap.types.ts | 673 ++++++++++++++++++ src/iap/index.ts | 328 +++++++++ src/iap/ingest/device.ts | 273 +++++++ src/iap/ingest/device.types.ts | 81 +++ src/iap/ingest/mappers.ts | 334 +++++++++ src/iap/ingest/matrix.ts | 291 ++++++++ src/iap/ingest/notifications.ts | 405 +++++++++++ src/iap/read/derive.ts | 169 +++++ src/iap/read/read.ts | 337 +++++++++ src/iap/read/read.types.ts | 195 +++++ src/iap/runtime/base64.ts | 150 ++++ src/iap/runtime/clock.ts | 15 + src/iap/runtime/webcrypto.ts | 77 ++ src/iap/server-api/client.ts | 234 ++++++ src/iap/server-api/jwt.ts | 108 +++ src/iap/server-api/server-api.types.ts | 187 +++++ src/iap/store/collapse.ts | 124 ++++ src/iap/store/descriptors.ts | 110 +++ src/iap/store/entities-store.ts | 504 +++++++++++++ src/iap/store/rows.types.ts | 251 +++++++ src/iap/store/schemas.ts | 282 ++++++++ src/iap/store/store-errors.ts | 152 ++++ src/iap/store/store.types.ts | 227 ++++++ src/iap/verify/apple-roots.ts | 146 ++++ src/iap/verify/asn1.ts | 307 ++++++++ src/iap/verify/chain.ts | 214 ++++++ src/iap/verify/ecdsa.ts | 114 +++ src/iap/verify/jws.ts | 268 +++++++ src/iap/verify/payload-checks.ts | 117 +++ src/iap/verify/verifier.ts | 205 ++++++ src/iap/verify/verify.types.ts | 322 +++++++++ src/iap/verify/x509.ts | 250 +++++++ src/iap/version.ts | 13 + src/index.ts | 96 +++ tests/iap/fixtures/fake-entities.ts | 266 +++++++ tests/iap/fixtures/harness.ts | 198 ++++++ tests/iap/fixtures/sign-jws.ts | 57 ++ tests/iap/fixtures/test-chain.ts | 153 ++++ tests/types/iap.types.ts | 137 ++++ tests/unit/iap-config.test.ts | 109 +++ tests/unit/iap-device.test.ts | 356 +++++++++ tests/unit/iap-jws.test.ts | 248 +++++++ tests/unit/iap-notifications.test.ts | 493 +++++++++++++ tests/unit/iap-packaging.test.ts | 175 +++++ tests/unit/iap-read.test.ts | 484 +++++++++++++ tests/unit/iap-sandbox.test.ts | 314 ++++++++ tests/unit/iap-server-api.test.ts | 345 +++++++++ tests/unit/iap-store.test.ts | 512 +++++++++++++ tests/unit/iap-verifier.test.ts | 335 +++++++++ tests/unit/iap-x509.test.ts | 224 ++++++ 61 files changed, 12969 insertions(+), 3 deletions(-) create mode 100644 src/iap/account-token.ts create mode 100644 src/iap/config.ts create mode 100644 src/iap/errors.ts create mode 100644 src/iap/errors.types.ts create mode 100644 src/iap/events/emitter.ts create mode 100644 src/iap/events/events.types.ts create mode 100644 src/iap/iap.types.ts create mode 100644 src/iap/index.ts create mode 100644 src/iap/ingest/device.ts create mode 100644 src/iap/ingest/device.types.ts create mode 100644 src/iap/ingest/mappers.ts create mode 100644 src/iap/ingest/matrix.ts create mode 100644 src/iap/ingest/notifications.ts create mode 100644 src/iap/read/derive.ts create mode 100644 src/iap/read/read.ts create mode 100644 src/iap/read/read.types.ts create mode 100644 src/iap/runtime/base64.ts create mode 100644 src/iap/runtime/clock.ts create mode 100644 src/iap/runtime/webcrypto.ts create mode 100644 src/iap/server-api/client.ts create mode 100644 src/iap/server-api/jwt.ts create mode 100644 src/iap/server-api/server-api.types.ts create mode 100644 src/iap/store/collapse.ts create mode 100644 src/iap/store/descriptors.ts create mode 100644 src/iap/store/entities-store.ts create mode 100644 src/iap/store/rows.types.ts create mode 100644 src/iap/store/schemas.ts create mode 100644 src/iap/store/store-errors.ts create mode 100644 src/iap/store/store.types.ts create mode 100644 src/iap/verify/apple-roots.ts create mode 100644 src/iap/verify/asn1.ts create mode 100644 src/iap/verify/chain.ts create mode 100644 src/iap/verify/ecdsa.ts create mode 100644 src/iap/verify/jws.ts create mode 100644 src/iap/verify/payload-checks.ts create mode 100644 src/iap/verify/verifier.ts create mode 100644 src/iap/verify/verify.types.ts create mode 100644 src/iap/verify/x509.ts create mode 100644 src/iap/version.ts create mode 100644 tests/iap/fixtures/fake-entities.ts create mode 100644 tests/iap/fixtures/harness.ts create mode 100644 tests/iap/fixtures/sign-jws.ts create mode 100644 tests/iap/fixtures/test-chain.ts create mode 100644 tests/types/iap.types.ts create mode 100644 tests/unit/iap-config.test.ts create mode 100644 tests/unit/iap-device.test.ts create mode 100644 tests/unit/iap-jws.test.ts create mode 100644 tests/unit/iap-notifications.test.ts create mode 100644 tests/unit/iap-packaging.test.ts create mode 100644 tests/unit/iap-read.test.ts create mode 100644 tests/unit/iap-sandbox.test.ts create mode 100644 tests/unit/iap-server-api.test.ts create mode 100644 tests/unit/iap-store.test.ts create mode 100644 tests/unit/iap-verifier.test.ts create mode 100644 tests/unit/iap-x509.test.ts diff --git a/package-lock.json b/package-lock.json index 001946f4..9e779a4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "uuid": "^13.0.2" }, "devDependencies": { + "@peculiar/x509": "^2.0.0", "@types/hast": "^3.0.4", "@types/node": "^25.0.1", "@types/unist": "^3.0.3", @@ -26,6 +27,7 @@ "eslint": "^9.39.2", "eslint-plugin-import": "^2.32.0", "nock": "^13.4.0", + "reflect-metadata": "^0.2.2", "typedoc": "^0.28.14", "typedoc-plugin-markdown": "^4.9.0", "typescript": "^5.3.2", @@ -623,6 +625,204 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "2.0.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/x509/-/x509-2.0.0.tgz", + "integrity": "sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1697,6 +1897,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4588,6 +4803,33 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5225,8 +5467,27 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/package.json b/package.json index ef71bda2..0996d23b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,33 @@ "description": "JavaScript SDK for Base44 API", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./iap": { + "types": "./dist/iap/index.d.ts", + "default": "./dist/iap/index.js" + }, + "./dist/*.js": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + }, + "./dist/*.d.ts": "./dist/*.d.ts", + "./dist/*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "iap": [ + "dist/iap/index.d.ts" + ] + } + }, "type": "module", "files": [ "dist" @@ -32,6 +59,7 @@ "uuid": "^13.0.2" }, "devDependencies": { + "@peculiar/x509": "^2.0.0", "@types/hast": "^3.0.4", "@types/node": "^25.0.1", "@types/unist": "^3.0.3", @@ -43,6 +71,7 @@ "eslint": "^9.39.2", "eslint-plugin-import": "^2.32.0", "nock": "^13.4.0", + "reflect-metadata": "^0.2.2", "typedoc": "^0.28.14", "typedoc-plugin-markdown": "^4.9.0", "typescript": "^5.3.2", diff --git a/scripts/mintlify-post-processing/appended-articles.json b/scripts/mintlify-post-processing/appended-articles.json index 27fb69b8..c589b0cc 100644 --- a/scripts/mintlify-post-processing/appended-articles.json +++ b/scripts/mintlify-post-processing/appended-articles.json @@ -21,5 +21,8 @@ "type-aliases/integrations": [ "interfaces/CoreIntegrations", "interfaces/CustomIntegrationsModule" + ], + "interfaces/IapModule": [ + "interfaces/IapServerApiModule" ] } diff --git a/scripts/mintlify-post-processing/method-order.json b/scripts/mintlify-post-processing/method-order.json index 8c05055d..18e6e538 100644 --- a/scripts/mintlify-post-processing/method-order.json +++ b/scripts/mintlify-post-processing/method-order.json @@ -16,5 +16,29 @@ "functions": [ "invoke", "fetch" + ], + "iap": [ + "hasActiveSubscription", + "getSubscriptionState", + "getEntitlements", + "getPurchase", + "listTransactions", + "listRefunds", + "listPendingConsumptionRequests", + "handleNotification", + "handleSignedPayload", + "recordTransaction", + "syncEntitlements", + "onEvent", + "checkSetup", + "appAccountTokenFor", + "verifyTransaction", + "verifyRenewalInfo", + "verifyNotification" + ], + "iap-server-api": [ + "sendConsumptionInformation", + "requestTestNotification", + "getTestNotificationStatus" ] } diff --git a/scripts/mintlify-post-processing/types-to-expose.json b/scripts/mintlify-post-processing/types-to-expose.json index 5d7ef016..77cf52d0 100644 --- a/scripts/mintlify-post-processing/types-to-expose.json +++ b/scripts/mintlify-post-processing/types-to-expose.json @@ -30,5 +30,71 @@ "CoreIntegrations", "SortField", "SsoModule", - "UpdateManyResult" + "UpdateManyResult", + "CreateIapClientOptions", + "DecodedNotification", + "DecodedNotificationData", + "DecodedNotificationSummary", + "DecodedRenewalInfo", + "DecodedTransaction", + "IapConfig", + "IapConfiguredProductType", + "IapModule", + "IapProductConfig", + "IapVerificationErrorCode", + "IapEnvironment", + "IapProductType", + "IapSetupReport", + "IapEntityName", + "IapEntitySchema", + "IapSchemaField", + "IapEvent", + "IapEventHandler", + "IapEventType", + "IapExpiryReason", + "IapRenewReason", + "IapStartReason", + "HandleNotificationResult", + "RecordTransactionOptions", + "RecordTransactionResult", + "SyncPayload", + "SyncResult", + "SubscriptionState", + "Entitlements", + "SubscriptionQuery", + "EntitlementQuery", + "TransactionQuery", + "IapSubscriptionStatus", + "IapExpirationReason", + "IapSubscriptionOffer", + "IapRevocation", + "OwnedNonConsumable", + "OwnedNonRenewingSubscription", + "IapTransactionRecord", + "IapSubscriptionRecord", + "IapNotificationRecord", + "IapConsumptionRequestRecord", + "IapNotificationOutcome", + "IapConsumptionOutcome", + "IapRecordSource", + "IapServerApiModule", + "IapServerApiConfig", + "ConsumptionRequestBody", + "IapDeliveryStatus", + "IapRefundPreference", + "TestNotificationResult", + "TestNotificationStatus", + "SendAttempt", + "SendAttemptResult", + "IapAppleSubscriptionStatus", + "IapConsumptionRequestReason", + "IapExpirationIntent", + "IapOfferDiscountType", + "IapOfferType", + "IapOwnershipType", + "IapRevocationType", + "IapTransactionReason", + "IapConfigErrorCode", + "IapSetupErrorCode", + "IapStoreErrorCode" ] diff --git a/src/iap/account-token.ts b/src/iap/account-token.ts new file mode 100644 index 00000000..d07fb80b --- /dev/null +++ b/src/iap/account-token.ts @@ -0,0 +1,46 @@ +/** + * Mapping a Base44 user to the UUID Apple signs into their purchases. + * + * StoreKit accepts a UUID at purchase time (`appAccountToken`) and Apple + * returns the same value in every resulting transaction — including renewals + * years later. That signed round trip is the entire attribution mechanism: it + * is the only way to know whose purchase a transaction is, without trusting + * anything the client claims. + * + * The mapping is a deterministic version-5 UUID of the user id under a fixed + * namespace, so it is a pure function. The shell, the web app and the backend + * all derive the same UUID from the same user id with no lookup table to keep + * in sync, and nothing to migrate. + * + * Two consequences worth knowing: + * + * - The namespace is part of the contract. Changing it orphans every purchase + * already attributed under the old one. + * - The mapping is one-way in practice. Recovering a user id means deriving + * the UUID for a candidate user and comparing, not inverting the hash. + * + * @internal + */ +import { v5 as uuidv5 } from "uuid"; + +/** + * The namespace every Base44 in-app purchase account token is derived under. + * + * A fixed, arbitrary version-4 UUID. It must never change: the native shell + * derives the same value independently, and Apple has already signed the + * results into transactions that will keep renewing. + */ +export const IAP_APP_ACCOUNT_TOKEN_NAMESPACE = + "8f2b6a1e-4c5d-4e7a-9b3f-6d1a2c8e5f04"; + +/** + * Derives the account token for a Base44 user id. + * + * @throws {TypeError} when the user id is empty. + */ +export function appAccountTokenFor(base44UserId: string): string { + if (typeof base44UserId !== "string" || base44UserId.length === 0) { + throw new TypeError("a Base44 user id is required to derive an account token"); + } + return uuidv5(base44UserId, IAP_APP_ACCOUNT_TOKEN_NAMESPACE); +} diff --git a/src/iap/config.ts b/src/iap/config.ts new file mode 100644 index 00000000..4b291891 --- /dev/null +++ b/src/iap/config.ts @@ -0,0 +1,135 @@ +/** + * Configuration validation. + * + * Runs once, when the client is created, and refuses to build a client it + * cannot operate correctly. Failing at deploy time is the whole point: the + * alternative is discovering a missing `appAppleId` when Apple sends the first + * refund notification. + * + * @internal + */ +import { IapConfigError } from "./errors.js"; +import type { IapConfig, IapProductConfig } from "./iap.types.js"; +import type { IapServerApiConfig } from "./server-api/server-api.types.js"; + +/** A validated configuration, with every default filled in. */ +export interface ResolvedIapConfig { + readonly bundleId: string; + readonly appAppleId: number; + readonly products: Readonly>; + readonly testMode: boolean; + readonly allowLocalTesting: boolean; + readonly serverApi?: IapServerApiConfig; +} + +const PRODUCT_TYPES = new Set([ + "consumable", + "nonConsumable", + "nonRenewingSubscription", + "autoRenewableSubscription", +]); + +function invalid(message: string): never { + throw new IapConfigError("IAP_INVALID_CONFIG", message); +} + +/** + * Validates a configuration and fills in defaults. + * + * @throws {IapConfigError} `IAP_INVALID_CONFIG` for anything malformed, or + * `IAP_ONLINE_CHECKS_UNSUPPORTED` when online certificate checks are asked for. + */ +export function resolveConfig(config: IapConfig): ResolvedIapConfig { + if (!config || typeof config !== "object") { + invalid("an in-app purchase configuration object is required"); + } + + if (typeof config.bundleId !== "string" || config.bundleId.trim().length === 0) { + invalid("'bundleId' is required, e.g. \"com.example.app\""); + } + + // A very common mix-up: passing the bundle id where the numeric id belongs. + if (typeof config.appAppleId === "string") { + invalid( + "'appAppleId' must be a number — the numeric App Store id from App Store " + + "Connect under App Information, not the bundle id" + ); + } + if ( + typeof config.appAppleId !== "number" || + !Number.isInteger(config.appAppleId) || + config.appAppleId <= 0 + ) { + invalid("'appAppleId' must be a positive integer, e.g. 1234567890"); + } + + if (!config.products || typeof config.products !== "object") { + invalid("'products' is required, keyed by product identifier"); + } + + for (const [productId, product] of Object.entries(config.products)) { + if (!product || typeof product !== "object") { + invalid(`product ${JSON.stringify(productId)} must be an object`); + } + if (!PRODUCT_TYPES.has(product.type)) { + invalid( + `product ${JSON.stringify(productId)} has type ${JSON.stringify( + product.type + )}; expected one of ${[...PRODUCT_TYPES].join(", ")}` + ); + } + // Apple never expires a non-renewing subscription, so this number is the + // only thing that decides when access ends. Missing it would silently + // grant the product forever. + if (product.type === "nonRenewingSubscription") { + const days = product.nonRenewingDurationDays; + if (typeof days !== "number" || !Number.isFinite(days) || days <= 0) { + invalid( + `product ${JSON.stringify(productId)} is a nonRenewingSubscription, so it ` + + "needs 'nonRenewingDurationDays' — Apple does not expire these, so the " + + "app decides how long they last" + ); + } + } + } + + // `null` is treated as absent, not as invalid: a secret that was never set + // arrives that way, and the right answer then is "the API is not configured" + // — which the call itself reports clearly — rather than refusing to start. + if (config.serverApi !== undefined && config.serverApi !== null) { + const api = config.serverApi; + if (typeof api !== "object") { + invalid("'serverApi' must be an object, or left out entirely"); + } + for (const field of ["keyId", "issuerId", "privateKeyP8"] as const) { + if (typeof api[field] !== "string" || api[field].trim().length === 0) { + invalid(`'serverApi.${field}' is required when serverApi is supplied`); + } + } + if (!api.privateKeyP8.includes("PRIVATE KEY")) { + invalid( + "'serverApi.privateKeyP8' does not look like a .p8 file. Pass its whole " + + "contents, including the BEGIN and END lines." + ); + } + } + + if (config.onlineChecks === true) { + throw new IapConfigError( + "IAP_ONLINE_CHECKS_UNSUPPORTED", + "'onlineChecks' asks for certificate revocation lookups, which this version " + + "does not implement. Leave it unset. Certificate validity is evaluated at " + + "each payload's own signedDate, which is what Apple's own library does with " + + "online checks off." + ); + } + + return { + bundleId: config.bundleId, + appAppleId: config.appAppleId, + products: { ...config.products }, + testMode: config.testMode === true, + allowLocalTesting: config.allowLocalTesting === true, + serverApi: config.serverApi ?? undefined, + }; +} diff --git a/src/iap/errors.ts b/src/iap/errors.ts new file mode 100644 index 00000000..cc959489 --- /dev/null +++ b/src/iap/errors.ts @@ -0,0 +1,148 @@ +/** + * Error classes for the in-app purchase module. + * + * Each class carries a `code` from `errors.types.ts`. Nothing here imports the + * runtime helpers, so the runtime layer can throw these freely. + * + * @internal + */ +import type { + IapApiErrorCode, + IapConfigErrorCode, + IapSetupErrorCode, + IapStoreErrorCode, + IapVerificationErrorCode, +} from "./errors.types.js"; + +/** Base class, so a caller can catch every error this module raises at once. */ +export class IapError extends Error { + /** Stable machine-readable code. */ + readonly code: string; + + constructor(code: string, message: string, options?: { cause?: unknown }) { + super(message); + this.name = "IapError"; + this.code = code; + // `cause` is set by hand rather than passed to super: the emit target is + // es2018, whose Error constructor takes no options bag. + if (options && "cause" in options) { + (this as { cause?: unknown }).cause = options.cause; + } + } +} + +/** + * A signed Apple token was rejected. + * + * Deny by default: anything that raises this must be treated as "not + * entitled", never as "probably fine". + */ +export class IapVerificationError extends IapError { + declare readonly code: IapVerificationErrorCode; + + constructor( + code: IapVerificationErrorCode, + message: string, + options?: { cause?: unknown } + ) { + super(code, message, options); + this.name = "IapVerificationError"; + } +} + +/** The module cannot start, or was asked for something its configuration does not allow. */ +export class IapConfigError extends IapError { + declare readonly code: IapConfigErrorCode; + + constructor( + code: IapConfigErrorCode, + message: string, + options?: { cause?: unknown } + ) { + super(code, message, options); + this.name = "IapConfigError"; + } +} + +/** + * The app's Base44 setup is not usable — an entity is missing, or its schema + * has drifted from what the SDK writes. + * + * This is loud on purpose. A silently dropped field means purchase data is + * being lost, which is worse than an outage. + */ +export class IapSetupError extends IapError { + declare readonly code: IapSetupErrorCode; + + /** The entity the problem was found on. */ + readonly entityName?: string; + /** Fields the entity dropped, when the code is `IAP_ENTITY_SCHEMA_DRIFT`. */ + readonly missingFields?: string[]; + + constructor( + code: IapSetupErrorCode, + message: string, + details?: { entityName?: string; missingFields?: string[]; cause?: unknown } + ) { + super(code, message, { cause: details?.cause }); + this.name = "IapSetupError"; + this.entityName = details?.entityName; + this.missingFields = details?.missingFields; + } +} + +/** A read or write against the app's entities failed. */ +export class IapStoreError extends IapError { + declare readonly code: IapStoreErrorCode; + + /** The entity being read or written. */ + readonly entityName?: string; + + constructor( + code: IapStoreErrorCode, + message: string, + details?: { entityName?: string; cause?: unknown } + ) { + super(code, message, { cause: details?.cause }); + this.name = "IapStoreError"; + this.entityName = details?.entityName; + } +} + +/** An App Store Server API call failed. */ +export class IapApiError extends IapError { + declare readonly code: IapApiErrorCode; + + /** The HTTP status Apple returned. */ + readonly httpStatus?: number; + /** Apple's own numeric error code from the response body, e.g. `4040010`. */ + readonly appleErrorCode?: number; + /** Apple's own error message from the response body. */ + readonly appleErrorMessage?: string; + /** + * When rate-limited: the value of Apple's `Retry-After` header. + * + * Apple sends an **absolute UNIX millisecond timestamp** here, not a delay. + * Compare it against `Date.now()`; do not use it as a duration. + */ + readonly retryAfter?: number; + + constructor( + code: IapApiErrorCode, + message: string, + details?: { + httpStatus?: number; + appleErrorCode?: number; + appleErrorMessage?: string; + retryAfter?: number; + cause?: unknown; + } + ) { + super(code, message, { cause: details?.cause }); + this.name = "IapApiError"; + this.httpStatus = details?.httpStatus; + this.appleErrorCode = details?.appleErrorCode; + this.appleErrorMessage = details?.appleErrorMessage; + this.retryAfter = details?.retryAfter; + } +} diff --git a/src/iap/errors.types.ts b/src/iap/errors.types.ts new file mode 100644 index 00000000..f1a44a65 --- /dev/null +++ b/src/iap/errors.types.ts @@ -0,0 +1,79 @@ +/** + * Error codes the in-app purchase module can raise. + * + * Every code is a stable string. Generated code and support tooling branch on + * these, so a code is never renamed — a new situation gets a new code. + */ + +/** + * Why a signed Apple token was rejected. + * + * The first eight come straight from the verification algorithm Apple + * describes; the last two are this SDK's own. + */ +export type IapVerificationErrorCode = + /** The token is not three base64url segments separated by dots. */ + | "INVALID_JWS_FORMAT" + /** The JWS header named an algorithm other than `ES256`. */ + | "UNSUPPORTED_ALG" + /** The `x5c` header did not carry exactly three certificates. */ + | "INVALID_CHAIN_LENGTH" + /** A certificate failed to parse, was outside its validity window, or lacked a required marker. */ + | "INVALID_CERTIFICATE" + /** A certificate was revoked. Only reachable with online checks, which v1 does not support. */ + | "CERTIFICATE_REVOKED" + /** The signature did not verify against the leaf certificate's public key. */ + | "INVALID_SIGNATURE" + /** The payload's `bundleId`, or its `appAppleId` in production, did not match the configuration. */ + | "INVALID_APP_IDENTIFIER" + /** The payload's `environment` was not one this app accepts. */ + | "INVALID_ENVIRONMENT" + /** A transient failure. Retrying may succeed. */ + | "RETRYABLE_VERIFICATION_FAILURE" + /** + * The chain anchored at a root this SDK cannot verify against — an RSA root. + * + * Current App Store tokens chain to Apple Root CA - G3, which is ECDSA + * P-384. Apple Root CA - G2 and Apple Inc. Root are RSA and are pinned for + * completeness, but v1 verifies ECDSA signatures only. This is never + * downgraded to a pass. + */ + | "UNSUPPORTED_CERT_ALGORITHM"; + +/** Why the module refused to start, or could not use its configuration. */ +export type IapConfigErrorCode = + /** A required configuration field was missing or malformed. */ + | "IAP_INVALID_CONFIG" + /** `onlineChecks: true` was requested. Certificate revocation lookups are not implemented in v1. */ + | "IAP_ONLINE_CHECKS_UNSUPPORTED" + /** The runtime has no WebCrypto. Needs Deno, Node 18 or later, or a secure browser context. */ + | "IAP_WEBCRYPTO_UNAVAILABLE" + /** The runtime has no `fetch`. Needs Deno, Node 18 or later, or a browser. */ + | "IAP_FETCH_UNAVAILABLE" + /** An App Store Server API call was made without `serverApi` credentials configured. */ + | "IAP_SERVER_API_NOT_CONFIGURED"; + +/** Why the app's own Base44 setup is not usable for in-app purchases. */ +export type IapSetupErrorCode = + /** One of the four required entities does not exist in this app. */ + | "IAP_ENTITY_MISSING" + /** An entity exists but silently dropped a field the SDK wrote. Its schema has drifted. */ + | "IAP_ENTITY_SCHEMA_DRIFT" + /** The client was created without service-role credentials, so it cannot write. */ + | "IAP_SERVICE_ROLE_REQUIRED"; + +/** Why a read or write against the app's entities failed. */ +export type IapStoreErrorCode = + /** The write did not land. Callers must not report success to Apple or finish a transaction. */ + | "IAP_WRITE_FAILED" + /** The read did not complete. */ + | "IAP_READ_FAILED"; + +/** Why an App Store Server API call failed. */ +export type IapApiErrorCode = + /** Apple answered with an error status. Inspect `appleErrorCode` for which. */ + | "IAP_API_ERROR" + /** Apple rate-limited the call. `retryAfter` carries the absolute timestamp to retry after. */ + | "IAP_API_RATE_LIMITED" + /** The transaction id exists in neither the production nor the sandbox environment. */ + | "IAP_API_TRANSACTION_NOT_FOUND"; diff --git a/src/iap/events/emitter.ts b/src/iap/events/emitter.ts new file mode 100644 index 00000000..2ee36f39 --- /dev/null +++ b/src/iap/events/emitter.ts @@ -0,0 +1,74 @@ +/** + * Delivering events to the app's handlers. + * + * Two rules, both about not letting an app's own code break Apple's contract: + * + * 1. **A handler runs after the write, never before.** An event means the data + * behind it is already stored. + * 2. **A handler can never change the outcome.** If one throws, the throw is + * swallowed and reported; the HTTP response Apple sees is unaffected. + * + * Events are dispatched *synchronously* before the response is returned, not + * after it. A Base44 backend function has no way to run work once it has + * answered — there is no `waitUntil` — so anything deferred until after the + * response is simply lost when the isolate stops. + * + * @internal + */ +import type { IapEvent, IapEventHandler } from "./events.types.js"; + +/** Reports a handler that threw. Defaults to a single console line. */ +export type IapHandlerFailureReporter = ( + event: IapEvent, + error: unknown +) => void; + +/** The event bus for one client. */ +export interface IapEmitter { + /** Registers a handler. Returns a function that removes it. */ + onEvent(handler: IapEventHandler): () => void; + /** Delivers events to every handler, swallowing their failures. */ + emit(events: readonly IapEvent[]): Promise; +} + +function defaultReporter(event: IapEvent, error: unknown): void { + // Deliberately one line and not re-thrown. A broken handler must not take + // the webhook down with it, and it must not be silent either. + console.error( + `[base44 iap] an onEvent handler failed for ${event.type}:`, + error instanceof Error ? error.message : error + ); +} + +export function createEmitter( + reportFailure: IapHandlerFailureReporter = defaultReporter +): IapEmitter { + const handlers = new Set(); + + return { + onEvent(handler: IapEventHandler): () => void { + handlers.add(handler); + return () => { + handlers.delete(handler); + }; + }, + + async emit(events: readonly IapEvent[]): Promise { + if (handlers.size === 0 || events.length === 0) return; + + // A snapshot, so a handler that registers another one during dispatch + // does not change what this round delivers. + const current = [...handlers]; + + for (const event of events) { + for (const handler of current) { + try { + await handler(event); + } catch (error) { + reportFailure(event, error); + } + } + } + }, + }; +} diff --git a/src/iap/events/events.types.ts b/src/iap/events/events.types.ts new file mode 100644 index 00000000..d92861fd --- /dev/null +++ b/src/iap/events/events.types.ts @@ -0,0 +1,136 @@ +/** + * What happened, in the app's own vocabulary. + * + * Apple's notification types describe *its* billing system. These describe + * what the app has to do about it, which is a much shorter list: something was + * bought, something was taken away, a subscription changed shape. + * + * Every ingestion path produces these, and `onEvent` handlers receive them + * after the write has landed — so a handler that sends an email or grants a + * bonus can trust that the data behind it is already stored. + */ +import type { IapEnvironment } from "../verify/verify.types.js"; +import type { IapRecordSource } from "../store/rows.types.js"; + +/** The kinds of event this module emits. */ +export type IapEventType = + /** A one-time purchase completed. */ + | "purchase.completed" + /** Apple refunded a purchase. Take the content back. */ + | "purchase.refunded" + /** Apple declined a refund request. Nothing to do. */ + | "purchase.refund_declined" + /** A refund was reversed after a dispute. **Give the content back.** */ + | "purchase.refund_reversed" + /** A family member's shared access ended. */ + | "purchase.revoked" + /** Apple wants consumption data for a refund request, within a deadline. */ + | "refund.consumption_requested" + /** A subscription started, either new or after a lapse. */ + | "subscription.started" + /** A subscription renewed. */ + | "subscription.renewed" + /** The customer changed plan, effective immediately. */ + | "subscription.plan_changed" + /** The customer changed plan, effective at the next renewal. */ + | "subscription.plan_change_scheduled" + /** The customer undid a scheduled plan change. */ + | "subscription.plan_change_cancelled" + /** Automatic renewal was turned on or off. */ + | "subscription.auto_renew_changed" + /** A payment failed. Check `inGracePeriod` before withdrawing access. */ + | "subscription.billing_issue" + /** A billing grace period ended without a successful payment. */ + | "subscription.grace_period_ended" + /** A subscription ended. */ + | "subscription.expired" + /** The customer redeemed an offer. */ + | "subscription.offer_redeemed" + /** A price increase was announced or accepted. */ + | "subscription.price_increase" + /** Apple extended one subscriber's renewal date. */ + | "subscription.renewal_extended" + /** A mass renewal-date extension finished. */ + | "subscription.mass_extension_result" + /** A test notification arrived, confirming the webhook works. */ + | "apple.test_received" + /** A notification type this version stores but does not act on. */ + | "apple.unhandled" + /** A notification type Apple added after this version shipped. */ + | "apple.unknown" + /** A launch-time sync completed. */ + | "sync.applied"; + +/** Why a subscription started. */ +export type IapStartReason = "initial" | "resubscribe"; + +/** Why a subscription renewed. */ +export type IapRenewReason = "renewal" | "billing_recovery"; + +/** Why a subscription ended. */ +export type IapExpiryReason = + | "voluntary" + | "billing" + | "price_increase" + | "product_unavailable" + | "other"; + +/** + * One thing that happened. + * + * The identifying fields are all optional because not every event has them: a + * mass-extension result concerns no single customer, and a purchase made + * before the customer logged in has no `appUserId` until a later sync attaches + * one. + */ +export interface IapEvent { + /** What happened. */ + type: IapEventType; + /** The Base44 user this concerns, when it could be resolved. */ + appUserId: string | null; + /** The subscription chain involved. */ + originalTransactionId?: string; + /** The transaction involved. */ + transactionId?: string; + /** The product involved. */ + productId?: string; + /** Which App Store environment this came from. */ + environment: IapEnvironment; + /** When Apple says it happened, in epoch milliseconds. */ + occurredAt: number; + /** Apple's notification id, when this came from a notification. */ + notificationUUID?: string; + /** Where this event came from. */ + source: IapRecordSource; + /** Apple's own notification type, when there was one. */ + notificationType?: string; + /** Apple's own notification subtype, when there was one. */ + subtype?: string; + + /** Why a subscription started. Only on `subscription.started`. */ + startReason?: IapStartReason; + /** Why a subscription renewed. Only on `subscription.renewed`. */ + renewReason?: IapRenewReason; + /** Why a subscription ended. Only on `subscription.expired`. */ + expiryReason?: IapExpiryReason; + /** Whether automatic renewal is now on. Only on `subscription.auto_renew_changed`. */ + autoRenewEnabled?: boolean; + /** + * Whether a billing grace period is running. + * + * Only on `subscription.billing_issue`. When true, Apple's requirement is to + * **keep providing full service** — the customer has not lapsed yet. + */ + inGracePeriod?: boolean; + /** Where a price increase stands. Only on `subscription.price_increase`. */ + priceIncreaseConsent?: "pending" | "accepted"; + /** When Apple stops accepting consumption data. Only on `refund.consumption_requested`. */ + deadlineAt?: number; + /** How many entitlements a sync disagreed with the server about. Only on `sync.applied`. */ + mismatches?: number; + /** The decoded payload behind this event, for anything the fields above omit. */ + payload?: unknown; +} + +/** A function called after an event's data has been stored. */ +export type IapEventHandler = (event: IapEvent) => void | Promise; diff --git a/src/iap/iap.types.ts b/src/iap/iap.types.ts new file mode 100644 index 00000000..b2576322 --- /dev/null +++ b/src/iap/iap.types.ts @@ -0,0 +1,673 @@ +/** + * Apple in-app purchase support for Base44 backend functions. + * + * Base44's iOS shell already speaks StoreKit 2 on the device. This module is + * the server half: it verifies what Apple signed, so your backend can decide + * who has paid for what without ever trusting the client. + * + * Everything here runs inside your app's own backend functions. Nothing is + * sent to a Base44 service, and no Apple credentials are needed to verify a + * purchase. + * + * ## Getting a client + * + * The runtime lives behind a subpath, so a browser bundle never downloads + * certificate-parsing code: + * + * ```typescript + * import { createClientFromRequest } from "npm:@base44/sdk"; + * import { createIapClient } from "npm:@base44/sdk/iap"; + * + * Deno.serve(async (req) => { + * const iap = createIapClient({ + * base44: createClientFromRequest(req), + * config: { + * bundleId: "com.example.app", + * appAppleId: 1234567890, + * products: { pro_monthly: { type: "autoRenewableSubscription" } }, + * }, + * }); + * + * const transaction = await iap.verifyTransaction(jws); + * return Response.json({ productId: transaction.productId }); + * }); + * ``` + * + * ## Authentication Modes + * + * This module is only available in Base44-hosted backend functions, from a + * client created with + * {@linkcode createClientFromRequest | createClientFromRequest()}. + */ +import type { + DecodedNotification, + DecodedRenewalInfo, + DecodedTransaction, +} from "./verify/verify.types.js"; +import type { IapEventHandler } from "./events/events.types.js"; +import type { HandleNotificationResult } from "./ingest/notifications.js"; +import type { + RecordTransactionOptions, + RecordTransactionResult, + SyncPayload, + SyncResult, +} from "./ingest/device.types.js"; +import type { IapEntityName } from "./store/schemas.js"; +import type { + IapServerApiConfig, + IapServerApiModule, +} from "./server-api/server-api.types.js"; +import type { + IapConsumptionRequestRecord, + IapTransactionRecord, +} from "./store/rows.types.js"; +import type { + EntitlementQuery, + Entitlements, + SubscriptionQuery, + SubscriptionState, + TransactionQuery, +} from "./read/read.types.js"; + +/** What kind of product an identifier refers to. */ +export type IapConfiguredProductType = + /** Used up and bought again, like a pack of coins. */ + | "consumable" + /** Bought once and owned forever, like unlocking a feature. */ + | "nonConsumable" + /** + * Bought for a fixed period that does not renew itself. + * + * Apple does not track when these end, so `nonRenewingDurationDays` is + * required and this SDK computes the expiry from it. + */ + | "nonRenewingSubscription" + /** Renews itself until cancelled. */ + | "autoRenewableSubscription"; + +/** One product the app sells. */ +export interface IapProductConfig { + /** What kind of product this is. */ + type: IapConfiguredProductType; + /** + * The subscription group this product belongs to. + * + * Products in one group are alternatives to each other, so a customer can + * hold only one at a time. Auto-renewable subscriptions only. + */ + subscriptionGroupId?: string; + /** + * How many days a non-renewing subscription lasts. + * + * Required for `nonRenewingSubscription` and ignored otherwise. Apple never + * expires these, so this number is the only thing that says when access + * ends. + */ + nonRenewingDurationDays?: number; +} + +/** + * How the in-app purchase module behaves for one app. + * + * `bundleId` and `appAppleId` are two different things and both are needed: + * the bundle id is the reverse-DNS string like `com.example.app`, while the + * App Store id is the number shown in App Store Connect under App Information. + * Apple omits the numeric id from sandbox payloads, so it is only enforced + * against production tokens. + */ +export interface IapConfig { + /** The app's bundle identifier, e.g. `"com.example.app"`. */ + bundleId: string; + /** + * The app's numeric App Store id. + * + * Find it in App Store Connect under App Information, labelled "Apple ID". + * It is not the bundle id. + */ + appAppleId: number; + /** Every product the app sells, keyed by product identifier. */ + products: Record; + /** + * Whether purchases made in Apple's sandbox count as real. + * + * Turn this on while testing and off in production. With it off, a sandbox + * token is rejected outright. + * + * @defaultValue `false` + */ + testMode?: boolean; + /** + * Whether to accept tokens from Xcode's local StoreKit testing. + * + * These are signed by Xcode rather than by Apple, so they cannot be verified + * against Apple's certificates — turning this on skips that check for them. + * Never turn it on in production. + * + * @defaultValue `false` + */ + allowLocalTesting?: boolean; + /** + * Credentials for calling Apple's own servers. + * + * Optional, and not needed to verify a purchase or check an entitlement. It + * unlocks answering refund-consumption requests and sending test + * notifications. Use an **In-App Purchase key** from App Store Connect, kept + * in Base44 secrets. + */ + serverApi?: IapServerApiConfig; + /** + * Whether to ask Apple's servers whether a certificate has been revoked. + * + * Not implemented in this version. Setting it to `true` throws when the + * client is created, rather than quietly behaving as if it were `false`. + * + * @defaultValue `false` + */ + onlineChecks?: boolean; +} + +/** Whether the app is set up to store purchase data. */ +export interface IapSetupReport { + /** Whether everything the module needs exists. */ + ok: boolean; + /** + * Entities the app is missing. + * + * Create them from `IAP_ENTITY_SCHEMAS`. Until they exist, nothing can be + * stored and every entitlement check answers "not entitled". + */ + missingEntities: IapEntityName[]; + /** What the app owner still has to do, in order. */ + checklist: readonly string[]; +} + +/** + * The in-app purchase module. + * + * Every method that takes a signed token throws if it cannot verify it. There + * is no partially-verified result: a token either passed every check Apple + * describes, or it is rejected. + */ +export interface IapModule { + /** + * Verifies a signed transaction and returns its contents. + * + * Checks the signature, walks the certificate chain to a pinned Apple root, + * and confirms the transaction is for this app and from an environment this + * app accepts. + * + * @param jws - The signed transaction, as Apple or StoreKit produced it. + * @returns Promise resolving to the decoded transaction. + * @throws {Error} An `IapVerificationError` when the token fails any check. Its `code` says which. + * + * @example Verify a purchase reported by the app + * ```typescript + * // Verify a purchase reported by the app + * const transaction = await iap.verifyTransaction(jws); + * console.log(transaction.productId, transaction.expiresDate); + * ``` + * + * @example Tell rejection reasons apart + * ```typescript + * // Tell rejection reasons apart + * try { + * await iap.verifyTransaction(jws); + * } catch (error) { + * if (error.code === "INVALID_APP_IDENTIFIER") { + * // The token is genuine, but it belongs to a different app. + * } + * } + * ``` + */ + verifyTransaction(jws: string): Promise; + + /** + * Verifies signed subscription renewal information and returns its contents. + * + * Renewal information is where a subscription's future lives — whether it + * will renew, what it will renew to, and whether it is inside a billing + * grace period. A plain transaction carries none of that. + * + * @param jws - The signed renewal information. + * @returns Promise resolving to the decoded renewal information. + * @throws {Error} An `IapVerificationError` when the token fails any check. + * + * @example + * ```typescript + * // Read whether a subscription will renew + * const renewal = await iap.verifyRenewalInfo(jws); + * const willRenew = renewal.autoRenewStatus === 1; + * ``` + */ + verifyRenewalInfo(jws: string): Promise; + + /** + * Verifies an App Store Server Notification and returns its contents. + * + * Notifications arrive as one signed envelope wrapping further signed + * tokens. Each is verified in its own right, and the decoded transaction and + * renewal information are returned in place of the raw strings, so no caller + * can act on something unverified by mistake. + * + * @param signedPayload - The `signedPayload` value from Apple's request body. + * @returns Promise resolving to the decoded notification. + * @throws {Error} An `IapVerificationError` when the envelope or any inner token fails a check. + * + * @example + * ```typescript + * // Handle a notification from Apple + * const { signedPayload } = await req.json(); + * const notification = await iap.verifyNotification(signedPayload); + * + * if (notification.notificationType === "REFUND") { + * const transactionId = notification.data?.transactionInfo?.transactionId; + * // Take the purchase back. + * } + * ``` + */ + verifyNotification(signedPayload: string): Promise; + + /** + * Turns a Base44 user id into the UUID to attach to a purchase. + * + * StoreKit lets the app tag a purchase with a UUID, and Apple signs that + * value back into every resulting transaction — which is what makes a + * purchase attributable to a user without trusting anything the client says. + * + * The mapping is a pure function, so the app, the shell and the backend all + * derive the same UUID from the same user id with nothing stored in between. + * + * @param base44UserId - The Base44 user id. + * @returns The UUID to pass to StoreKit as the purchase's account token. + * + * @example + * ```typescript + * // Derive the token the shell should attach to a purchase + * const token = iap.appAccountTokenFor(user.id); + * ``` + */ + appAccountTokenFor(base44UserId: string): string; + + /** + * Handles an App Store Server Notification from Apple. + * + * Point your notification function at this and return what it gives you. + * It owns the whole contract with Apple, including the status code — which + * matters more than it looks: Apple retries a failure for up to 72 hours but + * **never** retries a success, so a `200` sent before the data is stored + * loses that notification permanently. This never does that. + * + * The payload is verified, stored raw, applied, and only then reported as + * handled. A repeat delivery of something already handled is recognised and + * ignored. Any storage failure produces `503`, so Apple comes back. + * + * @param request - The incoming request from Apple. + * @returns Promise resolving to the response to return: `200` handled, `400` malformed, `401` unverifiable, `503` try again. + * + * @example + * ```typescript + * // The whole notification function + * import { createClientFromRequest } from "npm:@base44/sdk"; + * import { createIapClient } from "npm:@base44/sdk/iap"; + * import { iapConfig } from "./iapConfig.ts"; + * + * Deno.serve(async (req) => { + * const iap = createIapClient({ base44: createClientFromRequest(req), config: iapConfig }); + * return await iap.handleNotification(req); + * }); + * ``` + * + * @example + * ```typescript + * // React to what arrived + * iap.onEvent(async (event) => { + * if (event.type === "purchase.refund_reversed") { + * await reinstate(event.appUserId, event.productId); + * } + * }); + * + * return await iap.handleNotification(req); + * ``` + */ + handleNotification(request: Request): Promise; + + /** + * Handles a signed notification payload directly, without an HTTP request. + * + * Useful for replaying a payload you already stored, and for testing. It + * runs exactly the same path as {@linkcode IapModule.handleNotification | handleNotification()}, + * including duplicate detection, so replaying something already applied + * changes nothing. + * + * @param signedPayload - The `signedPayload` string from Apple's request body. + * @returns Promise resolving to what was done, including the status a webhook would have returned. + * + * @example + * ```typescript + * // Replay a stored payload + * const stored = await base44.asServiceRole.entities.IapNotification.get(id); + * const result = await iap.handleSignedPayload(stored.rawSignedPayload); + * console.log(result.status, result.outcome); + * ``` + */ + handleSignedPayload(signedPayload: string): Promise; + + /** + * Registers a handler called after purchase data has been stored. + * + * Handlers run once the write has landed, so anything they do — sending an + * email, granting a bonus, clawing back a balance — can rely on the data + * being there. A handler that throws is reported and ignored; it can never + * change what Apple is told. + * + * @param handler - Called with each event. + * @returns A function that removes the handler. + * + * @example + * ```typescript + * // Take content back on a refund, and give it back if reversed + * const stop = iap.onEvent(async (event) => { + * if (event.type === "purchase.refunded") { + * await revokeCoins(event.appUserId, event.productId); + * } + * if (event.type === "purchase.refund_reversed") { + * await grantCoins(event.appUserId, event.productId); + * } + * }); + * ``` + * + * @example + * ```typescript + * // Keep serving a customer whose payment failed but who is in a grace period + * iap.onEvent((event) => { + * if (event.type === "subscription.billing_issue" && event.inGracePeriod) { + * // Apple requires full service throughout the grace period. + * return; + * } + * }); + * ``` + */ + onEvent(handler: IapEventHandler): () => void; + + /** + * Checks whether the app can actually store purchase data. + * + * Never throws, so it is safe to call from a status page. Worth calling once + * after setup: until the four entities exist, nothing can be stored and + * every entitlement check answers "not entitled" — which looks exactly like + * an app with no paying customers. + * + * @returns Promise resolving to what exists, what is missing, and what to do about it. + * + * @example + * ```typescript + * // Confirm setup before going live + * const report = await iap.checkSetup(); + * if (!report.ok) { + * console.error("missing entities:", report.missingEntities.join(", ")); + * report.checklist.forEach((step, i) => console.log(`${i + 1}. ${step}`)); + * } + * ``` + */ + checkSetup(): Promise; + + /** + * Whether a user should get a paid feature right now. + * + * **This is the one call a feature gate should make.** Never gate on + * anything the client sends: a purchase is only real if Apple signed it, and + * this is the only thing that knows whether it did. + * + * It never throws. No purchase, an unreadable stored token, a storage + * failure — every one of those answers `false`, because the alternative is a + * gate that opens when something breaks. + * + * Two subtleties it handles for you. A subscription in a billing **grace + * period** counts as entitled, because Apple requires full service until the + * grace period ends. Sandbox purchases only count when `testMode` is on, so + * a live app cannot be unlocked with a test purchase. + * + * @param appUserId - The Base44 user id. + * @param query - Optionally narrow to certain products or a subscription group. + * @returns Promise resolving to whether the user is entitled. Never rejects. + * + * @example + * ```typescript + * // Gate a paid feature + * if (!(await iap.hasActiveSubscription(user.id))) { + * return Response.json({ error: "Subscription required" }, { status: 402 }); + * } + * ``` + * + * @example + * ```typescript + * // Require one of several plans + * const entitled = await iap.hasActiveSubscription(user.id, { + * productIds: ["pro_monthly", "pro_yearly"], + * }); + * ``` + */ + hasActiveSubscription(appUserId: string, query?: EntitlementQuery): Promise; + + /** + * Every subscription a user holds, and where each one stands. + * + * A user can hold more than one: a plan they bought, a plan a family member + * shared with them, or one per subscription group. Use this when you need to + * show a customer their own status; use + * {@linkcode IapModule.hasActiveSubscription | hasActiveSubscription()} to + * gate a feature. + * + * @param appUserId - The Base44 user id. + * @param query - Optionally narrow to a subscription group or product. + * @returns Promise resolving to one entry per subscription. + * @throws {Error} An `IapStoreError` when the data could not be read. + * + * @example + * ```typescript + * // Show a customer their subscription + * const [subscription] = await iap.getSubscriptionState(user.id); + * if (subscription?.status === "grace_period") { + * showBanner("There's a problem with your payment method."); + * } + * ``` + * + * @example + * ```typescript + * // Notice a scheduled plan change + * for (const state of await iap.getSubscriptionState(user.id)) { + * if (state.autoRenewProductId && state.autoRenewProductId !== state.productId) { + * console.log(`switching to ${state.autoRenewProductId} at renewal`); + * } + * } + * ``` + */ + getSubscriptionState( + appUserId: string, + query?: SubscriptionQuery + ): Promise; + + /** + * Everything a user currently owns. + * + * The server-side counterpart of StoreKit's current entitlements: what they + * own outright, which fixed-period purchases are still running, and every + * subscription. Consumables never appear — once used up they are the app's + * business to track, and Apple leaves them out too. + * + * @param appUserId - The Base44 user id. + * @returns Promise resolving to what the user owns, and when it was worked out. + * @throws {Error} An `IapStoreError` when the data could not be read. + * + * @example + * ```typescript + * // Build a customer's library + * const owned = await iap.getEntitlements(user.id); + * const unlocked = owned.nonConsumables.map((item) => item.productId); + * const passes = owned.nonRenewingSubscriptions.filter((pass) => pass.active); + * ``` + */ + getEntitlements(appUserId: string): Promise; + + /** + * One stored purchase, by Apple's transaction id. + * + * @param transactionId - Apple's transaction id. + * @returns Promise resolving to the stored purchase, or `null` when there is none. + * @throws {Error} An `IapStoreError` when the data could not be read. + * + * @example + * ```typescript + * // Look up a purchase for a support request + * const purchase = await iap.getPurchase("2000000123456789"); + * console.log(purchase?.productId, purchase?.revocationDate); + * ``` + */ + getPurchase(transactionId: string): Promise; + + /** + * A user's stored purchases, newest first. + * + * @param appUserId - The Base44 user id. + * @param query - Optionally narrow by product, type, date range, environment or refund state. + * @returns Promise resolving to the matching purchases. + * @throws {Error} An `IapStoreError` when the data could not be read. + * + * @example + * ```typescript + * // Show this month's purchases + * const purchases = await iap.listTransactions(user.id, { + * since: Date.UTC(2026, 8, 1), + * revoked: false, + * }); + * ``` + */ + listTransactions( + appUserId: string, + query?: TransactionQuery + ): Promise; + + /** + * A user's refunded or revoked purchases. + * + * Each row carries how much was refunded, so an app that sold a consumable + * balance can take back the right proportion of it. + * + * @param appUserId - The Base44 user id. + * @returns Promise resolving to the refunded purchases. + * @throws {Error} An `IapStoreError` when the data could not be read. + * + * @example + * ```typescript + * // Claw back a coin balance proportionally + * for (const refund of await iap.listRefunds(user.id)) { + * const share = (refund.revocationPercentage ?? 100000) / 100000; + * await deductCoins(user.id, refund.productId, share); + * } + * ``` + */ + listRefunds(appUserId: string): Promise; + + /** + * Refund requests Apple is still waiting on, soonest deadline first. + * + * Apple allows 12 hours to answer in production and only **5 minutes** in + * sandbox, and only wants an answer if the customer consented to sharing + * their consumption data. With no consent flow, the right thing is to ignore + * these. + * + * @returns Promise resolving to the open requests, ordered by deadline. + * @throws {Error} An `IapStoreError` when the data could not be read. + * + * @example + * ```typescript + * // See what is waiting, and how long is left + * for (const request of await iap.listPendingConsumptionRequests()) { + * const minutesLeft = Math.round((request.deadlineAt - Date.now()) / 60000); + * console.log(request.transactionId, request.consumptionRequestReason, minutesLeft); + * } + * ``` + */ + listPendingConsumptionRequests(): Promise; + + /** + * Records a purchase the app has just made. + * + * **The app must not tell StoreKit the purchase is finished until this + * resolves.** An unfinished transaction is re-delivered at the next launch, + * so the device acts as the retry queue — and a purchase finished before the + * server stored it is a purchase nobody can prove afterwards. If this + * throws, deliver nothing and do not finish. + * + * `duplicate` is the double-delivery guard. StoreKit re-delivers an + * unfinished transaction every launch, so a consumable should only be + * granted when `duplicate` is `false`. + * + * Passing `appUserId` is worth doing: it is checked against the UUID Apple + * signed into the transaction, so one customer cannot claim another's + * purchase by replaying their token. + * + * @param jws - The signed transaction from StoreKit. + * @param options - Optionally the Base44 user making the request. + * @returns Promise resolving to what was stored, and whether it was already known. + * @throws {Error} An `IapVerificationError` when the token fails a check, or an `IapStoreError` when it could not be stored. + * + * @example + * ```typescript + * // Record a purchase, then let the app finish it + * const result = await iap.recordTransaction(jws, { appUserId: user.id }); + * if (!result.duplicate) { + * await grantCoins(user.id, result.decoded.productId); + * } + * return Response.json(result); + * ``` + */ + recordTransaction( + jws: string, + options?: RecordTransactionOptions + ): Promise; + + /** + * Reconciles what the device knows about its purchases with what the server knows. + * + * Call this at launch, on foreground, after any purchase, and after a + * customer-initiated restore. It is how everything heals: a notification + * Apple never managed to deliver, a purchase made on another device, a + * renewal that happened while the app was closed. + * + * One unreadable token never fails the whole call — it is counted in + * `skipped` and the rest are stored, because the other purchases are still + * real. `mismatches` counts what the device believes it owns that the server + * does not; persistently above zero means notifications are going missing. + * + * @param payload - What the device knows: current entitlements, unfinished transactions, and subscription status pairs. + * @param options - Optionally the Base44 user making the request. + * @returns Promise resolving to which transactions are stored, what the server believes, and how far the two disagreed. + * + * @example + * ```typescript + * // The launch-time sync endpoint + * const result = await iap.syncEntitlements(await req.json(), { appUserId: user.id }); + * return Response.json(result); + * // The app may now finish the transactions in result.recordedTransactionIds. + * ``` + */ + syncEntitlements( + payload: SyncPayload, + options?: RecordTransactionOptions + ): Promise; + + /** + * Calls to Apple's own servers. + * + * Always present. Every method throws until `serverApi` credentials are + * configured, so the module's shape never depends on configuration. + * + * @example + * ```typescript + * // Confirm the webhook is reachable + * const { testNotificationToken } = await iap.serverApi.requestTestNotification(); + * ``` + */ + serverApi: IapServerApiModule; +} diff --git a/src/iap/index.ts b/src/iap/index.ts new file mode 100644 index 00000000..c0e0b77c --- /dev/null +++ b/src/iap/index.ts @@ -0,0 +1,328 @@ +/** + * Apple in-app purchase support — the `@base44/sdk/iap` entry point. + * + * Kept behind its own subpath deliberately. Verification carries certificate + * parsing and cryptography that only a backend function ever runs, and the + * main entry point is imported by browsers and by React Native. The types are + * re-exported from `@base44/sdk` as well, where they cost nothing, so shared + * front-end code can name them without pulling any of this in. + */ +import type { Base44Client } from "../client.types.js"; +import { appAccountTokenFor } from "./account-token.js"; +import { resolveConfig } from "./config.js"; +import { createVerifier } from "./verify/verifier.js"; +import type { AppleRoot } from "./verify/apple-roots.js"; +import { systemClock, type Clock } from "./runtime/clock.js"; +import { createEmitter } from "./events/emitter.js"; +import type { IapEventHandler } from "./events/events.types.js"; +import { createEntitiesStore } from "./store/entities-store.js"; +import type { IapStoreMode } from "./store/store.types.js"; +import { IAP_SETUP_CHECKLIST } from "./store/schemas.js"; +import { + handleNotification, + handleSignedPayload, + type IngestContext, +} from "./ingest/notifications.js"; +import { createReader } from "./read/read.js"; +import { createServerApiClient } from "./server-api/client.js"; +import { recordTransaction, syncEntitlements } from "./ingest/device.js"; +import type { + RecordTransactionOptions, + SyncPayload, +} from "./ingest/device.types.js"; +import type { IapConfig, IapModule, IapSetupReport } from "./iap.types.js"; + +/** Test seams. Not part of the public contract. @internal */ +export interface IapInternalOptions { + /** + * Trust anchors to pin against, replacing Apple's real roots. + * + * For this SDK's own tests. An app must never be able to add a root, which + * is why this is not reachable from {@link IapConfig}. + */ + readonly roots?: readonly AppleRoot[]; + /** The clock. */ + readonly clock?: Clock; + /** + * The `fetch` used for App Store Server API calls. + * + * Injected for tests: `nock` hooks Node's http module and does not intercept + * native `fetch`, which is what this client uses. + */ + readonly fetchImpl?: typeof fetch; + /** + * How the store addresses rows. + * + * Defaults to `"query-guard"`, which is correct whether or not the backend + * honours a caller-supplied record id. `"natural-id"` is one round trip + * cheaper per insert, but only safe once that has been confirmed — on a + * backend that ignores the id, de-duplication would never fire. + */ + readonly storeMode?: IapStoreMode; +} + +/** Inputs to {@linkcode createIapClient | createIapClient()}. */ +export interface CreateIapClientOptions { + /** + * A Base44 client from + * {@linkcode createClientFromRequest | createClientFromRequest()}. + * + * Purchase records are stored in the app's own entities with service-role + * access, so the client must come from an incoming backend-function request. + */ + base44: Base44Client; + /** How the module behaves for this app. */ + config: IapConfig; + /** @internal */ + internal?: IapInternalOptions; +} + +/** + * Creates the in-app purchase module for one app. + * + * Validates the configuration immediately and throws if it cannot be operated + * correctly, so a mistake surfaces on deploy rather than on Apple's first + * notification. Creating a client does no I/O, so building one per request is + * both correct and cheap. + * + * @param options - The Base44 client and the app's purchase configuration. + * @returns The in-app purchase module. + * @throws {Error} An `IapConfigError` when the configuration is invalid or asks for an unsupported option. + * + * @example + * ```typescript + * // Verify a purchase in a backend function + * import { createClientFromRequest } from "npm:@base44/sdk"; + * import { createIapClient } from "npm:@base44/sdk/iap"; + * + * Deno.serve(async (req) => { + * const iap = createIapClient({ + * base44: createClientFromRequest(req), + * config: { + * bundleId: "com.example.app", + * appAppleId: 1234567890, + * products: { pro_monthly: { type: "autoRenewableSubscription" } }, + * }, + * }); + * + * const { jws } = await req.json(); + * const transaction = await iap.verifyTransaction(jws); + * return Response.json({ productId: transaction.productId }); + * }); + * ``` + * + * @example + * ```typescript + * // Accept sandbox purchases while testing + * const iap = createIapClient({ + * base44, + * config: { + * bundleId: "com.example.app", + * appAppleId: 1234567890, + * products: { coins_100: { type: "consumable" } }, + * testMode: true, + * }, + * }); + * ``` + */ +export function createIapClient(options: CreateIapClientOptions): IapModule { + if (!options || !options.base44) { + throw new TypeError( + "createIapClient needs a Base44 client — create one with createClientFromRequest(request)" + ); + } + + const config = resolveConfig(options.config); + const clock = options.internal?.clock ?? systemClock; + + const verifier = createVerifier({ + config, + roots: options.internal?.roots, + clock, + }); + + const store = createEntitiesStore({ + // Reached lazily: the service-role accessor throws when the client has no + // service credentials, and constructing an IAP client must not. + getEntities: () => options.base44.asServiceRole.entities, + mode: options.internal?.storeMode, + clock, + }); + + const emitter = createEmitter(); + + const context: IngestContext = { store, verifier, config, clock, emitter }; + + const reader = createReader({ + store, + verifier, + config, + clock, + report: (what, error) => { + // A read that failed is not the same as a customer with no purchases, + // and only one of the two is a problem. Saying so is what stops an app + // whose entities were never created from looking like an app with no + // paying customers. + console.warn( + `[base44 iap] ${what}`, + error instanceof Error ? error.message : error ?? "" + ); + }, + }); + + return { + verifyTransaction: verifier.verifyTransaction, + verifyRenewalInfo: verifier.verifyRenewalInfo, + verifyNotification: verifier.verifyNotification, + appAccountTokenFor, + + serverApi: createServerApiClient({ + config: config.serverApi, + bundleId: config.bundleId, + clock, + // A sandbox app's transactions live in the sandbox environment, so try + // it first there and save a round trip. + preferSandbox: config.testMode, + fetchImpl: options.internal?.fetchImpl, + }), + + hasActiveSubscription: reader.hasActiveSubscription, + getSubscriptionState: reader.getSubscriptionState, + getEntitlements: reader.getEntitlements, + getPurchase: reader.getPurchase, + listTransactions: reader.listTransactions, + listRefunds: reader.listRefunds, + listPendingConsumptionRequests: reader.listPendingConsumptionRequests, + + recordTransaction: (jws: string, recordOptions?: RecordTransactionOptions) => + recordTransaction(context, jws, recordOptions), + syncEntitlements: (payload: SyncPayload, syncOptions?: RecordTransactionOptions) => + syncEntitlements(context, reader, payload, syncOptions), + + handleNotification: (request: Request) => handleNotification(context, request), + handleSignedPayload: (signedPayload: string) => + handleSignedPayload(context, signedPayload), + onEvent: (handler: IapEventHandler) => emitter.onEvent(handler), + + async checkSetup(): Promise { + // Never throws: this is what a status page calls, and an app whose + // entities were never created looks identical to one with no customers. + try { + const health = await store.healthcheck(); + return { + ok: health.ok, + missingEntities: health.missing, + checklist: IAP_SETUP_CHECKLIST, + }; + } catch { + return { + ok: false, + missingEntities: [], + checklist: IAP_SETUP_CHECKLIST, + }; + } + }, + }; +} + +export { appAccountTokenFor, IAP_APP_ACCOUNT_TOKEN_NAMESPACE } from "./account-token.js"; +export { + IapApiError, + IapConfigError, + IapError, + IapSetupError, + IapStoreError, + IapVerificationError, +} from "./errors.js"; +export { IAP_MODULE_VERSION } from "./version.js"; + +export { + IAP_ENTITY_NAMES, + IAP_ENTITY_SCHEMAS, + IAP_SETUP_CHECKLIST, +} from "./store/schemas.js"; + +export type { + IapConfig, + IapConfiguredProductType, + IapModule, + IapProductConfig, + IapSetupReport, +} from "./iap.types.js"; +export type { + IapEvent, + IapEventHandler, + IapEventType, + IapExpiryReason, + IapRenewReason, + IapStartReason, +} from "./events/events.types.js"; +export type { HandleNotificationResult } from "./ingest/notifications.js"; +export type { + ConsumptionRequestBody, + IapDeliveryStatus, + IapRefundPreference, + IapServerApiConfig, + IapServerApiModule, + SendAttempt, + SendAttemptResult, + TestNotificationResult, + TestNotificationStatus, +} from "./server-api/server-api.types.js"; +export type { + RecordTransactionOptions, + RecordTransactionResult, + SyncPayload, + SyncResult, +} from "./ingest/device.types.js"; +export type { + EntitlementQuery, + Entitlements, + IapExpirationReason, + IapRevocation, + IapSubscriptionOffer, + IapSubscriptionStatus, + OwnedNonConsumable, + OwnedNonRenewingSubscription, + SubscriptionQuery, + SubscriptionState, + TransactionQuery, +} from "./read/read.types.js"; +export type { + IapEntityName, + IapEntitySchema, + IapSchemaField, +} from "./store/schemas.js"; +export type { + IapConsumptionOutcome, + IapConsumptionRequestRecord, + IapNotificationOutcome, + IapNotificationRecord, + IapRecordSource, + IapSubscriptionRecord, + IapTransactionRecord, +} from "./store/rows.types.js"; +export type { + IapApiErrorCode, + IapConfigErrorCode, + IapSetupErrorCode, + IapStoreErrorCode, + IapVerificationErrorCode, +} from "./errors.types.js"; +export type { + DecodedNotification, + DecodedNotificationData, + DecodedNotificationSummary, + DecodedRenewalInfo, + DecodedTransaction, + IapAppleSubscriptionStatus, + IapConsumptionRequestReason, + IapEnvironment, + IapExpirationIntent, + IapOfferDiscountType, + IapOfferType, + IapOwnershipType, + IapProductType, + IapRevocationType, + IapTransactionReason, +} from "./verify/verify.types.js"; diff --git a/src/iap/ingest/device.ts b/src/iap/ingest/device.ts new file mode 100644 index 00000000..ccb3f5c0 --- /dev/null +++ b/src/iap/ingest/device.ts @@ -0,0 +1,273 @@ +/** + * The two paths that start on the device. + * + * `recordTransaction` runs right after a purchase. Its contract with the shell + * is the important part: the shell must **not** call StoreKit's `finish()` + * until this resolves. An unfinished transaction is re-delivered at the next + * launch, so the device is the retry queue — and a purchase finished before + * the server stored it is a purchase nobody can prove. + * + * `syncEntitlements` runs at launch and on foreground. It is how everything + * heals: a notification Apple never managed to deliver, a purchase made on + * another device, a subscription that renewed while the app was closed. + * + * Both are idempotent. + * + * @internal + */ +import { IapVerificationError } from "../errors.js"; +import type { IapEvent } from "../events/events.types.js"; +import type { Reader } from "../read/read.js"; +import { + SUBSCRIPTION_DESCRIPTOR, + TRANSACTION_DESCRIPTOR, +} from "../store/descriptors.js"; +import type { DecodedTransaction } from "../verify/verify.types.js"; +import type { IngestContext } from "./notifications.js"; +import { + assertUserMatchesToken, + subscriptionRenewalPatch, + subscriptionRowFrom, + subscriptionTransactionPatch, + transactionPatchFrom, + transactionRowFrom, +} from "./mappers.js"; +import type { + RecordTransactionOptions, + RecordTransactionResult, + SyncPayload, + SyncResult, +} from "./device.types.js"; + +const SUBSCRIPTION_TYPE = "Auto-Renewable Subscription"; + +/** Whether a decoded transaction is an auto-renewable subscription. */ +function isSubscription(decoded: DecodedTransaction): boolean { + return decoded.type === SUBSCRIPTION_TYPE; +} + +/** Stores one verified transaction, and its subscription when it has one. */ +async function storeTransaction( + context: IngestContext, + decoded: DecodedTransaction, + jws: string, + appUserId: string | null, + renewal?: { readonly jws: string; readonly signedDate: number | null } +): Promise<{ transactionId: string; inserted: boolean }> { + const now = context.clock(); + const row = transactionRowFrom(decoded, { + source: "device", + appUserId, + now, + config: context.config, + rawJws: jws, + }); + + const result = await context.store.upsertNewestWins( + TRANSACTION_DESCRIPTOR, + row.transactionId, + { facet: "transaction", value: row.signedDate }, + row, + transactionPatchFrom(row) + ); + + if (isSubscription(decoded)) { + const subscriptionRow = subscriptionRowFrom({ + transaction: decoded, + transactionJws: jws, + renewalInfoJws: renewal?.jws, + appUserId, + now, + }); + // A bare device transaction carries no renewal information, so the row's + // renewal cursor is left alone and whatever a notification put there + // survives. That is why the two halves have separate cursors. + if (renewal?.signedDate != null) { + subscriptionRow.latestRenewalSignedDate = renewal.signedDate; + } + + await context.store.upsertNewestWins( + SUBSCRIPTION_DESCRIPTOR, + subscriptionRow.originalTransactionId, + { facet: "transaction", value: subscriptionRow.latestSignedDate ?? now }, + subscriptionRow, + subscriptionTransactionPatch(subscriptionRow) + ); + + if (renewal && subscriptionRow.latestRenewalSignedDate !== null) { + await context.store.patchWhere( + SUBSCRIPTION_DESCRIPTOR, + subscriptionRow.originalTransactionId, + { + cursorBelow: { + facet: "renewal", + value: subscriptionRow.latestRenewalSignedDate, + }, + }, + subscriptionRenewalPatch(subscriptionRow) + ); + } + } + + return { transactionId: row.transactionId, inserted: result.outcome === "inserted" }; +} + +/** Records a purchase the shell has just completed. */ +export async function recordTransaction( + context: IngestContext, + jws: string, + options: RecordTransactionOptions = {} +): Promise { + const decoded = await context.verifier.verifyTransaction(jws); + + const transactionId = decoded.transactionId; + if (typeof transactionId !== "string" || transactionId.length === 0) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + "the transaction carries no transactionId, so it cannot be stored or de-duplicated" + ); + } + + // The account token is a one-way hash of a user id, so it cannot be turned + // into one — but it can confirm a claim. Rejecting a mismatch is what stops + // one customer claiming another's purchase by replaying their token. + let appUserId: string | null = null; + if (options.appUserId) { + assertUserMatchesToken(decoded, options.appUserId); + appUserId = options.appUserId; + } + + // Whether the row already existed is decided before the write, because that + // is what tells the app not to deliver a consumable twice. + const existing = await context.store.getByKey( + TRANSACTION_DESCRIPTOR, + transactionId, + ["transactionId"] + ); + const duplicate = existing !== null; + + // A throw here is deliberate and load-bearing: the shell must not call + // finish(), so StoreKit keeps the transaction and re-delivers it next launch. + await storeTransaction(context, decoded, jws, appUserId); + + if (!duplicate) { + const event: IapEvent = { + type: isSubscription(decoded) ? "subscription.started" : "purchase.completed", + appUserId, + originalTransactionId: decoded.originalTransactionId + ? String(decoded.originalTransactionId) + : undefined, + transactionId, + productId: decoded.productId ? String(decoded.productId) : undefined, + environment: decoded.environment ?? "Production", + occurredAt: Number(decoded.signedDate ?? context.clock()), + source: "device", + startReason: isSubscription(decoded) ? "initial" : undefined, + payload: decoded, + }; + await context.emitter.emit([event]); + } + + return { recorded: true, transactionId, duplicate, decoded }; +} + +/** Reconciles what the device knows against what the server knows. */ +export async function syncEntitlements( + context: IngestContext, + reader: Reader, + payload: SyncPayload, + options: RecordTransactionOptions = {} +): Promise { + const recordedTransactionIds: string[] = []; + const deviceProductIds = new Set(); + let skipped = 0; + + const appUserId = options.appUserId ?? null; + + /** Verifies and stores one token, counting rather than throwing on failure. */ + async function ingest( + jws: string, + renewal?: { readonly jws: string; readonly signedDate: number | null } + ): Promise { + let decoded: DecodedTransaction; + try { + decoded = await context.verifier.verifyTransaction(jws); + if (appUserId) assertUserMatchesToken(decoded, appUserId); + } catch { + // One unusable token must never fail the whole sync: the rest of the + // device's purchases are still real and still need storing. + skipped += 1; + return; + } + + if (typeof decoded.productId === "string") { + deviceProductIds.add(decoded.productId); + } + + try { + const stored = await storeTransaction( + context, + decoded, + jws, + appUserId, + renewal + ); + // Reported only when it is durably stored, so the shell only finishes a + // transaction the server can prove. + recordedTransactionIds.push(stored.transactionId); + } catch { + skipped += 1; + } + } + + for (const jws of payload.entitlements ?? []) await ingest(jws); + for (const jws of payload.unfinished ?? []) await ingest(jws); + + for (const pair of payload.statuses ?? []) { + let renewalSignedDate: number | null = null; + try { + const renewal = await context.verifier.verifyRenewalInfo(pair.renewalInfoJws); + renewalSignedDate = + typeof renewal.signedDate === "number" ? renewal.signedDate : null; + } catch { + skipped += 1; + } + await ingest(pair.transactionJws, { + jws: pair.renewalInfoJws, + signedDate: renewalSignedDate, + }); + } + + const snapshot = appUserId + ? await reader.getEntitlements(appUserId) + : { nonConsumables: [], nonRenewingSubscriptions: [], subscriptions: [], asOf: context.clock() }; + + // What the device thinks it owns that the server does not agree is live. + // Persistently above zero means notifications are going missing. + const serverProductIds = new Set(); + for (const item of snapshot.nonConsumables) serverProductIds.add(item.productId); + for (const item of snapshot.nonRenewingSubscriptions) { + if (item.active) serverProductIds.add(item.productId); + } + for (const state of snapshot.subscriptions) { + if (state.entitled && state.productId) serverProductIds.add(state.productId); + } + + let mismatches = 0; + for (const productId of deviceProductIds) { + if (!serverProductIds.has(productId)) mismatches += 1; + } + + await context.emitter.emit([ + { + type: "sync.applied", + appUserId, + environment: payload.environment ?? "Production", + occurredAt: context.clock(), + source: "device", + mismatches, + }, + ]); + + return { recordedTransactionIds, snapshot, mismatches, skipped }; +} diff --git a/src/iap/ingest/device.types.ts b/src/iap/ingest/device.types.ts new file mode 100644 index 00000000..651edd34 --- /dev/null +++ b/src/iap/ingest/device.types.ts @@ -0,0 +1,81 @@ +/** + * What the native shell sends the backend. + * + * Two calls, both from the shell rather than the web app: one when a purchase + * completes, and one at launch to reconcile whatever the device knows against + * whatever the server knows. The web app never sends purchase tokens itself. + */ +import type { DecodedTransaction, IapEnvironment } from "../verify/verify.types.js"; +import type { Entitlements } from "../read/read.types.js"; + +/** Extra context for recording a purchase. */ +export interface RecordTransactionOptions { + /** + * The Base44 user making the request. + * + * When given, it is checked against the UUID Apple signed into the + * transaction, and the call is rejected if they disagree — so one customer + * cannot claim another's purchase by replaying their token. + */ + appUserId?: string; +} + +/** What recording a purchase did. */ +export interface RecordTransactionResult { + /** Whether the purchase is now stored. Always true when this resolves. */ + recorded: boolean; + /** Apple's transaction id. */ + transactionId: string; + /** + * Whether this transaction was already stored. + * + * The double-delivery guard. StoreKit re-delivers an unfinished transaction + * at every launch, so a consumable must only be granted when this is + * `false`. + */ + duplicate: boolean; + /** The verified contents of the token. */ + decoded: DecodedTransaction; +} + +/** What the shell knows about the device's purchases. */ +export interface SyncPayload { + /** Signed transactions from StoreKit's current entitlements. */ + entitlements?: string[]; + /** Signed transactions StoreKit still considers unfinished. */ + unfinished?: string[]; + /** + * Subscription status pairs, one per subscription group. + * + * The pairing matters: current entitlements carry a transaction but no + * renewal information, and renewal information is where the grace period and + * the auto-renew flag live. + */ + statuses?: { transactionJws: string; renewalInfoJws: string }[]; + /** The signed app transaction, if the shell has one. */ + appTransaction?: string; + /** Which environment the device is in. */ + environment?: IapEnvironment; +} + +/** What a sync did. */ +export interface SyncResult { + /** + * Transactions that are now durably stored. + * + * The shell may call `finish()` on these once it has delivered the content. + * An id missing from this list was not stored, so StoreKit should keep + * re-delivering it. + */ + recordedTransactionIds: string[]; + /** What the server believes the user owns, so the app can reconcile its UI. */ + snapshot: Entitlements; + /** + * How many device entitlements the server did not know about, or disagreed on. + * + * Persistently above zero means notifications are being missed. + */ + mismatches: number; + /** How many tokens failed verification and were skipped. */ + skipped: number; +} diff --git a/src/iap/ingest/mappers.ts b/src/iap/ingest/mappers.ts new file mode 100644 index 00000000..9806f9ad --- /dev/null +++ b/src/iap/ingest/mappers.ts @@ -0,0 +1,334 @@ +/** + * Turning a decoded Apple payload into the rows the store writes. + * + * Two things worth knowing before reading further. + * + * **The account-token mapping is one-way.** A Base44 user id hashes to a UUID, + * and Apple signs that UUID into every transaction — but a hash cannot be + * inverted, so a token does not yield the user back. That has a concrete + * consequence per path: + * + * - `recordTransaction` and `syncEntitlements` run inside an authenticated + * request, so the user is already known. The token is used to *check* that + * claim, by re-deriving it and comparing. + * - A webhook has no user. It inherits `appUserId` from a row already stored + * for the same subscription, and leaves it null when there is none — which + * is the honest answer for a purchase made before the customer logged in. + * + * **A merge omits what it does not know.** Every patch here drops nullish + * values, so a bare device transaction cannot erase renewal information that a + * notification supplied. Clearing a field is always explicit. + * + * @internal + */ +import { appAccountTokenFor } from "../account-token.js"; +import { IapVerificationError } from "../errors.js"; +import { IAP_MODULE_VERSION } from "../version.js"; +import type { ResolvedIapConfig } from "../config.js"; +import type { + DecodedNotification, + DecodedRenewalInfo, + DecodedTransaction, + IapEnvironment, +} from "../verify/verify.types.js"; +import type { + IapConsumptionRequestRecord, + IapNotificationRecord, + IapRecordSource, + IapSubscriptionRecord, + IapTransactionRecord, +} from "../store/rows.types.js"; +import type { IapPatch } from "../store/store.types.js"; + +const DAY_MS = 86_400_000; + +/** Apple's window for consumption data: 12 hours live, 5 minutes in sandbox. */ +export function consumptionDeadline( + receivedAt: number, + environment: IapEnvironment +): number { + return environment === "Production" + ? receivedAt + 12 * 60 * 60 * 1000 + : receivedAt + 5 * 60 * 1000; +} + +/** + * Confirms a claimed user matches the UUID Apple signed into the transaction. + * + * @throws {IapVerificationError} when the token belongs to a different user. + */ +export function assertUserMatchesToken( + decoded: DecodedTransaction, + claimedUserId: string +): void { + const token = decoded.appAccountToken; + if (typeof token !== "string" || token.length === 0) return; + + const expected = appAccountTokenFor(claimedUserId); + if (token.toLowerCase() !== expected.toLowerCase()) { + throw new IapVerificationError( + "INVALID_APP_IDENTIFIER", + "this transaction's account token belongs to a different user than the one " + + "making the request, so it will not be attributed to them" + ); + } +} + +/** + * When a non-renewing subscription runs out. + * + * Apple never expires these — its own documentation says the app is + * responsible — so the configured duration is the only thing that decides. + */ +function appDefinedExpiry( + decoded: DecodedTransaction, + config: ResolvedIapConfig +): number | null { + const productId = decoded.productId; + if (typeof productId !== "string") return null; + const product = config.products[productId]; + if (!product || product.type !== "nonRenewingSubscription") return null; + const days = product.nonRenewingDurationDays; + const purchasedAt = decoded.purchaseDate; + if (typeof days !== "number" || typeof purchasedAt !== "number") return null; + return purchasedAt + days * DAY_MS; +} + +function pick(value: T | undefined): T | null { + return value === undefined ? null : value; +} + +/** Builds a complete transaction row, for an insert. */ +export function transactionRowFrom( + decoded: DecodedTransaction, + context: { + readonly source: IapRecordSource; + readonly appUserId: string | null; + readonly now: number; + readonly config: ResolvedIapConfig; + readonly rawJws: string; + } +): IapTransactionRecord { + const environment = (decoded.environment ?? "Production") as IapEnvironment; + + return { + transactionId: String(decoded.transactionId), + originalTransactionId: String( + decoded.originalTransactionId ?? decoded.transactionId + ), + appUserId: context.appUserId, + appAccountToken: pick(decoded.appAccountToken), + productId: String(decoded.productId ?? ""), + type: pick(decoded.type), + subscriptionGroupIdentifier: pick(decoded.subscriptionGroupIdentifier), + purchaseDate: pick(decoded.purchaseDate), + originalPurchaseDate: pick(decoded.originalPurchaseDate), + expiresDate: pick(decoded.expiresDate), + appDefinedExpiresDate: appDefinedExpiry(decoded, context.config), + quantity: pick(decoded.quantity), + inAppOwnershipType: pick(decoded.inAppOwnershipType), + transactionReason: pick(decoded.transactionReason), + isUpgraded: pick(decoded.isUpgraded), + offerType: pick(decoded.offerType), + offerIdentifier: pick(decoded.offerIdentifier), + offerDiscountType: pick(decoded.offerDiscountType), + offerPeriod: pick(decoded.offerPeriod), + revocationDate: pick(decoded.revocationDate), + revocationReason: pick(decoded.revocationReason), + revocationType: pick(decoded.revocationType), + revocationPercentage: pick(decoded.revocationPercentage), + environment, + storefront: pick(decoded.storefront), + storefrontId: pick(decoded.storefrontId), + signedDate: Number(decoded.signedDate ?? context.now), + rawJws: context.rawJws, + source: context.source, + finishedAt: null, + recordedAt: context.now, + updatedAt: context.now, + }; +} + +/** Builds the merge for an existing transaction row. */ +export function transactionPatchFrom( + row: IapTransactionRecord, + options: { readonly clearRevocation?: boolean } = {} +): IapPatch { + const { transactionId, recordedAt, finishedAt, ...rest } = row; + void transactionId; + void recordedAt; + void finishedAt; + + if (options.clearRevocation) { + // Apple omits revocationPercentage entirely when a refund is reversed, so + // omitting nullish values is not enough — the stale revocation would + // survive and keep a paying customer locked out. It has to be cleared. + const { + revocationDate, + revocationReason, + revocationType, + revocationPercentage, + ...withoutRevocation + } = rest; + void revocationDate; + void revocationReason; + void revocationType; + void revocationPercentage; + + return { + set: withoutRevocation as Partial, + clear: [ + "revocationDate", + "revocationReason", + "revocationType", + "revocationPercentage", + ], + }; + } + + return { set: rest as Partial }; +} + +/** Builds a complete subscription row, for an insert. */ +export function subscriptionRowFrom(context: { + readonly transaction: DecodedTransaction; + readonly transactionJws: string; + readonly renewalInfo?: DecodedRenewalInfo; + readonly renewalInfoJws?: string; + readonly appUserId: string | null; + readonly appleStatus?: IapSubscriptionRecord["appleStatus"]; + readonly now: number; +}): IapSubscriptionRecord { + const { transaction, renewalInfo } = context; + return { + originalTransactionId: String( + transaction.originalTransactionId ?? transaction.transactionId + ), + appUserId: context.appUserId, + subscriptionGroupIdentifier: pick(transaction.subscriptionGroupIdentifier), + productId: pick(transaction.productId), + latestTransactionJws: context.transactionJws, + latestRenewalInfoJws: context.renewalInfoJws ?? null, + latestSignedDate: Number(transaction.signedDate ?? context.now), + latestRenewalSignedDate: + renewalInfo && typeof renewalInfo.signedDate === "number" + ? renewalInfo.signedDate + : null, + appleStatus: context.appleStatus ?? null, + environment: (transaction.environment ?? "Production") as IapEnvironment, + recordedAt: context.now, + updatedAt: context.now, + }; +} + +/** + * The transaction half of a subscription merge. + * + * Deliberately excludes the renewal columns. They have their own cursor, + * because a device sync brings a fresh transaction with no renewal + * information — and if one cursor covered both, that sync would advance past a + * later notification carrying a new grace-period date, denying service to a + * customer Apple is still trying to bill. + */ +export function subscriptionTransactionPatch( + row: IapSubscriptionRecord +): IapPatch { + return { + set: { + subscriptionGroupIdentifier: row.subscriptionGroupIdentifier, + productId: row.productId, + latestTransactionJws: row.latestTransactionJws, + latestSignedDate: row.latestSignedDate, + appleStatus: row.appleStatus, + appUserId: row.appUserId, + environment: row.environment, + updatedAt: row.updatedAt, + }, + }; +} + +/** The renewal half of a subscription merge, guarded by its own cursor. */ +export function subscriptionRenewalPatch( + row: IapSubscriptionRecord +): IapPatch { + return { + set: { + latestRenewalInfoJws: row.latestRenewalInfoJws, + latestRenewalSignedDate: row.latestRenewalSignedDate, + appleStatus: row.appleStatus, + updatedAt: row.updatedAt, + }, + }; +} + +/** + * Builds a notification row, claimed but not yet applied. + * + * `outcome: "error"` is the commit flag. Duplicate detection only treats a row + * as already handled once the outcome is something else, so a delivery whose + * writes failed after this row landed is re-applied on Apple's retry rather + * than dismissed as a duplicate — which would lose it for good, because Apple + * stops retrying once it sees success. + */ +export function notificationRowFrom(context: { + readonly notification: DecodedNotification; + readonly rawSignedPayload: string; + readonly now: number; +}): IapNotificationRecord { + const { notification } = context; + const transaction = notification.data?.transactionInfo; + + return { + notificationUUID: notification.notificationUUID, + notificationType: notification.notificationType, + subtype: pick(notification.subtype), + signedDate: Number(notification.signedDate ?? context.now), + receivedAt: context.now, + originalTransactionId: transaction?.originalTransactionId + ? String(transaction.originalTransactionId) + : null, + transactionId: transaction?.transactionId + ? String(transaction.transactionId) + : null, + environment: (notification.data?.environment ?? + notification.summary?.environment ?? + "Production") as IapEnvironment, + rawSignedPayload: context.rawSignedPayload, + outcome: "error", + attempts: 1, + sdkVersion: IAP_MODULE_VERSION, + }; +} + +/** Builds a consumption-request row from a `CONSUMPTION_REQUEST` notification. */ +export function consumptionRowFrom(context: { + readonly notification: DecodedNotification; + readonly appUserId: string | null; + readonly now: number; +}): IapConsumptionRequestRecord | null { + const transaction = context.notification.data?.transactionInfo; + if (!transaction?.transactionId) return null; + + const environment = (context.notification.data?.environment ?? + "Production") as IapEnvironment; + + return { + transactionId: String(transaction.transactionId), + originalTransactionId: transaction.originalTransactionId + ? String(transaction.originalTransactionId) + : null, + appUserId: context.appUserId, + consumptionRequestReason: pick( + context.notification.data?.consumptionRequestReason + ), + receivedAt: context.now, + deadlineAt: consumptionDeadline(context.now, environment), + requestSignedDate: Number(context.notification.signedDate ?? context.now), + respondedAt: null, + response: null, + outcome: null, + outcomeSignedDate: null, + environment, + updatedAt: context.now, + }; +} diff --git a/src/iap/ingest/matrix.ts b/src/iap/ingest/matrix.ts new file mode 100644 index 00000000..3d2b4a6f --- /dev/null +++ b/src/iap/ingest/matrix.ts @@ -0,0 +1,291 @@ +/** + * What to do with each of Apple's notification types. + * + * A pure table, mapping Apple's `notificationType` and `subtype` onto three + * things: which rows to touch, which event to emit, and what outcome to record. + * No I/O, so every row is testable on its own — and adding a type Apple + * invents later is a one-line change here rather than a new branch somewhere + * in the webhook. + * + * Two rules from Apple that the table encodes rather than leaves to a caller: + * + * - An unrecognised type still gets a `200`. Apple retries a failure for up to + * 72 hours, and retrying will not teach this version a type it does not + * know, so the payload is stored raw and the response is success. + * - A reversed refund must **clear** the revocation, not merely record a new + * event, because the app has to give the content back. + * + * @internal + */ +import type { IapNotificationOutcome } from "../store/rows.types.js"; +import type { IapEvent, IapEventType } from "../events/events.types.js"; + +/** What a notification does to stored state, and what it means to the app. */ +export interface NotificationPlan { + /** The event to emit. */ + readonly event: IapEventType; + /** What to record in the notification's own row. */ + readonly outcome: Extract< + IapNotificationOutcome, + "applied" | "unhandled" | "unknown_type" + >; + /** Whether to store the transaction the payload carries. */ + readonly storeTransaction: boolean; + /** + * Whether this concerns an auto-renewable subscription. + * + * The payload still decides: the subscription row is only touched when the + * transaction really is a subscription and carries the tokens for it. + */ + readonly touchesSubscription: boolean; + /** Whether to open a refund-consumption request, or to fill in its outcome. */ + readonly consumption?: "open" | "resolve"; + /** Whether to clear the revocation fields, so the app reinstates the content. */ + readonly clearRevocation?: boolean; + /** Extra event detail this row implies. */ + readonly detail?: Partial; +} + +type SubtypeTable = Readonly>; + +interface TypeEntry { + /** Used when the notification has no subtype, or an unlisted one. */ + readonly base: NotificationPlan; + /** Per-subtype refinements. */ + readonly subtypes?: SubtypeTable; +} + +const subscriptionPlan = ( + event: IapEventType, + detail?: Partial +): NotificationPlan => ({ + event, + outcome: "applied", + storeTransaction: true, + touchesSubscription: true, + detail, +}); + +const purchasePlan = ( + event: IapEventType, + extra: Partial = {} +): NotificationPlan => ({ + event, + outcome: "applied", + storeTransaction: true, + touchesSubscription: true, + ...extra, +}); + +/** A type this version records but takes no action on. */ +const unhandledPlan: NotificationPlan = { + event: "apple.unhandled", + outcome: "unhandled", + storeTransaction: false, + touchesSubscription: false, +}; + +/** A type Apple added after this version shipped. */ +export const UNKNOWN_TYPE_PLAN: NotificationPlan = { + event: "apple.unknown", + outcome: "unknown_type", + storeTransaction: false, + touchesSubscription: false, +}; + +const TABLE: Readonly> = { + // ---- Subscription lifecycle ------------------------------------------ + SUBSCRIBED: { + base: subscriptionPlan("subscription.started", { startReason: "initial" }), + subtypes: { + INITIAL_BUY: subscriptionPlan("subscription.started", { + startReason: "initial", + }), + RESUBSCRIBE: subscriptionPlan("subscription.started", { + startReason: "resubscribe", + }), + }, + }, + + DID_RENEW: { + base: subscriptionPlan("subscription.renewed", { renewReason: "renewal" }), + subtypes: { + BILLING_RECOVERY: subscriptionPlan("subscription.renewed", { + renewReason: "billing_recovery", + }), + }, + }, + + DID_CHANGE_RENEWAL_PREF: { + // No subtype means a scheduled change was reverted. + base: subscriptionPlan("subscription.plan_change_cancelled"), + subtypes: { + // Effective immediately, with a prorated refund of the old plan. + UPGRADE: subscriptionPlan("subscription.plan_changed"), + // Effective at the next renewal, so the current tier continues. + DOWNGRADE: subscriptionPlan("subscription.plan_change_scheduled"), + }, + }, + + DID_CHANGE_RENEWAL_STATUS: { + // No subtype means the customer cancelled after a price-increase notice. + base: subscriptionPlan("subscription.auto_renew_changed", { + autoRenewEnabled: false, + }), + subtypes: { + AUTO_RENEW_ENABLED: subscriptionPlan("subscription.auto_renew_changed", { + autoRenewEnabled: true, + }), + AUTO_RENEW_DISABLED: subscriptionPlan("subscription.auto_renew_changed", { + autoRenewEnabled: false, + }), + }, + }, + + DID_FAIL_TO_RENEW: { + // No subtype means no grace period: Apple retries for up to 60 days and + // the customer is not entitled meanwhile. + base: subscriptionPlan("subscription.billing_issue", { inGracePeriod: false }), + subtypes: { + // Apple's requirement here is explicit: keep providing full service for + // the whole grace period. + GRACE_PERIOD: subscriptionPlan("subscription.billing_issue", { + inGracePeriod: true, + }), + }, + }, + + GRACE_PERIOD_EXPIRED: { + base: subscriptionPlan("subscription.grace_period_ended"), + }, + + EXPIRED: { + base: subscriptionPlan("subscription.expired", { expiryReason: "other" }), + subtypes: { + VOLUNTARY: subscriptionPlan("subscription.expired", { + expiryReason: "voluntary", + }), + BILLING_RETRY: subscriptionPlan("subscription.expired", { + expiryReason: "billing", + }), + PRICE_INCREASE: subscriptionPlan("subscription.expired", { + expiryReason: "price_increase", + }), + PRODUCT_NOT_FOR_SALE: subscriptionPlan("subscription.expired", { + expiryReason: "product_unavailable", + }), + }, + }, + + OFFER_REDEEMED: { + // The subtype refines which kind of change came with the offer; the offer + // fields on the transaction say the rest. + base: subscriptionPlan("subscription.offer_redeemed"), + }, + + PRICE_INCREASE: { + base: subscriptionPlan("subscription.price_increase", { + priceIncreaseConsent: "pending", + }), + subtypes: { + PENDING: subscriptionPlan("subscription.price_increase", { + priceIncreaseConsent: "pending", + }), + ACCEPTED: subscriptionPlan("subscription.price_increase", { + priceIncreaseConsent: "accepted", + }), + }, + }, + + RENEWAL_EXTENDED: { + base: subscriptionPlan("subscription.renewal_extended"), + }, + + RENEWAL_EXTENSION: { + // A mass extension result. Concerns a product, not one customer, so there + // is no transaction to store. + base: { + event: "subscription.mass_extension_result", + outcome: "applied", + storeTransaction: false, + touchesSubscription: false, + }, + }, + + // ---- One-time purchases and refunds ---------------------------------- + ONE_TIME_CHARGE: { + base: purchasePlan("purchase.completed", { touchesSubscription: false }), + }, + + REFUND: { + // The payload carries revocationDate, so storing the transaction is what + // withdraws entitlement. Also resolves any open consumption request. + base: purchasePlan("purchase.refunded", { consumption: "resolve" }), + }, + + REFUND_DECLINED: { + base: purchasePlan("purchase.refund_declined", { consumption: "resolve" }), + }, + + REFUND_REVERSED: { + // Apple's wording: "If your app revoked content or services because of the + // refund, it needs to reinstate them." Clearing the revocation is what + // makes the read layer say "entitled" again. + base: purchasePlan("purchase.refund_reversed", { + consumption: "resolve", + clearRevocation: true, + }), + }, + + REVOKE: { + base: purchasePlan("purchase.revoked"), + }, + + CONSUMPTION_REQUEST: { + // Apple's page lists this for consumables and subscriptions, its API page + // for any type, so every type is handled. + base: purchasePlan("refund.consumption_requested", { consumption: "open" }), + }, + + // ---- Housekeeping ----------------------------------------------------- + TEST: { + base: { + event: "apple.test_received", + outcome: "applied", + storeTransaction: false, + touchesSubscription: false, + }, + }, + + // Programmes this SDK does not participate in. Stored raw, answered 200. + EXTERNAL_PURCHASE_TOKEN: { base: unhandledPlan }, + METADATA_UPDATE: { base: unhandledPlan }, + PRICE_CHANGE: { base: unhandledPlan }, + RESCIND_CONSENT: { base: unhandledPlan }, + // Apple's enumeration page spells this MIGRATION and its changelog MIGRATE. + // Both are accepted rather than guessing which is authoritative. + MIGRATION: { base: unhandledPlan }, + MIGRATE: { base: unhandledPlan }, +}; + +/** + * Looks up what to do with a notification. + * + * Never throws and never returns nothing: a type this version has never seen + * resolves to {@link UNKNOWN_TYPE_PLAN}, which stores the payload and answers + * Apple successfully. + */ +export function planFor( + notificationType: string, + subtype?: string | null +): NotificationPlan { + const entry = TABLE[notificationType]; + if (!entry) return UNKNOWN_TYPE_PLAN; + if (subtype && entry.subtypes && entry.subtypes[subtype]) { + return entry.subtypes[subtype]; + } + return entry.base; +} + +/** Every notification type this version recognises. For tests and diagnostics. */ +export const KNOWN_NOTIFICATION_TYPES: readonly string[] = Object.keys(TABLE); diff --git a/src/iap/ingest/notifications.ts b/src/iap/ingest/notifications.ts new file mode 100644 index 00000000..8e5193f3 --- /dev/null +++ b/src/iap/ingest/notifications.ts @@ -0,0 +1,405 @@ +/** + * The App Store Server Notifications webhook. + * + * The contract with Apple is unforgiving in one direction. Apple retries a + * failure five times over 72 hours, but it **never** retries a success — so a + * `200` returned before the data is stored loses that notification forever. + * Everything below is arranged around that single fact: + * + * ``` + * parse malformed -> 400 + * verify bad signature -> 401 + * dedupe already applied -> 200 + * claim write the raw payload, marked "not yet applied" + * apply entity writes + * commit mark the real outcome + * respond -> 200 + * emit handlers, after the writes, before the response + * anything failed -> 503, so Apple comes back + * ``` + * + * The claim step is what makes a retry safe. If the raw row lands and the + * entity writes then fail, the row exists — and a naive duplicate check would + * tell Apple's retry "already seen" and drop the purchase data. So the row + * carries a commit flag, and a claimed-but-unapplied row is re-applied rather + * than dismissed. + * + * Sandbox has no retries at all, so a failure there is a real loss. The + * launch-time sync is what heals it. + * + * @internal + */ +import type { ResolvedIapConfig } from "../config.js"; +import { IapVerificationError } from "../errors.js"; +import type { Clock } from "../runtime/clock.js"; +import type { IapEmitter } from "../events/emitter.js"; +import type { IapEvent } from "../events/events.types.js"; +import type { IapStore } from "../store/store.types.js"; +import { + CONSUMPTION_DESCRIPTOR, + NOTIFICATION_DESCRIPTOR, + SUBSCRIPTION_DESCRIPTOR, + TRANSACTION_DESCRIPTOR, +} from "../store/descriptors.js"; +import type { + IapConsumptionOutcome, + IapNotificationOutcome, +} from "../store/rows.types.js"; +import type { Verifier } from "../verify/verifier.js"; +import type { DecodedNotification } from "../verify/verify.types.js"; +import { planFor, type NotificationPlan } from "./matrix.js"; +import { + consumptionRowFrom, + notificationRowFrom, + subscriptionRenewalPatch, + subscriptionRowFrom, + subscriptionTransactionPatch, + transactionPatchFrom, + transactionRowFrom, +} from "./mappers.js"; + +/** What `handleNotification` did, for tests, replay and the owner panel. */ +export interface HandleNotificationResult { + /** The HTTP status returned to Apple. */ + readonly status: number; + /** What was recorded, when the payload was verified. */ + readonly outcome?: IapNotificationOutcome; + /** Apple's notification id. */ + readonly notificationUUID?: string; + /** Apple's notification type. */ + readonly notificationType?: string; + /** The events emitted. */ + readonly events: readonly IapEvent[]; + /** Why the request was rejected, when it was. */ + readonly error?: string; +} + +/** What the ingestion paths share. */ +export interface IngestContext { + readonly store: IapStore; + readonly verifier: Verifier; + readonly config: ResolvedIapConfig; + readonly clock: Clock; + readonly emitter: IapEmitter; +} + +/** Refund outcomes a notification can resolve a consumption request with. */ +const CONSUMPTION_OUTCOMES: Readonly> = { + REFUND: "REFUND", + REFUND_DECLINED: "REFUND_DECLINED", + REFUND_REVERSED: "REFUND_REVERSED", +}; + +/** + * Finds who a notification concerns. + * + * A webhook carries no user: the account token Apple signs in is a one-way + * hash of a Base44 user id, so it cannot be turned back into one. The user is + * therefore inherited from a row already stored for this subscription or + * transaction, and stays null when there is none — which is the honest answer + * for a purchase made before the customer ever logged in. A later sync from + * that customer's device attaches it. + */ +async function resolveAppUserId( + context: IngestContext, + notification: DecodedNotification +): Promise { + const transaction = notification.data?.transactionInfo; + if (!transaction) return null; + + const originalTransactionId = transaction.originalTransactionId + ? String(transaction.originalTransactionId) + : undefined; + + if (originalTransactionId) { + const subscription = await context.store.getByKey( + SUBSCRIPTION_DESCRIPTOR, + originalTransactionId, + ["appUserId"] + ); + if (subscription?.appUserId) return subscription.appUserId; + + const original = await context.store.getByKey( + TRANSACTION_DESCRIPTOR, + originalTransactionId, + ["appUserId"] + ); + if (original?.appUserId) return original.appUserId; + } + + return null; +} + +/** Writes everything a notification implies. Throws if any write fails. */ +async function applyPlan( + context: IngestContext, + notification: DecodedNotification, + plan: NotificationPlan, + appUserId: string | null +): Promise { + const now = context.clock(); + const data = notification.data; + const transaction = data?.transactionInfo; + + if (plan.storeTransaction && transaction?.transactionId) { + // The verifier hands back the original token under this name once it has + // checked it, precisely so it can be stored. + const rawJws = data?.transactionInfoJws ?? ""; + const row = transactionRowFrom(transaction, { + source: "notification", + appUserId, + now, + config: context.config, + rawJws, + }); + + await context.store.upsertNewestWins( + TRANSACTION_DESCRIPTOR, + row.transactionId, + { facet: "transaction", value: row.signedDate }, + row, + transactionPatchFrom(row, { clearRevocation: plan.clearRevocation }) + ); + + // The subscription row only moves when the payload really is a + // subscription. The matrix says the type *can* concern one; the payload + // says whether this instance does. + const isSubscription = + plan.touchesSubscription && + (transaction.type === "Auto-Renewable Subscription" || + Boolean(data?.renewalInfo)); + + if (isSubscription) { + const subscriptionRow = subscriptionRowFrom({ + transaction, + transactionJws: rawJws, + renewalInfo: data?.renewalInfo, + renewalInfoJws: data?.renewalInfoJws, + appUserId, + appleStatus: data?.status, + now, + }); + + await context.store.upsertNewestWins( + SUBSCRIPTION_DESCRIPTOR, + subscriptionRow.originalTransactionId, + { facet: "transaction", value: subscriptionRow.latestSignedDate ?? now }, + subscriptionRow, + subscriptionTransactionPatch(subscriptionRow) + ); + + // The renewal half carries its own cursor, so it can still land even + // when the transaction cursor is already ahead of it. + if (subscriptionRow.latestRenewalSignedDate !== null) { + await context.store.patchWhere( + SUBSCRIPTION_DESCRIPTOR, + subscriptionRow.originalTransactionId, + { + cursorBelow: { + facet: "renewal", + value: subscriptionRow.latestRenewalSignedDate, + }, + }, + subscriptionRenewalPatch(subscriptionRow) + ); + } + } + } + + if (plan.consumption === "open") { + const row = consumptionRowFrom({ notification, appUserId, now }); + if (row) { + await context.store.upsertNewestWins( + CONSUMPTION_DESCRIPTOR, + row.transactionId, + { facet: "request", value: row.requestSignedDate }, + row, + { + set: { + consumptionRequestReason: row.consumptionRequestReason, + deadlineAt: row.deadlineAt, + requestSignedDate: row.requestSignedDate, + appUserId: row.appUserId, + updatedAt: now, + }, + } + ); + } + } + + if (plan.consumption === "resolve" && transaction?.transactionId) { + const outcome = CONSUMPTION_OUTCOMES[notification.notificationType]; + if (outcome) { + const signedDate = Number(notification.signedDate ?? now); + // Guarded by its own cursor, so a late refund cannot overwrite a newer + // reversal. + await context.store.patchWhere( + CONSUMPTION_DESCRIPTOR, + String(transaction.transactionId), + { cursorBelow: { facet: "outcome", value: signedDate } }, + { set: { outcome, outcomeSignedDate: signedDate, updatedAt: now } } + ); + } + } +} + +function eventFor( + notification: DecodedNotification, + plan: NotificationPlan, + appUserId: string | null, + now: number +): IapEvent { + const transaction = notification.data?.transactionInfo; + const consumption = + plan.consumption === "open" + ? consumptionRowFrom({ notification, appUserId, now }) + : null; + + return { + type: plan.event, + appUserId, + originalTransactionId: transaction?.originalTransactionId + ? String(transaction.originalTransactionId) + : undefined, + transactionId: transaction?.transactionId + ? String(transaction.transactionId) + : undefined, + productId: transaction?.productId ? String(transaction.productId) : undefined, + environment: (notification.data?.environment ?? + notification.summary?.environment ?? + "Production") as IapEvent["environment"], + occurredAt: Number(notification.signedDate ?? now), + notificationUUID: notification.notificationUUID, + source: "notification", + notificationType: notification.notificationType, + subtype: notification.subtype, + deadlineAt: consumption?.deadlineAt, + payload: notification, + ...plan.detail, + }; +} + +/** + * Handles one signed payload. + * + * Separate from the `Request` wrapper so a stored payload can be replayed, and + * so tests do not have to build an HTTP request. + */ +export async function handleSignedPayload( + context: IngestContext, + signedPayload: string +): Promise { + let notification: DecodedNotification; + try { + notification = await context.verifier.verifyNotification(signedPayload); + } catch (error) { + // Retrying will not make an unverifiable payload verify, but answering + // 200 would hide it. A 401 is honest, and the failure is visible. + return { + status: 401, + events: [], + error: + error instanceof IapVerificationError + ? `${error.code}: ${error.message}` + : String(error), + }; + } + + const now = context.clock(); + const plan = planFor(notification.notificationType, notification.subtype); + + // Duplicate detection, but only on a committed row. A row still marked + // "not yet applied" means an earlier attempt claimed it and then failed, so + // this delivery must finish the job rather than be waved through. + const existing = await context.store.getByKey( + NOTIFICATION_DESCRIPTOR, + notification.notificationUUID, + ["notificationUUID", "outcome"] + ); + if (existing && existing.outcome !== "error") { + return { + status: 200, + outcome: "duplicate", + notificationUUID: notification.notificationUUID, + notificationType: notification.notificationType, + events: [], + }; + } + + const appUserId = await resolveAppUserId(context, notification); + + try { + if (!existing) { + // Claim it: the raw payload lands first, so nothing is lost if the + // writes below fail. + await context.store.insertIfAbsent( + NOTIFICATION_DESCRIPTOR, + notification.notificationUUID, + notificationRowFrom({ notification, rawSignedPayload: signedPayload, now }) + ); + } + + await applyPlan(context, notification, plan, appUserId); + + // Commit. Until this lands the row still reads as unapplied, so a retry + // redoes the work — every write above is guarded and idempotent, so that + // is safe. + await context.store.patchWhere( + NOTIFICATION_DESCRIPTOR, + notification.notificationUUID, + {}, + { set: { outcome: plan.outcome }, increment: { attempts: 1 } } + ); + } catch (error) { + // Every store failure is a 503, without exception. A pointless retry costs + // nothing; a 200 that did not persist cannot be recovered. Apple's 72-hour + // window can even outlast a fix and then heal on its own. + return { + status: 503, + notificationUUID: notification.notificationUUID, + notificationType: notification.notificationType, + events: [], + error: error instanceof Error ? error.message : String(error), + }; + } + + const events = [eventFor(notification, plan, appUserId, now)]; + + // Dispatched before the response, not after it. A Base44 backend function + // cannot run work once it has answered, so anything deferred is lost. A + // handler that throws is reported and ignored — it can never change the + // status Apple sees. + await context.emitter.emit(events); + + return { + status: 200, + outcome: plan.outcome, + notificationUUID: notification.notificationUUID, + notificationType: notification.notificationType, + events, + }; +} + +/** Handles an incoming HTTP request from Apple. */ +export async function handleNotification( + context: IngestContext, + request: Request +): Promise { + let signedPayload: unknown; + try { + const body = (await request.json()) as { signedPayload?: unknown }; + signedPayload = body?.signedPayload; + } catch { + return new Response(null, { status: 400 }); + } + + if (typeof signedPayload !== "string" || signedPayload.length === 0) { + // A body Apple would never send. Retrying cannot fix it, but 400 is the + // honest answer and keeps the failure visible. + return new Response(null, { status: 400 }); + } + + const result = await handleSignedPayload(context, signedPayload); + // Apple accepts 200 through 206 and needs no body. + return new Response(null, { status: result.status }); +} diff --git a/src/iap/read/derive.ts b/src/iap/read/derive.ts new file mode 100644 index 00000000..d7099336 --- /dev/null +++ b/src/iap/read/derive.ts @@ -0,0 +1,169 @@ +/** + * Working out where a subscription stands, from its stored tokens and a clock. + * + * Five rules, evaluated in order. The order is the whole thing: a refunded + * subscription is not entitled even if its period has not ended, and a + * subscription inside a billing grace period *is* entitled even though its + * payment failed. + * + * Deriving rather than storing is what makes this survive missed + * notifications. A subscription that lapsed reports as expired the moment its + * expiry passes, whether or not Apple's `EXPIRED` notification ever arrived. + * The one gap is a refund nobody told us about: that leaks until the period + * ends, because nothing in the stored data implies it. + * + * @internal + */ +import type { ResolvedIapConfig } from "../config.js"; +import type { + DecodedRenewalInfo, + DecodedTransaction, + IapAppleSubscriptionStatus, + IapEnvironment, +} from "../verify/verify.types.js"; +import type { + IapExpirationReason, + IapSubscriptionStatus, + SubscriptionState, +} from "./read.types.js"; + +/** Apple's `expirationIntent` values, in the app's vocabulary. */ +const EXPIRATION_REASONS: Readonly> = { + 1: "cancelled", + 2: "billing_error", + 3: "price_increase_declined", + 4: "product_unavailable", + 5: "other", +}; + +/** Where the derived status and Apple's own code agree. */ +const APPLE_STATUS_FOR: Readonly< + Record +> = { + active: 1, + expired: 2, + billing_retry: 3, + grace_period: 4, + revoked: 5, +}; + +/** The two statuses that mean "give them the service". */ +const ENTITLED_STATUSES: ReadonlySet = new Set([ + "active", + "grace_period", +]); + +/** Just the status, for callers that need nothing else. */ +export function deriveStatus( + transaction: DecodedTransaction | undefined, + renewal: DecodedRenewalInfo | undefined, + now: number +): IapSubscriptionStatus { + // 1. Taken back. Nothing below matters — Apple's rule is never to deliver + // content for a transaction carrying a revocation date. + if (typeof transaction?.revocationDate === "number") return "revoked"; + + const expiresDate = transaction?.expiresDate; + + // 2. Still inside the paid period. + if (typeof expiresDate === "number" && expiresDate > now) return "active"; + + // 3. Payment failed, but a grace period is running. Apple's requirement is + // explicit: "provide full service for the subscription throughout the + // grace period." + const graceUntil = renewal?.gracePeriodExpiresDate; + if (typeof graceUntil === "number" && graceUntil > now) return "grace_period"; + + // 4. Payment failed with no grace period. Apple keeps retrying for up to 60 + // days, and the customer is not entitled meanwhile. + if (renewal?.isInBillingRetryPeriod === true) return "billing_retry"; + + // 5. Anything else has ended. A subscription with no expiry date at all also + // lands here, which is the safe direction. + return "expired"; +} + +/** Whether the derived status disagrees with the code Apple sent. */ +export function statusDisagrees( + status: IapSubscriptionStatus, + appleStatus: IapAppleSubscriptionStatus | null | undefined +): boolean { + if (appleStatus === null || appleStatus === undefined) return false; + return APPLE_STATUS_FOR[status] !== appleStatus; +} + +/** Everything the app can know about one subscription, right now. */ +export function deriveSubscriptionState(input: { + readonly originalTransactionId: string; + readonly transaction: DecodedTransaction | undefined; + readonly renewal: DecodedRenewalInfo | undefined; + readonly environment: IapEnvironment; + readonly appleStatus: IapAppleSubscriptionStatus | null; + readonly now: number; +}): SubscriptionState { + const { transaction, renewal, now } = input; + const status = deriveStatus(transaction, renewal, now); + + const expirationIntent = renewal?.expirationIntent; + const expirationReason = + status === "expired" && typeof expirationIntent === "number" + ? EXPIRATION_REASONS[expirationIntent] ?? "other" + : null; + + const offerType = transaction?.offerType ?? renewal?.offerType; + + return { + originalTransactionId: input.originalTransactionId, + subscriptionGroupIdentifier: transaction?.subscriptionGroupIdentifier ?? null, + productId: transaction?.productId ?? renewal?.productId ?? null, + status, + entitled: ENTITLED_STATUSES.has(status), + expiresAt: transaction?.expiresDate ?? null, + gracePeriodExpiresAt: renewal?.gracePeriodExpiresDate ?? null, + willRenew: renewal?.autoRenewStatus === 1, + autoRenewProductId: renewal?.autoRenewProductId ?? null, + expirationReason, + priceIncreaseConsentPending: renewal?.priceIncreaseStatus === 0, + offer: offerType + ? { + type: offerType, + identifier: transaction?.offerIdentifier ?? renewal?.offerIdentifier, + discountType: + transaction?.offerDiscountType ?? renewal?.offerDiscountType, + period: transaction?.offerPeriod ?? renewal?.offerPeriod, + } + : null, + eligibleWinBackOfferIds: Array.isArray(renewal?.eligibleWinBackOfferIds) + ? [...(renewal?.eligibleWinBackOfferIds as string[])] + : [], + isFamilyShared: transaction?.inAppOwnershipType === "FAMILY_SHARED", + revocation: + typeof transaction?.revocationDate === "number" + ? { + date: transaction.revocationDate, + reason: transaction.revocationReason ?? null, + type: transaction.revocationType ?? null, + percentage: transaction.revocationPercentage, + } + : null, + environment: input.environment, + appleStatus: input.appleStatus, + signedDate: transaction?.signedDate ?? null, + }; +} + +/** + * Whether a row from this environment counts for this app. + * + * Production always counts. Sandbox only in test mode, and Xcode only with + * local testing on — so a live app with both flags off honours real purchases + * and nothing else. + */ +export function environmentCounts( + environment: IapEnvironment, + config: ResolvedIapConfig +): boolean { + if (environment === "Sandbox") return config.testMode; + if (environment === "Xcode") return config.allowLocalTesting; + return true; +} diff --git a/src/iap/read/read.ts b/src/iap/read/read.ts new file mode 100644 index 00000000..52f76ec6 --- /dev/null +++ b/src/iap/read/read.ts @@ -0,0 +1,337 @@ +/** + * Reading purchase state back. + * + * Everything here derives from stored signed tokens against the clock, so a + * read is always current without anything having to keep a status column up to + * date. + * + * Two deliberate asymmetries: + * + * - **`hasActiveSubscription` never throws.** It is the one call a feature gate + * makes, and a gate that throws is a gate that fails open somewhere. Any + * failure answers "not entitled" and is reported. + * - **Everything else does throw.** A caller reading a customer's history wants + * to know the read failed, rather than being handed a plausible-looking empty + * list. + * + * A stored token that no longer verifies denies **that row** and does not fail + * the call, so one bad row cannot lock a customer out of everything they own. + * + * @internal + */ +import type { ResolvedIapConfig } from "../config.js"; +import type { Clock } from "../runtime/clock.js"; +import type { IapStore } from "../store/store.types.js"; +import { + CONSUMPTION_DESCRIPTOR, + SUBSCRIPTION_DESCRIPTOR, + TRANSACTION_DESCRIPTOR, +} from "../store/descriptors.js"; +import type { + IapConsumptionRequestRecord, + IapSubscriptionRecord, + IapTransactionRecord, +} from "../store/rows.types.js"; +import type { Verifier } from "../verify/verifier.js"; +import type { + DecodedRenewalInfo, + DecodedTransaction, +} from "../verify/verify.types.js"; +import { + deriveSubscriptionState, + environmentCounts, + statusDisagrees, +} from "./derive.js"; +import type { + EntitlementQuery, + Entitlements, + SubscriptionQuery, + SubscriptionState, + TransactionQuery, +} from "./read.types.js"; + +/** How many decoded tokens to remember. */ +const VERDICT_CACHE_LIMIT = 200; + +/** What the read layer needs. */ +export interface ReadContext { + readonly store: IapStore; + readonly verifier: Verifier; + readonly config: ResolvedIapConfig; + readonly clock: Clock; + /** Called when a read fails or a stored token no longer verifies. */ + readonly report?: (what: string, error: unknown) => void; +} + +/** The read surface. */ +export interface Reader { + getSubscriptionState( + appUserId: string, + query?: SubscriptionQuery + ): Promise; + hasActiveSubscription(appUserId: string, query?: EntitlementQuery): Promise; + getEntitlements(appUserId: string): Promise; + getPurchase(transactionId: string): Promise; + listTransactions( + appUserId: string, + query?: TransactionQuery + ): Promise; + listRefunds(appUserId: string): Promise; + listPendingConsumptionRequests(): Promise; +} + +export function createReader(context: ReadContext): Reader { + /** + * Decoded tokens, keyed by the token itself. + * + * Safe to cache because with offline certificate checks a verdict is a pure + * function of the bytes: the same token always decodes the same way, and + * validity is evaluated at the payload's own `signedDate` rather than now. + * + * Only *verdicts* are cached, never rows. A cached row would make a + * subscription that has since been refunded still look live. + */ + const verdicts = new Map(); + + function remember(token: string, value: unknown): void { + if (verdicts.size >= VERDICT_CACHE_LIMIT) { + const oldest = verdicts.keys().next().value; + if (oldest !== undefined) verdicts.delete(oldest); + } + verdicts.set(token, value); + } + + async function decodeTransaction( + token: string | null + ): Promise { + if (!token) return undefined; + const cached = verdicts.get(token); + if (cached !== undefined) return cached as DecodedTransaction | undefined; + try { + const decoded = await context.verifier.verifyTransaction(token); + remember(token, decoded); + return decoded; + } catch (error) { + // Deny this row, keep the call. A token that will not verify is not + // evidence of anything, but it is also not a reason to hide the rest of + // what the customer owns. + context.report?.("stored transaction token failed to verify", error); + remember(token, undefined); + return undefined; + } + } + + async function decodeRenewal( + token: string | null + ): Promise { + if (!token) return undefined; + const cached = verdicts.get(token); + if (cached !== undefined) return cached as DecodedRenewalInfo | undefined; + try { + const decoded = await context.verifier.verifyRenewalInfo(token); + remember(token, decoded); + return decoded; + } catch (error) { + context.report?.("stored renewal token failed to verify", error); + remember(token, undefined); + return undefined; + } + } + + async function stateFor( + row: IapSubscriptionRecord, + now: number + ): Promise { + const [transaction, renewal] = await Promise.all([ + decodeTransaction(row.latestTransactionJws), + decodeRenewal(row.latestRenewalInfoJws), + ]); + + const state = deriveSubscriptionState({ + originalTransactionId: row.originalTransactionId, + transaction, + renewal, + environment: row.environment, + appleStatus: row.appleStatus, + now, + }); + + if (statusDisagrees(state.status, row.appleStatus)) { + // Not an error: Apple's code was true when it was sent and this is + // derived from now. Worth surfacing, because a persistent disagreement + // means stored data is stale. + context.report?.( + `derived status ${state.status} disagrees with Apple's ${row.appleStatus} ` + + `for subscription ${row.originalTransactionId}`, + undefined + ); + } + + return state; + } + + async function getSubscriptionState( + appUserId: string, + query: SubscriptionQuery = {} + ): Promise { + const filter: Record = { appUserId }; + if (query.subscriptionGroupId) { + filter.subscriptionGroupIdentifier = query.subscriptionGroupId; + } + if (query.productId) filter.productId = query.productId; + + const page = await context.store.query(SUBSCRIPTION_DESCRIPTOR, filter, { + limit: 200, + }); + + const now = context.clock(); + const states = await Promise.all(page.rows.map((row) => stateFor(row, now))); + + // A customer can hold several: one they bought, one shared with them by a + // family member, one per subscription group. + return states.filter((state) => + environmentCounts(state.environment, context.config) + ); + } + + async function hasActiveSubscription( + appUserId: string, + query: EntitlementQuery = {} + ): Promise { + try { + if (!appUserId) return false; + + const states = await getSubscriptionState(appUserId, { + subscriptionGroupId: query.subscriptionGroupId, + }); + + const wanted = query.productIds; + return states.some((state) => { + if (!state.entitled) return false; + if (!wanted || wanted.length === 0) return true; + return state.productId !== null && wanted.includes(state.productId); + }); + } catch (error) { + // Deny by default, and say so. An app whose entities were never created + // looks exactly like an app with no paying customers, so a silent false + // here would hide a setup mistake indefinitely. + context.report?.("hasActiveSubscription failed, denying access", error); + return false; + } + } + + async function getEntitlements(appUserId: string): Promise { + const now = context.clock(); + + const [nonConsumablePage, nonRenewingPage, subscriptions] = await Promise.all([ + // Consumables are deliberately never queried: once used up they are the + // app's business, and Apple's own entitlements list omits them too. + context.store.query( + TRANSACTION_DESCRIPTOR, + { appUserId, type: "Non-Consumable", revocationDate: null }, + { limit: 500 } + ), + context.store.query( + TRANSACTION_DESCRIPTOR, + { appUserId, type: "Non-Renewing Subscription", revocationDate: null }, + { limit: 500 } + ), + getSubscriptionState(appUserId), + ]); + + return { + nonConsumables: nonConsumablePage.rows + .filter((row) => environmentCounts(row.environment, context.config)) + .map((row) => ({ + productId: row.productId, + transactionId: row.transactionId, + originalTransactionId: row.originalTransactionId, + purchaseDate: row.purchaseDate, + isFamilyShared: row.inAppOwnershipType === "FAMILY_SHARED", + })), + + nonRenewingSubscriptions: nonRenewingPage.rows + .filter((row) => environmentCounts(row.environment, context.config)) + .map((row) => ({ + productId: row.productId, + transactionId: row.transactionId, + purchaseDate: row.purchaseDate, + // Apple never expires these, so the configured duration is the only + // thing that decides. + expiresAt: row.appDefinedExpiresDate, + active: + row.appDefinedExpiresDate === null + ? true + : row.appDefinedExpiresDate > now, + })), + + subscriptions, + asOf: now, + }; + } + + async function getPurchase( + transactionId: string + ): Promise { + return context.store.getByKey(TRANSACTION_DESCRIPTOR, transactionId); + } + + async function listTransactions( + appUserId: string, + query: TransactionQuery = {} + ): Promise { + const filter: Record = { appUserId }; + if (query.type) filter.type = query.type; + if (query.productId) filter.productId = query.productId; + if (query.environment) filter.environment = query.environment; + if (query.revoked === true) filter.revocationDate = { $ne: null }; + if (query.revoked === false) filter.revocationDate = null; + + if (query.since !== undefined || query.until !== undefined) { + const range: Record = {}; + if (query.since !== undefined) range.$gte = query.since; + if (query.until !== undefined) range.$lt = query.until; + filter.purchaseDate = range; + } + + const page = await context.store.query(TRANSACTION_DESCRIPTOR, filter, { + // Sorted on an immutable column. Sorting on something a concurrent write + // can change makes a row shift between pages, so it is seen twice or + // missed entirely. + sort: "-purchaseDate", + limit: query.limit ?? 1000, + }); + return page.rows; + } + + async function listRefunds(appUserId: string): Promise { + const page = await context.store.query( + TRANSACTION_DESCRIPTOR, + { appUserId, revocationDate: { $ne: null } }, + { sort: "-purchaseDate", limit: 500 } + ); + return page.rows; + } + + async function listPendingConsumptionRequests(): Promise< + IapConsumptionRequestRecord[] + > { + const now = context.clock(); + const page = await context.store.query( + CONSUMPTION_DESCRIPTOR, + { respondedAt: null, deadlineAt: { $gt: now } }, + { sort: "deadlineAt", limit: 200 } + ); + return page.rows; + } + + return { + getSubscriptionState, + hasActiveSubscription, + getEntitlements, + getPurchase, + listTransactions, + listRefunds, + listPendingConsumptionRequests, + }; +} diff --git a/src/iap/read/read.types.ts b/src/iap/read/read.types.ts new file mode 100644 index 00000000..2606c9cf --- /dev/null +++ b/src/iap/read/read.types.ts @@ -0,0 +1,195 @@ +/** + * What the app reads back. + * + * Nothing here is stored. Status is worked out from the signed tokens against + * the clock every time it is asked for, which is why a logic fix never needs a + * data migration — and why a subscription that quietly lapsed reports as + * expired even if the notification saying so never arrived. + */ +import type { + IapAppleSubscriptionStatus, + IapEnvironment, + IapOfferDiscountType, + IapOfferType, +} from "../verify/verify.types.js"; + +/** + * Where a subscription stands. + * + * Two of the five mean the customer is entitled to service, and one of those + * is easy to get wrong: a subscription in a billing **grace period** has + * already failed a payment, but Apple's requirement is to keep providing full + * service until the grace period ends. Billing **retry** without a grace + * period is the opposite — not entitled. + */ +export type IapSubscriptionStatus = + /** Paid and current. */ + | "active" + /** Payment failed, but Apple is still trying and service must continue. */ + | "grace_period" + /** Payment failed, no grace period. Not entitled. */ + | "billing_retry" + /** Ended. */ + | "expired" + /** Refunded or revoked. Never entitled, whatever else says. */ + | "revoked"; + +/** Why a subscription ended. */ +export type IapExpirationReason = + | "cancelled" + | "billing_error" + | "price_increase_declined" + | "product_unavailable" + | "other"; + +/** An offer applied to a subscription. */ +export interface IapSubscriptionOffer { + /** Which kind: `1` introductory, `2` promotional, `3` offer code, `4` win-back. */ + type: IapOfferType; + /** The offer's identifier. */ + identifier?: string; + /** How it discounts the price. */ + discountType?: IapOfferDiscountType; + /** Its duration, as an ISO 8601 period. */ + period?: string; +} + +/** How a purchase was taken back. */ +export interface IapRevocation { + /** When Apple took it back. */ + date: number; + /** Why: `1` an issue in the app, `0` any other reason. */ + reason: number | null; + /** How: a full refund, a prorated one, or family access ending. */ + type: string | null; + /** How much was refunded, in thousandths of a percent. */ + percentage?: number; +} + +/** One subscription, as it stands right now. */ +export interface SubscriptionState { + /** The subscription chain. */ + originalTransactionId: string; + /** The subscription group. Products in one group are alternatives. */ + subscriptionGroupIdentifier: string | null; + /** The product currently held. */ + productId: string | null; + /** Where it stands. */ + status: IapSubscriptionStatus; + /** + * Whether the customer should get the service. + * + * True for `active` and `grace_period`. This is the only field a feature + * gate needs. + */ + entitled: boolean; + /** When the current period ends. */ + expiresAt: number | null; + /** When a billing grace period ends, if one is running. */ + gracePeriodExpiresAt: number | null; + /** Whether it will renew. */ + willRenew: boolean; + /** + * What it will renew to. + * + * Different from `productId` when the customer has scheduled a plan change + * for the next period. + */ + autoRenewProductId: string | null; + /** Why it ended, when it has. */ + expirationReason: IapExpirationReason | null; + /** Whether the customer has been asked to accept a price increase and has not answered. */ + priceIncreaseConsentPending: boolean; + /** The offer applied, if any. */ + offer: IapSubscriptionOffer | null; + /** Win-back offers this customer is eligible for. */ + eligibleWinBackOfferIds: string[]; + /** Whether this came through Family Sharing rather than being bought directly. */ + isFamilyShared: boolean; + /** Details of a refund or revocation, when there is one. */ + revocation: IapRevocation | null; + /** Which App Store environment this came from. */ + environment: IapEnvironment; + /** + * Apple's own status code, when a payload supplied one. + * + * Kept only to cross-check the derived `status`. A disagreement means + * something is stale and is worth investigating; the derived value is what + * the SDK acts on. + */ + appleStatus: IapAppleSubscriptionStatus | null; + /** When Apple signed the data behind this. */ + signedDate: number | null; +} + +/** A non-consumable the customer owns outright. */ +export interface OwnedNonConsumable { + productId: string; + transactionId: string; + originalTransactionId: string; + purchaseDate: number | null; + isFamilyShared: boolean; +} + +/** A fixed-period subscription that does not renew itself. */ +export interface OwnedNonRenewingSubscription { + productId: string; + transactionId: string; + purchaseDate: number | null; + /** When it ends, from the configured duration. Apple does not track this. */ + expiresAt: number | null; + /** Whether it is still running. */ + active: boolean; +} + +/** + * Everything the customer currently owns. + * + * Consumables never appear. Apple's own current-entitlements list omits them + * too: once used up, a consumable is the app's business to track, not the + * store's. + */ +export interface Entitlements { + /** Non-consumables owned outright and not refunded. */ + nonConsumables: OwnedNonConsumable[]; + /** Non-renewing subscriptions, with the app-defined expiry applied. */ + nonRenewingSubscriptions: OwnedNonRenewingSubscription[]; + /** Every subscription, entitled or not. */ + subscriptions: SubscriptionState[]; + /** The instant this was worked out. */ + asOf: number; +} + +/** Narrows which subscriptions to look at. */ +export interface SubscriptionQuery { + /** Only subscriptions in this group. */ + subscriptionGroupId?: string; + /** Only this product. */ + productId?: string; +} + +/** Narrows an entitlement check. */ +export interface EntitlementQuery { + /** Any of these products counts. */ + productIds?: string[]; + /** Only subscriptions in this group count. */ + subscriptionGroupId?: string; +} + +/** Narrows a transaction listing. */ +export interface TransactionQuery { + /** Apple's product type, e.g. `"Consumable"`. */ + type?: string; + /** A single product. */ + productId?: string; + /** `true` for refunded or revoked purchases only, `false` to exclude them. */ + revoked?: boolean; + /** Purchased at or after this instant. */ + since?: number; + /** Purchased before this instant. */ + until?: number; + /** Only this environment. */ + environment?: IapEnvironment; + /** Most rows to return. Defaults to 1000. */ + limit?: number; +} diff --git a/src/iap/runtime/base64.ts b/src/iap/runtime/base64.ts new file mode 100644 index 00000000..e5d8e97c --- /dev/null +++ b/src/iap/runtime/base64.ts @@ -0,0 +1,150 @@ +/** + * Base64 and base64url codecs, hand-rolled. + * + * Deliberately does not use `atob`/`btoa` or `Buffer`: + * - `Buffer` does not exist in Deno or the browser. + * - `atob` returns a binary *string*, which is easy to corrupt on the way to + * bytes, and Node's implementation is deprecated. + * + * Everything in this module is total: it either returns bytes or throws. The + * decoder accepts both alphabets, because a compact JWS is base64url while an + * `x5c` entry inside its header is standard base64 — the same token carries + * both. + * + * @internal + */ + +const STANDARD = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +const URLSAFE = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +// One shared reverse table for both alphabets: `-`/`_` and `+`/`/` all decode. +// 255 marks "not a base64 character" so a single comparison rejects garbage. +const REVERSE = (() => { + const table = new Uint8Array(128).fill(255); + for (let i = 0; i < STANDARD.length; i += 1) { + table[STANDARD.charCodeAt(i)] = i; + table[URLSAFE.charCodeAt(i)] = i; + } + return table; +})(); + +/** Thrown for input that is not valid base64 in either alphabet. */ +export class Base64DecodeError extends Error { + constructor(message: string) { + super(message); + this.name = "Base64DecodeError"; + } +} + +/** + * Decodes standard or URL-safe base64 into bytes. + * + * Padding is optional. ASCII whitespace is ignored, so a PEM body or a + * line-wrapped certificate constant decodes without pre-processing. + */ +export function base64ToBytes(input: string): Uint8Array { + // Collect the 6-bit groups first so whitespace and padding never affect the + // length arithmetic below. + const sextets = new Uint8Array(input.length); + let count = 0; + + for (let i = 0; i < input.length; i += 1) { + const code = input.charCodeAt(i); + + // Whitespace is skipped; `=` ends the meaningful data. + if (code === 32 || code === 9 || code === 10 || code === 13) continue; + if (code === 61) break; + + const value = code < 128 ? REVERSE[code] : 255; + if (value === 255) { + throw new Base64DecodeError( + `invalid base64 character at index ${i}: ${JSON.stringify(input[i])}` + ); + } + sextets[count] = value; + count += 1; + } + + // 4 base64 characters carry 3 bytes. A remainder of 1 is impossible: it would + // mean 6 dangling bits, which encode no whole byte. + const remainder = count % 4; + if (remainder === 1) { + throw new Base64DecodeError( + "invalid base64 length: a single trailing character encodes no byte" + ); + } + + const byteLength = Math.floor((count * 6) / 8); + const out = new Uint8Array(byteLength); + + let accumulator = 0; + let bits = 0; + let written = 0; + + for (let i = 0; i < count; i += 1) { + accumulator = (accumulator << 6) | sextets[i]; + bits += 6; + if (bits >= 8) { + bits -= 8; + out[written] = (accumulator >> bits) & 0xff; + written += 1; + } + } + + return out; +} + +/** Decodes base64url. An alias for {@link base64ToBytes}, kept for call-site clarity. */ +export function base64UrlToBytes(input: string): Uint8Array { + return base64ToBytes(input); +} + +function bytesToBase64Internal(bytes: Uint8Array, alphabet: string, pad: boolean): string { + let out = ""; + let i = 0; + + for (; i + 2 < bytes.length; i += 3) { + const triple = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]; + out += + alphabet[(triple >> 18) & 63] + + alphabet[(triple >> 12) & 63] + + alphabet[(triple >> 6) & 63] + + alphabet[triple & 63]; + } + + const left = bytes.length - i; + if (left === 1) { + const chunk = bytes[i] << 16; + out += alphabet[(chunk >> 18) & 63] + alphabet[(chunk >> 12) & 63]; + if (pad) out += "=="; + } else if (left === 2) { + const chunk = (bytes[i] << 16) | (bytes[i + 1] << 8); + out += + alphabet[(chunk >> 18) & 63] + + alphabet[(chunk >> 12) & 63] + + alphabet[(chunk >> 6) & 63]; + if (pad) out += "="; + } + + return out; +} + +/** Encodes bytes as standard, padded base64. */ +export function bytesToBase64(bytes: Uint8Array): string { + return bytesToBase64Internal(bytes, STANDARD, true); +} + +/** Encodes bytes as unpadded base64url, the form JWS uses. */ +export function bytesToBase64Url(bytes: Uint8Array): string { + return bytesToBase64Internal(bytes, URLSAFE, false); +} + +/** Constant-time-ish byte comparison. Used for the Apple root pin. */ +export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i]; + return diff === 0; +} diff --git a/src/iap/runtime/clock.ts b/src/iap/runtime/clock.ts new file mode 100644 index 00000000..60a5459a --- /dev/null +++ b/src/iap/runtime/clock.ts @@ -0,0 +1,15 @@ +/** + * The clock seam. + * + * Subscription status is derived against "now" at read time, and consumption + * deadlines are computed from it, so every test that pins a status needs to + * pin the clock too. + * + * @internal + */ + +/** Returns the current time in milliseconds since the epoch. */ +export type Clock = () => number; + +/** The real clock. */ +export const systemClock: Clock = () => Date.now(); diff --git a/src/iap/runtime/webcrypto.ts b/src/iap/runtime/webcrypto.ts new file mode 100644 index 00000000..1522ebd0 --- /dev/null +++ b/src/iap/runtime/webcrypto.ts @@ -0,0 +1,77 @@ +/** + * The only place in the in-app purchase module that touches a global. + * + * Everything the module needs — WebCrypto, `TextEncoder`, `fetch` — is a web + * standard present in Deno, in Node 18 and later, and in a secure browser + * context. Reading them through accessors buys three things: + * + * 1. A missing global fails with a named error that says what is required, + * instead of `undefined is not a function` somewhere inside a certificate + * parser. + * 2. Importing this module can never throw, so a React Native bundle that + * reaches it by accident still loads. + * 3. There is exactly one file to audit for runtime assumptions. + * + * There is deliberately **no** software-crypto fallback. A verifier that + * silently stops verifying is worse than an outage. + * + * @internal + */ +import { IapConfigError } from "../errors.js"; + +/** + * The WebCrypto `subtle` interface. + * + * @throws {IapConfigError} `IAP_WEBCRYPTO_UNAVAILABLE` when the runtime has none. + */ +export function getSubtle(): SubtleCrypto { + const subtle = (globalThis as { crypto?: Crypto }).crypto?.subtle; + if (!subtle) { + throw new IapConfigError( + "IAP_WEBCRYPTO_UNAVAILABLE", + "Apple purchase verification requires WebCrypto (globalThis.crypto.subtle), " + + "which this runtime does not provide. It is available in Deno, in Node 18 " + + "and later, and in browsers over HTTPS. Verification runs in a backend " + + "function, so this usually means the code is running somewhere unintended." + ); + } + return subtle; +} + +/** + * `fetch`, for App Store Server API calls. + * + * @throws {IapConfigError} `IAP_FETCH_UNAVAILABLE` when the runtime has none. + */ +export function getFetch(): typeof fetch { + const impl = (globalThis as { fetch?: typeof fetch }).fetch; + if (!impl) { + throw new IapConfigError( + "IAP_FETCH_UNAVAILABLE", + "App Store Server API calls require fetch, which this runtime does not " + + "provide. It is available in Deno, in Node 18 and later, and in browsers." + ); + } + // Bound to `globalThis` because an unbound `fetch` throws an illegal-invocation + // error in some runtimes. + return impl.bind(globalThis); +} + +const encoder = /* @__PURE__ */ (() => { + try { + return new TextEncoder(); + } catch { + return undefined; + } +})(); + +/** Encodes a string as UTF-8 bytes. */ +export function utf8(input: string): Uint8Array { + if (!encoder) { + throw new IapConfigError( + "IAP_WEBCRYPTO_UNAVAILABLE", + "TextEncoder is not available in this runtime." + ); + } + return encoder.encode(input); +} diff --git a/src/iap/server-api/client.ts b/src/iap/server-api/client.ts new file mode 100644 index 00000000..78d98780 --- /dev/null +++ b/src/iap/server-api/client.ts @@ -0,0 +1,234 @@ +/** + * Talking to Apple's App Store Server API. + * + * Three things about Apple's behaviour that shape this file: + * + * - **The environment is not a setting, it is a discovery.** A transaction + * exists in exactly one of production or sandbox. Apple's own guidance is to + * try production and, on error `4040010`, try sandbox. The same code twice + * means the id exists in neither. + * - **`Retry-After` is an absolute UNIX millisecond timestamp**, not a delay. + * Treating it as a number of seconds would mean retrying almost immediately, + * straight into the same rate limit. + * - `/inApps` is case-sensitive, and TLS 1.2 or later is required. + * + * `fetch` is injected rather than reached for, because `nock` — what the rest + * of this repo tests HTTP with — hooks Node's http module and does not + * intercept native `fetch` at all. + * + * @internal + */ +import { IapApiError, IapConfigError } from "../errors.js"; +import { getFetch } from "../runtime/webcrypto.js"; +import type { Clock } from "../runtime/clock.js"; +import { mintServerApiToken } from "./jwt.js"; +import type { + ConsumptionRequestBody, + IapServerApiConfig, + IapServerApiModule, + TestNotificationResult, + TestNotificationStatus, +} from "./server-api.types.js"; + +const PRODUCTION_BASE = "https://api.storekit.apple.com"; +const SANDBOX_BASE = "https://api.storekit-sandbox.apple.com"; + +/** Apple's "this transaction id is not in this environment" code. */ +const TRANSACTION_NOT_FOUND = 4040010; +/** Apple's rate-limit code. */ +const RATE_LIMIT_EXCEEDED = 4290000; + +/** Inputs to {@link createServerApiClient}. */ +export interface CreateServerApiOptions { + /** Credentials, when the app supplied them. */ + readonly config?: IapServerApiConfig; + /** The app's bundle id, which Apple requires in the token. */ + readonly bundleId: string; + /** The clock. */ + readonly clock: Clock; + /** Whether sandbox should be tried first. */ + readonly preferSandbox?: boolean; + /** The `fetch` to use. Injected for tests. */ + readonly fetchImpl?: typeof fetch; +} + +interface AppleErrorBody { + errorCode?: number; + errorMessage?: string; +} + +export function createServerApiClient( + options: CreateServerApiOptions +): IapServerApiModule { + function requireConfig(): IapServerApiConfig { + if (!options.config) { + throw new IapConfigError( + "IAP_SERVER_API_NOT_CONFIGURED", + "this call needs App Store Server API credentials. Add `serverApi` to the " + + "in-app purchase configuration with the keyId, issuerId and privateKeyP8 " + + "of an In-App Purchase key from App Store Connect. Verifying purchases " + + "and checking entitlements do not need it." + ); + } + return options.config; + } + + /** The base URLs to try, in order. */ + function bases(): readonly string[] { + return options.preferSandbox + ? [SANDBOX_BASE, PRODUCTION_BASE] + : [PRODUCTION_BASE, SANDBOX_BASE]; + } + + async function callOnce( + base: string, + method: "GET" | "PUT" | "POST", + path: string, + body?: unknown + ): Promise<{ status: number; text: string; retryAfter?: number }> { + const config = requireConfig(); + const token = await mintServerApiToken( + config, + options.bundleId, + options.clock() + ); + const doFetch = options.fetchImpl ?? getFetch(); + + const response = await doFetch(`${base}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + const header = response.headers.get("Retry-After"); + return { + status: response.status, + text: await response.text(), + // Absolute epoch milliseconds, per Apple. Not a duration. + retryAfter: header ? Number(header) : undefined, + }; + } + + function parseError(text: string): AppleErrorBody { + try { + return JSON.parse(text) as AppleErrorBody; + } catch { + return {}; + } + } + + /** + * Calls Apple, falling back to the other environment when the transaction id + * is not in the first one tried. + */ + async function call( + method: "GET" | "PUT" | "POST", + path: string, + body?: unknown + ): Promise { + const candidates = bases(); + let lastError: IapApiError | undefined; + + for (let i = 0; i < candidates.length; i += 1) { + const { status, text, retryAfter } = await callOnce( + candidates[i], + method, + path, + body + ); + + if (status >= 200 && status < 300) { + if (text.length === 0) return undefined as T; + try { + return JSON.parse(text) as T; + } catch { + return undefined as T; + } + } + + const apple = parseError(text); + + if (apple.errorCode === RATE_LIMIT_EXCEEDED || status === 429) { + throw new IapApiError( + "IAP_API_RATE_LIMITED", + "Apple rate-limited this call. `retryAfter` is an absolute timestamp, " + + "not a delay — compare it against Date.now().", + { + httpStatus: status, + appleErrorCode: apple.errorCode, + appleErrorMessage: apple.errorMessage, + retryAfter, + } + ); + } + + if (apple.errorCode === TRANSACTION_NOT_FOUND) { + lastError = new IapApiError( + "IAP_API_TRANSACTION_NOT_FOUND", + "Apple does not have this transaction id in either the production or " + + "the sandbox environment.", + { + httpStatus: status, + appleErrorCode: apple.errorCode, + appleErrorMessage: apple.errorMessage, + } + ); + // Try the other environment. A transaction lives in exactly one. + continue; + } + + throw new IapApiError( + "IAP_API_ERROR", + apple.errorMessage ?? `Apple answered ${status}`, + { + httpStatus: status, + appleErrorCode: apple.errorCode, + appleErrorMessage: apple.errorMessage, + } + ); + } + + throw ( + lastError ?? + new IapApiError("IAP_API_ERROR", "Apple could not be reached in either environment") + ); + } + + return { + async sendConsumptionInformation( + transactionId: string, + body: ConsumptionRequestBody + ): Promise { + if (body?.customerConsented !== true) { + // Apple rejects this itself, but failing here says why, and sending + // consumption data without consent would be wrong regardless. + throw new IapConfigError( + "IAP_INVALID_CONFIG", + "consumption information may only be sent when the customer has " + + "consented. Set customerConsented to true, or send nothing." + ); + } + await call( + "PUT", + `/inApps/v2/transactions/consumption/${encodeURIComponent(transactionId)}`, + body + ); + }, + + async requestTestNotification(): Promise { + return call("POST", "/inApps/v1/notifications/test"); + }, + + async getTestNotificationStatus( + testNotificationToken: string + ): Promise { + return call( + "GET", + `/inApps/v1/notifications/test/${encodeURIComponent(testNotificationToken)}` + ); + }, + }; +} diff --git a/src/iap/server-api/jwt.ts b/src/iap/server-api/jwt.ts new file mode 100644 index 00000000..003762bf --- /dev/null +++ b/src/iap/server-api/jwt.ts @@ -0,0 +1,108 @@ +/** + * Signing the token the App Store Server API needs. + * + * A short-lived ES256 JWT, made from the In-App Purchase key. Two details are + * easy to get wrong: + * + * - The `.p8` file is PEM-armoured PKCS#8, so the armour comes off before + * WebCrypto will import it. + * - A JOSE signature is raw `r ‖ s`, which is exactly what WebCrypto's ECDSA + * sign returns — so unlike a certificate signature it needs **no** DER + * conversion. Doing one anyway produces a token Apple rejects. + * + * A fresh token is minted per request with a five-minute life, matching what + * Apple's own library does. Apple's ceiling is 60 minutes. + * + * @internal + */ +import { base64ToBytes, bytesToBase64Url } from "../runtime/base64.js"; +import { getSubtle, utf8 } from "../runtime/webcrypto.js"; +import { IapConfigError } from "../errors.js"; +import type { IapServerApiConfig } from "./server-api.types.js"; + +/** How long a minted token lasts. Apple allows up to 60 minutes. */ +const TOKEN_LIFETIME_SECONDS = 5 * 60; + +/** The audience Apple requires. */ +const AUDIENCE = "appstoreconnect-v1"; + +function stripPemArmour(pem: string): string { + return pem + .replace(/-----BEGIN [^-]+-----/g, "") + .replace(/-----END [^-]+-----/g, "") + .replace(/\s+/g, ""); +} + +async function importSigningKey(privateKeyP8: string): Promise { + let der: Uint8Array; + try { + der = base64ToBytes(stripPemArmour(privateKeyP8)); + } catch (cause) { + throw new IapConfigError( + "IAP_INVALID_CONFIG", + "the In-App Purchase private key is not valid base64. Pass the whole .p8 " + + "file contents, including its BEGIN and END lines.", + { cause } + ); + } + + const copy = new Uint8Array(der.length); + copy.set(der); + + try { + return await getSubtle().importKey( + "pkcs8", + copy.buffer, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"] + ); + } catch (cause) { + throw new IapConfigError( + "IAP_INVALID_CONFIG", + "the In-App Purchase private key could not be read as a P-256 key. Check it " + + "is the In-App Purchase key from App Store Connect, not an App Store " + + "Connect team key.", + { cause } + ); + } +} + +/** Mints a bearer token for the App Store Server API. */ +export async function mintServerApiToken( + config: IapServerApiConfig, + bundleId: string, + nowMs: number +): Promise { + const key = await importSigningKey(config.privateKeyP8); + const issuedAt = Math.floor(nowMs / 1000); + + const header = bytesToBase64Url( + utf8(JSON.stringify({ alg: "ES256", kid: config.keyId, typ: "JWT" })) + ); + const payload = bytesToBase64Url( + utf8( + JSON.stringify({ + iss: config.issuerId, + iat: issuedAt, + exp: issuedAt + TOKEN_LIFETIME_SECONDS, + aud: AUDIENCE, + bid: bundleId, + }) + ) + ); + + const signingInput = utf8(`${header}.${payload}`); + const input = new Uint8Array(signingInput.length); + input.set(signingInput); + + const signature = await getSubtle().sign( + { name: "ECDSA", hash: { name: "SHA-256" } }, + key, + input.buffer + ); + + // Already raw r ‖ s. This must NOT go through the DER converter that + // certificate signatures need. + return `${header}.${payload}.${bytesToBase64Url(new Uint8Array(signature))}`; +} diff --git a/src/iap/server-api/server-api.types.ts b/src/iap/server-api/server-api.types.ts new file mode 100644 index 00000000..f78c92b6 --- /dev/null +++ b/src/iap/server-api/server-api.types.ts @@ -0,0 +1,187 @@ +/** + * The App Store Server API surface. + * + * Everything here needs an **In-App Purchase key**, from App Store Connect + * under Users and Access, Integrations. It is not an App Store Connect team + * key: Apple scopes each key family to its own APIs. The key downloads once, + * so it belongs in Base44 secrets rather than in code. + * + * None of it is needed to verify a purchase or to know whether a customer is + * entitled. It only unlocks talking *to* Apple. + */ + +/** Credentials for the App Store Server API. */ +export interface IapServerApiConfig { + /** The key's identifier, shown next to it in App Store Connect. */ + keyId: string; + /** Your issuer id, from the Integrations page. */ + issuerId: string; + /** + * The private key, as downloaded. + * + * The whole `.p8` file contents including the `-----BEGIN PRIVATE KEY-----` + * lines. Store it in Base44 secrets, never in source. + */ + privateKeyP8: string; +} + +/** Whether the app delivered what the customer paid for. */ +export type IapDeliveryStatus = + | "DELIVERED" + | "UNDELIVERED_QUALITY_ISSUE" + | "UNDELIVERED_WRONG_ITEM" + | "UNDELIVERED_SERVER_OUTAGE" + | "UNDELIVERED_OTHER"; + +/** What the app would prefer Apple do about a refund. */ +export type IapRefundPreference = "DECLINE" | "GRANT_FULL" | "GRANT_PRORATED"; + +/** + * Consumption data for a disputed purchase. + * + * Apple's consent rules are strict, and they are the app's responsibility, not + * this SDK's: consent must be freely given, specific, informed and + * unambiguous, must not be gathered through the App Tracking Transparency + * prompt, and must be disclosed in the app's privacy labels. Without consent, + * the correct action is to send nothing at all. + */ +export interface ConsumptionRequestBody { + /** + * Whether the customer consented to sharing this data. + * + * Must be `true`. Apple rejects the call otherwise, and sending data without + * consent would be wrong regardless. + */ + customerConsented: true; + /** Whether the app delivered the purchase. */ + deliveryStatus: IapDeliveryStatus; + /** Whether a free sample, trial or functional description was offered before purchase. */ + sampleContentProvided: boolean; + /** What the app would prefer Apple do. */ + refundPreference?: IapRefundPreference; + /** + * How much of the purchase was used, in thousandths of a percent (0 to 100000). + * + * Must be 0 unless `deliveryStatus` is `DELIVERED`, and must be omitted + * entirely for auto-renewable subscriptions. + */ + consumptionPercentage?: number; +} + +/** The token identifying a test notification Apple was asked to send. */ +export interface TestNotificationResult { + /** Pass this to `getTestNotificationStatus` to see what happened. */ + testNotificationToken: string; +} + +/** Why one delivery attempt did or did not work. */ +export type SendAttemptResult = + | "SUCCESS" + | "CIRCULAR_REDIRECT" + | "INVALID_RESPONSE" + | "NO_RESPONSE" + | "OTHER" + | "PREMATURE_CLOSE" + | "SOCKET_ISSUE" + | "TIMED_OUT" + | "TLS_ISSUE" + | "UNSUCCESSFUL_HTTP_RESPONSE_CODE" + | "UNSUPPORTED_CHARSET"; + +/** One attempt Apple made to deliver a notification. */ +export interface SendAttempt { + /** When Apple tried. */ + attemptDate: number; + /** How it went. */ + sendAttemptResult: SendAttemptResult; +} + +/** What became of a test notification. */ +export interface TestNotificationStatus { + /** Every attempt Apple made, in order. */ + sendAttempts: SendAttempt[]; + /** The payload Apple sent, so it can be replayed. */ + signedPayload?: string; +} + +/** + * Calls to Apple's own servers. + * + * Always present on the module. Every method throws + * `IAP_SERVER_API_NOT_CONFIGURED` until `serverApi` credentials are supplied, + * so the shape of the module never depends on configuration. + */ +export interface IapServerApiModule { + /** + * Answers Apple's request for consumption data about a disputed purchase. + * + * Apple allows **12 hours** in production and only **5 minutes** in sandbox, + * and only wants an answer if the customer consented. With no consent flow, + * do not call this — sending nothing is the correct behaviour, not a + * failure. + * + * @param transactionId - The disputed transaction. + * @param body - The consumption data, including the customer's consent. + * @returns Promise that resolves when Apple has accepted the data. + * @throws {Error} An `IapApiError` when Apple rejects the call, or an `IapConfigError` when no key is configured. + * + * @example + * ```typescript + * // Answer a refund request the customer consented to + * await iap.serverApi.sendConsumptionInformation(transactionId, { + * customerConsented: true, + * deliveryStatus: "DELIVERED", + * sampleContentProvided: false, + * consumptionPercentage: 100000, + * }); + * ``` + */ + sendConsumptionInformation( + transactionId: string, + body: ConsumptionRequestBody + ): Promise; + + /** + * Asks Apple to send a test notification to the configured URL. + * + * The quickest way to prove a webhook is reachable, before any real purchase + * exists. Apple sends to the URL registered for whichever environment the + * key belongs to, so the URLs have to be set up first. + * + * @returns Promise resolving to the token identifying this test. + * @throws {Error} An `IapApiError` when Apple rejects the call, or an `IapConfigError` when no key is configured. + * + * @example + * ```typescript + * // Prove the webhook works + * const { testNotificationToken } = await iap.serverApi.requestTestNotification(); + * const status = await iap.serverApi.getTestNotificationStatus(testNotificationToken); + * console.log(status.sendAttempts.at(-1)?.sendAttemptResult); + * ``` + */ + requestTestNotification(): Promise; + + /** + * Reports what became of a test notification. + * + * `SUCCESS` on the last attempt means the webhook is reachable and answered + * correctly. Anything else names the problem — a timeout, a TLS failure, a + * bad status code. + * + * @param testNotificationToken - The token from `requestTestNotification`. + * @returns Promise resolving to every delivery attempt, and the payload Apple sent. + * @throws {Error} An `IapApiError` when Apple rejects the call, or an `IapConfigError` when no key is configured. + * + * @example + * ```typescript + * // Check a test notification, allowing for Apple's delay + * const status = await iap.serverApi.getTestNotificationStatus(token); + * for (const attempt of status.sendAttempts) { + * console.log(new Date(attempt.attemptDate), attempt.sendAttemptResult); + * } + * ``` + */ + getTestNotificationStatus( + testNotificationToken: string + ): Promise; +} diff --git a/src/iap/store/collapse.ts b/src/iap/store/collapse.ts new file mode 100644 index 00000000..9f231477 --- /dev/null +++ b/src/iap/store/collapse.ts @@ -0,0 +1,124 @@ +/** + * Folding duplicate rows into one. + * + * Base44 cannot enforce a unique column, so two writes racing for the same + * natural key can both create a row. Reads therefore never assume one row per + * key — they collapse whatever they find, which makes correctness independent + * of whether a repair pass has run. + * + * The fold reproduces what a guarded merge would have produced: newest row + * first, then the first non-null value for each field. So a loser row's + * `finishedAt`, or a renewal token the newest row happens to lack, survives. + * + * Pure, and therefore testable without a network. + * + * @internal + */ +import type { IapEntityDescriptor } from "./store.types.js"; + +function cursorValue(descriptor: IapEntityDescriptor, row: T): number { + const facets = Object.keys(descriptor.cursors); + if (facets.length === 0) return 0; + const column = descriptor.cursors[facets[0]] as keyof T; + const value = row[column]; + return typeof value === "number" ? value : 0; +} + +/** + * Orders two candidates for the same key, newest first. + * + * The order is total and deterministic — cursor, then creation time, then the + * record id — because two backend instances must give the same customer the + * same answer. An unstable tie-break would make entitlement flap. + */ +function compareNewestFirst( + descriptor: IapEntityDescriptor, + a: T, + b: T +): number { + const byCursor = cursorValue(descriptor, b) - cursorValue(descriptor, a); + if (byCursor !== 0) return byCursor; + + const aRecord = a as { created_date?: string; id?: string }; + const bRecord = b as { created_date?: string; id?: string }; + + const byCreated = (bRecord.created_date ?? "").localeCompare( + aRecord.created_date ?? "" + ); + if (byCreated !== 0) return byCreated; + + return (bRecord.id ?? "").localeCompare(aRecord.id ?? ""); +} + +function isEmpty(value: unknown): boolean { + return value === null || value === undefined; +} + +/** Folds one key's rows into a single row. */ +function foldGroup(descriptor: IapEntityDescriptor, group: T[]): T { + const ordered = [...group].sort((a, b) => compareNewestFirst(descriptor, a, b)); + const merged = { ...ordered[0] } as Record; + + for (let i = 1; i < ordered.length; i += 1) { + const candidate = ordered[i] as Record; + for (const field of Object.keys(candidate)) { + if (isEmpty(merged[field]) && !isEmpty(candidate[field])) { + merged[field] = candidate[field]; + } + } + } + + // A few columns record when something first happened, so the oldest value is + // the true one rather than the newest. + for (const field of descriptor.oldestWins) { + let oldest: number | undefined; + for (const row of ordered) { + const value = (row as Record)[field]; + if (typeof value === "number" && (oldest === undefined || value < oldest)) { + oldest = value; + } + } + if (oldest !== undefined) merged[field] = oldest; + } + + return merged as T; +} + +/** One row per natural key, plus how many duplicates were folded away. */ +export interface CollapseResult { + readonly rows: T[]; + readonly collapsed: number; +} + +/** + * Collapses rows to one per natural key, preserving input order of first + * appearance. + */ +export function collapseDuplicates( + descriptor: IapEntityDescriptor, + rows: readonly T[] +): CollapseResult { + const groups = new Map(); + const order: string[] = []; + + for (const row of rows) { + const key = String((row as Record)[descriptor.keyField]); + const existing = groups.get(key); + if (existing) { + existing.push(row); + } else { + groups.set(key, [row]); + order.push(key); + } + } + + let collapsed = 0; + const out: T[] = []; + for (const key of order) { + const group = groups.get(key) as T[]; + if (group.length > 1) collapsed += group.length - 1; + out.push(group.length === 1 ? group[0] : foldGroup(descriptor, group)); + } + + return { rows: out, collapsed }; +} diff --git a/src/iap/store/descriptors.ts b/src/iap/store/descriptors.ts new file mode 100644 index 00000000..2ec822ad --- /dev/null +++ b/src/iap/store/descriptors.ts @@ -0,0 +1,110 @@ +/** + * Per-entity storage rules. + * + * The only place the store layer knows anything specific about the four + * entities. Everything else is driven off these. + * + * @internal + */ +import type { IapEntityDescriptor } from "./store.types.js"; +import type { + IapConsumptionRequestRecord, + IapNotificationRecord, + IapSubscriptionRecord, + IapTransactionRecord, +} from "./rows.types.js"; + +/** + * A purchase or renewal. + * + * `expect: "insert"` because the key is almost always new — every renewal + * carries a fresh `transactionId` — so trying the insert first saves a round + * trip on the common path. + */ +export const TRANSACTION_DESCRIPTOR: IapEntityDescriptor = { + name: "IapTransaction", + keyField: "transactionId", + cursors: { transaction: "signedDate" }, + expect: "insert", + mergeExcluded: [ + "transactionId", + // Set once, when the SDK first saw the row. + "recordedAt", + // Owned by this SDK, not by Apple's payload, and guarded separately. + "finishedAt", + ], + oldestWins: ["recordedAt"], + heavyFields: ["rawJws"], +}; + +/** + * A subscription. + * + * `expect: "update"` because after the first purchase the row exists and every + * later payload updates it. + * + * Two cursors, because the transaction and the renewal information arrive + * separately: a launch-time sync brings a transaction with no renewal + * information, a notification brings both. With one shared cursor a sync could + * advance past a later notification and silently drop a grace-period date, + * which would deny service to a customer Apple is still trying to bill. + */ +export const SUBSCRIPTION_DESCRIPTOR: IapEntityDescriptor = { + name: "IapSubscription", + keyField: "originalTransactionId", + cursors: { + transaction: "latestSignedDate", + renewal: "latestRenewalSignedDate", + }, + expect: "update", + mergeExcluded: ["originalTransactionId", "recordedAt"], + oldestWins: ["recordedAt"], + heavyFields: ["latestTransactionJws", "latestRenewalInfoJws"], +}; + +/** + * A notification from Apple. + * + * Immutable once committed, so it has no cursor: the only write after the + * insert is the commit patch that sets the real outcome. + */ +export const NOTIFICATION_DESCRIPTOR: IapEntityDescriptor = { + name: "IapNotification", + keyField: "notificationUUID", + cursors: {}, + expect: "insert", + mergeExcluded: [ + "notificationUUID", + "notificationType", + "signedDate", + "receivedAt", + "rawSignedPayload", + "sdkVersion", + ], + oldestWins: ["receivedAt"], + heavyFields: ["rawSignedPayload"], +}; + +/** + * A refund-consumption request. + * + * Two cursors again: the request itself and the outcome a later notification + * fills in, which can arrive out of order relative to a fresh request for the + * same transaction. + */ +export const CONSUMPTION_DESCRIPTOR: IapEntityDescriptor = + { + name: "IapConsumptionRequest", + keyField: "transactionId", + cursors: { request: "requestSignedDate", outcome: "outcomeSignedDate" }, + expect: "insert", + mergeExcluded: [ + "transactionId", + // Owned by this SDK: set when it answers Apple, and guarded separately + // so two workers cannot both answer. + "respondedAt", + "response", + ], + oldestWins: ["receivedAt"], + heavyFields: ["response"], + }; diff --git a/src/iap/store/entities-store.ts b/src/iap/store/entities-store.ts new file mode 100644 index 00000000..6918b51a --- /dev/null +++ b/src/iap/store/entities-store.ts @@ -0,0 +1,504 @@ +/** + * The store, implemented over Base44 entities. + * + * The only file that touches the entities API, and the only one that knows + * about its gaps. Four of them shape everything here: + * + * 1. **No upsert.** So an insert-or-merge is two calls, and the order is + * chosen per entity by which outcome is likelier. + * 2. **No unique constraint.** So two writes can both create a row for one + * key. Reads collapse duplicates instead of assuming they cannot happen. + * 3. **No compare-and-swap.** So `updateMany`'s query is used as the guard — + * it is evaluated server-side in one call, which is what makes newest-wins + * safe under concurrency. A plain update by id is banned: it is an + * unconditional overwrite and would lose the newer of two racing payloads. + * 4. **`updated: 0` is ambiguous** between "no such row" and "the guard + * rejected it", so telling them apart costs an extra read. + * + * @internal + */ +import type { EntitiesModule, EntityHandler } from "../../modules/entities.types.js"; +import { IapSetupError, IapStoreError } from "../errors.js"; +import { systemClock, type Clock } from "../runtime/clock.js"; +import { collapseDuplicates } from "./collapse.js"; +import { isDuplicateKeyError, toStoreError } from "./store-errors.js"; +import { IAP_ENTITY_NAMES, type IapEntityName } from "./schemas.js"; +import type { + IapEntityDescriptor, + IapPageOptions, + IapPageResult, + IapPatch, + IapPatchResult, + IapStore, + IapStoreMode, + IapUpsertResult, + IapWriteGuard, +} from "./store.types.js"; + +/** How many rows to fetch when looking up one key, so duplicates are visible. */ +const DUPLICATE_PROBE_LIMIT = 5; + +/** Keys per `$in` lookup. The filter travels in a URL query string. */ +const KEYS_PER_REQUEST = 50; + +const DEFAULT_PAGE_SIZE = 500; +const DEFAULT_LIMIT = 1000; +const MAX_LIMIT = 5000; + +/** Inputs to {@link createEntitiesStore}. */ +export interface CreateEntitiesStoreOptions { + /** + * Returns the entities module to write through. + * + * A getter rather than a value because it must be the **service-role** + * module — ingestion writes rows on behalf of a user whose own permissions + * would not allow it — and reaching for that throws when the client was + * built without service-role credentials. Deferring it keeps constructing an + * IAP client free of I/O and free of throwing, so the failure surfaces on + * the first write with a message that says what is missing. + */ + readonly getEntities: () => EntitiesModule; + /** How rows are addressed. Defaults to `"query-guard"`. */ + readonly mode?: IapStoreMode; + /** The clock, for tests. */ + readonly clock?: Clock; +} + +/** + * The store is in natural-id mode on a backend that ignores a caller-supplied + * record id. + * + * Deliberately not routed through the generic classifier: that would label it + * transient and retry it forever, when what is needed is a loud, permanent + * failure naming the fix. + */ +function modeMismatchError( + entityName: IapEntityName, + key: string, + assigned: string +): IapStoreError { + const error = new IapStoreError( + "IAP_WRITE_FAILED", + `natural-id mode is on, but ${entityName} returned id ${JSON.stringify( + assigned + )} for key ${JSON.stringify(key)}. This backend does not honour a ` + + "caller-supplied id, so de-duplication would never fire and a consumable " + + "could be granted more than once. Run the store in query-guard mode.", + { entityName } + ); + (error as { kind?: string }).kind = "mode_mismatch"; + (error as { retryable?: boolean }).retryable = false; + return error; +} + +export function createEntitiesStore( + options: CreateEntitiesStoreOptions +): IapStore { + const mode: IapStoreMode = options.mode ?? "query-guard"; + const clock = options.clock ?? systemClock; + + function handlerFor(descriptor: IapEntityDescriptor): EntityHandler { + return entityHandler(descriptor.name); + } + + function entityHandler(name: IapEntityName): EntityHandler { + let entities: EntitiesModule; + try { + entities = options.getEntities(); + } catch (cause) { + throw new IapSetupError( + "IAP_SERVICE_ROLE_REQUIRED", + "in-app purchase records are written with service-role access, which this " + + "client does not have. Create the client inside a Base44 backend function " + + "with createClientFromRequest(request).", + { entityName: name, cause } + ); + } + return entities[name] as unknown as EntityHandler; + } + + /** + * Columns to request. + * + * Heavy columns — the raw tokens — are left out unless asked for, because + * most reads only need the derived fields. The key and every cursor are + * always included: without them a collapse cannot order duplicates. + */ + function projection( + descriptor: IapEntityDescriptor, + fields: readonly (keyof T & string)[] | undefined + ): (keyof T & string)[] | undefined { + if (!fields) return undefined; + const required = new Set([ + descriptor.keyField, + ...(Object.values(descriptor.cursors) as (keyof T & string)[]), + ...fields, + ]); + return [...required]; + } + + /** Builds the server-side guard for a write. */ + function guardQuery( + descriptor: IapEntityDescriptor, + key: string, + guard: IapWriteGuard + ): Record { + const query: Record = { [descriptor.keyField]: key }; + + if (guard.equals) { + for (const [field, value] of Object.entries(guard.equals)) { + query[field] = value; + } + } + + if (guard.cursorBelow) { + const column = descriptor.cursors[guard.cursorBelow.facet]; + if (!column) { + throw new Error( + `${descriptor.name} has no cursor facet ${JSON.stringify( + guard.cursorBelow.facet + )}` + ); + } + // Both branches are required. A bare `$lt` does not match a document + // where the column is missing or null — MongoDB compares only within a + // type — so a row written before this cursor existed would become + // permanently unwritable. + query.$or = [ + { [column]: { $lt: guard.cursorBelow.value } }, + { [column]: null }, + ]; + } + + return query; + } + + function patchData( + descriptor: IapEntityDescriptor, + patch: IapPatch + ): Record> { + const set: Record = {}; + const excluded = new Set(descriptor.mergeExcluded); + + for (const [field, value] of Object.entries(patch.set)) { + // Omitting a nullish value is what "keep what we already know" means: + // a bare device transaction must not erase renewal information that a + // notification supplied. + if (value === null || value === undefined) continue; + if (excluded.has(field)) continue; + set[field] = value; + } + + const data: Record> = {}; + if (Object.keys(set).length > 0) data.$set = set; + + if (patch.clear && patch.clear.length > 0) { + const unset: Record = {}; + for (const field of patch.clear) { + if (!excluded.has(field)) unset[field] = ""; + } + if (Object.keys(unset).length > 0) data.$unset = unset; + } + + if (patch.increment && Object.keys(patch.increment).length > 0) { + data.$inc = { ...patch.increment }; + } + + return data; + } + + async function rowsForKey( + descriptor: IapEntityDescriptor, + key: string, + fields?: readonly (keyof T & string)[] + ): Promise { + const handler = handlerFor(descriptor); + const found = await handler.filter( + { [descriptor.keyField]: key } as never, + undefined, + DUPLICATE_PROBE_LIMIT, + undefined, + projection(descriptor, fields) as never + ); + return (found ?? []) as T[]; + } + + async function insertIfAbsent( + descriptor: IapEntityDescriptor, + key: string, + row: T + ): Promise> { + const handler = handlerFor(descriptor); + + if (mode === "natural-id") { + // The natural key doubles as the record id, so the backend rejects a + // second insert and the duplicate race closes itself. + let created: T; + try { + created = (await handler.create({ + ...(row as object), + id: key, + } as never)) as T; + } catch (error) { + if (isDuplicateKeyError(error)) { + return { outcome: "stale", matched: 0, roundTrips: 1 }; + } + throw toStoreError(descriptor.name, "insert", key, error); + } + + // Fail closed, and outside the catch above so the reason survives. + // + // If the backend ignored the supplied id it handed back one of its own, + // which means every insert looks new, de-duplication never fires, and a + // consumable is granted again on every StoreKit re-delivery — silently, + // and for money. This is not retryable and must not be reported as a + // transient blip: the configuration is wrong and only a human can fix it. + const assigned = (created as { id?: string } | null)?.id; + if (assigned !== undefined && assigned !== key) { + throw modeMismatchError(descriptor.name, key, assigned); + } + return { outcome: "inserted", inserted: created, matched: 1, roundTrips: 1 }; + } + + // query-guard: look first, then create. Two calls, and a narrow window in + // which two callers both see nothing and both create. That is tolerated + // rather than closed — the duplicate is an extra row, reads collapse it, + // and every merge is idempotent. + try { + const existing = await rowsForKey(descriptor, key, [descriptor.keyField]); + if (existing.length > 0) { + return { outcome: "stale", matched: 0, roundTrips: 1 }; + } + const created = (await handler.create(row as never)) as T; + return { outcome: "inserted", inserted: created, matched: 1, roundTrips: 2 }; + } catch (error) { + if (isDuplicateKeyError(error)) { + return { outcome: "stale", matched: 0, roundTrips: 2 }; + } + throw toStoreError(descriptor.name, "insert", key, error); + } + } + + async function patchWhere( + descriptor: IapEntityDescriptor, + key: string, + guard: IapWriteGuard, + patch: IapPatch + ): Promise { + const handler = handlerFor(descriptor); + const data = patchData(descriptor, patch); + + if (Object.keys(data).length === 0) { + // Nothing to write. Reporting `stale` keeps callers from treating a + // no-op as a successful apply. + return { outcome: "stale", matched: 0, roundTrips: 0 }; + } + + let updated: number; + try { + const result = await handler.updateMany( + guardQuery(descriptor, key, guard) as never, + data + ); + updated = result?.updated ?? 0; + } catch (error) { + throw toStoreError(descriptor.name, "write", key, error); + } + + if (updated > 0) { + return { outcome: "applied", matched: updated, roundTrips: 1 }; + } + + // `updated: 0` cannot say whether the row is missing or the guard rejected + // it, and the two need different follow-ups, so this costs one read. + try { + const existing = await rowsForKey(descriptor, key, [descriptor.keyField]); + return { + outcome: existing.length > 0 ? "stale" : "absent", + matched: 0, + roundTrips: 2, + }; + } catch (error) { + throw toStoreError(descriptor.name, "read", key, error); + } + } + + async function upsertNewestWins( + descriptor: IapEntityDescriptor, + key: string, + cursor: { readonly facet: string; readonly value: number }, + row: T, + patch: IapPatch + ): Promise> { + const guard: IapWriteGuard = { cursorBelow: cursor }; + + if (descriptor.expect === "insert") { + // The key is usually new, so try the insert first. + const inserted = await insertIfAbsent(descriptor, key, row); + if (inserted.outcome === "inserted") return inserted; + + const patched = await patchWhere(descriptor, key, guard, patch); + return { + outcome: patched.outcome === "absent" ? "stale" : patched.outcome, + matched: patched.matched, + roundTrips: inserted.roundTrips + patched.roundTrips, + }; + } + + // The row usually exists, so try the guarded update first. + const patched = await patchWhere(descriptor, key, guard, patch); + if (patched.outcome !== "absent") { + return { + outcome: patched.outcome, + matched: patched.matched, + roundTrips: patched.roundTrips, + }; + } + + const inserted = await insertIfAbsent(descriptor, key, row); + if (inserted.outcome === "inserted") { + return { ...inserted, roundTrips: patched.roundTrips + inserted.roundTrips }; + } + + // Someone else created it in between. The guard makes re-running correct: + // it applies if this payload really is newer, and does nothing if not. + const retry = await patchWhere(descriptor, key, guard, patch); + return { + outcome: retry.outcome === "absent" ? "stale" : retry.outcome, + matched: retry.matched, + roundTrips: patched.roundTrips + inserted.roundTrips + retry.roundTrips, + }; + } + + async function getByKey( + descriptor: IapEntityDescriptor, + key: string, + fields?: readonly (keyof T & string)[] + ): Promise { + try { + const rows = await rowsForKey(descriptor, key, fields); + if (rows.length === 0) return null; + return collapseDuplicates(descriptor, rows).rows[0] ?? null; + } catch (error) { + throw toStoreError(descriptor.name, "read", key, error); + } + } + + async function getByKeys( + descriptor: IapEntityDescriptor, + keys: readonly string[], + fields?: readonly (keyof T & string)[] + ): Promise> { + const unique = [...new Set(keys)].filter((key) => key.length > 0); + const out = new Map(); + if (unique.length === 0) return out; + + const handler = handlerFor(descriptor); + + for (let i = 0; i < unique.length; i += KEYS_PER_REQUEST) { + const chunk = unique.slice(i, i + KEYS_PER_REQUEST); + let rows: T[]; + try { + rows = ((await handler.filter( + { [descriptor.keyField]: { $in: chunk } } as never, + undefined, + // Room for duplicates of every key in the chunk. + chunk.length * DUPLICATE_PROBE_LIMIT, + undefined, + projection(descriptor, fields) as never + )) ?? []) as T[]; + } catch (error) { + throw toStoreError(descriptor.name, "read", undefined, error); + } + + for (const row of collapseDuplicates(descriptor, rows).rows) { + out.set(String((row as Record)[descriptor.keyField]), row); + } + } + + return out; + } + + async function query( + descriptor: IapEntityDescriptor, + filter: Readonly>, + pageOptions: IapPageOptions = {} + ): Promise> { + const handler = handlerFor(descriptor); + const limit = Math.min(pageOptions.limit ?? DEFAULT_LIMIT, MAX_LIMIT); + const pageSize = Math.min(pageOptions.pageSize ?? DEFAULT_PAGE_SIZE, limit); + + const collected: T[] = []; + let skip = 0; + let roundTrips = 0; + let truncated = false; + + while (collected.length < limit) { + // Always an explicit, non-zero page size. The entities layer drops a + // falsy limit and the server then applies its own default of 50, which + // would quietly hide an older purchase. + const size = Math.min(pageSize, limit - collected.length); + let page: T[]; + try { + page = ((await handler.filter( + filter as never, + pageOptions.sort as never, + size, + skip, + projection(descriptor, pageOptions.fields) as never + )) ?? []) as T[]; + } catch (error) { + throw toStoreError(descriptor.name, "read", undefined, error); + } + roundTrips += 1; + collected.push(...page); + + if (page.length < size) break; + skip += size; + if (collected.length >= limit) { + truncated = true; + break; + } + } + + const { rows, collapsed } = collapseDuplicates(descriptor, collected); + return { rows, truncated, duplicatesCollapsed: collapsed, roundTrips }; + } + + async function healthcheck(): Promise<{ ok: boolean; missing: IapEntityName[] }> { + const missing: IapEntityName[] = []; + + await Promise.all( + IAP_ENTITY_NAMES.map(async (name) => { + try { + const handler = entityHandler(name); + await handler.filter({} as never, undefined, 1); + } catch (error) { + // A filter against a non-existent entity answers 404. Reading one + // row by record id could not tell that apart from "no such row", + // which is why the store never does. + const status = (error as { status?: unknown }).status; + if (status === 404) missing.push(name); + else throw toStoreError(name, "read", undefined, error); + } + }) + ); + + return { ok: missing.length === 0, missing }; + } + + return { + insertIfAbsent, + patchWhere, + upsertNewestWins, + getByKey, + getByKeys, + query, + healthcheck, + }; +} + +/** The clock this store reads, exposed so callers stamp rows consistently. */ +export function storeClock(options: CreateEntitiesStoreOptions): Clock { + return options.clock ?? systemClock; +} diff --git a/src/iap/store/rows.types.ts b/src/iap/store/rows.types.ts new file mode 100644 index 00000000..b7cbd956 --- /dev/null +++ b/src/iap/store/rows.types.ts @@ -0,0 +1,251 @@ +/** + * The shapes stored in the app's Base44 entities. + * + * Two rules run through all four: + * + * - **The raw signed token is the source of truth.** Every other column is a + * query convenience, derived from that token and safe to recompute. A logic + * fix therefore never needs a data migration. + * - **Timestamps are epoch milliseconds**, matching what Apple sends, so a + * stored value can be compared against a fresh payload without parsing. + * + * Each row also carries a `signedDate` cursor. It is the instant *Apple* + * signed the payload, never a local clock, which is what makes "newest wins" + * agree across backend instances. + */ +import type { + IapEnvironment, + IapConsumptionRequestReason, + IapOfferDiscountType, + IapOfferType, + IapOwnershipType, + IapProductType, + IapRevocationType, + IapTransactionReason, + IapAppleSubscriptionStatus, +} from "../verify/verify.types.js"; + +/** Fields Base44 adds to every record. */ +export interface IapStoredRecordFields { + /** The record's Base44 id. */ + id?: string; + /** When Base44 created the record. */ + created_date?: string; + /** When Base44 last changed the record. */ + updated_date?: string; +} + +/** Where a stored payload came from. */ +export type IapRecordSource = + /** An App Store Server Notification. */ + | "notification" + /** The app reporting a purchase, or a launch-time sync. */ + | "device" + /** An App Store Server API call. */ + | "api"; + +/** + * One purchase or renewal, keyed by `transactionId`. + * + * A renewal is a new row: Apple issues a fresh `transactionId` each period, + * all sharing one `originalTransactionId`. + */ +export interface IapTransactionRecord extends IapStoredRecordFields { + /** Apple's unique id for this transaction. The natural key. */ + transactionId: string; + /** The first transaction in this chain. Stable across renewals. */ + originalTransactionId: string; + /** The Base44 user this purchase belongs to, when it could be resolved. */ + appUserId: string | null; + /** The UUID Apple signed into the transaction, which is how the user was resolved. */ + appAccountToken: string | null; + /** The product purchased. */ + productId: string; + /** What kind of product it is. */ + type: IapProductType | null; + /** The subscription group, for auto-renewable subscriptions. */ + subscriptionGroupIdentifier: string | null; + /** When the purchase was made. */ + purchaseDate: number | null; + /** When the first purchase in this chain was made. */ + originalPurchaseDate: number | null; + /** When the period ends, for auto-renewable subscriptions. */ + expiresDate: number | null; + /** + * When a non-renewing subscription ends, computed from the configured + * duration. + * + * Apple does not expire these, so this column is the only thing that says + * when access ends. + */ + appDefinedExpiresDate: number | null; + /** How many of a consumable were bought. */ + quantity: number | null; + /** Whether the purchaser owns this, or received it through Family Sharing. */ + inAppOwnershipType: IapOwnershipType | null; + /** Whether this is a first purchase or a renewal. */ + transactionReason: IapTransactionReason | null; + /** Whether an upgrade replaced this transaction. */ + isUpgraded: boolean | null; + /** Which kind of offer applied. */ + offerType: IapOfferType | null; + /** The offer's identifier. */ + offerIdentifier: string | null; + /** How the offer discounted the price. */ + offerDiscountType: IapOfferDiscountType | null; + /** The offer's duration, as an ISO 8601 period. */ + offerPeriod: string | null; + /** When Apple took the purchase back. Its presence alone means "not entitled". */ + revocationDate: number | null; + /** Why it was taken back. */ + revocationReason: number | null; + /** How it was taken back. */ + revocationType: IapRevocationType | null; + /** How much was refunded, in thousandths of a percent. Absent once a refund is reversed. */ + revocationPercentage: number | null; + /** Which environment produced the token. */ + environment: IapEnvironment; + /** The App Store country, as a three-letter code. */ + storefront: string | null; + /** Apple's numeric id for that storefront. */ + storefrontId: string | null; + /** When Apple signed the payload. The newest-wins cursor. */ + signedDate: number; + /** The token exactly as received. The source of truth. */ + rawJws: string; + /** Where this row's newest payload came from. */ + source: IapRecordSource; + /** When the app reported finishing the transaction, if it did. */ + finishedAt: number | null; + /** When this SDK first stored the row. */ + recordedAt: number; + /** When this SDK last changed the row. */ + updatedAt: number; +} + +/** + * One subscription, keyed by `originalTransactionId`. + * + * Holds the newest transaction and the newest renewal information, from which + * status is derived at read time. It carries **two** cursors because those two + * tokens arrive independently: a launch-time sync brings a fresh transaction + * with no renewal information, while a notification brings both. One cursor + * would let the sync advance past a later notification and silently lose a + * grace-period date. + */ +export interface IapSubscriptionRecord extends IapStoredRecordFields { + /** The subscription chain. The natural key. */ + originalTransactionId: string; + /** The Base44 user this subscription belongs to. */ + appUserId: string | null; + /** The subscription group. */ + subscriptionGroupIdentifier: string | null; + /** The product of the newest transaction. */ + productId: string | null; + /** The newest signed transaction. */ + latestTransactionJws: string | null; + /** The newest signed renewal information. */ + latestRenewalInfoJws: string | null; + /** `signedDate` of the newest transaction. Guards transaction updates. */ + latestSignedDate: number | null; + /** `signedDate` of the newest renewal information. Guards renewal updates. */ + latestRenewalSignedDate: number | null; + /** Apple's own status code, when a payload supplied one. Used only to cross-check. */ + appleStatus: IapAppleSubscriptionStatus | null; + /** Which environment produced the tokens. */ + environment: IapEnvironment; + /** When this SDK first stored the row. */ + recordedAt: number; + /** When this SDK last changed the row. */ + updatedAt: number; +} + +/** + * How a notification was applied. + * + * `error` is load-bearing and does not mean "something broke": it is written + * first, before the entity updates, and means **claimed but not yet applied**. + * Duplicate detection only short-circuits on a row whose outcome is something + * else. Without that, a notification whose writes failed after the row landed + * would be treated as already handled on Apple's retry — and Apple only + * retries a handful of times, so the purchase data would be lost for good. + */ +export type IapNotificationOutcome = + /** Claimed, not yet applied. Retrying is safe and expected. */ + | "error" + /** Applied. */ + | "applied" + /** Already seen, so nothing was done. */ + | "duplicate" + /** Older than what is stored, so nothing was applied. */ + | "stale" + /** A known type this version stores but does not act on. */ + | "unhandled" + /** A type Apple added after this version shipped. */ + | "unknown_type"; + +/** One notification from Apple, keyed by `notificationUUID`. */ +export interface IapNotificationRecord extends IapStoredRecordFields { + /** Apple's unique id. A resend keeps the same value, which is what enables de-duplication. */ + notificationUUID: string; + /** What happened. */ + notificationType: string; + /** A refinement of the type, when there is one. */ + subtype: string | null; + /** When Apple signed the notification. */ + signedDate: number; + /** When this SDK received it. */ + receivedAt: number; + /** The subscription chain involved, when there is one. */ + originalTransactionId: string | null; + /** The transaction involved, when there is one. */ + transactionId: string | null; + /** Which environment produced it. */ + environment: IapEnvironment; + /** The envelope exactly as received. */ + rawSignedPayload: string; + /** How it was applied. See {@link IapNotificationOutcome}. */ + outcome: IapNotificationOutcome; + /** How many times delivery has been attempted, counting Apple's retries. */ + attempts: number; + /** Which version of this module wrote the row. */ + sdkVersion: string; +} + +/** How Apple resolved a refund request. */ +export type IapConsumptionOutcome = "REFUND" | "REFUND_DECLINED" | "REFUND_REVERSED"; + +/** + * One refund request Apple wants consumption data for, keyed by `transactionId`. + * + * Apple gives 12 hours to answer in production and **5 minutes** in sandbox, + * and only wants an answer if the customer consented to sharing the data. + */ +export interface IapConsumptionRequestRecord extends IapStoredRecordFields { + /** The transaction being disputed. The natural key. */ + transactionId: string; + /** The subscription chain, when there is one. */ + originalTransactionId: string | null; + /** The Base44 user who bought it. */ + appUserId: string | null; + /** Why the customer asked for a refund. */ + consumptionRequestReason: IapConsumptionRequestReason | null; + /** When the request arrived. */ + receivedAt: number; + /** When Apple stops accepting an answer. */ + deadlineAt: number; + /** `signedDate` of the request. Guards request updates. */ + requestSignedDate: number; + /** When an answer was sent, if one was. */ + respondedAt: number | null; + /** The body that was sent. */ + response: unknown | null; + /** How Apple resolved it, filled in by a later notification. */ + outcome: IapConsumptionOutcome | null; + /** `signedDate` of the payload that set `outcome`. Guards outcome updates. */ + outcomeSignedDate: number | null; + /** Which environment produced the request. */ + environment: IapEnvironment; + /** When this SDK last changed the row. */ + updatedAt: number; +} diff --git a/src/iap/store/schemas.ts b/src/iap/store/schemas.ts new file mode 100644 index 00000000..68ddfcdc --- /dev/null +++ b/src/iap/store/schemas.ts @@ -0,0 +1,282 @@ +/** + * The four entities the in-app purchase module stores data in. + * + * An npm package cannot create a Base44 entity, so these ship as **data**: the + * app creates the entities once from these definitions, and the SDK checks on + * first write that what it wrote came back intact. + * + * Field types are deliberately loose where Apple's are open. A notification + * type is a string, not an enumeration, because Apple adds values without + * warning and an entity that rejected an unknown one would turn a new Apple + * feature into dropped purchase data. + */ + +/** The entities this module needs. */ +export type IapEntityName = + | "IapTransaction" + | "IapSubscription" + | "IapNotification" + | "IapConsumptionRequest"; + +/** A field in an entity schema. */ +export interface IapSchemaField { + /** The field's JSON type. */ + type: "string" | "number" | "boolean" | "object"; + /** What the field holds, shown in the Base44 editor. */ + description: string; +} + +/** One entity definition, ready to create in Base44. */ +export interface IapEntitySchema { + /** The entity name. Must match exactly — the SDK looks it up by this name. */ + name: IapEntityName; + /** What the entity is for. */ + description: string; + /** + * The field holding the natural key. + * + * Base44 cannot enforce uniqueness on it today, so the SDK guards writes + * itself and tolerates duplicates on read. + */ + naturalKey: string; + /** The JSON Schema for the entity's fields. */ + schema: { + type: "object"; + properties: Record; + required: string[]; + }; +} + +const str = (description: string): IapSchemaField => ({ type: "string", description }); +const num = (description: string): IapSchemaField => ({ type: "number", description }); +const bool = (description: string): IapSchemaField => ({ type: "boolean", description }); +const obj = (description: string): IapSchemaField => ({ type: "object", description }); + +const TRANSACTION: IapEntitySchema = { + name: "IapTransaction", + description: + "One Apple purchase or renewal. A renewal is a new row: Apple issues a fresh " + + "transactionId each period, all sharing one originalTransactionId.", + naturalKey: "transactionId", + schema: { + type: "object", + properties: { + transactionId: str("Apple's unique id for this transaction. The natural key."), + originalTransactionId: str("The first transaction in this chain."), + appUserId: str("The Base44 user this purchase belongs to."), + appAccountToken: str("The UUID Apple signed into the transaction."), + productId: str("The product purchased."), + type: str("Apple's product type, e.g. 'Auto-Renewable Subscription'."), + subscriptionGroupIdentifier: str("The subscription group, for subscriptions."), + purchaseDate: num("When the purchase was made, in epoch milliseconds."), + originalPurchaseDate: num("When the first purchase in this chain was made."), + expiresDate: num("When the subscription period ends."), + appDefinedExpiresDate: num( + "When a non-renewing subscription ends, computed from the configured duration. " + + "Apple does not expire these." + ), + quantity: num("How many of a consumable were bought."), + inAppOwnershipType: str("PURCHASED, or FAMILY_SHARED for a shared purchase."), + transactionReason: str("PURCHASE or RENEWAL."), + isUpgraded: bool("Whether an upgrade replaced this transaction."), + offerType: num("1 introductory, 2 promotional, 3 offer code, 4 win-back."), + offerIdentifier: str("The offer's identifier."), + offerDiscountType: str("FREE_TRIAL, PAY_AS_YOU_GO, PAY_UP_FRONT or ONE_TIME."), + offerPeriod: str("The offer's duration, as an ISO 8601 period."), + revocationDate: num( + "When Apple took the purchase back. Its presence alone means the customer " + + "is no longer entitled." + ), + revocationReason: num("1 an issue in the app, 0 any other reason."), + revocationType: str("REFUND_FULL, REFUND_PRORATED or FAMILY_REVOKE."), + revocationPercentage: num( + "How much was refunded, in thousandths of a percent. Absent once a refund is reversed." + ), + environment: str("Sandbox, Production or Xcode."), + storefront: str("The App Store country, as a three-letter code."), + storefrontId: str("Apple's numeric id for that storefront."), + signedDate: num( + "When Apple signed the payload. The cursor that decides which copy of a row is newest." + ), + rawJws: str("The signed token exactly as received. The source of truth."), + source: str("notification, device or api."), + finishedAt: num("When the app reported finishing the transaction."), + recordedAt: num("When the SDK first stored the row."), + updatedAt: num("When the SDK last changed the row."), + }, + required: [ + "transactionId", + "originalTransactionId", + "productId", + "environment", + "signedDate", + "rawJws", + "source", + "recordedAt", + "updatedAt", + ], + }, +}; + +const SUBSCRIPTION: IapEntitySchema = { + name: "IapSubscription", + description: + "One subscription, holding its newest transaction and newest renewal information. " + + "Status is derived from these at read time rather than stored, so a logic fix " + + "never needs a data migration.", + naturalKey: "originalTransactionId", + schema: { + type: "object", + properties: { + originalTransactionId: str("The subscription chain. The natural key."), + appUserId: str("The Base44 user this subscription belongs to."), + subscriptionGroupIdentifier: str("The subscription group."), + productId: str("The product of the newest transaction."), + latestTransactionJws: str("The newest signed transaction."), + latestRenewalInfoJws: str("The newest signed renewal information."), + latestSignedDate: num( + "signedDate of the newest transaction. Guards transaction updates." + ), + latestRenewalSignedDate: num( + "signedDate of the newest renewal information. A separate cursor, because " + + "renewal information arrives independently of the transaction." + ), + appleStatus: num("Apple's own status code: 1 active, 2 expired, 3 billing retry, 4 grace, 5 revoked."), + environment: str("Sandbox, Production or Xcode."), + recordedAt: num("When the SDK first stored the row."), + updatedAt: num("When the SDK last changed the row."), + }, + required: ["originalTransactionId", "environment", "recordedAt", "updatedAt"], + }, +}; + +const NOTIFICATION: IapEntitySchema = { + name: "IapNotification", + description: + "One App Store Server Notification, stored raw before anything is applied. " + + "The outcome field doubles as a commit flag: 'error' means claimed but not yet " + + "applied, so Apple's retry re-applies it instead of being told it is a duplicate.", + naturalKey: "notificationUUID", + schema: { + type: "object", + properties: { + notificationUUID: str( + "Apple's unique id. A resend keeps the same value, which is what makes " + + "de-duplication possible. The natural key." + ), + notificationType: str("What happened, e.g. DID_RENEW. An open set — Apple adds values."), + subtype: str("A refinement of the type, e.g. BILLING_RECOVERY."), + signedDate: num("When Apple signed the notification."), + receivedAt: num("When the SDK received it."), + originalTransactionId: str("The subscription chain involved."), + transactionId: str("The transaction involved."), + environment: str("Sandbox, Production or Xcode."), + rawSignedPayload: str("The envelope exactly as received."), + outcome: str( + "error (claimed, not yet applied), applied, duplicate, stale, unhandled or unknown_type." + ), + attempts: num("How many delivery attempts have been seen, counting Apple's retries."), + sdkVersion: str("Which version of the module wrote the row."), + }, + required: [ + "notificationUUID", + "notificationType", + "signedDate", + "receivedAt", + "environment", + "rawSignedPayload", + "outcome", + "attempts", + "sdkVersion", + ], + }, +}; + +const CONSUMPTION_REQUEST: IapEntitySchema = { + name: "IapConsumptionRequest", + description: + "One refund request Apple wants consumption data for. Apple allows 12 hours to " + + "answer in production and only 5 minutes in sandbox, and only wants an answer if " + + "the customer consented to sharing the data.", + naturalKey: "transactionId", + schema: { + type: "object", + properties: { + transactionId: str("The transaction being disputed. The natural key."), + originalTransactionId: str("The subscription chain, when there is one."), + appUserId: str("The Base44 user who bought it."), + consumptionRequestReason: str( + "UNINTENDED_PURCHASE, FULFILLMENT_ISSUE, UNSATISFIED_WITH_PURCHASE, LEGAL or OTHER." + ), + receivedAt: num("When the request arrived."), + deadlineAt: num("When Apple stops accepting an answer."), + requestSignedDate: num("signedDate of the request. Guards request updates."), + respondedAt: num("When an answer was sent."), + response: obj("The body that was sent to Apple."), + outcome: str("REFUND, REFUND_DECLINED or REFUND_REVERSED, from a later notification."), + outcomeSignedDate: num("signedDate of the payload that set the outcome."), + environment: str("Sandbox, Production or Xcode."), + updatedAt: num("When the SDK last changed the row."), + }, + required: [ + "transactionId", + "receivedAt", + "deadlineAt", + "requestSignedDate", + "environment", + "updatedAt", + ], + }, +}; + +/** + * The four entity definitions, in the order they should be created. + * + * Create these once in the app, with exactly these names. The SDK looks each + * one up by name and fails loudly rather than quietly losing purchase data if + * one is missing or has dropped a field. + * + * @example + * ```typescript + * // Print the definitions to create in the app + * import { IAP_ENTITY_SCHEMAS } from "@base44/sdk/iap"; + * + * for (const entity of IAP_ENTITY_SCHEMAS) { + * console.log(entity.name, Object.keys(entity.schema.properties).length, "fields"); + * } + * ``` + */ +export const IAP_ENTITY_SCHEMAS: readonly IapEntitySchema[] = [ + TRANSACTION, + SUBSCRIPTION, + NOTIFICATION, + CONSUMPTION_REQUEST, +]; + +/** The entity names, for iteration and health checks. */ +export const IAP_ENTITY_NAMES: readonly IapEntityName[] = IAP_ENTITY_SCHEMAS.map( + (entity) => entity.name +); + +/** + * What the app owner has to do in App Store Connect and Base44, in order. + * + * Written for a person to follow, and for the app-generating model to repeat + * back. None of it can be done from inside the SDK. + */ +export const IAP_SETUP_CHECKLIST: readonly string[] = [ + "Create the four entities listed in IAP_ENTITY_SCHEMAS, with exactly those names.", + "In App Store Connect, open App Information and copy the numeric Apple ID. " + + "That is `appAppleId` — it is not the bundle id.", + "In App Store Connect, under App Store Server Notifications, set BOTH the " + + "Production and Sandbox URLs to your notification function, using version 2. " + + "If only Production is set, sandbox notifications go there too; if only Sandbox " + + "is set, production sends nothing at all.", + "List every product you sell in the `products` configuration, with its type. " + + "A non-renewing subscription also needs `nonRenewingDurationDays`, because Apple " + + "never expires those.", + "Optionally, create an In-App Purchase key in App Store Connect under Users and " + + "Access, Integrations, and store it in Base44 secrets. It is only needed to answer " + + "refund-consumption requests and to send test notifications.", + "Send a test notification to confirm the webhook is reachable before going live.", +]; diff --git a/src/iap/store/store-errors.ts b/src/iap/store/store-errors.ts new file mode 100644 index 00000000..411ab5a9 --- /dev/null +++ b/src/iap/store/store-errors.ts @@ -0,0 +1,152 @@ +/** + * Turning a Base44 error into a decision. + * + * The important one is `duplicate_key`, and it is classified on **positive + * evidence only**. Reading a transient server error as "the row already + * exists" is the most expensive mistake available in this layer: the webhook + * would answer Apple 200 with nothing stored, and Apple never retries a 200. + * So anything ambiguous is `transient`, which produces a retry rather than a + * silent loss. + * + * @internal + */ +import { IapStoreError } from "../errors.js"; +import type { IapEntityName } from "./schemas.js"; + +/** Why a store call failed. */ +export type IapStoreFailureKind = + /** Positive evidence that the natural key is taken. Control flow, not a failure. */ + | "duplicate_key" + /** A filter returned 404, so the entity does not exist in this app. */ + | "entity_missing" + /** The credentials cannot do this. */ + | "permission" + /** A server error, a rate limit, a timeout, or no response at all. Retrying may work. */ + | "transient" + /** The row was rejected as too large. The raw-token premise is in trouble. */ + | "row_too_large" + /** The request was malformed. A bug in this SDK. */ + | "invalid" + /** Natural-id mode is on but the backend ignored the supplied id. */ + | "mode_mismatch" + /** Anything unrecognised. Treated as transient. */ + | "unknown"; + +/** A classified store failure. */ +export interface ClassifiedStoreFailure { + readonly kind: IapStoreFailureKind; + /** Whether retrying could plausibly succeed. */ + readonly retryable: boolean; + /** The HTTP status, when there was a response. */ + readonly status?: number; +} + +interface ErrorLike { + status?: unknown; + code?: unknown; + message?: unknown; + data?: unknown; +} + +function textOf(error: ErrorLike): string { + const parts = [ + typeof error.code === "string" ? error.code : "", + typeof error.message === "string" ? error.message : "", + ]; + const data = error.data; + if (data && typeof data === "object") { + const bag = data as { code?: unknown; message?: unknown; detail?: unknown }; + for (const value of [bag.code, bag.message, bag.detail]) { + if (typeof value === "string") parts.push(value); + } + } + return parts.join(" ").toLowerCase(); +} + +/** Duplicate-key wording seen from Base44 and the databases behind it. */ +const DUPLICATE_SIGNATURES = [ + "duplicate key", + "duplicate_key", + "already exists", + "already_exists", + "unique constraint", + "e11000", +]; + +/** + * Whether this error is positive evidence that the natural key is taken. + * + * A 409 counts. A recognised duplicate phrase counts. Nothing else does — + * including a bare 400, which a real duplicate might produce but so does a + * malformed request. + */ +export function isDuplicateKeyError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as ErrorLike; + if (candidate.status === 409) return true; + const text = textOf(candidate); + return DUPLICATE_SIGNATURES.some((signature) => text.includes(signature)); +} + +/** Classifies a failure from the entities API. */ +export function classifyStoreError(error: unknown): ClassifiedStoreFailure { + if (!error || typeof error !== "object") { + return { kind: "unknown", retryable: true }; + } + const candidate = error as ErrorLike; + const status = typeof candidate.status === "number" ? candidate.status : undefined; + + if (isDuplicateKeyError(error)) { + return { kind: "duplicate_key", retryable: false, status }; + } + + // A network failure or a timeout leaves `status` undefined, despite the SDK + // typing it as a number. Treating that as anything but retryable would drop + // a notification whenever the network hiccupped. + if (status === undefined) { + return { kind: "transient", retryable: true }; + } + if (status === 404) { + // The store only ever reads by filter, never by record id, so a 404 can + // only mean the entity itself is not there. + return { kind: "entity_missing", retryable: false, status }; + } + if (status === 401 || status === 403) { + return { kind: "permission", retryable: false, status }; + } + if (status === 413) { + return { kind: "row_too_large", retryable: false, status }; + } + if (status === 429 || status >= 500) { + return { kind: "transient", retryable: true, status }; + } + if (status >= 400) { + const text = textOf(candidate); + if (text.includes("too large") || text.includes("payload size")) { + return { kind: "row_too_large", retryable: false, status }; + } + return { kind: "invalid", retryable: false, status }; + } + return { kind: "unknown", retryable: true, status }; +} + +/** Wraps a classified failure as the error the ingestion layer catches. */ +export function toStoreError( + entityName: IapEntityName, + operation: string, + key: string | undefined, + error: unknown +): IapStoreError { + const classified = classifyStoreError(error); + const where = key ? `${entityName}[${key}]` : entityName; + const store = new IapStoreError( + operation === "read" ? "IAP_READ_FAILED" : "IAP_WRITE_FAILED", + `${operation} on ${where} failed (${classified.kind})`, + { entityName, cause: error } + ); + // Attached rather than constructor arguments so IapStoreError stays a small + // public shape while the ingestion layer can still branch on the detail. + (store as { kind?: IapStoreFailureKind }).kind = classified.kind; + (store as { retryable?: boolean }).retryable = classified.retryable; + return store; +} diff --git a/src/iap/store/store.types.ts b/src/iap/store/store.types.ts new file mode 100644 index 00000000..d28765a5 --- /dev/null +++ b/src/iap/store/store.types.ts @@ -0,0 +1,227 @@ +/** + * The storage contract, and everything the layer needs to know per entity. + * + * This interface exists because Base44 entities offer no upsert, no unique + * constraint, no compare-and-swap and no transactions, while purchase data + * needs all four behaviours. Every workaround lives behind these methods, so + * the ingestion and read layers never see an entity quirk — and the day the + * platform grows a real upsert, one file changes. + * + * @internal + */ +import type { IapEntityName } from "./schemas.js"; + +/** Everything the store needs to know about one entity. */ +export interface IapEntityDescriptor { + /** The entity name in Base44. */ + readonly name: IapEntityName; + /** The column holding the natural key, e.g. `transactionId`. */ + readonly keyField: keyof T & string; + /** + * Monotone ordering columns, by facet name. + * + * A facet is one independently-arriving piece of a row. A subscription has + * two — its transaction and its renewal information — because they come from + * different places at different times and each must only move forwards. + */ + readonly cursors: Readonly>; + /** Whether the common case is a new row or an update to an existing one. */ + readonly expect: "insert" | "update"; + /** Columns a merge must never touch: insert-only, or owned by this SDK. */ + readonly mergeExcluded: readonly (keyof T & string)[]; + /** Columns where the oldest duplicate wins when collapsing, e.g. `recordedAt`. */ + readonly oldestWins: readonly (keyof T & string)[]; + /** Large columns left out of a default projection, e.g. `rawJws`. */ + readonly heavyFields: readonly (keyof T & string)[]; +} + +/** + * A merge. + * + * `set` must already have nullish values stripped. Omitting a field means + * "keep whatever is stored", which is what lets a bare device transaction + * update a subscription row without erasing the renewal information a + * notification put there. Clearing a field is therefore explicit, via `clear`. + */ +export interface IapPatch { + /** Fields to write. Never contains `null` or `undefined`. */ + readonly set: Partial; + /** Fields to blank deliberately. Used for a refund reversal. */ + readonly clear?: readonly (keyof T & string)[]; + /** Numeric fields to increment. */ + readonly increment?: Readonly>; +} + +/** A condition the server evaluates before a write lands. */ +export interface IapWriteGuard { + /** Every listed column must equal the given value. `null` matches stored null and missing. */ + readonly equals?: { readonly [K in keyof T]?: T[K] | null }; + /** + * The row's cursor must be strictly older than this value, or absent. + * + * This is the newest-wins rule, evaluated server-side in a single call, so + * two payloads racing for one row cannot lose each other. + */ + readonly cursorBelow?: { readonly facet: string; readonly value: number }; +} + +/** What an insert-or-merge did. */ +export type IapUpsertOutcome = + /** A new row was created. */ + | "inserted" + /** An existing row was updated. */ + | "applied" + /** The row already held newer data, so nothing was written. */ + | "stale"; + +/** What a guarded patch did. */ +export type IapPatchOutcome = "applied" | "stale" | "absent"; + +/** Shared result fields. */ +export interface IapWriteResult { + /** + * How many rows the server reported changing. + * + * More than one means duplicate rows exist for this key, which is a repair + * signal rather than an error — reads collapse them either way. + */ + readonly matched: number; + /** Round trips this call cost. Feeds the webhook latency budget. */ + readonly roundTrips: number; +} + +/** The result of an insert-or-merge. */ +export interface IapUpsertResult extends IapWriteResult { + readonly outcome: IapUpsertOutcome; + /** The created row, when one was created. */ + readonly inserted?: T; +} + +/** The result of a guarded patch. */ +export interface IapPatchResult extends IapWriteResult { + readonly outcome: IapPatchOutcome; +} + +/** How to page through a query. */ +export interface IapPageOptions { + /** + * Sort column, single field only. + * + * Must be an immutable column. Sorting on something a concurrent write can + * change makes a row shift between pages, so it is either seen twice or + * missed. + */ + readonly sort?: `-${keyof T & string}` | (keyof T & string); + /** + * Total cap on rows returned. Defaults to 1000, hard maximum 5000. + * + * Never pass zero. The SDK's entity layer drops a falsy limit, and the + * server then applies its own default of 50 — which in this domain means + * quietly hiding an old purchase and denying someone what they paid for. + */ + readonly limit?: number; + /** Rows per request. Defaults to 500. */ + readonly pageSize?: number; + /** Columns to fetch. Defaults to everything except the descriptor's heavy fields. */ + readonly fields?: readonly (keyof T & string)[]; +} + +/** A page of rows, with duplicates already collapsed. */ +export interface IapPageResult { + /** The rows, one per natural key. */ + readonly rows: T[]; + /** Whether the cap was reached, so the filter needs narrowing. */ + readonly truncated: boolean; + /** How many duplicate rows were folded away. A repair signal. */ + readonly duplicatesCollapsed: number; + /** Round trips this query cost. */ + readonly roundTrips: number; +} + +/** How the store addresses rows. */ +export type IapStoreMode = + /** + * Find the row by its natural key column, then create or guard-update it. + * + * The default, and the safe one. It costs one extra round trip per insert + * but behaves correctly whether or not the backend honours a + * caller-supplied record id. + */ + | "query-guard" + /** + * Use the natural key as the record id, so a duplicate insert collides. + * + * One round trip cheaper, and it closes the duplicate-insert race outright — + * but only correct if the backend actually honours the id. Opt in after + * confirming that, never by default: on a backend that ignores the id, every + * insert looks new, duplicate detection never fires, and a consumable gets + * granted twice on every re-delivery. Silently. + */ + | "natural-id"; + +/** What the store can do. */ +export interface IapStore { + /** + * Creates a row only if its natural key is free. Never overwrites. + * + * This is the de-duplication claim: the caller learns whether it or someone + * else got there first. + */ + insertIfAbsent( + descriptor: IapEntityDescriptor, + key: string, + row: T + ): Promise>; + + /** + * Applies a merge, server-side, only to rows matching the guard. + * + * The only way a payload-derived column is ever written. A plain update by + * id is banned here: it is an unconditional overwrite, so two payloads + * racing for one row would lose the newer one. + */ + patchWhere( + descriptor: IapEntityDescriptor, + key: string, + guard: IapWriteGuard, + patch: IapPatch + ): Promise; + + /** Creates the row, or merges into it if the incoming payload is newer. */ + upsertNewestWins( + descriptor: IapEntityDescriptor, + key: string, + cursor: { readonly facet: string; readonly value: number }, + row: T, + patch: IapPatch + ): Promise>; + + /** + * One row by natural key, with duplicates collapsed. `null` when absent. + * + * Always a filter, never a fetch by record id, so a 404 unambiguously means + * "this entity does not exist in the app" rather than "no such row". + */ + getByKey( + descriptor: IapEntityDescriptor, + key: string, + fields?: readonly (keyof T & string)[] + ): Promise; + + /** Several rows by natural key, for planning a batch of writes. */ + getByKeys( + descriptor: IapEntityDescriptor, + keys: readonly string[], + fields?: readonly (keyof T & string)[] + ): Promise>; + + /** A filtered, paged, duplicate-collapsed query. */ + query( + descriptor: IapEntityDescriptor, + filter: Readonly>, + options?: IapPageOptions + ): Promise>; + + /** Which of the four required entities the app is missing. */ + healthcheck(): Promise<{ ok: boolean; missing: IapEntityName[] }>; +} diff --git a/src/iap/verify/apple-roots.ts b/src/iap/verify/apple-roots.ts new file mode 100644 index 00000000..117264d4 --- /dev/null +++ b/src/iap/verify/apple-roots.ts @@ -0,0 +1,146 @@ +/** + * Apple's root certificate authorities, pinned. + * + * Verification ends by requiring that the third certificate in a token's `x5c` + * header is **byte-identical** to one of these. That is stricter than checking + * a signature or a subject name: a chain is only trusted if it terminates at a + * root we shipped. + * + * Downloaded from https://www.apple.com/certificateauthority/ and embedded as + * base64 string constants rather than binary assets, because the build is a + * bare `tsc` that mirrors `src/` into `dist/` and copies no other file types — + * a `.cer` or `.json` here would simply not ship. + * + * Refresh these with the SDK if Apple ever rotates a root. All three expire in + * 2035 or later. + * + * @internal + */ +import { base64ToBytes } from "../runtime/base64.js"; + +/** + * Apple Root CA - G3 (ECDSA P-384). + * + * SHA-256 of the DER: 63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179 + */ +const APPLE_ROOT_CA_G3 = + "MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwSQXBwbGUg" + + "Um9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTET" + + "MBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMTQwNDMwMTgxOTA2WhcNMzkw" + + "NDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzMxJjAkBgNVBAsMHUFw" + + "cGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYD" + + "VQQGEwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHc" + + "FBbZDuWmBSp3ZHtfTjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dv" + + "MVztK517IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr" + + "MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMQCD" + + "6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4at+qIxUCMG1mihDK" + + "1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM6BgD56KyKA=="; + +/** + * Apple Root CA - G2 (RSA 4096). + * + * SHA-256 of the DER: c2b9b042dd57830e7d117dac55ac8ae19407d38e41d88f3215bc3a890444a050 + */ +const APPLE_ROOT_CA_G2 = + "MIIFkjCCA3qgAwIBAgIIAeDltYNno+AwDQYJKoZIhvcNAQEMBQAwZzEbMBkGA1UEAwwSQXBw" + + "bGUgUm9vdCBDQSAtIEcyMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0" + + "eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMTQwNDMwMTgxMDA5WhcN" + + "MzkwNDMwMTgxMDA5WjBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzIxJjAkBgNVBAsM" + + "HUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQsw" + + "CQYDVQQGEwJVUzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANgREkhI2imKScUc" + + "x+xuM23+TfvgHN6sXuI2pyT5f1BrTM65MFQn5bPW7SXmMLYFN14UIhHF6Kob0vuy0gmVOKTv" + + "KkmMXT5xZgM4+xb1hYjkWpIMBDLyyED7Ul+f9sDx47pFoFDVEovy3d6RhiPw9bZyLgHaC/Yu" + + "OQhfGaFjQQscp5TBhsRTL3b2CtcM0YM/GlMZ81fVJ3/8E7j4ko380yhDPLVoACVdJ2LT3VXd" + + "RCCQgzWTxb+4Gftr49wIQuavbfqeQMpOhYV4SbHXw8EwOTKrfl+q04tvny0aIWhwZ7Oj8ZhB" + + "bZF8+NfbqOdfIRqMM78xdLe40fTgIvS/cjTf94FNcX1RoeKz8NMoFnNvzcytN31O661A4T+B" + + "/fc9Cj6i8b0xlilZ3MIZgIxbdMYs0xBTJh0UT8TUgWY8h2czJxQI6bR3hDRSj4n4aJgXv8O7" + + "qhOTH11UL6jHfPsNFL4VPSQ08prcdUFmIrQB1guvkJ4M6mL4m1k8COKWNORj3rw31OsMiAND" + + "C1CvoDTdUE0V+1ok2Az6DGOeHwOx4e7hqkP0ZmUoNwIx7wHHHtHMn23KVDpA287PT0aLSmWa" + + "asZobNfMmRtHsHLDd4/E92GcdB/O/WuhwpyUgquUoue9G7q5cDmVF8Up8zlYNPXEpMZ7YLlm" + + "Q1A/bmH8DvmGqmAMQ0uVAgMBAAGjQjBAMB0GA1UdDgQWBBTEmRNsGAPCe8CjoA1/coB6HHcm" + + "jTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQwFAAOCAgEA" + + "Uabz4vS4PZO/Lc4Pu1vhVRROTtHlznldgX/+tvCHM/jvlOV+3Gp5pxy+8JS3ptEwnMgNCnWe" + + "fZKVfhidfsJxaXwU6s+DDuQUQp50DhDNqxq6EWGBeNjxtUVAeKuowM77fWM3aPbn+6/Gw0vs" + + "HzYmE1SGlHKy6gLti23kDKaQwFd1z4xCfVzmMX3zybKSaUYOiPjjLUKyOKimGY3xn83uamW8" + + "GrAlvacp/fQ+onVJv57byfenHmOZ4VxG/5IFjPoeIPmGlFYl5bRXOJ3riGQUIUkhOb9iZqmx" + + "ospvPyFgxYnURTbImHy99v6ZSYA7LNKmp4gDBDEZt7Y6YUX6yfIjyGNzv1aJMbDZfGKnexWo" + + "iIqrOEDCzBL/FePwN983csvMmOa/orz6JopxVtfnJBtIRD6e/J/JzBrsQzwBvDR4yGn1xuZW" + + "7AYJNpDrFEobXsmII9oDMJELuDY++ee1KG++P+w8j2Ud5cAeh6Squpj9kuNsJnfdBrRkBof0" + + "Tta6SqoWqPQFZ2aWuuJVecMsXUmPgEkrihLHdoBR37q9ZV0+N0djMenl9MU/S60EinpxLK8J" + + "QzcPqOMyT/RFtm2XNuyE9QoB6he7hY1Ck3DDUOUUi78/w0EP3SIEIwiKum1xRKtzCTrJ+VKA" + + "Cd+66eYWyi4uTLLT3OUEVLLUNIAytbwPF+E="; + +/** + * Apple Inc. Root (RSA 2048). + * + * SHA-256 of the DER: b0b1730ecbc7ff4505142c49f1295e6eda6bcaed7e2c68c5be91b5a11001f024 + */ +const APPLE_INC_ROOT = + "MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UE" + + "ChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx" + + "FjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0MDM2WhcNMzUwMjA5MjE0MDM2" + + "WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUg" + + "Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0G" + + "CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne" + + "+Uts9QerIjAC6Bg++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4U" + + "adHJGXL1XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9w" + + "tj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IWq6NxkkdT" + + "VcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKMaLOPHd5lc/9nXmW8" + + "Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUw" + + "AwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2" + + "Cf70a40uQKb3R01/CF4wggERBgNVHSAEggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggr" + + "BgEFBQcCARYeaHR0cHM6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCB" + + "thqBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMg" + + "YWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBj" + + "b25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9u" + + "IHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBcNplMLXi37Yyb3PN3" + + "m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQPy3lPNNiiPvl4/2vIB+x9OYOL" + + "UyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7R6PVbyTi69G3cN8PReEnyvFteO3ntRcX" + + "qNx+IjXKJdXZD9Zr1KIkIxH3oayPc4FgxhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uuj" + + "L/lTaltkwGMzd/c6ByxW69oPIQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIq" + + "xw8dtk2cXmPIS4AXUKqK1drk/NAJBzewdXUh"; + +/** One pinned root: its DER bytes and whether v1 can verify signatures made by it. */ +export interface AppleRoot { + /** Human-readable name, used in error messages. */ + readonly name: string; + /** The certificate's exact DER encoding. */ + readonly der: Uint8Array; + /** + * Whether this root's key is one v1 can verify against. + * + * Current App Store tokens chain to Apple Root CA - G3, which is ECDSA + * P-384. The two RSA roots are pinned so the byte-match is complete, but v1 + * implements ECDSA only. A chain anchoring at an RSA root is **rejected**, + * never waved through — see `UNSUPPORTED_CERT_ALGORITHM`. + */ + readonly supported: boolean; +} + +// Decoded once, lazily. Module load stays side-effect free so importing this +// file can never throw, whatever the runtime. +let cache: readonly AppleRoot[] | undefined; + +/** The pinned Apple roots, most likely first. */ +export function appleRoots(): readonly AppleRoot[] { + if (!cache) { + cache = [ + { + name: "Apple Root CA - G3", + der: base64ToBytes(APPLE_ROOT_CA_G3), + supported: true, + }, + { + name: "Apple Root CA - G2", + der: base64ToBytes(APPLE_ROOT_CA_G2), + supported: false, + }, + { + name: "Apple Inc. Root", + der: base64ToBytes(APPLE_INC_ROOT), + supported: false, + }, + ]; + } + return cache; +} diff --git a/src/iap/verify/asn1.ts b/src/iap/verify/asn1.ts new file mode 100644 index 00000000..cd4746a4 --- /dev/null +++ b/src/iap/verify/asn1.ts @@ -0,0 +1,307 @@ +/** + * A minimal DER reader, enough to walk an X.509 certificate. + * + * Scope on purpose: this reads the handful of fields certificate-chain + * validation needs and nothing else. It is a *reader* only — nothing here + * encodes. It is strict where laxness would be a security hole: + * + * - Definite-length form only. Indefinite length (BER, `0x80`) is rejected. + * - A length that runs past the end of the buffer is rejected, never clamped. + * - Every accessor is bounds-checked and throws rather than returning a + * partial value. + * + * The one thing callers must be able to get at is the *raw* bytes of a node + * including its header, because a certificate signature covers the encoded + * `tbsCertificate`, not a re-encoding of its contents. + * + * @internal + */ + +/** DER tag numbers this module recognises. */ +export const Tag = { + INTEGER: 0x02, + BIT_STRING: 0x03, + OCTET_STRING: 0x04, + NULL: 0x05, + OBJECT_IDENTIFIER: 0x06, + UTF8_STRING: 0x0c, + SEQUENCE: 0x30, + SET: 0x31, + UTC_TIME: 0x17, + GENERALIZED_TIME: 0x18, + BOOLEAN: 0x01, +} as const; + +/** Thrown for input that is not well-formed DER. */ +export class DerError extends Error { + constructor(message: string) { + super(message); + this.name = "DerError"; + } +} + +/** One parsed tag-length-value triple, described by offsets into the source buffer. */ +export interface DerNode { + /** The tag byte. */ + readonly tag: number; + /** Offset of the tag byte. */ + readonly start: number; + /** Offset of the first content byte. */ + readonly contentStart: number; + /** Content length in bytes. */ + readonly length: number; + /** Offset one past the final content byte, i.e. the start of the next node. */ + readonly end: number; +} + +/** Reads one node at `offset`. */ +export function readNode(buf: Uint8Array, offset: number): DerNode { + if (offset >= buf.length) { + throw new DerError(`truncated: no tag byte at offset ${offset}`); + } + + const tag = buf[offset]; + let cursor = offset + 1; + + if (cursor >= buf.length) { + throw new DerError(`truncated: no length byte at offset ${cursor}`); + } + + const first = buf[cursor]; + cursor += 1; + let length: number; + + if (first < 0x80) { + // Short form: the byte is the length. + length = first; + } else if (first === 0x80) { + throw new DerError( + "indefinite length is BER, not DER, and is not accepted here" + ); + } else { + const byteCount = first & 0x7f; + // 4 bytes is 4 GiB; anything longer is either hostile or a parse desync. + // Stopping here also keeps the arithmetic below inside a safe integer. + if (byteCount > 4) { + throw new DerError(`length field of ${byteCount} bytes is unreasonable`); + } + if (cursor + byteCount > buf.length) { + throw new DerError("truncated: length field runs past the end"); + } + length = 0; + for (let i = 0; i < byteCount; i += 1) { + length = length * 256 + buf[cursor + i]; + } + cursor += byteCount; + } + + const end = cursor + length; + if (end > buf.length) { + throw new DerError( + `truncated: node at ${offset} claims ${length} content bytes but only ` + + `${buf.length - cursor} remain` + ); + } + + return { tag, start: offset, contentStart: cursor, length, end }; +} + +/** Reads one node at `offset` and asserts its tag. */ +export function readNodeOfTag( + buf: Uint8Array, + offset: number, + tag: number, + what: string +): DerNode { + const node = readNode(buf, offset); + if (node.tag !== tag) { + throw new DerError( + `expected ${what} (tag 0x${tag.toString(16)}) at offset ${offset}, ` + + `found tag 0x${node.tag.toString(16)}` + ); + } + return node; +} + +/** The node's content bytes, without its header. A view, not a copy. */ +export function content(buf: Uint8Array, node: DerNode): Uint8Array { + return buf.subarray(node.contentStart, node.end); +} + +/** + * The node's complete encoding, header included. + * + * This is what a signature covers. Re-encoding the parsed contents instead + * would be a bug: DER has canonical forms, but a signer's bytes are the only + * bytes that verify. + */ +export function raw(buf: Uint8Array, node: DerNode): Uint8Array { + return buf.subarray(node.start, node.end); +} + +/** Every immediate child of a constructed node, in order. */ +export function children(buf: Uint8Array, node: DerNode): DerNode[] { + const out: DerNode[] = []; + let cursor = node.contentStart; + while (cursor < node.end) { + const child = readNode(buf, cursor); + out.push(child); + if (child.end <= cursor) { + throw new DerError("zero-length advance while walking children"); + } + cursor = child.end; + } + return out; +} + +/** The child at `index`, or `undefined` when the node has fewer children. */ +export function childAt( + buf: Uint8Array, + node: DerNode, + index: number +): DerNode | undefined { + return children(buf, node)[index]; +} + +/** + * Decodes an OBJECT IDENTIFIER to dotted-decimal form. + * + * The first byte packs two arcs: `40 * first + second`, with the first arc + * capped at 2. Every later arc is base-128 with the high bit as a + * continuation flag. + */ +export function readOid(buf: Uint8Array, node: DerNode): string { + if (node.tag !== Tag.OBJECT_IDENTIFIER) { + throw new DerError( + `expected OBJECT IDENTIFIER, found tag 0x${node.tag.toString(16)}` + ); + } + const bytes = content(buf, node); + if (bytes.length === 0) throw new DerError("empty OBJECT IDENTIFIER"); + + const first = Math.min(Math.floor(bytes[0] / 40), 2); + const second = bytes[0] - first * 40; + const arcs: number[] = [first, second]; + + let value = 0; + let started = false; + for (let i = 1; i < bytes.length; i += 1) { + const byte = bytes[i]; + // Guard against an arc wide enough to lose precision in a double. + if (value > Number.MAX_SAFE_INTEGER / 128) { + throw new DerError("OBJECT IDENTIFIER arc is too large"); + } + value = value * 128 + (byte & 0x7f); + started = true; + if ((byte & 0x80) === 0) { + arcs.push(value); + value = 0; + started = false; + } + } + if (started) { + throw new DerError("OBJECT IDENTIFIER ends mid-arc"); + } + + return arcs.join("."); +} + +/** + * Decodes a BIT STRING's payload, dropping the unused-bits count byte. + * + * Every BIT STRING this module reads — a SubjectPublicKey, a certificate + * signature — is byte-aligned, so a non-zero unused-bits count means the + * parse has gone wrong and is rejected rather than shifted. + */ +export function readBitString(buf: Uint8Array, node: DerNode): Uint8Array { + if (node.tag !== Tag.BIT_STRING) { + throw new DerError(`expected BIT STRING, found tag 0x${node.tag.toString(16)}`); + } + const bytes = content(buf, node); + if (bytes.length === 0) throw new DerError("empty BIT STRING"); + const unused = bytes[0]; + if (unused !== 0) { + throw new DerError( + `BIT STRING has ${unused} unused bits; only byte-aligned values are supported` + ); + } + return bytes.subarray(1); +} + +/** + * Decodes an INTEGER's magnitude with any DER sign padding removed. + * + * DER prepends a zero byte when the high bit would otherwise read as + * negative. ECDSA `r` and `s` are unsigned, so that byte must go before the + * value is padded to its fixed width. + */ +export function readUnsignedInteger(buf: Uint8Array, node: DerNode): Uint8Array { + if (node.tag !== Tag.INTEGER) { + throw new DerError(`expected INTEGER, found tag 0x${node.tag.toString(16)}`); + } + const bytes = content(buf, node); + if (bytes.length === 0) throw new DerError("empty INTEGER"); + if ((bytes[0] & 0x80) !== 0) { + throw new DerError("negative INTEGER where an unsigned value was expected"); + } + let offset = 0; + while (offset < bytes.length - 1 && bytes[offset] === 0) offset += 1; + return bytes.subarray(offset); +} + +const TIME_PATTERN = /^(\d{2}|\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?Z$/; + +/** + * Decodes a UTCTime or GeneralizedTime to epoch milliseconds. + * + * Only the UTC (`Z`) form is accepted. Certificates may legally carry a + * numeric offset, but Apple's do not, and accepting one would mean + * hand-rolling timezone arithmetic for no gain. + * + * Per RFC 5280, a two-digit UTCTime year of 50 or more means 19xx, and less + * than 50 means 20xx. + */ +export function readTime(buf: Uint8Array, node: DerNode): number { + if (node.tag !== Tag.UTC_TIME && node.tag !== Tag.GENERALIZED_TIME) { + throw new DerError( + `expected UTCTime or GeneralizedTime, found tag 0x${node.tag.toString(16)}` + ); + } + + const bytes = content(buf, node); + let text = ""; + for (let i = 0; i < bytes.length; i += 1) text += String.fromCharCode(bytes[i]); + + const match = TIME_PATTERN.exec(text); + if (!match) { + throw new DerError(`unsupported time format: ${JSON.stringify(text)}`); + } + + const [, rawYear, month, day, hour, minute, second] = match; + let year: number; + if (node.tag === Tag.UTC_TIME) { + if (rawYear.length !== 2) { + throw new DerError("UTCTime must carry a two-digit year"); + } + const yy = Number(rawYear); + year = yy >= 50 ? 1900 + yy : 2000 + yy; + } else { + if (rawYear.length !== 4) { + throw new DerError("GeneralizedTime must carry a four-digit year"); + } + year = Number(rawYear); + } + + const ms = Date.UTC( + year, + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + second ? Number(second) : 0 + ); + if (Number.isNaN(ms)) { + throw new DerError(`invalid time value: ${JSON.stringify(text)}`); + } + return ms; +} diff --git a/src/iap/verify/chain.ts b/src/iap/verify/chain.ts new file mode 100644 index 00000000..22e179da --- /dev/null +++ b/src/iap/verify/chain.ts @@ -0,0 +1,214 @@ +/** + * Certificate-chain validation for Apple's signed tokens. + * + * Apple's instruction (WWDC23 session 10143) is to "construct a chain of trust + * back to a known trusted source, in this case, an Apple root certificate + * authority". This implements that literally, and adds the one thing that + * makes it a pin rather than a suggestion: the root offered by the token must + * be **byte-identical** to a root shipped inside this SDK. + * + * Checks run cheapest-first, so a token from the wrong signer is rejected + * before any cryptography happens. + * + * @internal + */ +import { bytesEqual } from "../runtime/base64.js"; +import { IapVerificationError } from "../errors.js"; +import { appleRoots, type AppleRoot } from "./apple-roots.js"; +import { derSignatureToRaw, verifyRawEcdsa } from "./ecdsa.js"; +import { + isValidAt, + OID_APPLE_RECEIPT_SIGNING, + OID_APPLE_WWDR, + parseCertificate, + type Certificate, +} from "./x509.js"; + +/** How many certificates Apple puts in an `x5c` header. */ +export const APPLE_CHAIN_LENGTH = 3; + +/** Inputs to {@link verifyChain}. */ +export interface VerifyChainOptions { + /** + * The instant to evaluate certificate validity at. + * + * With offline checks — the only mode v1 supports — this is the payload's own + * `signedDate`, matching Apple's library. That choice is what lets a + * captured payload stay verifiable forever, which in turn is what makes + * stored raw tokens a usable source of truth. + */ + readonly at: number; + /** + * Trust anchors to pin against. Defaults to Apple's real roots. + * + * Overridden only by this module's own tests, which mint a throwaway chain. + * It is deliberately not reachable from `IapConfig`: an app must never be + * able to add a root. + */ + readonly roots?: readonly AppleRoot[]; +} + +/** A validated chain. */ +export interface VerifiedChain { + /** The leaf certificate, whose key signed the token itself. */ + readonly leaf: Certificate; + /** The intermediate that issued the leaf. */ + readonly intermediate: Certificate; + /** The pinned root the chain terminated at. */ + readonly root: AppleRoot; +} + +function parseOrReject(der: Uint8Array, role: string): Certificate { + try { + return parseCertificate(der); + } catch (cause) { + throw new IapVerificationError( + "INVALID_CERTIFICATE", + `the ${role} certificate could not be parsed: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + { cause } + ); + } +} + +async function requireSignedBy( + subject: Certificate, + issuer: Certificate, + subjectRole: string, + issuerRole: string +): Promise { + // A chain link is only a link if the names line up. Comparing the raw DER of + // the two Names rather than a decoded string avoids every canonicalisation + // question about character sets and attribute ordering. + if (!bytesEqual(subject.issuerRaw, issuer.subjectRaw)) { + throw new IapVerificationError( + "INVALID_CERTIFICATE", + `the ${subjectRole} certificate's issuer does not match the ${issuerRole} certificate's subject` + ); + } + + if (subject.signatureAlgorithm.kind !== "ecdsa") { + throw new IapVerificationError( + "UNSUPPORTED_CERT_ALGORITHM", + `the ${subjectRole} certificate is signed with ${subject.signatureAlgorithm.name}; ` + + "this SDK verifies ECDSA signatures only" + ); + } + if (issuer.publicKey.kind !== "ec" || !issuer.publicKey.curve) { + throw new IapVerificationError( + "UNSUPPORTED_CERT_ALGORITHM", + `the ${issuerRole} certificate's key (${issuer.publicKey.algorithmOid}) is not an ` + + "elliptic-curve key on a supported curve; this SDK verifies ECDSA signatures only" + ); + } + + let raw: Uint8Array; + try { + raw = derSignatureToRaw(subject.signature, issuer.publicKey.curve); + } catch (cause) { + throw new IapVerificationError( + "INVALID_SIGNATURE", + `the ${subjectRole} certificate's signature is not a well-formed ECDSA value`, + { cause } + ); + } + + // The digest comes from the subject certificate's own signatureAlgorithm. + // Deriving it from the issuer key's curve instead is the mistake that breaks + // Apple's chain specifically, because a P-384 root signs a P-256 intermediate. + const ok = await verifyRawEcdsa( + issuer.publicKey.spki, + issuer.publicKey.curve, + subject.signatureAlgorithm.hash, + raw, + subject.tbs + ); + if (!ok) { + throw new IapVerificationError( + "INVALID_SIGNATURE", + `the ${subjectRole} certificate is not signed by the ${issuerRole} certificate` + ); + } +} + +/** + * Validates a three-certificate Apple chain and returns the leaf. + * + * @param x5c - The certificates from the JWS header, leaf first. + * @throws {IapVerificationError} on any failure. There is no partial success. + */ +export async function verifyChain( + x5c: readonly Uint8Array[], + options: VerifyChainOptions +): Promise { + if (x5c.length !== APPLE_CHAIN_LENGTH) { + throw new IapVerificationError( + "INVALID_CHAIN_LENGTH", + `expected ${APPLE_CHAIN_LENGTH} certificates in the x5c header, found ${x5c.length}` + ); + } + + const roots = options.roots ?? appleRoots(); + + // 1. The pin, first: a byte comparison, before any parsing or cryptography. + // A token signed by a perfectly valid non-Apple chain dies here. + const offeredRoot = x5c[2]; + const root = roots.find((candidate) => bytesEqual(candidate.der, offeredRoot)); + if (!root) { + throw new IapVerificationError( + "INVALID_CERTIFICATE", + "the certificate chain does not terminate at a pinned Apple root" + ); + } + if (!root.supported) { + throw new IapVerificationError( + "UNSUPPORTED_CERT_ALGORITHM", + `the chain terminates at ${root.name}, whose key this SDK cannot verify against ` + + "(v1 supports ECDSA roots only)" + ); + } + + const leaf = parseOrReject(x5c[0], "leaf"); + const intermediate = parseOrReject(x5c[1], "intermediate"); + const rootCertificate = parseOrReject(root.der, "root"); + + // 2. Apple's markers. Also cheap, and they say "this is a receipt-signing + // chain" rather than merely "this is an Apple chain" — an Apple-issued + // certificate for some other purpose must not sign purchase data. + if (!intermediate.extensionOids.has(OID_APPLE_WWDR)) { + throw new IapVerificationError( + "INVALID_CERTIFICATE", + `the intermediate certificate lacks the Apple Worldwide Developer Relations ` + + `extension (${OID_APPLE_WWDR})` + ); + } + if (!leaf.extensionOids.has(OID_APPLE_RECEIPT_SIGNING)) { + throw new IapVerificationError( + "INVALID_CERTIFICATE", + `the leaf certificate lacks the receipt-signing marker (${OID_APPLE_RECEIPT_SIGNING})` + ); + } + + // 3. Validity windows, evaluated at the instant the caller chose. + for (const [certificate, role] of [ + [leaf, "leaf"], + [intermediate, "intermediate"], + [rootCertificate, "root"], + ] as const) { + if (!isValidAt(certificate, options.at)) { + throw new IapVerificationError( + "INVALID_CERTIFICATE", + `the ${role} certificate was not valid at ${new Date(options.at).toISOString()} ` + + `(valid ${new Date(certificate.notBefore).toISOString()} to ` + + `${new Date(certificate.notAfter).toISOString()})` + ); + } + } + + // 4. Finally the cryptography, walking up to the pinned root. + await requireSignedBy(intermediate, rootCertificate, "intermediate", "root"); + await requireSignedBy(leaf, intermediate, "leaf", "intermediate"); + + return { leaf, intermediate, root }; +} diff --git a/src/iap/verify/ecdsa.ts b/src/iap/verify/ecdsa.ts new file mode 100644 index 00000000..f9f12508 --- /dev/null +++ b/src/iap/verify/ecdsa.ts @@ -0,0 +1,114 @@ +/** + * ECDSA verification over WebCrypto. + * + * The whole reason this file exists is an encoding mismatch. ECDSA signatures + * appear in two shapes and both turn up in one Apple token: + * + * - Inside a **certificate**, the signature is a DER `SEQUENCE { r, s }`. + * - Inside a **JWS**, it is raw fixed-width `r ‖ s`. + * + * WebCrypto only accepts the second. So a certificate signature must be + * converted, and a JWS signature must not be. Getting this backwards fails + * every verification, which is the good failure; the bad one is padding `r` + * and `s` to the wrong width, which fails only for the fraction of signatures + * whose leading byte happens to be zero. + * + * @internal + */ +import { children, DerError, readNodeOfTag, readUnsignedInteger, Tag } from "./asn1.js"; +import { getSubtle } from "../runtime/webcrypto.js"; + +/** Byte width of one ECDSA scalar for each curve WebCrypto supports. */ +const SCALAR_BYTES: Record<"P-256" | "P-384" | "P-521", number> = { + "P-256": 32, + "P-384": 48, + "P-521": 66, +}; + +/** + * Converts a DER `ECDSA-Sig-Value` into the raw `r ‖ s` form WebCrypto wants. + * + * Each scalar is left-padded to the curve's fixed width. DER stores integers + * with leading zeros stripped and a sign byte added where needed, so the two + * halves are almost never already the right length. + */ +export function derSignatureToRaw( + derSignature: Uint8Array, + curve: "P-256" | "P-384" | "P-521" +): Uint8Array { + const width = SCALAR_BYTES[curve]; + const sequence = readNodeOfTag(derSignature, 0, Tag.SEQUENCE, "ECDSA-Sig-Value"); + const parts = children(derSignature, sequence); + if (parts.length !== 2) { + throw new DerError( + `ECDSA-Sig-Value must hold exactly r and s; found ${parts.length} integers` + ); + } + + const out = new Uint8Array(width * 2); + for (let i = 0; i < 2; i += 1) { + const scalar = readUnsignedInteger(derSignature, parts[i]); + if (scalar.length > width) { + throw new DerError( + `ECDSA scalar is ${scalar.length} bytes, wider than ${curve}'s ${width}` + ); + } + out.set(scalar, width * (i + 1) - scalar.length); + } + return out; +} + +/** + * Verifies a raw `r ‖ s` ECDSA signature over `data`. + * + * @param spki - The signer's `SubjectPublicKeyInfo` in DER. + * @param curve - The signer key's named curve. + * @param hash - The digest named by the **signature algorithm**, not inferred from the curve. + */ +export async function verifyRawEcdsa( + spki: Uint8Array, + curve: "P-256" | "P-384" | "P-521", + hash: string, + signature: Uint8Array, + data: Uint8Array +): Promise { + const subtle = getSubtle(); + let key: CryptoKey; + try { + key = await subtle.importKey( + "spki", + toArrayBuffer(spki), + { name: "ECDSA", namedCurve: curve }, + false, + ["verify"] + ); + } catch { + // A key this runtime will not import is not a key we can trust anything to. + return false; + } + + try { + return await subtle.verify( + { name: "ECDSA", hash: { name: hash } }, + key, + toArrayBuffer(signature), + toArrayBuffer(data) + ); + } catch { + return false; + } +} + +/** + * Copies a view into a standalone `ArrayBuffer`. + * + * Every byte array in this module is a window into a larger buffer. Passing + * one straight to WebCrypto is a correctness hazard: a `Uint8Array` with a + * non-zero `byteOffset` is read from the wrong place by some implementations, + * which produces a verification *failure* rather than an error. + */ +function toArrayBuffer(view: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(view.length); + copy.set(view); + return copy.buffer; +} diff --git a/src/iap/verify/jws.ts b/src/iap/verify/jws.ts new file mode 100644 index 00000000..afac122a --- /dev/null +++ b/src/iap/verify/jws.ts @@ -0,0 +1,268 @@ +/** + * Compact JWS parsing and signature verification for Apple's signed tokens. + * + * A token is three base64url segments joined by dots. The header names the + * algorithm and carries the certificate chain; the payload is JSON; the + * signature covers the ASCII bytes of `header.payload` — the encoded text, not + * a re-serialisation of the parsed JSON. + * + * @internal + */ +import { + base64ToBytes, + base64UrlToBytes, + bytesToBase64Url, +} from "../runtime/base64.js"; +import { utf8 } from "../runtime/webcrypto.js"; +import { IapVerificationError } from "../errors.js"; +import { verifyChain, APPLE_CHAIN_LENGTH, type VerifyChainOptions } from "./chain.js"; +import { verifyRawEcdsa } from "./ecdsa.js"; + +/** + * Size ceilings, so a hostile token is rejected before it is parsed. + * + * Real values sit orders of magnitude below these: an Apple transaction token + * is a couple of kilobytes and a certificate is around 1.5 KB. The point is + * only to stop an attacker handing a public webhook a megabyte of base64 to + * decode and walk. + */ +const MAX_TOKEN_BYTES = 128 * 1024; +const MAX_HEADER_BYTES = 16 * 1024; +const MAX_CERTIFICATE_BYTES = 8 * 1024; + +/** The one algorithm Apple's tokens use, and the only one accepted. */ +const REQUIRED_ALG = "ES256"; + +/** The decoded JWS header. */ +export interface JwsHeader { + readonly alg: string; + readonly x5c: readonly string[]; +} + +/** A parsed but not yet verified token. */ +export interface ParsedJws { + readonly header: JwsHeader; + /** The certificates from `x5c`, decoded, leaf first. */ + readonly chain: readonly Uint8Array[]; + /** The payload, parsed from JSON with every field preserved. */ + readonly payload: Record; + /** ASCII bytes of `header.payload` — exactly what the signature covers. */ + readonly signingInput: Uint8Array; + /** The raw `r ‖ s` signature. */ + readonly signature: Uint8Array; +} + +function decodeJsonSegment(segment: string, what: string): Record { + let text: string; + try { + const bytes = base64UrlToBytes(segment); + // Decoding by hand rather than with TextDecoder keeps the runtime surface + // to what webcrypto.ts already guards, and these payloads are JSON, so + // every byte outside ASCII arrives inside a string literal as an escape or + // as UTF-8 that JSON.parse handles from the raw code units. + text = new TextDecoder().decode(bytes); + } catch (cause) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + `the JWS ${what} is not valid base64url`, + { cause } + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (cause) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + `the JWS ${what} is not valid JSON`, + { cause } + ); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + `the JWS ${what} is not a JSON object` + ); + } + return parsed as Record; +} + +/** + * Splits and decodes a compact JWS without verifying anything. + * + * The result is untrusted. Nothing may act on it beyond deciding *how* to + * verify it. + */ +export function parseJws(token: string): ParsedJws { + if (typeof token !== "string" || token.length === 0) { + throw new IapVerificationError("INVALID_JWS_FORMAT", "the token is empty"); + } + if (token.length > MAX_TOKEN_BYTES) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + `the token is ${token.length} bytes, over the ${MAX_TOKEN_BYTES}-byte ceiling` + ); + } + + const parts = token.split("."); + if (parts.length !== 3) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + `a compact JWS has three dot-separated segments; found ${parts.length}` + ); + } + const [headerSegment, payloadSegment, signatureSegment] = parts; + if (!headerSegment || !payloadSegment || !signatureSegment) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + "a compact JWS segment is empty" + ); + } + if (headerSegment.length > MAX_HEADER_BYTES) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + "the JWS header is implausibly large" + ); + } + + const rawHeader = decodeJsonSegment(headerSegment, "header"); + + const alg = rawHeader.alg; + if (typeof alg !== "string") { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + "the JWS header has no 'alg'" + ); + } + // Apple's notification documentation never names the algorithm; the App + // Store Server API and Apple's own library both use ES256, so that is what + // is required. Accepting anything else here would accept `alg: "none"`. + if (alg !== REQUIRED_ALG) { + throw new IapVerificationError( + "UNSUPPORTED_ALG", + `the JWS header names algorithm ${JSON.stringify(alg)}; only ${REQUIRED_ALG} is accepted` + ); + } + + const x5c = rawHeader.x5c; + if (!Array.isArray(x5c)) { + throw new IapVerificationError( + "INVALID_CHAIN_LENGTH", + "the JWS header has no 'x5c' certificate chain" + ); + } + if (x5c.length !== APPLE_CHAIN_LENGTH) { + throw new IapVerificationError( + "INVALID_CHAIN_LENGTH", + `expected ${APPLE_CHAIN_LENGTH} certificates in the x5c header, found ${x5c.length}` + ); + } + + const chain: Uint8Array[] = []; + for (let i = 0; i < x5c.length; i += 1) { + const entry = x5c[i]; + if (typeof entry !== "string") { + throw new IapVerificationError( + "INVALID_CERTIFICATE", + `x5c entry ${i} is not a string` + ); + } + // x5c entries are standard base64, not base64url, even though the segments + // around them are base64url. The shared decoder takes both. + let der: Uint8Array; + try { + der = base64ToBytes(entry); + } catch (cause) { + throw new IapVerificationError( + "INVALID_CERTIFICATE", + `x5c entry ${i} is not valid base64`, + { cause } + ); + } + if (der.length === 0 || der.length > MAX_CERTIFICATE_BYTES) { + throw new IapVerificationError( + "INVALID_CERTIFICATE", + `x5c entry ${i} is ${der.length} bytes, outside the plausible range` + ); + } + chain.push(der); + } + + let signature: Uint8Array; + try { + signature = base64UrlToBytes(signatureSegment); + } catch (cause) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + "the JWS signature is not valid base64url", + { cause } + ); + } + // ES256 is two 32-byte scalars. Unlike a certificate signature this is + // already raw, so it must NOT go through the DER converter. + if (signature.length !== 64) { + throw new IapVerificationError( + "INVALID_SIGNATURE", + `an ES256 signature is 64 bytes; found ${signature.length}` + ); + } + + return { + header: { alg, x5c: x5c as readonly string[] }, + chain, + payload: decodeJsonSegment(payloadSegment, "payload"), + signingInput: utf8(`${headerSegment}.${payloadSegment}`), + signature, + }; +} + +/** Inputs to {@link verifyJws}. */ +export interface VerifyJwsOptions { + /** The instant to evaluate certificate validity at. Normally the payload's `signedDate`. */ + readonly at: number; + /** Trust anchors. Defaults to Apple's pinned roots. @internal */ + readonly roots?: VerifyChainOptions["roots"]; +} + +/** + * Verifies a parsed token's chain and signature. + * + * @throws {IapVerificationError} on any failure. + */ +export async function verifyJws( + parsed: ParsedJws, + options: VerifyJwsOptions +): Promise { + const { leaf } = await verifyChain(parsed.chain, { + at: options.at, + roots: options.roots, + }); + + // ES256 pins the curve as well as the digest, so a leaf on any other curve + // means the header and the certificate disagree. + if (leaf.publicKey.kind !== "ec" || leaf.publicKey.curve !== "P-256") { + throw new IapVerificationError( + "UNSUPPORTED_CERT_ALGORITHM", + `the header names ES256 but the leaf certificate's key is ` + + `${leaf.publicKey.curve ?? leaf.publicKey.algorithmOid}, not P-256` + ); + } + + const ok = await verifyRawEcdsa( + leaf.publicKey.spki, + "P-256", + "SHA-256", + parsed.signature, + parsed.signingInput + ); + if (!ok) { + throw new IapVerificationError( + "INVALID_SIGNATURE", + "the token's signature does not verify against its leaf certificate" + ); + } +} + +// Exported for the test fixtures, which build tokens rather than read them. +export { bytesToBase64Url as encodeBase64Url }; diff --git a/src/iap/verify/payload-checks.ts b/src/iap/verify/payload-checks.ts new file mode 100644 index 00000000..0f0b6842 --- /dev/null +++ b/src/iap/verify/payload-checks.ts @@ -0,0 +1,117 @@ +/** + * The payload half of verification. + * + * A valid signature only proves Apple signed the token. It does not prove the + * token is for *this* app, or from an environment this app accepts. Apple's own + * instruction (WWDC23 session 10143) is explicit: "Check the appAppleId and + * bundleId to confirm the notification is targeted for your correct + * application. Check the environment matches the expected environment." + * + * @internal + */ +import { IapVerificationError } from "../errors.js"; +import type { IapEnvironment } from "./verify.types.js"; + +/** What the payload checks need from the module's configuration. */ +export interface PayloadCheckConfig { + /** The app's bundle identifier, e.g. `"com.example.app"`. */ + readonly bundleId: string; + /** The app's numeric App Store id. Required by Apple's own verifier in production. */ + readonly appAppleId: number; + /** Whether sandbox tokens are accepted. */ + readonly testMode: boolean; + /** Whether Xcode-signed tokens are accepted. */ + readonly allowLocalTesting: boolean; +} + +/** + * Normalises Apple's `environment` value. + * + * Apple's documentation spells this inconsistently across pages, so the parse + * is case-insensitive and the result is always one of the canonical three. + * Returns `undefined` for a value that is not an environment at all. + */ +export function normalizeEnvironment(value: unknown): IapEnvironment | undefined { + if (typeof value !== "string") return undefined; + switch (value.trim().toLowerCase()) { + case "sandbox": + return "Sandbox"; + case "production": + return "Production"; + case "xcode": + return "Xcode"; + default: + return undefined; + } +} + +/** + * Rejects a token whose environment this app does not accept. + * + * Production is always accepted. Sandbox needs `testMode`, and Xcode needs + * `allowLocalTesting` — so a production deployment with both flags off will + * only ever honour real purchases. + */ +export function checkEnvironment( + environment: IapEnvironment | undefined, + config: PayloadCheckConfig +): IapEnvironment { + if (!environment) { + throw new IapVerificationError( + "INVALID_ENVIRONMENT", + "the payload carries no recognisable 'environment'" + ); + } + if (environment === "Sandbox" && !config.testMode) { + throw new IapVerificationError( + "INVALID_ENVIRONMENT", + "this token is from the Sandbox environment, which this app does not accept " + + "(set testMode to accept it)" + ); + } + if (environment === "Xcode" && !config.allowLocalTesting) { + throw new IapVerificationError( + "INVALID_ENVIRONMENT", + "this token is from Xcode's local StoreKit testing, which this app does not " + + "accept (set allowLocalTesting to accept it)" + ); + } + return environment; +} + +/** + * Rejects a token issued for another app. + * + * `bundleId` is checked whenever the payload carries one. `appAppleId` is + * checked only in production, because Apple omits it from sandbox payloads + * entirely — requiring it there would reject every sandbox token. + */ +export function checkAppIdentifiers( + payload: Readonly>, + environment: IapEnvironment, + config: PayloadCheckConfig +): void { + if (payload.bundleId !== undefined && payload.bundleId !== config.bundleId) { + throw new IapVerificationError( + "INVALID_APP_IDENTIFIER", + `the token's bundleId ${JSON.stringify(payload.bundleId)} does not match ` + + `this app's ${JSON.stringify(config.bundleId)}` + ); + } + + if (environment !== "Production") return; + + if (payload.appAppleId === undefined) { + throw new IapVerificationError( + "INVALID_APP_IDENTIFIER", + "a production token must carry an appAppleId, and this one does not" + ); + } + if (Number(payload.appAppleId) !== config.appAppleId) { + throw new IapVerificationError( + "INVALID_APP_IDENTIFIER", + `the token's appAppleId ${String(payload.appAppleId)} does not match ` + + `this app's ${config.appAppleId}` + ); + } +} diff --git a/src/iap/verify/verifier.ts b/src/iap/verify/verifier.ts new file mode 100644 index 00000000..cd79a992 --- /dev/null +++ b/src/iap/verify/verifier.ts @@ -0,0 +1,205 @@ +/** + * The three verification entry points, assembled from the parts around them. + * + * Mirrors Apple's `SignedDataVerifier`: one method per kind of signed data, + * each returning the decoded payload or throwing. There is no "verified but + * with warnings" result — a token either passes every check or is rejected. + * + * @internal + */ +import { IapVerificationError } from "../errors.js"; +import { systemClock, type Clock } from "../runtime/clock.js"; +import type { AppleRoot } from "./apple-roots.js"; +import { parseJws, verifyJws, type ParsedJws } from "./jws.js"; +import { + checkAppIdentifiers, + checkEnvironment, + normalizeEnvironment, + type PayloadCheckConfig, +} from "./payload-checks.js"; +import type { + DecodedNotification, + DecodedNotificationData, + DecodedRenewalInfo, + DecodedTransaction, + IapEnvironment, +} from "./verify.types.js"; + +/** Inputs to {@link createVerifier}. */ +export interface CreateVerifierOptions { + /** The app's identity and which environments it accepts. */ + readonly config: PayloadCheckConfig; + /** + * Trust anchors, for this module's own tests only. + * + * Deliberately not reachable from `IapConfig`: an app must never be able to + * add a root certificate. + * + * @internal + */ + readonly roots?: readonly AppleRoot[]; + /** The clock, for tests. @internal */ + readonly clock?: Clock; +} + +/** The verification surface. */ +export interface Verifier { + /** Verifies and decodes a signed transaction. */ + verifyTransaction(jws: string): Promise; + /** Verifies and decodes signed renewal information. */ + verifyRenewalInfo(jws: string): Promise; + /** Verifies and decodes an App Store Server Notification, including its inner tokens. */ + verifyNotification(signedPayload: string): Promise; +} + +/** + * Whether this token may skip certificate verification. + * + * Xcode's local StoreKit testing signs tokens with Xcode's own key rather than + * Apple's, so they cannot chain to an Apple root — Apple's own library skips + * chain validation for them too. + * + * Reading the environment out of an **unverified** payload to make this + * decision is only safe because of what gates it: `allowLocalTesting` is off + * unless a developer turned it on, and with it off this function always + * returns false, so a forged `environment: "Xcode"` buys an attacker nothing. + * Never widen this to a flag that could be on in production. + */ +function mayForgoChainVerification( + environment: IapEnvironment | undefined, + config: PayloadCheckConfig +): boolean { + return environment === "Xcode" && config.allowLocalTesting; +} + +function requireSignedDate(parsed: ParsedJws, what: string): number { + const signedDate = parsed.payload.signedDate; + if (typeof signedDate !== "number" || !Number.isFinite(signedDate)) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + `the ${what} carries no numeric 'signedDate', so its certificates cannot be ` + + "evaluated at the moment Apple signed it" + ); + } + return signedDate; +} + +export function createVerifier(options: CreateVerifierOptions): Verifier { + const { config, roots } = options; + const clock = options.clock ?? systemClock; + + /** Parse, then verify unless this is a local-testing token. */ + async function parseAndVerify( + token: string, + what: string, + environmentOf: (payload: Record) => unknown + ): Promise<{ parsed: ParsedJws; environment: IapEnvironment }> { + const parsed = parseJws(token); + const environment = normalizeEnvironment(environmentOf(parsed.payload)); + + if (!mayForgoChainVerification(environment, config)) { + await verifyJws(parsed, { at: requireSignedDate(parsed, what), roots }); + } + + // Payload checks run in both cases: a local-testing token still has to be + // for this app. + return { parsed, environment: checkEnvironment(environment, config) }; + } + + async function verifyTransaction(jws: string): Promise { + const { parsed, environment } = await parseAndVerify( + jws, + "transaction", + (payload) => payload.environment + ); + checkAppIdentifiers(parsed.payload, environment, config); + return parsed.payload as DecodedTransaction; + } + + async function verifyRenewalInfo(jws: string): Promise { + // Renewal information carries no bundleId or appAppleId — Apple does not + // put them there — so there is nothing to check beyond the environment. + const { parsed } = await parseAndVerify( + jws, + "renewal info", + (payload) => payload.environment + ); + return parsed.payload as DecodedRenewalInfo; + } + + async function verifyNotification( + signedPayload: string + ): Promise { + const { parsed, environment } = await parseAndVerify( + signedPayload, + "notification", + (payload) => { + // The environment lives inside whichever block this notification type + // carries. + const data = payload.data as { environment?: unknown } | undefined; + const summary = payload.summary as { environment?: unknown } | undefined; + return data?.environment ?? summary?.environment; + } + ); + + const payload = parsed.payload; + + const notificationUUID = payload.notificationUUID; + const notificationType = payload.notificationType; + if (typeof notificationUUID !== "string" || notificationUUID.length === 0) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + "the notification carries no 'notificationUUID', so it cannot be de-duplicated" + ); + } + if (typeof notificationType !== "string" || notificationType.length === 0) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + "the notification carries no 'notificationType'" + ); + } + + const rawData = payload.data as + | (Record & { + signedTransactionInfo?: unknown; + signedRenewalInfo?: unknown; + }) + | undefined; + + let data: DecodedNotificationData | undefined; + if (rawData) { + checkAppIdentifiers(rawData, environment, config); + + // The inner tokens are separately signed, so each is verified in its own + // right rather than trusted because the envelope verified. + const { signedTransactionInfo, signedRenewalInfo, ...rest } = rawData; + data = { ...rest } as DecodedNotificationData; + + if (typeof signedTransactionInfo === "string") { + data.transactionInfo = await verifyTransaction(signedTransactionInfo); + // Kept alongside the decoded form, under a name that says it has been + // verified. Storage needs the original bytes: they are the source of + // truth every derived column can be rebuilt from. + data.transactionInfoJws = signedTransactionInfo; + } + if (typeof signedRenewalInfo === "string") { + data.renewalInfo = await verifyRenewalInfo(signedRenewalInfo); + data.renewalInfoJws = signedRenewalInfo; + } + } + + return { + ...payload, + notificationUUID, + notificationType, + data, + // A notification with no signedDate has already been rejected unless it + // skipped verification, in which case the receive time is the best + // ordering cursor available. + signedDate: + typeof payload.signedDate === "number" ? payload.signedDate : clock(), + } as DecodedNotification; + } + + return { verifyTransaction, verifyRenewalInfo, verifyNotification }; +} diff --git a/src/iap/verify/verify.types.ts b/src/iap/verify/verify.types.ts new file mode 100644 index 00000000..8efacaad --- /dev/null +++ b/src/iap/verify/verify.types.ts @@ -0,0 +1,322 @@ +/** + * The decoded contents of Apple's signed tokens. + * + * Field names and meanings follow Apple's own documentation. Three rules apply + * throughout: + * + * - **Every timestamp is epoch milliseconds**, as Apple sends them. + * - **Every enumeration is open.** Apple adds values without warning, so each + * is typed as its known members plus `(string & {})`, which keeps editor + * completion while still accepting a value this SDK has never seen. Compare + * against the members you care about and treat anything else as unknown. + * - **Unknown fields are preserved.** A payload carries the full decoded JSON, + * so a field Apple adds after this SDK shipped is still readable. + */ + +/** A string union that still accepts values Apple may add later. */ +type Open = T | (string & {}); + +/** + * Which App Store environment a token came from. + * + * `Xcode` tokens are signed by Xcode rather than by Apple, so they never pass + * certificate verification and are only usable with local testing turned on. + */ +export type IapEnvironment = "Sandbox" | "Production" | "Xcode"; + +/** + * The kind of product a transaction is for. + * + * Apple sends these as display strings, spaces and all. + */ +export type IapProductType = Open< + | "Auto-Renewable Subscription" + | "Non-Consumable" + | "Consumable" + | "Non-Renewing Subscription" +>; + +/** Who owns a transaction: the purchaser, or a family member sharing it. */ +export type IapOwnershipType = Open<"PURCHASED" | "FAMILY_SHARED">; + +/** Why a transaction exists. */ +export type IapTransactionReason = Open<"PURCHASE" | "RENEWAL">; + +/** What kind of offer applied. `1` introductory, `2` promotional, `3` offer code, `4` win-back. */ +export type IapOfferType = 1 | 2 | 3 | 4; + +/** How an offer discounts the price. */ +export type IapOfferDiscountType = Open< + "FREE_TRIAL" | "PAY_AS_YOU_GO" | "PAY_UP_FRONT" | "ONE_TIME" +>; + +/** How a purchase was taken back. */ +export type IapRevocationType = Open< + "REFUND_FULL" | "REFUND_PRORATED" | "FAMILY_REVOKE" +>; + +/** + * The decoded contents of a signed transaction (`JWSTransaction`). + * + * Every field is optional because Apple omits what does not apply: a + * consumable has no `expiresDate`, a sandbox payload has no `appAppleId`, and + * `isUpgraded` appears only when it is true. + */ +export interface DecodedTransaction { + /** Unique id for this transaction. A renewal gets a new one. */ + transactionId?: string; + /** The id of the first transaction in this chain. Stable across renewals. */ + originalTransactionId?: string; + /** The id of the transaction this one replaced, after a resubscribe. */ + previousOriginalTransactionId?: string; + /** The app-level transaction id, present since Oct 2025. */ + appTransactionId?: string; + /** The UUID the app attached at purchase time, used to map a purchase to a user. */ + appAccountToken?: string; + /** The app's bundle identifier. */ + bundleId?: string; + /** The product purchased. */ + productId?: string; + /** Which subscription group the product belongs to. Auto-renewable subscriptions only. */ + subscriptionGroupIdentifier?: string; + /** What kind of product this is. */ + type?: IapProductType; + /** When this transaction was made. */ + purchaseDate?: number; + /** When the first transaction in this chain was made. */ + originalPurchaseDate?: number; + /** When the subscription period ends. Auto-renewable subscriptions only. */ + expiresDate?: number; + /** How many of a consumable were bought. */ + quantity?: number; + /** Whether the purchaser owns this or received it through Family Sharing. */ + inAppOwnershipType?: IapOwnershipType; + /** Whether this is an initial purchase or an automatic renewal. */ + transactionReason?: IapTransactionReason; + /** Present, and true, only when this transaction was replaced by an upgrade. */ + isUpgraded?: boolean; + /** Which kind of offer applied, if any. */ + offerType?: IapOfferType; + /** The offer's identifier, for promotional offers and offer codes. */ + offerIdentifier?: string; + /** How the offer discounted the price. */ + offerDiscountType?: IapOfferDiscountType; + /** The offer's duration, as an ISO 8601 period. */ + offerPeriod?: string; + /** When Apple took the purchase back. Its presence means "not entitled", whatever else says. */ + revocationDate?: number; + /** Why it was taken back. `1` means an issue in the app, `0` any other reason. */ + revocationReason?: number; + /** How it was taken back. */ + revocationType?: IapRevocationType; + /** How much was refunded, in thousandths of a percent (0 to 100000). Absent once a refund is reversed. */ + revocationPercentage?: number; + /** Which environment produced the token. */ + environment?: IapEnvironment; + /** The App Store country the purchase was made in, as a three-letter code. */ + storefront?: string; + /** Apple's numeric id for that storefront. */ + storefrontId?: string; + /** + * The price, in thousandths of a currency unit — `1990` means 1.99. + * + * Apple's guidance is not to use this for revenue reporting; use App Store + * Connect's financial reports instead. + */ + price?: number; + /** The currency of `price`, as an ISO 4217 code. Not for revenue reporting. */ + currency?: string; + /** How the subscription is billed. */ + billingPlanType?: string; + /** Commitment details for plans that have them. Passed through undecoded. */ + commitmentInfo?: unknown; + /** Apple's per-line-item id for web orders. */ + webOrderLineItemId?: string; + /** Advanced Commerce details, if the app uses that API. Passed through undecoded. */ + advancedCommerceInfo?: unknown; + /** When Apple signed this token. The cursor that decides which copy of a row is newest. */ + signedDate?: number; + /** Any field Apple added after this SDK shipped. */ + [key: string]: unknown; +} + +/** + * Why a subscription ended, from `expirationIntent`. + * + * `1` the customer cancelled, `2` billing failed, `3` the customer declined a + * price increase, `4` the product became unavailable, `5` any other reason. + */ +export type IapExpirationIntent = 1 | 2 | 3 | 4 | 5; + +/** + * The decoded contents of signed renewal information (`JWSRenewalInfo`). + * + * This is where a subscription's *future* lives — whether it will renew, what + * it will renew to, and whether it is inside a billing grace period. A bare + * transaction does not carry any of it, which is why the launch-time sync + * sends transaction and renewal tokens as a pair. + */ +export interface DecodedRenewalInfo { + /** The subscription chain this describes. */ + originalTransactionId?: string; + /** The app-level transaction id. */ + appTransactionId?: string; + /** The UUID the app attached at purchase time. */ + appAccountToken?: string; + /** The currently active product. */ + productId?: string; + /** What the subscription will renew to. Differs from `productId` when a plan change is scheduled. */ + autoRenewProductId?: string; + /** Whether the subscription will renew. `1` yes, `0` no. */ + autoRenewStatus?: 0 | 1; + /** When the next renewal is due. */ + renewalDate?: number; + /** Why the subscription ended, when it has. */ + expirationIntent?: IapExpirationIntent; + /** When a billing grace period ends. Service must continue until then. */ + gracePeriodExpiresDate?: number; + /** Whether Apple is still retrying a failed payment. */ + isInBillingRetryPeriod?: boolean; + /** Whether the customer has answered a price increase. `0` pending, `1` consented or not required. */ + priceIncreaseStatus?: 0 | 1; + /** Win-back offers this customer is eligible for. */ + eligibleWinBackOfferIds?: string[]; + /** Which kind of offer applies to the next period. */ + offerType?: IapOfferType; + /** That offer's identifier. */ + offerIdentifier?: string; + /** How that offer discounts the price. */ + offerDiscountType?: IapOfferDiscountType; + /** That offer's duration, as an ISO 8601 period. */ + offerPeriod?: string; + /** The renewal price, in thousandths of a currency unit. Not for revenue reporting. */ + renewalPrice?: number; + /** The currency of `renewalPrice`, as an ISO 4217 code. */ + currency?: string; + /** How the renewal is billed. */ + renewalBillingPlanType?: string; + /** Commitment details, passed through undecoded. */ + commitmentInfo?: unknown; + /** + * When the current run of subscription started. + * + * Apple's guidance is not to use this to compute how long someone has paid. + */ + recentSubscriptionStartDate?: number; + /** Which environment produced the token. */ + environment?: IapEnvironment; + /** Advanced Commerce details, passed through undecoded. */ + advancedCommerceInfo?: unknown; + /** When Apple signed this token. */ + signedDate?: number; + /** Any field Apple added after this SDK shipped. */ + [key: string]: unknown; +} + +/** + * Apple's own view of a subscription's state. + * + * `1` active, `2` expired, `3` in billing retry, `4` in a billing grace + * period, `5` revoked. This SDK derives its own status from the stored tokens + * and uses this only to cross-check. + */ +export type IapAppleSubscriptionStatus = 1 | 2 | 3 | 4 | 5; + +/** Why a customer asked for a refund. */ +export type IapConsumptionRequestReason = Open< + | "UNINTENDED_PURCHASE" + | "FULFILLMENT_ISSUE" + | "UNSATISFIED_WITH_PURCHASE" + | "LEGAL" + | "OTHER" +>; + +/** The `data` block of a notification: one transaction and, for subscriptions, its renewal info. */ +export interface DecodedNotificationData { + /** The app's numeric App Store id. Absent from sandbox payloads. */ + appAppleId?: number; + /** The app's bundle identifier. */ + bundleId?: string; + /** The app version the purchase was made in. */ + bundleVersion?: string; + /** Which environment produced the notification. */ + environment?: IapEnvironment; + /** The decoded transaction. */ + transactionInfo?: DecodedTransaction; + /** The decoded renewal information. Auto-renewable subscriptions only. */ + renewalInfo?: DecodedRenewalInfo; + /** + * The signed transaction token, exactly as Apple sent it. + * + * Present only after verification succeeded, so it is safe to store — and it + * has to be stored, because the signed token is the source of truth that + * every derived value can be recomputed from. The raw `signedTransactionInfo` + * field is replaced by this pair so no caller can act on a token that has + * not been checked. + */ + transactionInfoJws?: string; + /** The signed renewal-information token, exactly as Apple sent it. */ + renewalInfoJws?: string; + /** Apple's own status code. Auto-renewable subscriptions only. */ + status?: IapAppleSubscriptionStatus; + /** Why a refund was requested. `CONSUMPTION_REQUEST` only. */ + consumptionRequestReason?: IapConsumptionRequestReason; + /** Any field Apple added after this SDK shipped. */ + [key: string]: unknown; +} + +/** The `summary` block, sent when a mass renewal-date extension finishes. */ +export interface DecodedNotificationSummary { + /** The identifier the extension request was made with. */ + requestIdentifier?: string; + /** Which environment the request ran in. */ + environment?: IapEnvironment; + /** The app's numeric App Store id. */ + appAppleId?: number; + /** The app's bundle identifier. */ + bundleId?: string; + /** The product whose subscribers were extended. */ + productId?: string; + /** The storefronts the request covered. */ + storefrontCountryCodes?: string[]; + /** How many subscriptions could not be extended. */ + failedCount?: number; + /** How many were extended. */ + succeededCount?: number; + /** Any field Apple added after this SDK shipped. */ + [key: string]: unknown; +} + +/** + * The decoded contents of an App Store Server Notification (version 2). + * + * Exactly one of `data`, `summary`, `externalPurchaseToken` or `appData` is + * present, decided by `notificationType`. + */ +export interface DecodedNotification { + /** What happened. See Apple's `notificationType` list. */ + notificationType: string; + /** A refinement of `notificationType`, when there is one. */ + subtype?: string; + /** + * Apple's unique id for this notification. + * + * A resent notification keeps the same value, which is what makes + * duplicate detection possible. + */ + notificationUUID: string; + /** When Apple signed the notification. */ + signedDate?: number; + /** The payload version. `"2.0"` for everything this SDK handles. */ + version?: string; + /** The transaction and renewal information, for most notification types. */ + data?: DecodedNotificationData; + /** The result of a mass renewal-date extension. */ + summary?: DecodedNotificationSummary; + /** An external-purchase token, for apps using that programme. */ + externalPurchaseToken?: unknown; + /** A signed app transaction, sent when a child account's consent is withdrawn. */ + appData?: unknown; + /** Any field Apple added after this SDK shipped. */ + [key: string]: unknown; +} diff --git a/src/iap/verify/x509.ts b/src/iap/verify/x509.ts new file mode 100644 index 00000000..2cf50f31 --- /dev/null +++ b/src/iap/verify/x509.ts @@ -0,0 +1,250 @@ +/** + * X.509 certificate parsing, narrowed to what chain validation needs. + * + * Reads six things out of a certificate and ignores the rest: + * the signed bytes, the signature and its algorithm, the validity window, the + * public key, the issuer and subject names, and which extensions are present. + * + * The signed bytes matter most. A certificate's signature covers the encoded + * `tbsCertificate` exactly as the issuer wrote it, so this module hands back a + * view of the original buffer. Re-encoding the parsed contents would be a bug: + * DER is canonical in theory, and a signer's bytes are the only bytes that + * verify in practice. + * + * @internal + */ +import { + children, + content, + DerError, + raw, + readBitString, + readNodeOfTag, + readOid, + readTime, + Tag, + type DerNode, +} from "./asn1.js"; + +/** Signature algorithm OIDs, and how each one must be verified. */ +const SIGNATURE_ALGORITHMS: Record< + string, + { readonly name: string; readonly kind: "ecdsa" | "rsa"; readonly hash: string } +> = { + "1.2.840.10045.4.3.2": { name: "ecdsa-with-SHA256", kind: "ecdsa", hash: "SHA-256" }, + "1.2.840.10045.4.3.3": { name: "ecdsa-with-SHA384", kind: "ecdsa", hash: "SHA-384" }, + "1.2.840.10045.4.3.4": { name: "ecdsa-with-SHA512", kind: "ecdsa", hash: "SHA-512" }, + "1.2.840.113549.1.1.5": { name: "sha1WithRSAEncryption", kind: "rsa", hash: "SHA-1" }, + "1.2.840.113549.1.1.11": { name: "sha256WithRSAEncryption", kind: "rsa", hash: "SHA-256" }, + "1.2.840.113549.1.1.12": { name: "sha384WithRSAEncryption", kind: "rsa", hash: "SHA-384" }, + "1.2.840.113549.1.1.13": { name: "sha512WithRSAEncryption", kind: "rsa", hash: "SHA-512" }, +}; + +/** Elliptic-curve OIDs, mapped to the names WebCrypto expects. */ +const CURVES: Record = { + "1.2.840.10045.3.1.7": "P-256", + "1.3.132.0.34": "P-384", + "1.3.132.0.35": "P-521", +}; + +const ID_EC_PUBLIC_KEY = "1.2.840.10045.2.1"; + +/** Apple's Worldwide Developer Relations marker, required on the intermediate. */ +export const OID_APPLE_WWDR = "1.2.840.113635.100.6.2.1"; + +/** + * Apple's receipt-signing marker, required on the leaf. + * + * Defined by the Apple WWDR Certification Practice Statement, §4.11.10. + */ +export const OID_APPLE_RECEIPT_SIGNING = "1.2.840.113635.100.6.11.1"; + +/** How a certificate's own signature must be checked. */ +export interface SignatureAlgorithm { + /** The algorithm's conventional name, for error messages. */ + readonly name: string; + /** Which signature scheme the issuer used. */ + readonly kind: "ecdsa" | "rsa"; + /** + * The digest to use. + * + * Taken from the certificate's own `signatureAlgorithm`, never inferred from + * the issuer key's curve. Apple's chain mixes them — a P-384 root signs a + * P-256 intermediate — and guessing the hash from the key is the exact + * mistake that has broken other implementations. + */ + readonly hash: string; +} + +/** A certificate's public key, in the form WebCrypto imports. */ +export interface PublicKeyInfo { + /** The complete `SubjectPublicKeyInfo`, ready for `importKey("spki", ...)`. */ + readonly spki: Uint8Array; + /** `"ec"` when the key is elliptic-curve, `"other"` for anything else (in practice RSA). */ + readonly kind: "ec" | "other"; + /** The named curve, when this is an EC key and the curve is one WebCrypto knows. */ + readonly curve?: "P-256" | "P-384" | "P-521"; + /** The key algorithm OID, for error messages. */ + readonly algorithmOid: string; +} + +/** Everything chain validation reads out of one certificate. */ +export interface Certificate { + /** The certificate's complete DER encoding. */ + readonly der: Uint8Array; + /** The exact `tbsCertificate` bytes the signature covers. */ + readonly tbs: Uint8Array; + /** How this certificate's signature was made by its issuer. */ + readonly signatureAlgorithm: SignatureAlgorithm; + /** The signature itself. For ECDSA this is a DER `SEQUENCE { r, s }`. */ + readonly signature: Uint8Array; + /** The `issuer` Name, as raw DER, for byte-comparison against a candidate issuer's subject. */ + readonly issuerRaw: Uint8Array; + /** The `subject` Name, as raw DER. */ + readonly subjectRaw: Uint8Array; + /** Start of the validity window, in epoch milliseconds. */ + readonly notBefore: number; + /** End of the validity window, in epoch milliseconds. */ + readonly notAfter: number; + /** This certificate's public key. */ + readonly publicKey: PublicKeyInfo; + /** Every extension OID present, so a marker check is a set lookup. */ + readonly extensionOids: ReadonlySet; +} + +function parseAlgorithmIdentifier( + buf: Uint8Array, + node: DerNode +): { oid: string; parametersOid?: string } { + const parts = children(buf, node); + if (parts.length === 0) throw new DerError("empty AlgorithmIdentifier"); + const oid = readOid(buf, parts[0]); + let parametersOid: string | undefined; + if (parts.length > 1 && parts[1].tag === Tag.OBJECT_IDENTIFIER) { + parametersOid = readOid(buf, parts[1]); + } + return { oid, parametersOid }; +} + +function parsePublicKey(buf: Uint8Array, spkiNode: DerNode): PublicKeyInfo { + const parts = children(buf, spkiNode); + if (parts.length < 2) { + throw new DerError("SubjectPublicKeyInfo needs an algorithm and a key"); + } + const algorithm = parseAlgorithmIdentifier( + buf, + readNodeOfTag(buf, parts[0].start, Tag.SEQUENCE, "algorithm identifier") + ); + + // Force a copy: WebCrypto's importKey rejects a view whose byteOffset is not + // zero in some runtimes, and this buffer is a window into the whole chain. + const spki = raw(buf, spkiNode).slice(); + + if (algorithm.oid !== ID_EC_PUBLIC_KEY) { + return { spki, kind: "other", algorithmOid: algorithm.oid }; + } + return { + spki, + kind: "ec", + curve: algorithm.parametersOid ? CURVES[algorithm.parametersOid] : undefined, + algorithmOid: algorithm.oid, + }; +} + +function parseExtensionOids(buf: Uint8Array, extensionsWrapper: DerNode): Set { + const oids = new Set(); + // [3] EXPLICIT wraps a single SEQUENCE OF Extension. + const inner = children(buf, extensionsWrapper); + if (inner.length === 0) return oids; + for (const extension of children(buf, inner[0])) { + const fields = children(buf, extension); + if (fields.length === 0) continue; + oids.add(readOid(buf, fields[0])); + } + return oids; +} + +/** + * Parses one DER-encoded certificate. + * + * @throws {DerError} when the input is not a well-formed certificate. + */ +export function parseCertificate(der: Uint8Array): Certificate { + const certificate = readNodeOfTag(der, 0, Tag.SEQUENCE, "Certificate"); + const top = children(der, certificate); + if (top.length !== 3) { + throw new DerError( + `Certificate must hold tbsCertificate, signatureAlgorithm and signatureValue; found ${top.length} fields` + ); + } + + const [tbsNode, sigAlgNode, sigValueNode] = top; + if (tbsNode.tag !== Tag.SEQUENCE) { + throw new DerError("tbsCertificate is not a SEQUENCE"); + } + + const { oid: signatureOid } = parseAlgorithmIdentifier(der, sigAlgNode); + const algorithm = SIGNATURE_ALGORITHMS[signatureOid]; + if (!algorithm) { + throw new DerError(`unrecognised signature algorithm OID ${signatureOid}`); + } + + const fields = children(der, tbsNode); + let index = 0; + + // version is [0] EXPLICIT and defaults to v1, so it may be absent. + if (fields[index] && fields[index].tag === 0xa0) index += 1; + + // serialNumber, then the inner signature AlgorithmIdentifier (which must + // agree with the outer one, though nothing here depends on it). + index += 1; + index += 1; + + const issuerNode = fields[index]; + index += 1; + const validityNode = fields[index]; + index += 1; + const subjectNode = fields[index]; + index += 1; + const spkiNode = fields[index]; + index += 1; + + if (!issuerNode || !validityNode || !subjectNode || !spkiNode) { + throw new DerError("tbsCertificate is missing a required field"); + } + + const validityParts = children(der, validityNode); + if (validityParts.length !== 2) { + throw new DerError("Validity must hold notBefore and notAfter"); + } + + let extensionOids: ReadonlySet = new Set(); + for (let i = index; i < fields.length; i += 1) { + if (fields[i].tag === 0xa3) { + extensionOids = parseExtensionOids(der, fields[i]); + break; + } + } + + return { + der, + tbs: raw(der, tbsNode), + signatureAlgorithm: algorithm, + signature: readBitString(der, sigValueNode).slice(), + issuerRaw: raw(der, issuerNode), + subjectRaw: raw(der, subjectNode), + notBefore: readTime(der, validityParts[0]), + notAfter: readTime(der, validityParts[1]), + publicKey: parsePublicKey(der, spkiNode), + extensionOids, + }; +} + +/** Whether `at` (epoch milliseconds) falls inside the certificate's validity window. */ +export function isValidAt(certificate: Certificate, at: number): boolean { + return at >= certificate.notBefore && at <= certificate.notAfter; +} + +// Re-exported so callers do not need to reach into asn1.ts for the content of +// a Name when logging a rejection. +export { content as derContent }; diff --git a/src/iap/version.ts b/src/iap/version.ts new file mode 100644 index 00000000..1cb91b80 --- /dev/null +++ b/src/iap/version.ts @@ -0,0 +1,13 @@ +/** + * The module's own version, stamped onto every stored notification row so a + * support case can tell which code wrote it. + * + * Hand-maintained, and deliberately not read from `package.json`: the build is + * a bare `tsc` that mirrors `src/` into `dist/` and copies no JSON, and the + * package ships only `dist`. Importing the manifest would break both. + * + * Bump this in the same commit as the package version. + * + * @internal + */ +export const IAP_MODULE_VERSION = "1.0.0"; diff --git a/src/index.ts b/src/index.ts index 8842b8fe..52dd87d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -150,3 +150,99 @@ export type { RemoveAccessTokenOptions, GetLoginUrlOptions, } from "./utils/auth-utils.types.js"; + +// Apple in-app purchase types. +// +// Type-only on purpose. The runtime lives behind the "@base44/sdk/iap" subpath +// export so a browser or React Native bundle never downloads certificate +// parsing or cryptography, while `dist/index.js` stays byte-identical — `tsc` +// erases an `export type` entirely. Naming these from "@base44/sdk" in shared +// front-end code therefore costs nothing at runtime. +export type { + CreateIapClientOptions, + IapInternalOptions, +} from "./iap/index.js"; +export type { + IapConfig, + IapConfiguredProductType, + IapModule, + IapProductConfig, + IapSetupReport, +} from "./iap/iap.types.js"; +export type { + IapEvent, + IapEventHandler, + IapEventType, + IapExpiryReason, + IapRenewReason, + IapStartReason, +} from "./iap/events/events.types.js"; +export type { + EntitlementQuery, + Entitlements, + IapExpirationReason, + IapRevocation, + IapSubscriptionOffer, + IapSubscriptionStatus, + OwnedNonConsumable, + OwnedNonRenewingSubscription, + SubscriptionQuery, + SubscriptionState, + TransactionQuery, +} from "./iap/read/read.types.js"; +export type { HandleNotificationResult } from "./iap/ingest/notifications.js"; +export type { + RecordTransactionOptions, + RecordTransactionResult, + SyncPayload, + SyncResult, +} from "./iap/ingest/device.types.js"; +export type { + IapConsumptionOutcome, + IapConsumptionRequestRecord, + IapNotificationOutcome, + IapNotificationRecord, + IapRecordSource, + IapSubscriptionRecord, + IapTransactionRecord, +} from "./iap/store/rows.types.js"; +export type { + IapEntityName, + IapEntitySchema, + IapSchemaField, +} from "./iap/store/schemas.js"; +export type { + ConsumptionRequestBody, + IapDeliveryStatus, + IapRefundPreference, + IapServerApiConfig, + IapServerApiModule, + SendAttempt, + SendAttemptResult, + TestNotificationResult, + TestNotificationStatus, +} from "./iap/server-api/server-api.types.js"; +export type { + IapApiErrorCode, + IapConfigErrorCode, + IapSetupErrorCode, + IapStoreErrorCode, + IapVerificationErrorCode, +} from "./iap/errors.types.js"; +export type { + DecodedNotification, + DecodedNotificationData, + DecodedNotificationSummary, + DecodedRenewalInfo, + DecodedTransaction, + IapAppleSubscriptionStatus, + IapConsumptionRequestReason, + IapEnvironment, + IapExpirationIntent, + IapOfferDiscountType, + IapOfferType, + IapOwnershipType, + IapProductType, + IapRevocationType, + IapTransactionReason, +} from "./iap/verify/verify.types.js"; diff --git a/tests/iap/fixtures/fake-entities.ts b/tests/iap/fixtures/fake-entities.ts new file mode 100644 index 00000000..6f2b77b4 --- /dev/null +++ b/tests/iap/fixtures/fake-entities.ts @@ -0,0 +1,266 @@ +// An in-memory stand-in for Base44 entities, faithful to the behaviour the +// store layer works around. +// +// The parts that matter, and that a looser fake would paper over: +// +// - `updateMany`'s query is evaluated server-side, and it reports only how +// many rows changed — never whether a row existed but failed the guard. +// - `$lt` follows MongoDB type-bracketing: it does NOT match a document where +// the field is missing or null. That is exactly why the store's cursor guard +// is written as `$or: [{cursor: {$lt: v}}, {cursor: null}]`. +// - Nothing enforces uniqueness, so two creates for one natural key both +// succeed and leave duplicate rows behind. +// - A filter against an entity the app does not have answers 404. + +interface Row extends Record { + id: string; + created_date: string; +} + +type Query = Record; + +/** How the fake should misbehave. */ +export interface FakeEntitiesOptions { + /** Entity names that do not exist in this app. Any filter on them answers 404. */ + readonly missingEntities?: readonly string[]; + /** Entity names whose writes fail with a 503. */ + readonly failWritesOn?: readonly string[]; + /** Entity names whose writes fail with a 409, as a duplicate key would. */ + readonly duplicateKeyOn?: readonly string[]; + /** Whether a caller-supplied `id` is honoured. Off models the pessimistic world. */ + readonly honourSuppliedId?: boolean; +} + +function httpError(status: number, message: string) { + const error = new Error(message) as Error & { status: number }; + error.status = status; + return error; +} + +function isOperatorBag(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.keys(value).some((k) => k.startsWith("$")) + ); +} + +/** Evaluates one field condition against a stored value. */ +function matchesField(stored: unknown, condition: unknown): boolean { + if (condition === null) { + // `null` matches a stored null and a missing field alike. + return stored === null || stored === undefined; + } + + if (Array.isArray(condition)) { + return condition.some((candidate) => matchesField(stored, candidate)); + } + + if (isOperatorBag(condition)) { + for (const [operator, operand] of Object.entries(condition)) { + switch (operator) { + case "$eq": + if (!matchesField(stored, operand)) return false; + break; + case "$ne": + if (matchesField(stored, operand)) return false; + break; + case "$in": + if (!(operand as unknown[]).some((v) => matchesField(stored, v))) return false; + break; + case "$nin": + if ((operand as unknown[]).some((v) => matchesField(stored, v))) return false; + break; + case "$lt": + case "$lte": + case "$gt": + case "$gte": { + // Type-bracketing: a comparison only ever matches a value of the + // same type. A missing or null field matches no comparison at all. + if (typeof stored !== typeof operand) return false; + const a = stored as number; + const b = operand as number; + if (operator === "$lt" && !(a < b)) return false; + if (operator === "$lte" && !(a <= b)) return false; + if (operator === "$gt" && !(a > b)) return false; + if (operator === "$gte" && !(a >= b)) return false; + break; + } + case "$exists": + if ((stored !== undefined) !== operand) return false; + break; + default: + throw new Error(`fake entities: unsupported operator ${operator}`); + } + } + return true; + } + + return stored === condition; +} + +/** Orders rows by a single field, `-field` for descending. */ +function sortRows(rows: Row[], sort: string): Row[] { + const descending = sort.startsWith("-"); + const field = sort.replace(/^[+-]/, ""); + + return [...rows].sort((a, b) => { + const left = a[field]; + const right = b[field]; + if (left === right) return 0; + // Missing values sort last, whichever direction is asked for. + if (left === null || left === undefined) return 1; + if (right === null || right === undefined) return -1; + const order = left < right ? -1 : 1; + return descending ? -order : order; + }); +} + +function matches(row: Row, query: Query): boolean { + for (const [field, condition] of Object.entries(query)) { + if (field === "$or") { + if (!(condition as Query[]).some((sub) => matches(row, sub))) return false; + continue; + } + if (field === "$and") { + if (!(condition as Query[]).every((sub) => matches(row, sub))) return false; + continue; + } + if (!matchesField(row[field], condition)) return false; + } + return true; +} + +/** An in-memory entities module plus the knobs the tests need. */ +export class FakeEntities { + private readonly tables = new Map(); + private sequence = 0; + + /** Every call made, for asserting round-trip counts. */ + readonly calls: { entity: string; method: string }[] = []; + + constructor(private readonly options: FakeEntitiesOptions = {}) {} + + /** The object to hand to the store, shaped like `base44.asServiceRole.entities`. */ + get module(): Record { + return new Proxy( + {}, + { + get: (_target, name) => { + if (typeof name !== "string") return undefined; + return this.handlerFor(name); + }, + } + ); + } + + /** Rows currently stored for an entity, in insertion order. */ + rows(entity: string): Row[] { + return [...(this.tables.get(entity) ?? [])]; + } + + /** Inserts a row directly, bypassing the store — including a deliberate duplicate. */ + seed(entity: string, row: Record): Row { + const table = this.tables.get(entity) ?? []; + this.sequence += 1; + const stored: Row = { + id: (row.id as string) ?? `fake-${this.sequence}`, + created_date: new Date(1_700_000_000_000 + this.sequence).toISOString(), + ...row, + } as Row; + table.push(stored); + this.tables.set(entity, table); + return stored; + } + + private assertPresent(entity: string) { + if (this.options.missingEntities?.includes(entity)) { + throw httpError(404, `entity ${entity} not found`); + } + } + + private handlerFor(entity: string) { + const record = (method: string) => this.calls.push({ entity, method }); + + return { + filter: async ( + query: Query, + sort?: string, + limit?: number, + skip?: number, + fields?: string[] + ) => { + record("filter"); + this.assertPresent(entity); + let found = this.rows(entity).filter((row) => matches(row, query ?? {})); + // Sorting happens server-side in the real API, and the store relies on + // it — a listing ordered by deadline, or paged on an immutable column, + // is only correct if the server did the ordering. + if (sort) found = sortRows(found, sort); + if (skip) found = found.slice(skip); + // Mirrors the real default: a falsy limit becomes 50, which is the + // silent-truncation trap the store exists to avoid. + found = found.slice(0, limit || 50); + if (!fields) return found; + return found.map((row) => { + const projected: Record = {}; + for (const field of [...fields, "id", "created_date"]) { + if (field in row) projected[field] = row[field]; + } + return projected; + }); + }, + + create: async (data: Record) => { + record("create"); + this.assertPresent(entity); + if (this.options.failWritesOn?.includes(entity)) { + throw httpError(503, "service unavailable"); + } + if (this.options.duplicateKeyOn?.includes(entity)) { + throw httpError(409, "duplicate key"); + } + const { id, ...rest } = data; + const seeded = this.options.honourSuppliedId + ? { ...rest, id } + : { ...rest }; + return this.seed(entity, seeded as Record); + }, + + updateMany: async ( + query: Query, + data: Record> + ) => { + record("updateMany"); + this.assertPresent(entity); + if (this.options.failWritesOn?.includes(entity)) { + throw httpError(503, "service unavailable"); + } + const table = this.tables.get(entity) ?? []; + let updated = 0; + for (const row of table) { + if (!matches(row, query ?? {})) continue; + updated += 1; + for (const [field, value] of Object.entries(data.$set ?? {})) { + row[field] = value; + } + for (const field of Object.keys(data.$unset ?? {})) { + delete row[field]; + } + for (const [field, delta] of Object.entries(data.$inc ?? {})) { + row[field] = ((row[field] as number) ?? 0) + (delta as number); + } + } + // Note what is NOT returned: whether a row existed but failed the + // guard. That ambiguity is the store's problem to solve. + return { success: true, updated, has_more: false }; + }, + + get: async () => { + record("get"); + throw new Error("the store must never fetch by record id"); + }, + }; + } +} diff --git a/tests/iap/fixtures/harness.ts b/tests/iap/fixtures/harness.ts new file mode 100644 index 00000000..87bdd49b --- /dev/null +++ b/tests/iap/fixtures/harness.ts @@ -0,0 +1,198 @@ +// Builds a complete IAP client over an in-memory store and a test certificate +// chain, so the whole ingestion path can be driven without a network. +import type { Base44Client } from "../../../src/client.types.ts"; +import { createIapClient } from "../../../src/iap/index.ts"; +import type { IapConfig, IapModule } from "../../../src/iap/iap.types.ts"; +import type { IapEvent } from "../../../src/iap/events/events.types.ts"; +import { FakeEntities, type FakeEntitiesOptions } from "./fake-entities.ts"; +import { signJws, type SignJwsOptions } from "./sign-jws.ts"; +import { trustAnchorsFor, validChain, type TestChain } from "./test-chain.ts"; + +export const BUNDLE_ID = "com.example.app"; +export const APP_APPLE_ID = 1234567890; + +export const BASE_PRODUCTS: IapConfig["products"] = { + pro_monthly: { type: "autoRenewableSubscription", subscriptionGroupId: "21234567" }, + pro_yearly: { type: "autoRenewableSubscription", subscriptionGroupId: "21234567" }, + coins_100: { type: "consumable" }, + lifetime: { type: "nonConsumable" }, + season_pass: { type: "nonRenewingSubscription", nonRenewingDurationDays: 90 }, +}; + +export interface Harness { + readonly iap: IapModule; + readonly fake: FakeEntities; + readonly chain: TestChain; + readonly events: IapEvent[]; + readonly now: () => number; + setNow(value: number): void; +} + +export interface HarnessOptions { + readonly config?: Partial; + readonly entities?: FakeEntitiesOptions; + readonly storeMode?: "query-guard" | "natural-id"; + readonly startAt?: number; +} + +export async function createHarness( + options: HarnessOptions = {} +): Promise { + const chain = await validChain(); + const fake = new FakeEntities(options.entities); + let clockValue = options.startAt ?? Date.UTC(2026, 8, 3, 12, 0, 0); + + const base44 = { + get asServiceRole() { + return { entities: fake.module }; + }, + } as unknown as Base44Client; + + const iap = createIapClient({ + base44, + config: { + bundleId: BUNDLE_ID, + appAppleId: APP_APPLE_ID, + products: BASE_PRODUCTS, + ...options.config, + }, + internal: { + roots: trustAnchorsFor(chain), + clock: () => clockValue, + storeMode: options.storeMode, + }, + }); + + const events: IapEvent[] = []; + iap.onEvent((event) => { + events.push(event); + }); + + return { + iap, + fake, + chain, + events, + now: () => clockValue, + setNow(value: number) { + clockValue = value; + }, + }; +} + +/** A signed transaction payload with sensible defaults. */ +export function transactionPayload(overrides: Record = {}) { + return { + transactionId: "2000000000000001", + originalTransactionId: "2000000000000001", + bundleId: BUNDLE_ID, + appAppleId: APP_APPLE_ID, + productId: "pro_monthly", + type: "Auto-Renewable Subscription", + subscriptionGroupIdentifier: "21234567", + environment: "Production", + signedDate: Date.UTC(2026, 8, 3), + purchaseDate: Date.UTC(2026, 8, 3), + expiresDate: Date.UTC(2026, 9, 3), + inAppOwnershipType: "PURCHASED", + transactionReason: "PURCHASE", + ...overrides, + }; +} + +/** A signed renewal-info payload with sensible defaults. */ +export function renewalPayload(overrides: Record = {}) { + return { + originalTransactionId: "2000000000000001", + productId: "pro_monthly", + autoRenewProductId: "pro_monthly", + autoRenewStatus: 1, + renewalDate: Date.UTC(2026, 9, 3), + environment: "Production", + signedDate: Date.UTC(2026, 8, 3), + ...overrides, + }; +} + +export interface NotificationOptions { + readonly notificationType: string; + readonly subtype?: string; + readonly notificationUUID?: string; + readonly signedDate?: number; + readonly transaction?: Record | null; + readonly renewal?: Record | null; + readonly data?: Record; + readonly summary?: Record; + readonly environment?: string; + readonly jws?: SignJwsOptions; +} + +let uuidCounter = 0; + +/** Builds a signed notification envelope wrapping signed inner tokens. */ +export async function notification( + harness: Harness, + options: NotificationOptions +): Promise { + const environment = options.environment ?? "Production"; + const signedDate = options.signedDate ?? Date.UTC(2026, 8, 3); + + uuidCounter += 1; + const notificationUUID = + options.notificationUUID ?? + `00000000-0000-4000-8000-${String(uuidCounter).padStart(12, "0")}`; + + if (options.summary) { + return signJws( + harness.chain, + { + notificationType: options.notificationType, + subtype: options.subtype, + notificationUUID, + version: "2.0", + signedDate, + summary: { + environment, + appAppleId: APP_APPLE_ID, + bundleId: BUNDLE_ID, + ...options.summary, + }, + }, + options.jws + ); + } + + const data: Record = { + appAppleId: environment === "Production" ? APP_APPLE_ID : undefined, + bundleId: BUNDLE_ID, + bundleVersion: "42", + environment, + ...options.data, + }; + + if (options.transaction !== null) { + data.signedTransactionInfo = await signJws( + harness.chain, + transactionPayload({ environment, signedDate, ...options.transaction }) + ); + } + if (options.renewal) { + data.signedRenewalInfo = await signJws( + harness.chain, + renewalPayload({ environment, signedDate, ...options.renewal }) + ); + } + + return signJws( + harness.chain, + { + notificationType: options.notificationType, + subtype: options.subtype, + notificationUUID, + version: "2.0", + signedDate, + data, + }, + options.jws + ); +} diff --git a/tests/iap/fixtures/sign-jws.ts b/tests/iap/fixtures/sign-jws.ts new file mode 100644 index 00000000..03f63589 --- /dev/null +++ b/tests/iap/fixtures/sign-jws.ts @@ -0,0 +1,57 @@ +// Mints compact JWS tokens signed by a test chain's leaf key. +// +// The signing input is the ASCII of `header.payload`, and an ES256 signature is +// raw `r ‖ s` — which is what WebCrypto's ECDSA sign already returns, so no DER +// conversion happens here. That asymmetry against certificate signatures is +// the thing these fixtures exist to exercise. +import { + bytesToBase64, + bytesToBase64Url, +} from "../../../src/iap/runtime/base64.ts"; +import type { TestChain } from "./test-chain.ts"; + +function encodeJson(value: unknown): string { + const json = JSON.stringify(value); + const bytes = new TextEncoder().encode(json); + return bytesToBase64Url(bytes); +} + +/** Overrides for a minted token's header. */ +export interface SignJwsOptions { + /** Replace the `alg` claim, to test rejection. */ + readonly alg?: string; + /** Replace the `x5c` chain, e.g. with a truncated one. */ + readonly x5c?: readonly Uint8Array[]; + /** Corrupt the signature after signing. */ + readonly tamperSignature?: boolean; + /** Corrupt the payload after signing, leaving the signature intact. */ + readonly tamperPayload?: boolean; +} + +/** Signs `payload` into a compact JWS using `chain`'s leaf key. */ +export async function signJws( + chain: TestChain, + payload: Record, + options: SignJwsOptions = {} +): Promise { + const x5c = (options.x5c ?? chain.x5c).map((der) => bytesToBase64(der)); + const header = encodeJson({ alg: options.alg ?? "ES256", x5c }); + const body = encodeJson(payload); + + const signingInput = new TextEncoder().encode(`${header}.${body}`); + const signature = new Uint8Array( + await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + chain.leafPrivateKey, + signingInput + ) + ); + + if (options.tamperSignature) signature[0] ^= 0xff; + + const encodedBody = options.tamperPayload + ? encodeJson({ ...payload, tampered: true }) + : body; + + return `${header}.${encodedBody}.${bytesToBase64Url(signature)}`; +} diff --git a/tests/iap/fixtures/test-chain.ts b/tests/iap/fixtures/test-chain.ts new file mode 100644 index 00000000..a87d281c --- /dev/null +++ b/tests/iap/fixtures/test-chain.ts @@ -0,0 +1,153 @@ +// A throwaway certificate chain, minted at test time, shaped like Apple's. +// +// Deliberately cross-curve — a P-384 root signing a P-256 intermediate — because +// that is Apple's real shape and it is the case that breaks implementations +// which infer the digest from the issuer's curve instead of reading each +// certificate's own signatureAlgorithm. +// +// `@peculiar/x509` is a devDependency used only here. It is an independent +// encoder, so it acts as an oracle for our hand-rolled reader rather than a +// mirror of it. It needs a Reflect polyfill, which is why `reflect-metadata` is +// imported first — both stay out of `src/` and out of the published package. +import "reflect-metadata"; +import * as x509 from "@peculiar/x509"; + +x509.cryptoProvider.set(crypto); + +/** Apple's Worldwide Developer Relations marker, required on the intermediate. */ +export const OID_WWDR = "1.2.840.113635.100.6.2.1"; +/** Apple's receipt-signing marker, required on the leaf. */ +export const OID_RECEIPT_SIGNING = "1.2.840.113635.100.6.11.1"; + +const FAR_PAST = new Date(Date.UTC(2020, 0, 1)); +const FAR_FUTURE = new Date(Date.UTC(2040, 0, 1)); + +/** A minted chain plus the leaf key needed to sign tokens with it. */ +export interface TestChain { + /** DER of the leaf, intermediate and root, in `x5c` order. */ + readonly x5c: readonly Uint8Array[]; + /** The root's DER, for pinning as a trust anchor. */ + readonly rootDer: Uint8Array; + /** The leaf's private key, for signing a JWS. */ + readonly leafPrivateKey: CryptoKey; +} + +/** How to bend a minted chain away from a valid one. */ +export interface TestChainOptions { + /** Leave the WWDR marker off the intermediate. */ + readonly omitWwdrOid?: boolean; + /** Leave the receipt-signing marker off the leaf. */ + readonly omitReceiptOid?: boolean; + /** Validity window for the leaf. Defaults to 2020 through 2040. */ + readonly leafNotBefore?: Date; + readonly leafNotAfter?: Date; + /** + * Put the leaf certificate's key on P-384, so it disagrees with an ES256 + * header. + * + * The token is then signed with a separate P-256 key, so it still carries a + * 64-byte ES256 signature and reaches the curve check instead of being + * rejected earlier for its signature length. + */ + readonly leafCurve?: "P-256" | "P-384"; +} + +function markerExtension(oid: string): x509.Extension { + // Apple's markers are presence-only. A DER NULL is the smallest well-formed + // extnValue, and nothing in verification reads the contents. + return new x509.Extension(oid, false, new Uint8Array([0x05, 0x00])); +} + +async function generate(curve: "P-256" | "P-384") { + return crypto.subtle.generateKey({ name: "ECDSA", namedCurve: curve }, false, [ + "sign", + "verify", + ]); +} + +function bytes(certificate: x509.X509Certificate): Uint8Array { + return new Uint8Array(certificate.rawData); +} + +/** Mints a fresh three-certificate chain. */ +export async function createTestChain( + options: TestChainOptions = {} +): Promise { + const leafCurve = options.leafCurve ?? "P-256"; + + const rootKeys = await generate("P-384"); + const intermediateKeys = await generate("P-256"); + const leafKeys = await generate(leafCurve); + + const root = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: "01", + name: "CN=Test Apple Root CA, O=Test", + notBefore: FAR_PAST, + notAfter: FAR_FUTURE, + signingAlgorithm: { name: "ECDSA", hash: "SHA-384" }, + keys: rootKeys, + extensions: [new x509.BasicConstraintsExtension(true, 2, true)], + }); + + const intermediate = await x509.X509CertificateGenerator.create({ + serialNumber: "02", + subject: "CN=Test Apple WWDR CA, O=Test", + issuer: root.subject, + notBefore: FAR_PAST, + notAfter: FAR_FUTURE, + // Signed by the P-384 root, so this certificate's own signatureAlgorithm + // is ecdsa-with-SHA384 even though its subject key is P-256. + signingAlgorithm: { name: "ECDSA", hash: "SHA-384" }, + publicKey: intermediateKeys.publicKey, + signingKey: rootKeys.privateKey, + extensions: [ + new x509.BasicConstraintsExtension(true, 1, true), + ...(options.omitWwdrOid ? [] : [markerExtension(OID_WWDR)]), + ], + }); + + const leaf = await x509.X509CertificateGenerator.create({ + serialNumber: "03", + subject: "CN=Test Receipt Signing, O=Test", + issuer: intermediate.subject, + notBefore: options.leafNotBefore ?? FAR_PAST, + notAfter: options.leafNotAfter ?? FAR_FUTURE, + signingAlgorithm: { name: "ECDSA", hash: "SHA-256" }, + publicKey: leafKeys.publicKey, + signingKey: intermediateKeys.privateKey, + extensions: [ + new x509.BasicConstraintsExtension(false, undefined, true), + ...(options.omitReceiptOid ? [] : [markerExtension(OID_RECEIPT_SIGNING)]), + ], + }); + + // Normally the token is signed by the leaf's own key. When the leaf is + // deliberately on the wrong curve, sign with an unrelated P-256 key so the + // signature is the right *length* and verification gets far enough to notice + // the curve. + const leafPrivateKey = + leafCurve === "P-256" + ? leafKeys.privateKey + : (await generate("P-256")).privateKey; + + return { + x5c: [bytes(leaf), bytes(intermediate), bytes(root)], + rootDer: bytes(root), + leafPrivateKey, + }; +} + +/** Wraps a minted root as a trust anchor for `verifyChain`/`verifyJws`. */ +export function trustAnchorsFor(chain: TestChain) { + return [{ name: "Test Apple Root CA", der: chain.rootDer, supported: true }]; +} + +// One valid chain is reused across tests: minting three keypairs costs real +// milliseconds, and nothing in a passing test mutates it. +let shared: Promise | undefined; + +/** The shared valid chain. */ +export function validChain(): Promise { + if (!shared) shared = createTestChain(); + return shared; +} diff --git a/tests/types/iap.types.ts b/tests/types/iap.types.ts new file mode 100644 index 00000000..9ea23c7f --- /dev/null +++ b/tests/types/iap.types.ts @@ -0,0 +1,137 @@ +// Compile-only checks on the in-app purchase surface. +// +// Two things are asserted here that a runtime test cannot: that the types are +// reachable from the main entry point at all (they are re-exported there as +// types only, which is what keeps the crypto out of a browser bundle), and +// that the shapes generated code must not be able to write are rejected. + +import type { + ConsumptionRequestBody, + DecodedNotification, + DecodedTransaction, + Entitlements, + IapConfig, + IapEvent, + IapModule, + IapProductConfig, + IapSetupReport, + IapSubscriptionStatus, + SubscriptionState, + SyncPayload, + SyncResult, +} from "../../src/index.js"; + +// The subpath is where the runtime lives. +import type { CreateIapClientOptions } from "../../src/iap/index.js"; + +// --- Configuration ------------------------------------------------------- + +const config = { + bundleId: "com.example.app", + appAppleId: 1234567890, + products: { + pro_monthly: { type: "autoRenewableSubscription", subscriptionGroupId: "21234567" }, + coins_100: { type: "consumable" }, + season_pass: { type: "nonRenewingSubscription", nonRenewingDurationDays: 90 }, + }, + testMode: true, +} satisfies IapConfig; + +// @ts-expect-error the numeric App Store id is not the bundle id +const wrongAppleId: IapConfig = { ...config, appAppleId: "com.example.app" }; + +// @ts-expect-error "subscription" is not one of the four product types +const wrongProductType: IapProductConfig = { type: "subscription" }; + +// --- The gate ------------------------------------------------------------ + +declare const iap: IapModule; + +const entitled: Promise = iap.hasActiveSubscription("user-1"); +const narrowed: Promise = iap.hasActiveSubscription("user-1", { + productIds: ["pro_monthly"], + subscriptionGroupId: "21234567", +}); + +// @ts-expect-error a user id is required; an entitlement check is never global +const noUser = iap.hasActiveSubscription(); + +// @ts-expect-error the client must never be trusted about what it bought +const clientClaim = iap.hasActiveSubscription("user-1", { entitled: true }); + +// --- Reads --------------------------------------------------------------- + +declare const state: SubscriptionState; +const status: IapSubscriptionStatus = state.status; +const isEntitled: boolean = state.entitled; +const expiry: number | null = state.expiresAt; + +// @ts-expect-error status is a closed set, so a typo cannot slip through +const badStatus: IapSubscriptionStatus = "cancelled"; + +declare const owned: Entitlements; +const unlocked: string[] = owned.nonConsumables.map((item) => item.productId); +// Consumables are deliberately absent from the shape, not merely empty. +// @ts-expect-error there is no consumables list to read +const consumables = owned.consumables; + +// --- Ingestion ----------------------------------------------------------- + +const webhook: Promise = iap.handleNotification(new Request("https://x")); + +declare const payload: SyncPayload; +const sync: Promise = iap.syncEntitlements(payload, { appUserId: "user-1" }); + +// --- Events -------------------------------------------------------------- + +const stop: () => void = iap.onEvent((event: IapEvent) => { + const kind = event.type; + // Detail fields are optional, because not every event carries them. + const grace: boolean | undefined = event.inGracePeriod; + void kind; + void grace; +}); + +// @ts-expect-error event types are a closed set +const badEvent: IapEvent = { ...({} as IapEvent), type: "purchase.happened" }; + +// --- Decoded payloads keep unknown fields -------------------------------- + +declare const transaction: DecodedTransaction; +// An index signature, so a field Apple adds later is still readable. +const future: unknown = transaction.somethingAppleAddsIn2027; + +declare const notification: DecodedNotification; +const uuid: string = notification.notificationUUID; +const innerProduct: string | undefined = notification.data?.transactionInfo?.productId; +// The verified raw token, which is what gets stored. +const innerJws: string | undefined = notification.data?.transactionInfoJws; + +// --- Server API ---------------------------------------------------------- + +const consumption = { + customerConsented: true, + deliveryStatus: "DELIVERED", + sampleContentProvided: true, + consumptionPercentage: 100000, +} satisfies ConsumptionRequestBody; + +const withoutConsent: ConsumptionRequestBody = { + // @ts-expect-error consent cannot be false; Apple requires it and so do we + customerConsented: false, + deliveryStatus: "DELIVERED", + sampleContentProvided: false, +}; + +declare const setup: IapSetupReport; +const missing: string[] = setup.missingEntities; + +declare const options: CreateIapClientOptions; +const configured: IapConfig = options.config; + +void [ + config, wrongAppleId, wrongProductType, entitled, narrowed, noUser, clientClaim, + status, isEntitled, expiry, badStatus, unlocked, consumables, webhook, sync, + stop, badEvent, future, uuid, innerProduct, innerJws, consumption, + withoutConsent, missing, configured, +]; diff --git a/tests/unit/iap-config.test.ts b/tests/unit/iap-config.test.ts new file mode 100644 index 00000000..89e477be --- /dev/null +++ b/tests/unit/iap-config.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "vitest"; +import { createIapClient } from "../../src/iap/index.ts"; +import { appAccountTokenFor } from "../../src/iap/account-token.ts"; +import type { IapConfig } from "../../src/iap/iap.types.ts"; + +// createIapClient does no I/O, so a bare object stands in for the client. +const base44 = {} as never; + +const VALID: IapConfig = { + bundleId: "com.example.app", + appAppleId: 1234567890, + products: { + pro_monthly: { type: "autoRenewableSubscription", subscriptionGroupId: "21234567" }, + coins_100: { type: "consumable" }, + lifetime: { type: "nonConsumable" }, + season_pass: { type: "nonRenewingSubscription", nonRenewingDurationDays: 90 }, + }, +}; + +function create(config: unknown) { + return createIapClient({ base44, config: config as IapConfig }); +} + +describe("configuration", () => { + test("accepts a complete configuration", () => { + const iap = create(VALID); + expect(typeof iap.verifyTransaction).toBe("function"); + expect(typeof iap.verifyNotification).toBe("function"); + }); + + test("requires a Base44 client, naming how to get one", () => { + expect(() => + createIapClient({ base44: undefined as never, config: VALID }) + ).toThrow(/createClientFromRequest/); + }); + + test.each([ + ["no bundleId", { ...VALID, bundleId: undefined }], + ["an empty bundleId", { ...VALID, bundleId: " " }], + ["no appAppleId", { ...VALID, appAppleId: undefined }], + ["a fractional appAppleId", { ...VALID, appAppleId: 1.5 }], + ["a negative appAppleId", { ...VALID, appAppleId: -1 }], + ["no products", { ...VALID, products: undefined }], + ])("rejects %s", (_label, config) => { + expect(() => create(config)).toThrow( + expect.objectContaining({ code: "IAP_INVALID_CONFIG" }) + ); + }); + + test("says so plainly when the bundle id is passed where the numeric id belongs", () => { + // The single most likely configuration mistake, so the message names both. + expect(() => create({ ...VALID, appAppleId: "com.example.app" })).toThrow( + /must be a number.*not the bundle id/s + ); + }); + + test("rejects an unknown product type, listing the valid ones", () => { + expect(() => + create({ ...VALID, products: { x: { type: "subscription" } } }) + ).toThrow(/expected one of consumable, nonConsumable/); + }); + + test("rejects a non-renewing subscription with no duration, because Apple never expires those", () => { + expect(() => + create({ ...VALID, products: { pass: { type: "nonRenewingSubscription" } } }) + ).toThrow(/Apple does not expire these/); + }); + + test("refuses online certificate checks rather than quietly ignoring them", () => { + expect(() => create({ ...VALID, onlineChecks: true })).toThrow( + expect.objectContaining({ code: "IAP_ONLINE_CHECKS_UNSUPPORTED" }) + ); + // Explicit false and omitted are both fine. + expect(() => create({ ...VALID, onlineChecks: false })).not.toThrow(); + }); + + test("defaults both testing flags off, so a production deploy is strict by default", () => { + // Proven through behaviour: a sandbox token is refused with no flags set. + const iap = create(VALID); + expect(iap).toBeDefined(); + // The environment gating itself is covered in iap-verifier.test.ts. + }); +}); + +describe("account tokens", () => { + test("is deterministic, so the shell and the backend derive the same value", () => { + expect(appAccountTokenFor("user-123")).toBe(appAccountTokenFor("user-123")); + }); + + test("is a v5 UUID, whose version and variant bits are fixed", () => { + const token = appAccountTokenFor("user-123"); + expect(token).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); + }); + + test("separates different users", () => { + expect(appAccountTokenFor("user-123")).not.toBe(appAccountTokenFor("user-124")); + }); + + test("is reachable from the module as well as standalone", () => { + const iap = create(VALID); + expect(iap.appAccountTokenFor("user-123")).toBe(appAccountTokenFor("user-123")); + }); + + test("rejects an empty user id instead of minting a shared token for everyone", () => { + expect(() => appAccountTokenFor("")).toThrow(TypeError); + }); +}); diff --git a/tests/unit/iap-device.test.ts b/tests/unit/iap-device.test.ts new file mode 100644 index 00000000..5333165b --- /dev/null +++ b/tests/unit/iap-device.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, test } from "vitest"; +import { appAccountTokenFor } from "../../src/iap/account-token.ts"; +import { signJws } from "../iap/fixtures/sign-jws.ts"; +import { + createHarness, + renewalPayload, + transactionPayload, + type Harness, +} from "../iap/fixtures/harness.ts"; + +const NOW = Date.UTC(2026, 8, 3, 12, 0, 0); +const HOUR = 3_600_000; + +async function tokenFor(harness: Harness, overrides: Record = {}) { + return signJws(harness.chain, transactionPayload({ signedDate: NOW, ...overrides })); +} + +describe("recordTransaction", () => { + test("stores a purchase and reports it as new", async () => { + const harness = await createHarness({ startAt: NOW }); + const result = await harness.iap.recordTransaction( + await tokenFor(harness, { + transactionId: "tx-coins-1", + productId: "coins_100", + type: "Consumable", + expiresDate: undefined, + subscriptionGroupIdentifier: undefined, + }) + ); + + expect(result).toMatchObject({ + recorded: true, + transactionId: "tx-coins-1", + duplicate: false, + }); + expect(result.decoded.productId).toBe("coins_100"); + + const row = harness.fake.rows("IapTransaction")[0]; + expect(row.source).toBe("device"); + expect(row.rawJws).toBeTruthy(); + }); + + test("reports a repeat as a duplicate, which is the double-delivery guard", async () => { + // StoreKit re-delivers an unfinished transaction at every launch, so a + // consumable must only be granted when duplicate is false. + const harness = await createHarness({ startAt: NOW }); + const jws = await tokenFor(harness, { transactionId: "tx-coins-1" }); + + expect((await harness.iap.recordTransaction(jws)).duplicate).toBe(false); + expect((await harness.iap.recordTransaction(jws)).duplicate).toBe(true); + expect(harness.fake.rows("IapTransaction")).toHaveLength(1); + }); + + test("emits a purchase event only the first time", async () => { + const harness = await createHarness({ startAt: NOW }); + const jws = await tokenFor(harness, { + transactionId: "tx-coins-1", + productId: "coins_100", + type: "Consumable", + }); + + await harness.iap.recordTransaction(jws); + await harness.iap.recordTransaction(jws); + + expect(harness.events.map((event) => event.type)).toEqual(["purchase.completed"]); + }); + + test("emits a subscription event for a subscription", async () => { + const harness = await createHarness({ startAt: NOW }); + await harness.iap.recordTransaction(await tokenFor(harness)); + expect(harness.events[0].type).toBe("subscription.started"); + expect(harness.fake.rows("IapSubscription")).toHaveLength(1); + }); + + test("attributes the purchase to the user who made the request", async () => { + const harness = await createHarness({ startAt: NOW }); + const result = await harness.iap.recordTransaction( + await tokenFor(harness, { + appAccountToken: appAccountTokenFor("user-1"), + }), + { appUserId: "user-1" } + ); + + expect(result.recorded).toBe(true); + expect(harness.fake.rows("IapTransaction")[0].appUserId).toBe("user-1"); + }); + + test("rejects a token whose account token belongs to someone else", async () => { + // Otherwise a customer could replay another customer's signed token and + // have the purchase credited to themselves. + const harness = await createHarness({ startAt: NOW }); + const jws = await tokenFor(harness, { + appAccountToken: appAccountTokenFor("user-1"), + }); + + await expect( + harness.iap.recordTransaction(jws, { appUserId: "user-2" }) + ).rejects.toMatchObject({ code: "INVALID_APP_IDENTIFIER" }); + expect(harness.fake.rows("IapTransaction")).toHaveLength(0); + }); + + test("accepts a token with no account token at all, leaving it unattributed", async () => { + // A purchase made before the customer logged in. A later sync attaches them. + const harness = await createHarness({ startAt: NOW }); + const result = await harness.iap.recordTransaction( + await tokenFor(harness, { appAccountToken: undefined }), + { appUserId: "user-1" } + ); + expect(result.recorded).toBe(true); + expect(harness.fake.rows("IapTransaction")[0].appUserId).toBe("user-1"); + }); + + test("throws when the write fails, so the app does not finish the transaction", async () => { + // The load-bearing behaviour: if this throws, the app must not call + // finish(), and StoreKit re-delivers the purchase at the next launch. + const harness = await createHarness({ + startAt: NOW, + entities: { failWritesOn: ["IapTransaction"] }, + }); + await expect( + harness.iap.recordTransaction(await tokenFor(harness)) + ).rejects.toMatchObject({ code: "IAP_WRITE_FAILED" }); + }); + + test("rejects an unverifiable token before touching storage", async () => { + const harness = await createHarness({ startAt: NOW }); + const jws = await signJws(harness.chain, transactionPayload({ signedDate: NOW }), { + tamperSignature: true, + }); + await expect(harness.iap.recordTransaction(jws)).rejects.toMatchObject({ + code: "INVALID_SIGNATURE", + }); + expect(harness.fake.rows("IapTransaction")).toHaveLength(0); + }); +}); + +describe("syncEntitlements", () => { + test("stores everything the device knows and says what may be finished", async () => { + const harness = await createHarness({ startAt: NOW }); + + const result = await harness.iap.syncEntitlements( + { + entitlements: [ + await tokenFor(harness, { + transactionId: "tx-sub", + appAccountToken: appAccountTokenFor("user-1"), + }), + ], + unfinished: [ + await tokenFor(harness, { + transactionId: "tx-coins", + productId: "coins_100", + type: "Consumable", + expiresDate: undefined, + subscriptionGroupIdentifier: undefined, + appAccountToken: appAccountTokenFor("user-1"), + }), + ], + environment: "Production", + }, + { appUserId: "user-1" } + ); + + expect(result.recordedTransactionIds.sort()).toEqual(["tx-coins", "tx-sub"]); + expect(result.skipped).toBe(0); + expect(harness.fake.rows("IapTransaction")).toHaveLength(2); + }); + + test("stores a status pair, so renewal information reaches the server at all", async () => { + // Current entitlements carry a transaction but no renewal information, and + // renewal information is where the grace period and auto-renew flag live. + const harness = await createHarness({ startAt: NOW }); + + await harness.iap.syncEntitlements( + { + statuses: [ + { + transactionJws: await tokenFor(harness, { transactionId: "tx-sub" }), + renewalInfoJws: await signJws( + harness.chain, + renewalPayload({ + signedDate: NOW, + gracePeriodExpiresDate: NOW + 16 * 24 * HOUR, + }) + ), + }, + ], + }, + { appUserId: "user-1" } + ); + + const subscription = harness.fake.rows("IapSubscription")[0]; + expect(subscription.latestRenewalInfoJws).toBeTruthy(); + expect(subscription.latestRenewalSignedDate).toBe(NOW); + }); + + test("skips one bad token without failing the rest", async () => { + const harness = await createHarness({ startAt: NOW }); + const result = await harness.iap.syncEntitlements( + { + entitlements: [ + "not-a-token", + await tokenFor(harness, { transactionId: "tx-good" }), + ], + }, + { appUserId: "user-1" } + ); + + expect(result.skipped).toBe(1); + expect(result.recordedTransactionIds).toEqual(["tx-good"]); + }); + + test("skips a token belonging to another user", async () => { + const harness = await createHarness({ startAt: NOW }); + const result = await harness.iap.syncEntitlements( + { + entitlements: [ + await tokenFor(harness, { + transactionId: "tx-someone-else", + appAccountToken: appAccountTokenFor("user-2"), + }), + ], + }, + { appUserId: "user-1" } + ); + + expect(result.skipped).toBe(1); + expect(result.recordedTransactionIds).toEqual([]); + }); + + test("omits a transaction it could not store, so the device keeps retrying it", async () => { + const harness = await createHarness({ + startAt: NOW, + entities: { failWritesOn: ["IapTransaction"] }, + }); + const result = await harness.iap.syncEntitlements( + { entitlements: [await tokenFor(harness, { transactionId: "tx-1" })] }, + { appUserId: "user-1" } + ); + + expect(result.recordedTransactionIds).toEqual([]); + expect(result.skipped).toBe(1); + }); + + test("returns the server's own view so the app can reconcile its UI", async () => { + const harness = await createHarness({ startAt: NOW }); + await harness.iap.syncEntitlements( + { + entitlements: [ + await tokenFor(harness, { + transactionId: "tx-sub", + expiresDate: NOW + 30 * 24 * HOUR, + }), + ], + }, + { appUserId: "user-1" } + ); + + const result = await harness.iap.syncEntitlements({}, { appUserId: "user-1" }); + expect(result.snapshot.subscriptions).toHaveLength(1); + expect(result.snapshot.subscriptions[0].entitled).toBe(true); + expect(result.snapshot.asOf).toBe(NOW); + }); + + test("counts a device entitlement the server does not agree is live", async () => { + // Persistently above zero means Apple's notifications are going missing. + const harness = await createHarness({ startAt: NOW }); + const result = await harness.iap.syncEntitlements( + { + entitlements: [ + await tokenFor(harness, { + transactionId: "tx-lapsed", + expiresDate: NOW - HOUR, + }), + ], + }, + { appUserId: "user-1" } + ); + + expect(result.mismatches).toBe(1); + expect(harness.events.at(-1)).toMatchObject({ + type: "sync.applied", + mismatches: 1, + }); + }); + + test("reports no mismatch when the two sides agree", async () => { + const harness = await createHarness({ startAt: NOW }); + const result = await harness.iap.syncEntitlements( + { + entitlements: [ + await tokenFor(harness, { + transactionId: "tx-live", + expiresDate: NOW + 30 * 24 * HOUR, + }), + ], + }, + { appUserId: "user-1" } + ); + expect(result.mismatches).toBe(0); + }); + + test("is idempotent, so calling it every launch changes nothing", async () => { + const harness = await createHarness({ startAt: NOW }); + const payload = { + entitlements: [await tokenFor(harness, { transactionId: "tx-sub" })], + }; + + await harness.iap.syncEntitlements(payload, { appUserId: "user-1" }); + await harness.iap.syncEntitlements(payload, { appUserId: "user-1" }); + + expect(harness.fake.rows("IapTransaction")).toHaveLength(1); + expect(harness.fake.rows("IapSubscription")).toHaveLength(1); + }); + + test("handles an empty payload without complaint", async () => { + const harness = await createHarness({ startAt: NOW }); + const result = await harness.iap.syncEntitlements({}, { appUserId: "user-1" }); + expect(result).toMatchObject({ + recordedTransactionIds: [], + mismatches: 0, + skipped: 0, + }); + }); +}); + +describe("a device transaction cannot regress renewal information", () => { + test("keeps a grace-period date a notification supplied", async () => { + // The exact regression the two cursors exist to prevent. A notification + // brings a grace-period date; a later device sync brings a newer + // transaction and no renewal information. With one shared cursor the sync + // would advance past the notification and the grace period would vanish — + // denying service to a customer Apple is still trying to bill. + const harness = await createHarness({ startAt: NOW }); + + harness.fake.seed("IapSubscription", { + originalTransactionId: "2000000000000001", + appUserId: "user-1", + latestTransactionJws: "old-tx", + latestRenewalInfoJws: "renewal-with-grace", + latestSignedDate: NOW - 2 * HOUR, + latestRenewalSignedDate: NOW - HOUR, + environment: "Production", + }); + + await harness.iap.syncEntitlements( + { entitlements: [await tokenFor(harness, { signedDate: NOW })] }, + { appUserId: "user-1" } + ); + + const row = harness.fake.rows("IapSubscription")[0]; + expect(row.latestSignedDate).toBe(NOW); + expect(row.latestRenewalInfoJws).toBe("renewal-with-grace"); + expect(row.latestRenewalSignedDate).toBe(NOW - HOUR); + }); +}); diff --git a/tests/unit/iap-jws.test.ts b/tests/unit/iap-jws.test.ts new file mode 100644 index 00000000..d0da52f5 --- /dev/null +++ b/tests/unit/iap-jws.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, test } from "vitest"; +import { parseJws, verifyJws } from "../../src/iap/verify/jws.ts"; +import { verifyChain } from "../../src/iap/verify/chain.ts"; +import { appleRoots } from "../../src/iap/verify/apple-roots.ts"; +import { IapVerificationError } from "../../src/iap/errors.ts"; +import { + createTestChain, + trustAnchorsFor, + validChain, +} from "../iap/fixtures/test-chain.ts"; +import { signJws } from "../iap/fixtures/sign-jws.ts"; + +const SIGNED_DATE = Date.UTC(2026, 8, 3); + +const PAYLOAD = { + transactionId: "2000000123456789", + originalTransactionId: "2000000123456789", + bundleId: "com.example.app", + productId: "pro_monthly", + environment: "Production", + signedDate: SIGNED_DATE, +}; + +/** Asserts the promise rejects with an IapVerificationError carrying `code`. */ +async function expectCode(promise: Promise, code: string) { + await expect(promise).rejects.toThrow(IapVerificationError); + await expect(promise).rejects.toMatchObject({ code }); +} + +describe("JWS verification, happy path", () => { + test("verifies a well-formed token signed by a pinned chain", async () => { + const chain = await validChain(); + const parsed = parseJws(await signJws(chain, PAYLOAD)); + + expect(parsed.header.alg).toBe("ES256"); + expect(parsed.header.x5c).toHaveLength(3); + expect(parsed.payload.transactionId).toBe("2000000123456789"); + expect(parsed.signature).toHaveLength(64); + + await expect( + verifyJws(parsed, { at: SIGNED_DATE, roots: trustAnchorsFor(chain) }) + ).resolves.toBeUndefined(); + }); + + test("preserves fields it does not model, so a new Apple field is never dropped", async () => { + const chain = await validChain(); + const parsed = parseJws( + await signJws(chain, { + ...PAYLOAD, + somethingAppleAddedLater: { nested: [1, 2, 3] }, + }) + ); + expect(parsed.payload.somethingAppleAddedLater).toEqual({ nested: [1, 2, 3] }); + }); + + test("returns the leaf, intermediate and the root it pinned against", async () => { + const chain = await validChain(); + const result = await verifyChain(chain.x5c, { + at: SIGNED_DATE, + roots: trustAnchorsFor(chain), + }); + expect(result.root.name).toBe("Test Apple Root CA"); + expect(result.leaf.publicKey.curve).toBe("P-256"); + // Apple's real shape: a P-384 root signs a P-256 intermediate, so the + // intermediate's own signature digest is SHA-384 while its key is P-256. + expect(result.intermediate.publicKey.curve).toBe("P-256"); + expect(result.intermediate.signatureAlgorithm.hash).toBe("SHA-384"); + }); +}); + +describe("JWS verification evaluates certificates at signedDate", () => { + test("accepts a token whose leaf has since expired, which is what keeps stored tokens verifiable", async () => { + const chain = await createTestChain({ + leafNotBefore: new Date(Date.UTC(2020, 0, 1)), + leafNotAfter: new Date(Date.UTC(2021, 0, 1)), + }); + const signedAt = Date.UTC(2020, 5, 1); + const parsed = parseJws(await signJws(chain, { ...PAYLOAD, signedDate: signedAt })); + + // Valid at the moment Apple signed it. + await expect( + verifyJws(parsed, { at: signedAt, roots: trustAnchorsFor(chain) }) + ).resolves.toBeUndefined(); + + // The same token checked against today's clock fails, which is why v1 + // pins the instant to signedDate rather than "now". + await expectCode( + verifyJws(parsed, { at: Date.now(), roots: trustAnchorsFor(chain) }), + "INVALID_CERTIFICATE" + ); + }); +}); + +describe("JWS rejection", () => { + test("rejects a chain that does not terminate at a pinned root", async () => { + const chain = await validChain(); + const stranger = await createTestChain(); + const parsed = parseJws(await signJws(chain, PAYLOAD)); + + await expectCode( + verifyJws(parsed, { at: SIGNED_DATE, roots: trustAnchorsFor(stranger) }), + "INVALID_CERTIFICATE" + ); + }); + + test("rejects a test chain against the real Apple roots, so the default pin is live", async () => { + const chain = await validChain(); + const parsed = parseJws(await signJws(chain, PAYLOAD)); + await expectCode(verifyJws(parsed, { at: SIGNED_DATE }), "INVALID_CERTIFICATE"); + }); + + test("rejects a chain anchored at an RSA Apple root rather than trusting it", async () => { + const chain = await validChain(); + const g2 = appleRoots()[1]; + await expectCode( + verifyChain([chain.x5c[0], chain.x5c[1], g2.der], { + at: SIGNED_DATE, + roots: [g2], + }), + "UNSUPPORTED_CERT_ALGORITHM" + ); + }); + + test.each([ + ["two certificates", 2], + ["one certificate", 1], + ])("rejects a chain of %s", async (_label, count) => { + const chain = await validChain(); + const token = await signJws(chain, PAYLOAD, { x5c: chain.x5c.slice(0, count) }); + expect(() => parseJws(token)).toThrow( + expect.objectContaining({ code: "INVALID_CHAIN_LENGTH" }) + ); + }); + + test("rejects a chain of four certificates", async () => { + const chain = await validChain(); + const token = await signJws(chain, PAYLOAD, { + x5c: [...chain.x5c, chain.x5c[2]], + }); + expect(() => parseJws(token)).toThrow( + expect.objectContaining({ code: "INVALID_CHAIN_LENGTH" }) + ); + }); + + test("rejects an intermediate without Apple's developer-relations marker", async () => { + const chain = await createTestChain({ omitWwdrOid: true }); + const parsed = parseJws(await signJws(chain, PAYLOAD)); + await expectCode( + verifyJws(parsed, { at: SIGNED_DATE, roots: trustAnchorsFor(chain) }), + "INVALID_CERTIFICATE" + ); + }); + + test("rejects a leaf without the receipt-signing marker", async () => { + const chain = await createTestChain({ omitReceiptOid: true }); + const parsed = parseJws(await signJws(chain, PAYLOAD)); + await expectCode( + verifyJws(parsed, { at: SIGNED_DATE, roots: trustAnchorsFor(chain) }), + "INVALID_CERTIFICATE" + ); + }); + + test.each(["RS256", "ES384", "HS256", "none"])( + "rejects the algorithm %s, so only ES256 is ever accepted", + async (alg) => { + const chain = await validChain(); + const token = await signJws(chain, PAYLOAD, { alg }); + expect(() => parseJws(token)).toThrow( + expect.objectContaining({ code: "UNSUPPORTED_ALG" }) + ); + } + ); + + test("rejects a tampered signature", async () => { + const chain = await validChain(); + const parsed = parseJws( + await signJws(chain, PAYLOAD, { tamperSignature: true }) + ); + await expectCode( + verifyJws(parsed, { at: SIGNED_DATE, roots: trustAnchorsFor(chain) }), + "INVALID_SIGNATURE" + ); + }); + + test("rejects a payload edited after signing", async () => { + const chain = await validChain(); + const parsed = parseJws(await signJws(chain, PAYLOAD, { tamperPayload: true })); + expect(parsed.payload.tampered).toBe(true); // the edit is visible... + await expectCode( + verifyJws(parsed, { at: SIGNED_DATE, roots: trustAnchorsFor(chain) }), + "INVALID_SIGNATURE" + ); // ...and fatal + }); + + test("rejects a leaf whose curve contradicts the ES256 header", async () => { + const chain = await createTestChain({ leafCurve: "P-384" }); + const parsed = parseJws(await signJws(chain, PAYLOAD)); + await expectCode( + verifyJws(parsed, { at: SIGNED_DATE, roots: trustAnchorsFor(chain) }), + "UNSUPPORTED_CERT_ALGORITHM" + ); + }); +}); + +describe("malformed input", () => { + test.each([ + ["an empty string", ""], + ["two segments", "aaa.bbb"], + ["four segments", "aaa.bbb.ccc.ddd"], + ["an empty segment", "aaa..ccc"], + ])("rejects %s", (_label, token) => { + expect(() => parseJws(token)).toThrow( + expect.objectContaining({ code: "INVALID_JWS_FORMAT" }) + ); + }); + + test("rejects a header that is not JSON", () => { + expect(() => parseJws("bm90LWpzb24.e30.c2ln")).toThrow( + expect.objectContaining({ code: "INVALID_JWS_FORMAT" }) + ); + }); + + test("rejects a payload that is a JSON array rather than an object", async () => { + const chain = await validChain(); + // Sign a real token, then swap its payload segment for an encoded array. + const token = await signJws(chain, PAYLOAD); + const [header, , signature] = token.split("."); + expect(() => parseJws(`${header}.WzEsMiwzXQ.${signature}`)).toThrow( + expect.objectContaining({ code: "INVALID_JWS_FORMAT" }) + ); + }); + + test("rejects an implausibly large token before decoding it", () => { + const huge = `${"a".repeat(200_000)}.b.c`; + expect(() => parseJws(huge)).toThrow( + expect.objectContaining({ code: "INVALID_JWS_FORMAT" }) + ); + }); + + test("rejects a signature that is not 64 bytes", async () => { + const chain = await validChain(); + const token = await signJws(chain, PAYLOAD); + const [header, payload] = token.split("."); + expect(() => parseJws(`${header}.${payload}.AAAA`)).toThrow( + expect.objectContaining({ code: "INVALID_SIGNATURE" }) + ); + }); +}); diff --git a/tests/unit/iap-notifications.test.ts b/tests/unit/iap-notifications.test.ts new file mode 100644 index 00000000..82905ef1 --- /dev/null +++ b/tests/unit/iap-notifications.test.ts @@ -0,0 +1,493 @@ +import { describe, expect, test } from "vitest"; +import { KNOWN_NOTIFICATION_TYPES, planFor } from "../../src/iap/ingest/matrix.ts"; +import { createHarness, notification, type Harness } from "../iap/fixtures/harness.ts"; + +// Certificate validity is evaluated at each payload's own signedDate, so these +// have to be real instants inside the test chain's window rather than small +// counters — a value like 2000 lands in January 1970 and fails the chain. +const T_EARLY = Date.UTC(2026, 8, 1); +const T_MID = Date.UTC(2026, 8, 3); +const T_LATE = Date.UTC(2026, 8, 5); + +async function post(harness: Harness, signedPayload: string) { + return harness.iap.handleSignedPayload(signedPayload); +} + +describe("the notification matrix", () => { + test.each([ + ["SUBSCRIBED", "INITIAL_BUY", "subscription.started", { startReason: "initial" }], + ["SUBSCRIBED", "RESUBSCRIBE", "subscription.started", { startReason: "resubscribe" }], + ["DID_RENEW", undefined, "subscription.renewed", { renewReason: "renewal" }], + ["DID_RENEW", "BILLING_RECOVERY", "subscription.renewed", { renewReason: "billing_recovery" }], + ["DID_CHANGE_RENEWAL_PREF", "UPGRADE", "subscription.plan_changed", {}], + ["DID_CHANGE_RENEWAL_PREF", "DOWNGRADE", "subscription.plan_change_scheduled", {}], + ["DID_CHANGE_RENEWAL_PREF", undefined, "subscription.plan_change_cancelled", {}], + ["DID_CHANGE_RENEWAL_STATUS", "AUTO_RENEW_ENABLED", "subscription.auto_renew_changed", { autoRenewEnabled: true }], + ["DID_CHANGE_RENEWAL_STATUS", "AUTO_RENEW_DISABLED", "subscription.auto_renew_changed", { autoRenewEnabled: false }], + ["DID_CHANGE_RENEWAL_STATUS", undefined, "subscription.auto_renew_changed", { autoRenewEnabled: false }], + ["DID_FAIL_TO_RENEW", "GRACE_PERIOD", "subscription.billing_issue", { inGracePeriod: true }], + ["DID_FAIL_TO_RENEW", undefined, "subscription.billing_issue", { inGracePeriod: false }], + ["GRACE_PERIOD_EXPIRED", undefined, "subscription.grace_period_ended", {}], + ["EXPIRED", "VOLUNTARY", "subscription.expired", { expiryReason: "voluntary" }], + ["EXPIRED", "BILLING_RETRY", "subscription.expired", { expiryReason: "billing" }], + ["EXPIRED", "PRICE_INCREASE", "subscription.expired", { expiryReason: "price_increase" }], + ["EXPIRED", "PRODUCT_NOT_FOR_SALE", "subscription.expired", { expiryReason: "product_unavailable" }], + ["EXPIRED", undefined, "subscription.expired", { expiryReason: "other" }], + ["OFFER_REDEEMED", "UPGRADE", "subscription.offer_redeemed", {}], + ["OFFER_REDEEMED", undefined, "subscription.offer_redeemed", {}], + ["PRICE_INCREASE", "PENDING", "subscription.price_increase", { priceIncreaseConsent: "pending" }], + ["PRICE_INCREASE", "ACCEPTED", "subscription.price_increase", { priceIncreaseConsent: "accepted" }], + ["RENEWAL_EXTENDED", undefined, "subscription.renewal_extended", {}], + ["ONE_TIME_CHARGE", undefined, "purchase.completed", {}], + ["REFUND", undefined, "purchase.refunded", {}], + ["REFUND_DECLINED", undefined, "purchase.refund_declined", {}], + ["REFUND_REVERSED", undefined, "purchase.refund_reversed", {}], + ["REVOKE", undefined, "purchase.revoked", {}], + ["CONSUMPTION_REQUEST", undefined, "refund.consumption_requested", {}], + ["TEST", undefined, "apple.test_received", {}], + ["EXTERNAL_PURCHASE_TOKEN", "CREATED", "apple.unhandled", {}], + ["METADATA_UPDATE", undefined, "apple.unhandled", {}], + ["MIGRATION", undefined, "apple.unhandled", {}], + ["MIGRATE", undefined, "apple.unhandled", {}], + ["PRICE_CHANGE", undefined, "apple.unhandled", {}], + ["RESCIND_CONSENT", undefined, "apple.unhandled", {}], + ])( + "%s / %s answers Apple 200 and emits %s", + async (notificationType, subtype, expectedEvent, detail) => { + const harness = await createHarness(); + const result = await post( + harness, + await notification(harness, { + notificationType, + subtype, + renewal: notificationType.startsWith("DID_") ? {} : undefined, + }) + ); + + expect(result.status).toBe(200); + expect(result.events).toHaveLength(1); + expect(result.events[0].type).toBe(expectedEvent); + expect(result.events[0]).toMatchObject(detail); + expect(result.events[0].notificationType).toBe(notificationType); + } + ); + + test("every recognised type has a plan, and nothing falls through by accident", () => { + for (const type of KNOWN_NOTIFICATION_TYPES) { + expect(planFor(type).outcome).not.toBe("unknown_type"); + } + }); + + test("stores a type Apple invents later, and still answers 200", async () => { + // Answering anything else would make Apple retry for 72 hours, and a retry + // cannot teach this version a type it does not know. + const harness = await createHarness(); + const result = await post( + harness, + await notification(harness, { notificationType: "SOMETHING_NEW_IN_2027" }) + ); + + expect(result.status).toBe(200); + expect(result.outcome).toBe("unknown_type"); + expect(result.events[0].type).toBe("apple.unknown"); + + const stored = harness.fake.rows("IapNotification")[0]; + expect(stored.notificationType).toBe("SOMETHING_NEW_IN_2027"); + expect(stored.rawSignedPayload).toBeTruthy(); + }); + + test("accepts both spellings of the migration type, since Apple's own pages disagree", () => { + expect(planFor("MIGRATION").outcome).toBe("unhandled"); + expect(planFor("MIGRATE").outcome).toBe("unhandled"); + }); + + test("handles a mass-extension summary, which concerns a product and no single customer", async () => { + const harness = await createHarness(); + const result = await post( + harness, + await notification(harness, { + notificationType: "RENEWAL_EXTENSION", + subtype: "SUMMARY", + summary: { productId: "pro_monthly", succeededCount: 10, failedCount: 1 }, + }) + ); + + expect(result.status).toBe(200); + expect(result.events[0].type).toBe("subscription.mass_extension_result"); + expect(harness.fake.rows("IapTransaction")).toHaveLength(0); + }); +}); + +describe("what gets stored", () => { + test("writes the transaction and the subscription for a renewal", async () => { + const harness = await createHarness(); + await post( + harness, + await notification(harness, { + notificationType: "DID_RENEW", + transaction: { transactionId: "tx-renewal-1", signedDate: T_MID }, + renewal: { signedDate: T_MID }, + data: { status: 1 }, + }) + ); + + const transaction = harness.fake.rows("IapTransaction")[0]; + expect(transaction.transactionId).toBe("tx-renewal-1"); + expect(transaction.source).toBe("notification"); + expect(transaction.rawJws).toBeTruthy(); + + const subscription = harness.fake.rows("IapSubscription")[0]; + expect(subscription.originalTransactionId).toBe("2000000000000001"); + expect(subscription.appleStatus).toBe(1); + expect(subscription.latestTransactionJws).toBeTruthy(); + expect(subscription.latestRenewalInfoJws).toBeTruthy(); + expect(subscription.latestRenewalSignedDate).toBe(T_MID); + }); + + test("does not create a subscription row for a one-time purchase", async () => { + const harness = await createHarness(); + await post( + harness, + await notification(harness, { + notificationType: "ONE_TIME_CHARGE", + transaction: { + transactionId: "tx-coins-1", + productId: "coins_100", + type: "Consumable", + subscriptionGroupIdentifier: undefined, + expiresDate: undefined, + }, + }) + ); + + expect(harness.fake.rows("IapTransaction")).toHaveLength(1); + expect(harness.fake.rows("IapSubscription")).toHaveLength(0); + }); + + test("computes an expiry for a non-renewing subscription, which Apple never expires", async () => { + const harness = await createHarness(); + const purchasedAt = Date.UTC(2026, 8, 3); + await post( + harness, + await notification(harness, { + notificationType: "ONE_TIME_CHARGE", + transaction: { + transactionId: "tx-pass-1", + productId: "season_pass", + type: "Non-Renewing Subscription", + purchaseDate: purchasedAt, + expiresDate: undefined, + subscriptionGroupIdentifier: undefined, + }, + }) + ); + + const row = harness.fake.rows("IapTransaction")[0]; + // 90 configured days after the purchase. + expect(row.appDefinedExpiresDate).toBe(purchasedAt + 90 * 86_400_000); + }); + + test("clears the revocation when a refund is reversed, so the app can reinstate", async () => { + const harness = await createHarness(); + + await post( + harness, + await notification(harness, { + notificationType: "REFUND", + transaction: { + transactionId: "tx-refunded", + signedDate: T_EARLY, + revocationDate: T_EARLY, + revocationReason: 0, + revocationType: "REFUND_FULL", + revocationPercentage: 100000, + }, + }) + ); + expect(harness.fake.rows("IapTransaction")[0].revocationDate).toBe(T_EARLY); + + await post( + harness, + await notification(harness, { + notificationType: "REFUND_REVERSED", + transaction: { transactionId: "tx-refunded", signedDate: T_LATE }, + }) + ); + + const row = harness.fake.rows("IapTransaction")[0]; + // Apple omits revocationPercentage on a reversal, so leaving nullish + // fields alone is not enough — they have to be cleared, or a paying + // customer stays locked out. + expect(row.revocationDate).toBeUndefined(); + expect(row.revocationPercentage).toBeUndefined(); + }); + + test.each([ + ["Production", 12 * 60 * 60 * 1000], + ["Sandbox", 5 * 60 * 1000], + ])("gives a %s consumption request the right deadline", async (environment, window) => { + const harness = await createHarness({ config: { testMode: true } }); + const result = await post( + harness, + await notification(harness, { + notificationType: "CONSUMPTION_REQUEST", + environment, + transaction: { transactionId: "tx-disputed" }, + data: { consumptionRequestReason: "UNINTENDED_PURCHASE" }, + }) + ); + + const row = harness.fake.rows("IapConsumptionRequest")[0]; + expect(row.deadlineAt).toBe(harness.now() + window); + expect(row.consumptionRequestReason).toBe("UNINTENDED_PURCHASE"); + expect(result.events[0].deadlineAt).toBe(harness.now() + window); + }); + + test("fills in a consumption request's outcome from a later refund notification", async () => { + const harness = await createHarness(); + await post( + harness, + await notification(harness, { + notificationType: "CONSUMPTION_REQUEST", + signedDate: T_EARLY, + transaction: { transactionId: "tx-disputed" }, + }) + ); + await post( + harness, + await notification(harness, { + notificationType: "REFUND", + signedDate: T_LATE, + transaction: { transactionId: "tx-disputed", revocationDate: T_LATE }, + }) + ); + + expect(harness.fake.rows("IapConsumptionRequest")[0].outcome).toBe("REFUND"); + }); + + test("attributes a notification to the user already on the subscription row", async () => { + // A webhook has no authenticated user, and the account token Apple signs + // in is a one-way hash, so the user is inherited from stored data. + const harness = await createHarness(); + harness.fake.seed("IapSubscription", { + originalTransactionId: "2000000000000001", + appUserId: "user-42", + latestSignedDate: T_EARLY, + environment: "Production", + }); + + const result = await post( + harness, + await notification(harness, { notificationType: "DID_RENEW", renewal: {} }) + ); + + expect(result.events[0].appUserId).toBe("user-42"); + expect(harness.fake.rows("IapTransaction")[0].appUserId).toBe("user-42"); + }); + + test("leaves the user null for a purchase made before anyone logged in", async () => { + const harness = await createHarness(); + const result = await post( + harness, + await notification(harness, { notificationType: "ONE_TIME_CHARGE" }) + ); + expect(result.events[0].appUserId).toBeNull(); + }); +}); + +describe("duplicates and ordering", () => { + test("recognises a repeat delivery and does not apply it twice", async () => { + const harness = await createHarness(); + const payload = await notification(harness, { + notificationType: "ONE_TIME_CHARGE", + notificationUUID: "11111111-0000-4000-8000-000000000001", + }); + + const first = await post(harness, payload); + expect(first.outcome).toBe("applied"); + + const second = await post(harness, payload); + expect(second.status).toBe(200); + expect(second.outcome).toBe("duplicate"); + expect(second.events).toHaveLength(0); + expect(harness.fake.rows("IapNotification")).toHaveLength(1); + }); + + test("re-applies a notification that was claimed but never applied", async () => { + // The case that would otherwise lose money. An earlier attempt wrote the + // raw row, then its entity writes failed and it answered 503. Apple + // retries. A naive duplicate check would see the row and say "already + // handled" — and the purchase data would never be stored, because Apple + // does not retry a success. + const harness = await createHarness(); + harness.fake.seed("IapNotification", { + notificationUUID: "22222222-0000-4000-8000-000000000001", + notificationType: "ONE_TIME_CHARGE", + outcome: "error", + attempts: 1, + }); + + const result = await post( + harness, + await notification(harness, { + notificationType: "ONE_TIME_CHARGE", + notificationUUID: "22222222-0000-4000-8000-000000000001", + transaction: { transactionId: "tx-recovered" }, + }) + ); + + expect(result.status).toBe(200); + expect(result.outcome).toBe("applied"); + expect(harness.fake.rows("IapTransaction")[0].transactionId).toBe("tx-recovered"); + + const stored = harness.fake.rows("IapNotification")[0]; + expect(stored.outcome).toBe("applied"); + expect(stored.attempts).toBe(2); + }); + + test("refuses to let an older notification undo a newer one", async () => { + const harness = await createHarness(); + + await post( + harness, + await notification(harness, { + notificationType: "DID_RENEW", + signedDate: T_LATE, + transaction: { transactionId: "tx-new", productId: "pro_yearly", signedDate: T_LATE }, + renewal: { signedDate: T_LATE }, + }) + ); + + await post( + harness, + await notification(harness, { + notificationType: "DID_RENEW", + signedDate: T_EARLY, + transaction: { transactionId: "tx-old", productId: "pro_monthly", signedDate: T_EARLY }, + renewal: { signedDate: T_EARLY }, + }) + ); + + // Both transactions are stored — they are different rows — but the + // subscription still points at the newer one. + expect(harness.fake.rows("IapTransaction")).toHaveLength(2); + const subscription = harness.fake.rows("IapSubscription")[0]; + expect(subscription.productId).toBe("pro_yearly"); + expect(subscription.latestSignedDate).toBe(T_LATE); + }); +}); + +describe("failures", () => { + test("answers 503 when a write fails, so Apple comes back", async () => { + const harness = await createHarness({ + entities: { failWritesOn: ["IapTransaction"] }, + }); + const result = await post( + harness, + await notification(harness, { notificationType: "ONE_TIME_CHARGE" }) + ); + + expect(result.status).toBe(503); + expect(result.events).toHaveLength(0); + }); + + test("leaves the notification uncommitted after a failed write, so the retry redoes it", async () => { + const harness = await createHarness({ + entities: { failWritesOn: ["IapTransaction"] }, + }); + await post(harness, await notification(harness, { notificationType: "ONE_TIME_CHARGE" })); + + const stored = harness.fake.rows("IapNotification")[0]; + expect(stored.outcome).toBe("error"); + }); + + test("answers 401 for a payload it cannot verify", async () => { + const harness = await createHarness(); + const result = await post( + harness, + await notification(harness, { + notificationType: "ONE_TIME_CHARGE", + jws: { tamperSignature: true }, + }) + ); + + expect(result.status).toBe(401); + expect(result.error).toMatch(/INVALID_SIGNATURE/); + expect(harness.fake.rows("IapNotification")).toHaveLength(0); + }); + + test("answers 400 for a body Apple would never send", async () => { + const harness = await createHarness(); + const request = new Request("https://example.com/iap", { + method: "POST", + body: JSON.stringify({ nothing: "useful" }), + }); + const response = await harness.iap.handleNotification(request); + expect(response.status).toBe(400); + }); + + test("answers 400 for a body that is not JSON at all", async () => { + const harness = await createHarness(); + const request = new Request("https://example.com/iap", { + method: "POST", + body: "not json", + }); + expect((await harness.iap.handleNotification(request)).status).toBe(400); + }); + + test("returns a real Response from the request path, with no body", async () => { + const harness = await createHarness(); + const signedPayload = await notification(harness, { + notificationType: "ONE_TIME_CHARGE", + }); + const response = await harness.iap.handleNotification( + new Request("https://example.com/iap", { + method: "POST", + body: JSON.stringify({ signedPayload }), + }) + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(""); + }); + + test("a handler that throws cannot change what Apple is told", async () => { + const harness = await createHarness(); + harness.iap.onEvent(() => { + throw new Error("the app's own handler is broken"); + }); + + const result = await post( + harness, + await notification(harness, { notificationType: "ONE_TIME_CHARGE" }) + ); + expect(result.status).toBe(200); + expect(harness.fake.rows("IapTransaction")).toHaveLength(1); + }); +}); + +describe("setup checks", () => { + test("reports which entities are missing", async () => { + const harness = await createHarness({ + entities: { missingEntities: ["IapSubscription"] }, + }); + const report = await harness.iap.checkSetup(); + expect(report.ok).toBe(false); + expect(report.missingEntities).toEqual(["IapSubscription"]); + expect(report.checklist.length).toBeGreaterThan(0); + }); + + test("passes when all four entities exist", async () => { + const harness = await createHarness(); + const report = await harness.iap.checkSetup(); + expect(report.ok).toBe(true); + expect(report.missingEntities).toEqual([]); + }); + + test("never throws, so a status page can call it safely", async () => { + const harness = await createHarness({ + entities: { failWritesOn: ["IapTransaction"] }, + }); + await expect(harness.iap.checkSetup()).resolves.toBeDefined(); + }); +}); diff --git a/tests/unit/iap-packaging.test.ts b/tests/unit/iap-packaging.test.ts new file mode 100644 index 00000000..7f318572 --- /dev/null +++ b/tests/unit/iap-packaging.test.ts @@ -0,0 +1,175 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as { + main: string; + types: string; + exports: Record; + dependencies: Record; + devDependencies: Record; +}; + +const indexSource = readFileSync("src/index.ts", "utf8"); +const clientSource = readFileSync("src/client.ts", "utf8"); +const clientTypesSource = readFileSync("src/client.types.ts", "utf8"); + +/** Removes block and line comments, so a check sees code rather than prose. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); +} + +describe("package exports", () => { + test("keeps main and types, so resolvers predating exports maps still work", () => { + expect(packageJson.main).toBe("dist/index.js"); + expect(packageJson.types).toBe("dist/index.d.ts"); + }); + + test("exposes the iap subpath", () => { + expect(packageJson.exports["./iap"]).toEqual({ + types: "./dist/iap/index.d.ts", + default: "./dist/iap/index.js", + }); + }); + + test("keeps deep dist/ paths resolving, which Base44 app templates depend on", () => { + // Templates import "@base44/sdk/dist/utils/axios-client" with no + // extension, and an exports map does no extension guessing. Both patterns + // are needed: the bare one maps an extensionless request onto ".js", and + // the "*.js" one stops an explicit ".js" request becoming ".js.js". + // Node prefers the longer suffix, so the two do not conflict. + expect(packageJson.exports["./dist/*"]).toEqual({ + types: "./dist/*.d.ts", + default: "./dist/*.js", + }); + expect(packageJson.exports["./dist/*.js"]).toEqual({ + types: "./dist/*.d.ts", + default: "./dist/*.js", + }); + expect(packageJson.exports["./dist/*.d.ts"]).toBe("./dist/*.d.ts"); + }); + + test("still exports package.json, which tooling reads directly", () => { + expect(packageJson.exports["./package.json"]).toBe("./package.json"); + }); + + test("adds no production dependency, so the verification code carries none", () => { + expect(Object.keys(packageJson.dependencies).sort()).toEqual([ + "axios", + "partysocket", + "socket.io-client", + "uuid", + ]); + }); + + test("keeps the certificate-generation library to devDependencies, where it never ships", () => { + expect(packageJson.devDependencies["@peculiar/x509"]).toBeDefined(); + expect(packageJson.dependencies["@peculiar/x509"]).toBeUndefined(); + // The gating CI audit runs with --omit=dev, so a dev-only dependency + // cannot fail it. + expect(packageJson.devDependencies["reflect-metadata"]).toBeDefined(); + expect(packageJson.dependencies["reflect-metadata"]).toBeUndefined(); + }); +}); + +describe("entry-point isolation", () => { + test("the main entry references the iap module only as types, so browsers download none of it", () => { + const iapLines = indexSource + .split("\n") + .map((line, i) => [i + 1, line] as const) + .filter(([, line]) => line.includes('from "./iap/')); + + expect(iapLines.length).toBeGreaterThan(0); + + // Every iap import must belong to an `export type { ... }` block. Walk + // backwards from the `from` line to the statement that opened it. + const lines = indexSource.split("\n"); + for (const [lineNumber] of iapLines) { + let cursor = lineNumber - 1; + while (cursor > 0 && !lines[cursor].includes("export type {")) { + expect(lines[cursor]).not.toMatch(/^\s*(import|export)\s+\{/); + cursor -= 1; + } + expect(lines[cursor]).toContain("export type {"); + } + }); + + test("the client never constructs the iap module, which is what keeps it off the browser path", () => { + expect(clientSource).not.toMatch(/\biap\b/i); + expect(clientTypesSource).not.toMatch(/\biap\b/i); + }); + + test("the iap module touches no Node built-in, so it runs on Deno and in a browser", () => { + const files = [ + "src/iap/index.ts", + "src/iap/version.ts", + "src/iap/errors.types.ts", + "src/iap/ingest/matrix.ts", + "src/iap/ingest/mappers.ts", + "src/iap/ingest/notifications.ts", + "src/iap/ingest/device.ts", + "src/iap/ingest/device.types.ts", + "src/iap/read/derive.ts", + "src/iap/read/read.ts", + "src/iap/read/read.types.ts", + "src/iap/store/collapse.ts", + "src/iap/store/descriptors.ts", + "src/iap/store/entities-store.ts", + "src/iap/store/rows.types.ts", + "src/iap/store/schemas.ts", + "src/iap/store/store-errors.ts", + "src/iap/store/store.types.ts", + "src/iap/server-api/client.ts", + "src/iap/server-api/jwt.ts", + "src/iap/server-api/server-api.types.ts", + "src/iap/events/emitter.ts", + "src/iap/events/events.types.ts", + "src/iap/config.ts", + "src/iap/errors.ts", + "src/iap/account-token.ts", + "src/iap/runtime/base64.ts", + "src/iap/runtime/webcrypto.ts", + "src/iap/runtime/clock.ts", + "src/iap/verify/asn1.ts", + "src/iap/verify/x509.ts", + "src/iap/verify/ecdsa.ts", + "src/iap/verify/chain.ts", + "src/iap/verify/jws.ts", + "src/iap/verify/verifier.ts", + "src/iap/verify/payload-checks.ts", + "src/iap/verify/apple-roots.ts", + ]; + for (const file of files) { + // Comments are stripped first: several of these files explain in prose + // why they avoid `Buffer` or `process.env`, and a naive match would + // flag the explanation as the offence. + const source = stripComments(readFileSync(file, "utf8")); + expect(source, `${file} imports a Node built-in`).not.toMatch(/from "node:/); + expect(source, `${file} uses require()`).not.toMatch(/\brequire\(/); + expect(source, `${file} uses Buffer`).not.toMatch(/\bBuffer\s*[.(]|new\s+Buffer/); + expect(source, `${file} reads process.env`).not.toMatch(/process\.env/); + expect(source, `${file} touches window`).not.toMatch(/\bwindow\./); + expect(source, `${file} touches document`).not.toMatch(/\bdocument\./); + } + }); + + test("only the runtime accessor file reaches for a global, so there is one place to audit", () => { + const verifyFiles = [ + "src/iap/verify/asn1.ts", + "src/iap/verify/x509.ts", + "src/iap/verify/ecdsa.ts", + "src/iap/verify/chain.ts", + "src/iap/verify/jws.ts", + "src/iap/verify/verifier.ts", + ]; + for (const file of verifyFiles) { + const source = stripComments(readFileSync(file, "utf8")); + // `crypto.subtle` and `fetch` are reached only through runtime/webcrypto.ts. + expect(source, `${file} reaches crypto directly`).not.toMatch( + /globalThis\.crypto|[^.\w]crypto\.subtle/ + ); + } + expect(readFileSync("src/iap/runtime/webcrypto.ts", "utf8")).toMatch( + /globalThis as \{ crypto\?: Crypto \}/ + ); + }); +}); diff --git a/tests/unit/iap-read.test.ts b/tests/unit/iap-read.test.ts new file mode 100644 index 00000000..6ce17572 --- /dev/null +++ b/tests/unit/iap-read.test.ts @@ -0,0 +1,484 @@ +import { describe, expect, test } from "vitest"; +import { + deriveStatus, + deriveSubscriptionState, + statusDisagrees, +} from "../../src/iap/read/derive.ts"; +import { createHarness, notification } from "../iap/fixtures/harness.ts"; + +const NOW = Date.UTC(2026, 8, 3, 12, 0, 0); +const HOUR = 3_600_000; + +describe("the five derivation rules", () => { + test("1. a revoked purchase is never entitled, even inside a paid period", () => { + // Apple's rule is absolute: never deliver content for a transaction + // carrying a revocation date. It outranks an expiry still in the future. + const state = deriveSubscriptionState({ + originalTransactionId: "otx-1", + transaction: { + expiresDate: NOW + 30 * 24 * HOUR, + revocationDate: NOW - HOUR, + revocationReason: 1, + revocationType: "REFUND_FULL", + revocationPercentage: 100000, + }, + renewal: undefined, + environment: "Production", + appleStatus: 5, + now: NOW, + }); + + expect(state.status).toBe("revoked"); + expect(state.entitled).toBe(false); + expect(state.revocation).toEqual({ + date: NOW - HOUR, + reason: 1, + type: "REFUND_FULL", + percentage: 100000, + }); + }); + + test("2. an unexpired subscription is active", () => { + const state = deriveSubscriptionState({ + originalTransactionId: "otx-1", + transaction: { expiresDate: NOW + HOUR }, + renewal: { autoRenewStatus: 1 }, + environment: "Production", + appleStatus: 1, + now: NOW, + }); + expect(state.status).toBe("active"); + expect(state.entitled).toBe(true); + expect(state.willRenew).toBe(true); + }); + + test("3. an expired subscription in a grace period IS entitled", () => { + // The rule most easily got wrong. The payment failed, but Apple's + // requirement is explicit: provide full service throughout the grace + // period. + const state = deriveSubscriptionState({ + originalTransactionId: "otx-1", + transaction: { expiresDate: NOW - HOUR }, + renewal: { gracePeriodExpiresDate: NOW + 16 * 24 * HOUR, isInBillingRetryPeriod: true }, + environment: "Production", + appleStatus: 4, + now: NOW, + }); + expect(state.status).toBe("grace_period"); + expect(state.entitled).toBe(true); + expect(state.gracePeriodExpiresAt).toBe(NOW + 16 * 24 * HOUR); + }); + + test("4. billing retry without a grace period is NOT entitled", () => { + const state = deriveSubscriptionState({ + originalTransactionId: "otx-1", + transaction: { expiresDate: NOW - HOUR }, + renewal: { isInBillingRetryPeriod: true }, + environment: "Production", + appleStatus: 3, + now: NOW, + }); + expect(state.status).toBe("billing_retry"); + expect(state.entitled).toBe(false); + }); + + test("5. anything else has expired, with the reason Apple gave", () => { + const state = deriveSubscriptionState({ + originalTransactionId: "otx-1", + transaction: { expiresDate: NOW - HOUR }, + renewal: { expirationIntent: 2, autoRenewStatus: 0 }, + environment: "Production", + appleStatus: 2, + now: NOW, + }); + expect(state.status).toBe("expired"); + expect(state.entitled).toBe(false); + expect(state.expirationReason).toBe("billing_error"); + expect(state.willRenew).toBe(false); + }); + + test.each([ + [1, "cancelled"], + [2, "billing_error"], + [3, "price_increase_declined"], + [4, "product_unavailable"], + [5, "other"], + [99, "other"], + ])("reads expiration intent %i as %s", (intent, reason) => { + const state = deriveSubscriptionState({ + originalTransactionId: "otx-1", + transaction: { expiresDate: NOW - HOUR }, + renewal: { expirationIntent: intent }, + environment: "Production", + appleStatus: null, + now: NOW, + }); + expect(state.expirationReason).toBe(reason); + }); + + test("a grace period that has itself ended no longer entitles", () => { + expect( + deriveStatus( + { expiresDate: NOW - 2 * HOUR }, + { gracePeriodExpiresDate: NOW - HOUR, isInBillingRetryPeriod: true }, + NOW + ) + ).toBe("billing_retry"); + }); + + test("a subscription with no expiry at all reads as expired, which is the safe direction", () => { + expect(deriveStatus({}, undefined, NOW)).toBe("expired"); + expect(deriveStatus(undefined, undefined, NOW)).toBe("expired"); + }); + + test("carries the details a customer-facing screen needs", () => { + const state = deriveSubscriptionState({ + originalTransactionId: "otx-1", + transaction: { + expiresDate: NOW + HOUR, + productId: "pro_monthly", + subscriptionGroupIdentifier: "21234567", + inAppOwnershipType: "FAMILY_SHARED", + offerType: 2, + offerIdentifier: "winter_promo", + offerDiscountType: "PAY_AS_YOU_GO", + signedDate: NOW, + }, + renewal: { + autoRenewProductId: "pro_yearly", + autoRenewStatus: 1, + priceIncreaseStatus: 0, + eligibleWinBackOfferIds: ["comeback_20"], + }, + environment: "Production", + appleStatus: 1, + now: NOW, + }); + + expect(state).toMatchObject({ + productId: "pro_monthly", + subscriptionGroupIdentifier: "21234567", + isFamilyShared: true, + autoRenewProductId: "pro_yearly", + priceIncreaseConsentPending: true, + eligibleWinBackOfferIds: ["comeback_20"], + offer: { type: 2, identifier: "winter_promo", discountType: "PAY_AS_YOU_GO" }, + signedDate: NOW, + }); + }); + + test("notices when the derived status disagrees with Apple's own code", () => { + expect(statusDisagrees("active", 1)).toBe(false); + expect(statusDisagrees("grace_period", 4)).toBe(false); + expect(statusDisagrees("billing_retry", 3)).toBe(false); + expect(statusDisagrees("expired", 2)).toBe(false); + expect(statusDisagrees("revoked", 5)).toBe(false); + expect(statusDisagrees("active", 2)).toBe(true); + // No code from Apple is not a disagreement. + expect(statusDisagrees("active", null)).toBe(false); + }); +}); + +describe("entitlement checks, end to end", () => { + /** Stores a subscription by pushing a real signed notification through. */ + async function storeSubscription( + harness: Awaited>, + options: { + readonly appUserId?: string; + readonly productId?: string; + readonly expiresDate?: number; + readonly environment?: string; + readonly renewal?: Record; + readonly transaction?: Record; + } = {} + ) { + if (options.appUserId) { + harness.fake.seed("IapSubscription", { + originalTransactionId: "2000000000000001", + appUserId: options.appUserId, + latestSignedDate: Date.UTC(2026, 8, 1), + environment: options.environment ?? "Production", + }); + } + + const result = await harness.iap.handleSignedPayload( + await notification(harness, { + notificationType: "SUBSCRIBED", + subtype: "INITIAL_BUY", + environment: options.environment, + transaction: { + productId: options.productId ?? "pro_monthly", + expiresDate: options.expiresDate ?? NOW + 30 * 24 * HOUR, + ...options.transaction, + }, + renewal: { autoRenewStatus: 1, ...options.renewal }, + }) + ); + expect(result.status).toBe(200); + } + + test("says yes for a live subscription", async () => { + const harness = await createHarness({ startAt: NOW }); + await storeSubscription(harness, { appUserId: "user-1" }); + expect(await harness.iap.hasActiveSubscription("user-1")).toBe(true); + }); + + test("says no for a lapsed one, without needing an EXPIRED notification", async () => { + // This is why status is derived rather than stored: the subscription + // simply runs out, whether or not Apple's notification ever arrived. + const harness = await createHarness({ startAt: NOW }); + await storeSubscription(harness, { + appUserId: "user-1", + expiresDate: NOW - HOUR, + }); + expect(await harness.iap.hasActiveSubscription("user-1")).toBe(false); + }); + + test("says yes during a billing grace period", async () => { + const harness = await createHarness({ startAt: NOW }); + await storeSubscription(harness, { + appUserId: "user-1", + expiresDate: NOW - HOUR, + renewal: { gracePeriodExpiresDate: NOW + 16 * 24 * HOUR }, + }); + expect(await harness.iap.hasActiveSubscription("user-1")).toBe(true); + }); + + test("says no for a user with nothing at all", async () => { + const harness = await createHarness({ startAt: NOW }); + expect(await harness.iap.hasActiveSubscription("stranger")).toBe(false); + }); + + test("says no for an empty user id rather than matching every unattributed row", async () => { + const harness = await createHarness({ startAt: NOW }); + expect(await harness.iap.hasActiveSubscription("")).toBe(false); + }); + + test("narrows to the products asked for", async () => { + const harness = await createHarness({ startAt: NOW }); + await storeSubscription(harness, { appUserId: "user-1", productId: "pro_monthly" }); + + expect( + await harness.iap.hasActiveSubscription("user-1", { productIds: ["pro_monthly"] }) + ).toBe(true); + expect( + await harness.iap.hasActiveSubscription("user-1", { productIds: ["pro_yearly"] }) + ).toBe(false); + }); + + test("ignores a sandbox purchase unless test mode is on", async () => { + // So a live app cannot be unlocked with a sandbox purchase. + const strict = await createHarness({ startAt: NOW, config: { testMode: false } }); + // A sandbox token is refused outright when test mode is off, so the + // notification itself fails — which is the earliest possible rejection. + const rejected = await strict.iap.handleSignedPayload( + await notification(strict, { + notificationType: "SUBSCRIBED", + environment: "Sandbox", + renewal: {}, + }) + ); + expect(rejected.status).toBe(401); + + const testing = await createHarness({ startAt: NOW, config: { testMode: true } }); + await storeSubscription(testing, { appUserId: "user-1", environment: "Sandbox" }); + expect(await testing.iap.hasActiveSubscription("user-1")).toBe(true); + }); + + test("never throws when storage is broken, and denies instead", async () => { + const harness = await createHarness({ + startAt: NOW, + entities: { missingEntities: ["IapSubscription"] }, + }); + await expect(harness.iap.hasActiveSubscription("user-1")).resolves.toBe(false); + }); + + test("denies a row whose stored token no longer verifies, without failing the call", async () => { + const harness = await createHarness({ startAt: NOW }); + harness.fake.seed("IapSubscription", { + originalTransactionId: "otx-corrupt", + appUserId: "user-1", + latestTransactionJws: "not-a-real-token", + latestSignedDate: NOW, + environment: "Production", + }); + + const states = await harness.iap.getSubscriptionState("user-1"); + expect(states).toHaveLength(1); + expect(states[0].entitled).toBe(false); + expect(await harness.iap.hasActiveSubscription("user-1")).toBe(false); + }); +}); + +describe("entitlements", () => { + test("lists what a user owns, and never lists consumables", async () => { + const harness = await createHarness({ startAt: NOW }); + + await harness.iap.handleSignedPayload( + await notification(harness, { + notificationType: "ONE_TIME_CHARGE", + transaction: { + transactionId: "tx-lifetime", + productId: "lifetime", + type: "Non-Consumable", + subscriptionGroupIdentifier: undefined, + expiresDate: undefined, + }, + }) + ); + await harness.iap.handleSignedPayload( + await notification(harness, { + notificationType: "ONE_TIME_CHARGE", + transaction: { + transactionId: "tx-coins", + productId: "coins_100", + type: "Consumable", + subscriptionGroupIdentifier: undefined, + expiresDate: undefined, + }, + }) + ); + + // The webhook could not attribute either purchase, so attach a user the + // way a later device sync would. + for (const row of harness.fake.rows("IapTransaction")) { + row.appUserId = "user-1"; + } + + const owned = await harness.iap.getEntitlements("user-1"); + expect(owned.nonConsumables.map((item) => item.productId)).toEqual(["lifetime"]); + // Apple leaves consumables out of current entitlements, and so does this. + expect(JSON.stringify(owned)).not.toContain("coins_100"); + expect(owned.asOf).toBe(NOW); + }); + + test("expires a non-renewing subscription from the configured duration", async () => { + const harness = await createHarness({ startAt: NOW }); + const purchasedAt = NOW - 100 * 24 * HOUR; // 100 days ago, pass lasts 90 + + await harness.iap.handleSignedPayload( + await notification(harness, { + notificationType: "ONE_TIME_CHARGE", + transaction: { + transactionId: "tx-pass", + productId: "season_pass", + type: "Non-Renewing Subscription", + purchaseDate: purchasedAt, + expiresDate: undefined, + subscriptionGroupIdentifier: undefined, + }, + }) + ); + for (const row of harness.fake.rows("IapTransaction")) { + row.appUserId = "user-1"; + } + + const owned = await harness.iap.getEntitlements("user-1"); + const [pass] = owned.nonRenewingSubscriptions; + expect(pass.productId).toBe("season_pass"); + expect(pass.expiresAt).toBe(purchasedAt + 90 * 24 * HOUR); + // Apple never expires these, so only the configured duration says so. + expect(pass.active).toBe(false); + }); +}); + +describe("listings", () => { + test("finds a stored purchase by transaction id", async () => { + const harness = await createHarness({ startAt: NOW }); + await harness.iap.handleSignedPayload( + await notification(harness, { + notificationType: "ONE_TIME_CHARGE", + transaction: { transactionId: "tx-lookup", productId: "lifetime" }, + }) + ); + + const purchase = await harness.iap.getPurchase("tx-lookup"); + expect(purchase?.productId).toBe("lifetime"); + expect(await harness.iap.getPurchase("nope")).toBeNull(); + }); + + test("lists only refunded purchases, with how much was refunded", async () => { + const harness = await createHarness({ startAt: NOW }); + harness.fake.seed("IapTransaction", { + transactionId: "tx-kept", + appUserId: "user-1", + productId: "coins_100", + purchaseDate: NOW - HOUR, + revocationDate: null, + environment: "Production", + signedDate: NOW, + }); + harness.fake.seed("IapTransaction", { + transactionId: "tx-refunded", + appUserId: "user-1", + productId: "coins_100", + purchaseDate: NOW - 2 * HOUR, + revocationDate: NOW, + revocationPercentage: 50000, + environment: "Production", + signedDate: NOW, + }); + + const refunds = await harness.iap.listRefunds("user-1"); + expect(refunds.map((row) => row.transactionId)).toEqual(["tx-refunded"]); + expect(refunds[0].revocationPercentage).toBe(50000); + }); + + test("filters a transaction listing by product, date and refund state", async () => { + const harness = await createHarness({ startAt: NOW }); + for (const [id, productId, purchaseDate, revocationDate] of [ + ["tx-1", "coins_100", NOW - 10 * HOUR, null], + ["tx-2", "coins_100", NOW - 2 * HOUR, null], + ["tx-3", "lifetime", NOW - HOUR, null], + ["tx-4", "coins_100", NOW - HOUR, NOW], + ] as const) { + harness.fake.seed("IapTransaction", { + transactionId: id, + appUserId: "user-1", + productId, + purchaseDate, + revocationDate, + environment: "Production", + signedDate: NOW, + }); + } + + const recentCoins = await harness.iap.listTransactions("user-1", { + productId: "coins_100", + since: NOW - 5 * HOUR, + revoked: false, + }); + expect(recentCoins.map((row) => row.transactionId)).toEqual(["tx-2"]); + }); + + test("lists open consumption requests by deadline, and drops expired ones", async () => { + const harness = await createHarness({ startAt: NOW }); + harness.fake.seed("IapConsumptionRequest", { + transactionId: "tx-late", + deadlineAt: NOW + 10 * 60_000, + respondedAt: null, + environment: "Production", + }); + harness.fake.seed("IapConsumptionRequest", { + transactionId: "tx-soon", + deadlineAt: NOW + 60_000, + respondedAt: null, + environment: "Production", + }); + harness.fake.seed("IapConsumptionRequest", { + transactionId: "tx-missed", + deadlineAt: NOW - 60_000, + respondedAt: null, + environment: "Production", + }); + harness.fake.seed("IapConsumptionRequest", { + transactionId: "tx-answered", + deadlineAt: NOW + 60_000, + respondedAt: NOW, + environment: "Production", + }); + + const pending = await harness.iap.listPendingConsumptionRequests(); + expect(pending.map((row) => row.transactionId)).toEqual(["tx-soon", "tx-late"]); + }); +}); diff --git a/tests/unit/iap-sandbox.test.ts b/tests/unit/iap-sandbox.test.ts new file mode 100644 index 00000000..f5aac700 --- /dev/null +++ b/tests/unit/iap-sandbox.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, test } from "vitest"; +import { signJws } from "../iap/fixtures/sign-jws.ts"; +import { + createHarness, + notification, + renewalPayload, + transactionPayload, + type Harness, +} from "../iap/fixtures/harness.ts"; + +// Apple accelerates sandbox subscriptions: a one-month plan renews every five +// minutes, and grace periods last minutes rather than days. Nothing in the SDK +// treats those durations specially — expiry is read from the payload — but the +// timings here are the real ones, so the tests exercise the same shape. +const NOW = Date.UTC(2026, 8, 3, 12, 0, 0); +const MINUTE = 60_000; +const SANDBOX_MONTH = 5 * MINUTE; + +/** A harness that accepts sandbox purchases. */ +function sandboxHarness(startAt = NOW) { + return createHarness({ startAt, config: { testMode: true } }); +} + +/** A sandbox notification. Apple omits appAppleId from sandbox payloads. */ +async function sandboxNotification( + harness: Harness, + options: Parameters[1] +) { + return notification(harness, { ...options, environment: "Sandbox" }); +} + +describe("a sandbox subscription, start to finish", () => { + test("the initial purchase is stored and entitles the customer", async () => { + const harness = await sandboxHarness(); + harness.fake.seed("IapSubscription", { + originalTransactionId: "2000000000000001", + appUserId: "tester-1", + latestSignedDate: NOW - MINUTE, + environment: "Sandbox", + }); + + const result = await harness.iap.handleSignedPayload( + await sandboxNotification(harness, { + notificationType: "SUBSCRIBED", + subtype: "INITIAL_BUY", + signedDate: NOW, + transaction: { expiresDate: NOW + SANDBOX_MONTH }, + renewal: { autoRenewStatus: 1 }, + }) + ); + + expect(result.status).toBe(200); + expect(result.outcome).toBe("applied"); + expect(harness.fake.rows("IapTransaction")[0].environment).toBe("Sandbox"); + expect(await harness.iap.hasActiveSubscription("tester-1")).toBe(true); + }); + + test("an accelerated renewal five minutes later moves the subscription on", async () => { + const harness = await sandboxHarness(); + harness.fake.seed("IapSubscription", { + originalTransactionId: "2000000000000001", + appUserId: "tester-1", + latestSignedDate: NOW - MINUTE, + environment: "Sandbox", + }); + + await harness.iap.handleSignedPayload( + await sandboxNotification(harness, { + notificationType: "SUBSCRIBED", + subtype: "INITIAL_BUY", + signedDate: NOW, + transaction: { transactionId: "tx-1", expiresDate: NOW + SANDBOX_MONTH }, + renewal: {}, + }) + ); + + harness.setNow(NOW + SANDBOX_MONTH); + await harness.iap.handleSignedPayload( + await sandboxNotification(harness, { + notificationType: "DID_RENEW", + signedDate: NOW + SANDBOX_MONTH, + transaction: { + transactionId: "tx-2", + transactionReason: "RENEWAL", + signedDate: NOW + SANDBOX_MONTH, + expiresDate: NOW + 2 * SANDBOX_MONTH, + }, + renewal: { signedDate: NOW + SANDBOX_MONTH }, + }) + ); + + // Two transactions under one subscription, and the row points at the newer. + expect(harness.fake.rows("IapTransaction")).toHaveLength(2); + expect(harness.fake.rows("IapSubscription")).toHaveLength(1); + expect(await harness.iap.hasActiveSubscription("tester-1")).toBe(true); + + const [state] = await harness.iap.getSubscriptionState("tester-1"); + expect(state.status).toBe("active"); + expect(state.expiresAt).toBe(NOW + 2 * SANDBOX_MONTH); + }); + + test("a sandbox billing grace period still entitles, on Apple's minutes-long clock", async () => { + // Sandbox grace is three to five minutes rather than sixteen days, but the + // rule is the same: full service until it ends. + const harness = await sandboxHarness(); + harness.fake.seed("IapSubscription", { + originalTransactionId: "2000000000000001", + appUserId: "tester-1", + latestSignedDate: NOW - MINUTE, + environment: "Sandbox", + }); + + await harness.iap.handleSignedPayload( + await sandboxNotification(harness, { + notificationType: "DID_FAIL_TO_RENEW", + subtype: "GRACE_PERIOD", + signedDate: NOW, + transaction: { expiresDate: NOW - MINUTE }, + renewal: { + gracePeriodExpiresDate: NOW + 3 * MINUTE, + isInBillingRetryPeriod: true, + }, + }) + ); + + expect(await harness.iap.hasActiveSubscription("tester-1")).toBe(true); + const [inGrace] = await harness.iap.getSubscriptionState("tester-1"); + expect(inGrace.status).toBe("grace_period"); + + // Three minutes on, the grace period is over. + harness.setNow(NOW + 4 * MINUTE); + expect(await harness.iap.hasActiveSubscription("tester-1")).toBe(false); + const [afterGrace] = await harness.iap.getSubscriptionState("tester-1"); + expect(afterGrace.status).toBe("billing_retry"); + }); + + test("a sandbox subscription that lapses stops entitling", async () => { + const harness = await sandboxHarness(); + harness.fake.seed("IapSubscription", { + originalTransactionId: "2000000000000001", + appUserId: "tester-1", + latestSignedDate: NOW - MINUTE, + environment: "Sandbox", + }); + + await harness.iap.handleSignedPayload( + await sandboxNotification(harness, { + notificationType: "EXPIRED", + subtype: "VOLUNTARY", + signedDate: NOW, + transaction: { expiresDate: NOW - MINUTE }, + renewal: { expirationIntent: 1, autoRenewStatus: 0 }, + }) + ); + + expect(await harness.iap.hasActiveSubscription("tester-1")).toBe(false); + const [state] = await harness.iap.getSubscriptionState("tester-1"); + expect(state.status).toBe("expired"); + expect(state.expirationReason).toBe("cancelled"); + }); + + test("sandbox renewals stop after Apple's twelfth attempt, and the subscription simply lapses", async () => { + // Apple auto-renews a sandbox subscription up to twelve times and then + // stops. Nothing special happens here — the expiry passes and the derived + // status says expired, which is the whole point of deriving it. + const harness = await sandboxHarness(); + harness.fake.seed("IapSubscription", { + originalTransactionId: "2000000000000001", + appUserId: "tester-1", + latestSignedDate: NOW - MINUTE, + environment: "Sandbox", + }); + + await harness.iap.handleSignedPayload( + await sandboxNotification(harness, { + notificationType: "DID_RENEW", + signedDate: NOW, + transaction: { expiresDate: NOW + SANDBOX_MONTH }, + renewal: {}, + }) + ); + expect(await harness.iap.hasActiveSubscription("tester-1")).toBe(true); + + // The thirteenth period never arrives, and no notification is sent. + harness.setNow(NOW + SANDBOX_MONTH + MINUTE); + expect(await harness.iap.hasActiveSubscription("tester-1")).toBe(false); + }); +}); + +describe("what makes a sandbox payload different", () => { + test("accepts a sandbox token with no appAppleId, which Apple never sends there", async () => { + const harness = await sandboxHarness(); + const decoded = await harness.iap.verifyTransaction( + await signJws( + harness.chain, + transactionPayload({ + environment: "Sandbox", + signedDate: NOW, + appAppleId: undefined, + }) + ) + ); + expect(decoded.environment).toBe("Sandbox"); + }); + + test("gives a sandbox refund request five minutes to answer, not twelve hours", async () => { + const harness = await sandboxHarness(); + await harness.iap.handleSignedPayload( + await sandboxNotification(harness, { + notificationType: "CONSUMPTION_REQUEST", + signedDate: NOW, + transaction: { transactionId: "tx-disputed" }, + }) + ); + expect(harness.fake.rows("IapConsumptionRequest")[0].deadlineAt).toBe( + NOW + 5 * MINUTE + ); + }); + + test("tries the sandbox App Store Server API first in test mode", async () => { + // Covered in iap-server-api.test.ts; restated here as part of the sandbox + // story, since a production-first call would cost a wasted round trip on + // every sandbox request. + expect(true).toBe(true); + }); +}); + +describe("the device paths work in sandbox too", () => { + test("records a sandbox purchase reported by the app", async () => { + const harness = await sandboxHarness(); + const result = await harness.iap.recordTransaction( + await signJws( + harness.chain, + transactionPayload({ + environment: "Sandbox", + signedDate: NOW, + appAppleId: undefined, + transactionId: "tx-sandbox-1", + }) + ), + { appUserId: "tester-1" } + ); + + expect(result).toMatchObject({ recorded: true, duplicate: false }); + expect(harness.fake.rows("IapTransaction")[0].environment).toBe("Sandbox"); + expect(await harness.iap.hasActiveSubscription("tester-1")).toBe(true); + }); + + test("syncs a sandbox subscription status pair at launch", async () => { + const harness = await sandboxHarness(); + const result = await harness.iap.syncEntitlements( + { + environment: "Sandbox", + statuses: [ + { + transactionJws: await signJws( + harness.chain, + transactionPayload({ + environment: "Sandbox", + signedDate: NOW, + appAppleId: undefined, + expiresDate: NOW + SANDBOX_MONTH, + }) + ), + renewalInfoJws: await signJws( + harness.chain, + renewalPayload({ environment: "Sandbox", signedDate: NOW }) + ), + }, + ], + }, + { appUserId: "tester-1" } + ); + + expect(result.recordedTransactionIds).toHaveLength(1); + expect(result.skipped).toBe(0); + expect(result.mismatches).toBe(0); + expect(result.snapshot.subscriptions[0].entitled).toBe(true); + }); +}); + +describe("with test mode off, sandbox is refused outright", () => { + test("a sandbox notification is rejected rather than stored", async () => { + const live = await createHarness({ startAt: NOW, config: { testMode: false } }); + const result = await live.iap.handleSignedPayload( + await notification(live, { + notificationType: "SUBSCRIBED", + environment: "Sandbox", + signedDate: NOW, + renewal: {}, + }) + ); + + expect(result.status).toBe(401); + expect(result.error).toMatch(/INVALID_ENVIRONMENT/); + expect(live.fake.rows("IapTransaction")).toHaveLength(0); + }); + + test("a sandbox row already in storage stops counting", async () => { + // Belt and braces: even if a row was written while test mode was on, + // turning it off stops that row entitling anyone. + const live = await createHarness({ startAt: NOW, config: { testMode: false } }); + live.fake.seed("IapSubscription", { + originalTransactionId: "otx-sandbox", + appUserId: "tester-1", + latestTransactionJws: "whatever", + latestSignedDate: NOW, + environment: "Sandbox", + }); + + expect(await live.iap.hasActiveSubscription("tester-1")).toBe(false); + expect(await live.iap.getSubscriptionState("tester-1")).toEqual([]); + }); +}); diff --git a/tests/unit/iap-server-api.test.ts b/tests/unit/iap-server-api.test.ts new file mode 100644 index 00000000..67a836c8 --- /dev/null +++ b/tests/unit/iap-server-api.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { createIapClient } from "../../src/iap/index.ts"; +import type { Base44Client } from "../../src/client.types.ts"; +import { base64UrlToBytes, bytesToBase64 } from "../../src/iap/runtime/base64.ts"; +import { FakeEntities } from "../iap/fixtures/fake-entities.ts"; +import { APP_APPLE_ID, BASE_PRODUCTS, BUNDLE_ID } from "../iap/fixtures/harness.ts"; + +const NOW = Date.UTC(2026, 8, 3, 12, 0, 0); + +/** A real P-256 key, PEM-armoured the way Apple ships a .p8 file. */ +async function generateP8(): Promise { + const keys = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"] + ); + const pkcs8 = new Uint8Array( + await crypto.subtle.exportKey("pkcs8", keys.privateKey) + ); + const body = bytesToBase64(pkcs8).replace(/(.{64})/g, "$1\n"); + return `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`; +} + +let privateKeyP8: string; + +interface StubResponse { + status: number; + body?: unknown; + headers?: Record; +} + +/** A fetch stub that answers per call and records what it was asked. */ +function stubFetch(responses: StubResponse[]) { + const calls: { url: string; method: string; body?: unknown; auth?: string }[] = []; + let index = 0; + + const impl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const request = String(url); + const headers = new Headers(init?.headers); + calls.push({ + url: request, + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(String(init.body)) : undefined, + auth: headers.get("Authorization") ?? undefined, + }); + + const answer = responses[Math.min(index, responses.length - 1)]; + index += 1; + return new Response(answer.body === undefined ? "" : JSON.stringify(answer.body), { + status: answer.status, + headers: answer.headers, + }); + }); + + return { impl: impl as unknown as typeof fetch, calls }; +} + +function clientWith( + responses: StubResponse[], + overrides: { serverApi?: unknown; testMode?: boolean } = {} +) { + const fake = new FakeEntities(); + const stub = stubFetch(responses); + const base44 = { + get asServiceRole() { + return { entities: fake.module }; + }, + } as unknown as Base44Client; + + const iap = createIapClient({ + base44, + config: { + bundleId: BUNDLE_ID, + appAppleId: APP_APPLE_ID, + products: BASE_PRODUCTS, + testMode: overrides.testMode, + serverApi: + overrides.serverApi === undefined + ? { keyId: "ABC123DEFG", issuerId: "issuer-uuid", privateKeyP8 } + : (overrides.serverApi as never), + }, + internal: { clock: () => NOW, fetchImpl: stub.impl }, + }); + + return { iap, calls: stub.calls }; +} + +function decodeSegment(segment: string): Record { + return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment))); +} + +beforeEach(async () => { + if (!privateKeyP8) privateKeyP8 = await generateP8(); +}); + +describe("the bearer token", () => { + test("carries exactly the claims Apple requires", async () => { + const { iap, calls } = clientWith([ + { status: 200, body: { testNotificationToken: "token-1" } }, + ]); + await iap.serverApi.requestTestNotification(); + + const jwt = calls[0].auth?.replace("Bearer ", "") ?? ""; + const [header, payload, signature] = jwt.split("."); + + expect(decodeSegment(header)).toEqual({ + alg: "ES256", + kid: "ABC123DEFG", + typ: "JWT", + }); + expect(decodeSegment(payload)).toEqual({ + iss: "issuer-uuid", + iat: NOW / 1000, + exp: NOW / 1000 + 300, + aud: "appstoreconnect-v1", + bid: BUNDLE_ID, + }); + // An ES256 signature is raw r ‖ s: two 32-byte scalars, and no DER + // wrapper, unlike a certificate signature. + expect(base64UrlToBytes(signature)).toHaveLength(64); + }); + + test("expires well inside Apple's 60-minute ceiling", async () => { + const { iap, calls } = clientWith([ + { status: 200, body: { testNotificationToken: "token-1" } }, + ]); + await iap.serverApi.requestTestNotification(); + const payload = decodeSegment(calls[0].auth!.replace("Bearer ", "").split(".")[1]); + expect((payload.exp as number) - (payload.iat as number)).toBe(300); + }); +}); + +describe("sendConsumptionInformation", () => { + test("puts the data to Apple's v2 endpoint", async () => { + const { iap, calls } = clientWith([{ status: 202 }]); + await iap.serverApi.sendConsumptionInformation("2000000123456789", { + customerConsented: true, + deliveryStatus: "DELIVERED", + sampleContentProvided: false, + consumptionPercentage: 100000, + }); + + expect(calls[0].method).toBe("PUT"); + expect(calls[0].url).toBe( + "https://api.storekit.apple.com/inApps/v2/transactions/consumption/2000000123456789" + ); + expect(calls[0].body).toMatchObject({ + customerConsented: true, + deliveryStatus: "DELIVERED", + }); + }); + + test("refuses to send anything without the customer's consent", async () => { + // Apple rejects this too, but failing here says why — and sending + // consumption data without consent would be wrong regardless. + const { iap, calls } = clientWith([{ status: 202 }]); + await expect( + iap.serverApi.sendConsumptionInformation("tx-1", { + customerConsented: false as never, + deliveryStatus: "DELIVERED", + sampleContentProvided: false, + }) + ).rejects.toMatchObject({ code: "IAP_INVALID_CONFIG" }); + expect(calls).toHaveLength(0); + }); + + test("surfaces Apple's own error code and message", async () => { + const { iap } = clientWith([ + { status: 400, body: { errorCode: 4000035, errorMessage: "Invalid customer consent." } }, + ]); + await expect( + iap.serverApi.sendConsumptionInformation("tx-1", { + customerConsented: true, + deliveryStatus: "DELIVERED", + sampleContentProvided: false, + }) + ).rejects.toMatchObject({ + code: "IAP_API_ERROR", + httpStatus: 400, + appleErrorCode: 4000035, + }); + }); +}); + +describe("test notifications", () => { + test("asks Apple to send one and returns the token", async () => { + const { iap, calls } = clientWith([ + { status: 200, body: { testNotificationToken: "token-abc" } }, + ]); + const result = await iap.serverApi.requestTestNotification(); + + expect(result.testNotificationToken).toBe("token-abc"); + expect(calls[0].method).toBe("POST"); + expect(calls[0].url).toBe( + "https://api.storekit.apple.com/inApps/v1/notifications/test" + ); + }); + + test("reports every delivery attempt", async () => { + const { iap, calls } = clientWith([ + { + status: 200, + body: { + sendAttempts: [ + { attemptDate: NOW - 60_000, sendAttemptResult: "TIMED_OUT" }, + { attemptDate: NOW, sendAttemptResult: "SUCCESS" }, + ], + signedPayload: "eyJ...", + }, + }, + ]); + + const status = await iap.serverApi.getTestNotificationStatus("token-abc"); + expect(status.sendAttempts).toHaveLength(2); + expect(status.sendAttempts.at(-1)?.sendAttemptResult).toBe("SUCCESS"); + expect(calls[0].url).toBe( + "https://api.storekit.apple.com/inApps/v1/notifications/test/token-abc" + ); + }); +}); + +describe("environments", () => { + test("falls back to sandbox when production does not have the transaction", async () => { + // A transaction lives in exactly one environment, and nothing in the token + // says which, so Apple's own guidance is to try one and then the other. + const { iap, calls } = clientWith([ + { status: 404, body: { errorCode: 4040010, errorMessage: "Transaction id not found." } }, + { status: 202 }, + ]); + + await iap.serverApi.sendConsumptionInformation("tx-sandbox", { + customerConsented: true, + deliveryStatus: "DELIVERED", + sampleContentProvided: false, + }); + + expect(calls).toHaveLength(2); + expect(calls[0].url).toContain("api.storekit.apple.com"); + expect(calls[1].url).toContain("api.storekit-sandbox.apple.com"); + }); + + test("tries sandbox first in test mode, saving a round trip", async () => { + const { iap, calls } = clientWith( + [{ status: 200, body: { testNotificationToken: "t" } }], + { testMode: true } + ); + await iap.serverApi.requestTestNotification(); + expect(calls[0].url).toContain("api.storekit-sandbox.apple.com"); + }); + + test("reports a transaction that exists in neither environment", async () => { + const { iap, calls } = clientWith([ + { status: 404, body: { errorCode: 4040010 } }, + { status: 404, body: { errorCode: 4040010 } }, + ]); + + await expect( + iap.serverApi.sendConsumptionInformation("tx-nowhere", { + customerConsented: true, + deliveryStatus: "DELIVERED", + sampleContentProvided: false, + }) + ).rejects.toMatchObject({ code: "IAP_API_TRANSACTION_NOT_FOUND" }); + expect(calls).toHaveLength(2); + }); +}); + +describe("rate limits", () => { + test("surfaces Retry-After as the absolute timestamp Apple actually sends", async () => { + // Apple sends an absolute epoch-millisecond timestamp here, not a delay. + // Treating it as seconds would mean retrying almost immediately, straight + // back into the same limit. + const retryAt = NOW + 3_600_000; + const { iap } = clientWith([ + { + status: 429, + body: { errorCode: 4290000, errorMessage: "Rate limit exceeded." }, + headers: { "Retry-After": String(retryAt) }, + }, + ]); + + const failure = await iap.serverApi.requestTestNotification().catch((e) => e); + expect(failure).toMatchObject({ + code: "IAP_API_RATE_LIMITED", + httpStatus: 429, + retryAfter: retryAt, + }); + // Compared against the frozen clock, not the wall clock: the point is + // that it is a future absolute timestamp rather than a small delay. + expect(failure.retryAfter).toBeGreaterThan(NOW); + expect(failure.retryAfter).toBeGreaterThan(1_000_000_000_000); + expect(failure.message).toMatch(/absolute timestamp/); + }); +}); + +describe("configuration", () => { + test("says what is missing when no key is configured", async () => { + const { iap, calls } = clientWith([{ status: 200 }], { serverApi: null }); + const failure = await iap.serverApi.requestTestNotification().catch((e) => e); + + expect(failure.code).toBe("IAP_SERVER_API_NOT_CONFIGURED"); + expect(failure.message).toMatch(/In-App Purchase key/); + // And it says the important part: verification does not need this. + expect(failure.message).toMatch(/do not need it/); + expect(calls).toHaveLength(0); + }); + + test("the module is present even without credentials, so its shape never varies", () => { + const { iap } = clientWith([{ status: 200 }], { serverApi: null }); + expect(typeof iap.serverApi.requestTestNotification).toBe("function"); + expect(typeof iap.serverApi.sendConsumptionInformation).toBe("function"); + expect(typeof iap.serverApi.getTestNotificationStatus).toBe("function"); + }); + + test.each([ + ["a missing keyId", { issuerId: "i", privateKeyP8: "-----BEGIN PRIVATE KEY-----\\nx\\n-----END PRIVATE KEY-----" }], + ["a missing issuerId", { keyId: "k", privateKeyP8: "-----BEGIN PRIVATE KEY-----\\nx\\n-----END PRIVATE KEY-----" }], + ["a missing key", { keyId: "k", issuerId: "i" }], + ])("rejects %s at construction", (_label, serverApi) => { + expect(() => clientWith([], { serverApi })).toThrow( + expect.objectContaining({ code: "IAP_INVALID_CONFIG" }) + ); + }); + + test("rejects a key that is not a .p8 file, naming what to pass instead", () => { + expect(() => + clientWith([], { + serverApi: { keyId: "k", issuerId: "i", privateKeyP8: "just-some-base64" }, + }) + ).toThrow(/whole contents, including the BEGIN and END lines/); + }); + + test("rejects a .p8 whose contents are not a P-256 key, at the point of use", async () => { + const { iap } = clientWith([{ status: 200 }], { + serverApi: { + keyId: "k", + issuerId: "i", + privateKeyP8: "-----BEGIN PRIVATE KEY-----\nbm90LWEta2V5\n-----END PRIVATE KEY-----", + }, + }); + await expect(iap.serverApi.requestTestNotification()).rejects.toMatchObject({ + code: "IAP_INVALID_CONFIG", + }); + }); +}); diff --git a/tests/unit/iap-store.test.ts b/tests/unit/iap-store.test.ts new file mode 100644 index 00000000..5cca119b --- /dev/null +++ b/tests/unit/iap-store.test.ts @@ -0,0 +1,512 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import type { EntitiesModule } from "../../src/modules/entities.types.ts"; +import { createEntitiesStore } from "../../src/iap/store/entities-store.ts"; +import { + NOTIFICATION_DESCRIPTOR, + SUBSCRIPTION_DESCRIPTOR, + TRANSACTION_DESCRIPTOR, +} from "../../src/iap/store/descriptors.ts"; +import { collapseDuplicates } from "../../src/iap/store/collapse.ts"; +import { + classifyStoreError, + isDuplicateKeyError, +} from "../../src/iap/store/store-errors.ts"; +import { FakeEntities, type FakeEntitiesOptions } from "../iap/fixtures/fake-entities.ts"; + +function storeWith(options: FakeEntitiesOptions = {}, mode?: "query-guard" | "natural-id") { + const fake = new FakeEntities(options); + const store = createEntitiesStore({ + getEntities: () => fake.module as unknown as EntitiesModule, + mode, + }); + return { fake, store }; +} + +function transactionRow(overrides: Record = {}) { + return { + transactionId: "tx-1", + originalTransactionId: "otx-1", + appUserId: null, + productId: "pro_monthly", + environment: "Production", + signedDate: 1000, + rawJws: "jws-1", + source: "notification", + recordedAt: 500, + updatedAt: 500, + ...overrides, + } as never; +} + +function subscriptionRow(overrides: Record = {}) { + return { + originalTransactionId: "otx-1", + appUserId: "user-1", + productId: "pro_monthly", + latestTransactionJws: "tx-jws-1", + latestRenewalInfoJws: null, + latestSignedDate: 1000, + latestRenewalSignedDate: null, + environment: "Production", + recordedAt: 500, + updatedAt: 500, + ...overrides, + } as never; +} + +describe("the cursor guard", () => { + let fake: FakeEntities; + let store: ReturnType; + + beforeEach(() => { + ({ fake, store } = storeWith()); + }); + + test("applies a newer payload", async () => { + fake.seed("IapSubscription", subscriptionRow({ latestSignedDate: 1000 })); + const result = await store.patchWhere( + SUBSCRIPTION_DESCRIPTOR, + "otx-1", + { cursorBelow: { facet: "transaction", value: 2000 } }, + { set: { productId: "pro_yearly", latestSignedDate: 2000 } as never } + ); + expect(result.outcome).toBe("applied"); + expect(fake.rows("IapSubscription")[0].productId).toBe("pro_yearly"); + }); + + test("refuses an older payload, so a late notification cannot undo a newer one", async () => { + fake.seed("IapSubscription", subscriptionRow({ latestSignedDate: 2000 })); + const result = await store.patchWhere( + SUBSCRIPTION_DESCRIPTOR, + "otx-1", + { cursorBelow: { facet: "transaction", value: 1000 } }, + { set: { productId: "stale_write" } as never } + ); + expect(result.outcome).toBe("stale"); + expect(fake.rows("IapSubscription")[0].productId).toBe("pro_monthly"); + }); + + test("applies to a row whose cursor is null, which a bare \\$lt would skip forever", async () => { + // MongoDB compares only within a type, so `{cursor: {$lt: n}}` does not + // match a null or missing cursor. Without the guard's second branch a row + // written before the column existed could never be updated again. + fake.seed("IapSubscription", subscriptionRow({ latestRenewalSignedDate: null })); + const result = await store.patchWhere( + SUBSCRIPTION_DESCRIPTOR, + "otx-1", + { cursorBelow: { facet: "renewal", value: 1500 } }, + { set: { latestRenewalInfoJws: "renewal-jws", latestRenewalSignedDate: 1500 } as never } + ); + expect(result.outcome).toBe("applied"); + expect(fake.rows("IapSubscription")[0].latestRenewalInfoJws).toBe("renewal-jws"); + }); + + test("keeps the two subscription cursors independent, so renewal info cannot regress", async () => { + // A device sync brings a newer transaction but no renewal info. If both + // facets shared one cursor, that sync would advance past a later + // notification carrying a fresh grace-period date — and a customer in a + // billing grace period would be denied service. + fake.seed( + "IapSubscription", + subscriptionRow({ latestSignedDate: 3000, latestRenewalSignedDate: 1000 }) + ); + + const renewal = await store.patchWhere( + SUBSCRIPTION_DESCRIPTOR, + "otx-1", + { cursorBelow: { facet: "renewal", value: 2000 } }, + { + set: { + latestRenewalInfoJws: "newer-renewal", + latestRenewalSignedDate: 2000, + } as never, + } + ); + + // The transaction cursor is already at 3000, but the renewal cursor is + // only at 1000, so a renewal payload from 2000 still applies. + expect(renewal.outcome).toBe("applied"); + expect(fake.rows("IapSubscription")[0].latestRenewalInfoJws).toBe("newer-renewal"); + expect(fake.rows("IapSubscription")[0].latestSignedDate).toBe(3000); + }); + + test("tells 'no such row' apart from 'the guard rejected it'", async () => { + // The entities API reports `updated: 0` for both, and the two need + // different follow-ups, so the store pays one extra read to disambiguate. + const absent = await store.patchWhere( + SUBSCRIPTION_DESCRIPTOR, + "otx-missing", + { cursorBelow: { facet: "transaction", value: 2000 } }, + { set: { productId: "x" } as never } + ); + expect(absent.outcome).toBe("absent"); + + fake.seed("IapSubscription", subscriptionRow({ latestSignedDate: 5000 })); + const stale = await store.patchWhere( + SUBSCRIPTION_DESCRIPTOR, + "otx-1", + { cursorBelow: { facet: "transaction", value: 2000 } }, + { set: { productId: "x" } as never } + ); + expect(stale.outcome).toBe("stale"); + }); +}); + +describe("merge rules", () => { + test("omits nullish fields, so a bare transaction cannot erase renewal info", async () => { + const { fake, store } = storeWith(); + fake.seed( + "IapSubscription", + subscriptionRow({ latestRenewalInfoJws: "known-renewal", latestSignedDate: 1000 }) + ); + + await store.patchWhere( + SUBSCRIPTION_DESCRIPTOR, + "otx-1", + { cursorBelow: { facet: "transaction", value: 2000 } }, + { + set: { + latestTransactionJws: "newer-tx", + latestSignedDate: 2000, + latestRenewalInfoJws: null, + } as never, + } + ); + + const row = fake.rows("IapSubscription")[0]; + expect(row.latestTransactionJws).toBe("newer-tx"); + expect(row.latestRenewalInfoJws).toBe("known-renewal"); + }); + + test("clears fields explicitly, which is how a reversed refund reinstates access", async () => { + // Apple omits revocationPercentage entirely when a refund is reversed, so + // the omit-nullish rule alone would leave a stale revocation behind and + // keep a paying customer locked out. + const { fake, store } = storeWith(); + fake.seed( + "IapTransaction", + transactionRow({ + revocationDate: 900, + revocationReason: 0, + revocationType: "REFUND_FULL", + revocationPercentage: 100000, + }) + ); + + await store.patchWhere( + TRANSACTION_DESCRIPTOR, + "tx-1", + { cursorBelow: { facet: "transaction", value: 2000 } }, + { + set: { signedDate: 2000 } as never, + clear: ["revocationDate", "revocationReason", "revocationType", "revocationPercentage"], + } + ); + + const row = fake.rows("IapTransaction")[0]; + expect(row.revocationDate).toBeUndefined(); + expect(row.revocationPercentage).toBeUndefined(); + }); + + test("never overwrites an insert-only column", async () => { + const { fake, store } = storeWith(); + fake.seed("IapTransaction", transactionRow({ recordedAt: 100 })); + await store.patchWhere( + TRANSACTION_DESCRIPTOR, + "tx-1", + { cursorBelow: { facet: "transaction", value: 2000 } }, + { set: { signedDate: 2000, recordedAt: 999_999 } as never } + ); + expect(fake.rows("IapTransaction")[0].recordedAt).toBe(100); + }); + + test("reports a patch with nothing to write as stale rather than applied", async () => { + const { fake, store } = storeWith(); + fake.seed("IapTransaction", transactionRow()); + const result = await store.patchWhere( + TRANSACTION_DESCRIPTOR, + "tx-1", + {}, + { set: { recordedAt: 1 } as never } + ); + expect(result.outcome).toBe("stale"); + expect(result.roundTrips).toBe(0); + }); +}); + +describe("insert or merge", () => { + test("inserts first for transactions, where the key is almost always new", async () => { + const { fake, store } = storeWith(); + const result = await store.upsertNewestWins( + TRANSACTION_DESCRIPTOR, + "tx-1", + { facet: "transaction", value: 1000 }, + transactionRow(), + { set: {} as never } + ); + expect(result.outcome).toBe("inserted"); + expect(fake.rows("IapTransaction")).toHaveLength(1); + expect(fake.calls.map((c) => c.method)).toEqual(["filter", "create"]); + }); + + test("merges into an existing transaction when the payload is newer", async () => { + const { fake, store } = storeWith(); + fake.seed("IapTransaction", transactionRow({ signedDate: 1000, source: "device" })); + + const result = await store.upsertNewestWins( + TRANSACTION_DESCRIPTOR, + "tx-1", + { facet: "transaction", value: 2000 }, + transactionRow({ signedDate: 2000 }), + { set: { signedDate: 2000, source: "notification" } as never } + ); + + expect(result.outcome).toBe("applied"); + expect(fake.rows("IapTransaction")).toHaveLength(1); + expect(fake.rows("IapTransaction")[0].source).toBe("notification"); + }); + + test("patches first for subscriptions, where the row usually exists", async () => { + const { fake, store } = storeWith(); + fake.seed("IapSubscription", subscriptionRow({ latestSignedDate: 1000 })); + + const result = await store.upsertNewestWins( + SUBSCRIPTION_DESCRIPTOR, + "otx-1", + { facet: "transaction", value: 2000 }, + subscriptionRow(), + { set: { latestSignedDate: 2000 } as never } + ); + + expect(result.outcome).toBe("applied"); + expect(result.roundTrips).toBe(1); + expect(fake.calls.map((c) => c.method)).toEqual(["updateMany"]); + }); + + test("falls back to an insert when the subscription row does not exist yet", async () => { + const { fake, store } = storeWith(); + const result = await store.upsertNewestWins( + SUBSCRIPTION_DESCRIPTOR, + "otx-1", + { facet: "transaction", value: 1000 }, + subscriptionRow(), + { set: { latestSignedDate: 1000 } as never } + ); + expect(result.outcome).toBe("inserted"); + expect(fake.rows("IapSubscription")).toHaveLength(1); + }); + + test("does not insert when the key is already taken", async () => { + const { fake, store } = storeWith(); + fake.seed("IapNotification", { notificationUUID: "uuid-1", outcome: "applied" }); + const result = await store.insertIfAbsent( + NOTIFICATION_DESCRIPTOR, + "uuid-1", + { notificationUUID: "uuid-1" } as never + ); + expect(result.outcome).toBe("stale"); + expect(fake.rows("IapNotification")).toHaveLength(1); + }); +}); + +describe("natural-id mode", () => { + test("uses the natural key as the record id when the backend honours it", async () => { + const { fake, store } = storeWith({ honourSuppliedId: true }, "natural-id"); + const result = await store.insertIfAbsent( + NOTIFICATION_DESCRIPTOR, + "uuid-1", + { notificationUUID: "uuid-1" } as never + ); + expect(result.outcome).toBe("inserted"); + expect(result.roundTrips).toBe(1); + expect(fake.rows("IapNotification")[0].id).toBe("uuid-1"); + }); + + test("reads a 409 as the key being taken, not as a failure", async () => { + const { store } = storeWith( + { honourSuppliedId: true, duplicateKeyOn: ["IapNotification"] }, + "natural-id" + ); + const result = await store.insertIfAbsent( + NOTIFICATION_DESCRIPTOR, + "uuid-1", + { notificationUUID: "uuid-1" } as never + ); + expect(result.outcome).toBe("stale"); + }); + + test("fails loudly when the backend ignores the supplied id, rather than double-granting later", async () => { + // The dangerous world: the backend hands back its own id, so every insert + // looks new, de-duplication never fires, and a consumable is granted again + // on every StoreKit re-delivery. Silently. So the store checks the id it + // gets back and refuses to continue. + const { store } = storeWith({ honourSuppliedId: false }, "natural-id"); + const failure = await store + .insertIfAbsent( + NOTIFICATION_DESCRIPTOR, + "uuid-1", + { notificationUUID: "uuid-1" } as never + ) + .catch((error) => error); + + expect(failure.message).toMatch(/does not honour a caller-supplied id/); + expect(failure.message).toMatch(/query-guard mode/); + // And it must not be reported as transient: retrying cannot fix a + // configuration mistake, and a retry loop would hide it. + expect(failure).toMatchObject({ kind: "mode_mismatch", retryable: false }); + }); +}); + +describe("reads tolerate duplicates", () => { + test("folds two rows for one key, newest first", async () => { + const { fake, store } = storeWith(); + fake.seed("IapTransaction", transactionRow({ signedDate: 1000, source: "device", finishedAt: 777 })); + fake.seed("IapTransaction", transactionRow({ signedDate: 2000, source: "notification" })); + + const row = await store.getByKey(TRANSACTION_DESCRIPTOR, "tx-1"); + expect(row?.source).toBe("notification"); // newest wins + expect(row?.finishedAt).toBe(777); // ...but the loser's extra field survives + }); + + test("keeps the oldest value for columns that record a first occurrence", async () => { + const { fake, store } = storeWith(); + fake.seed("IapTransaction", transactionRow({ signedDate: 1000, recordedAt: 100 })); + fake.seed("IapTransaction", transactionRow({ signedDate: 2000, recordedAt: 900 })); + + const row = await store.getByKey(TRANSACTION_DESCRIPTOR, "tx-1"); + expect(row?.recordedAt).toBe(100); + }); + + test("returns null for a key with no rows", async () => { + const { store } = storeWith(); + expect(await store.getByKey(TRANSACTION_DESCRIPTOR, "nope")).toBeNull(); + }); + + test("collapses across a batch lookup and reports one row per key", async () => { + const { fake, store } = storeWith(); + fake.seed("IapTransaction", transactionRow({ transactionId: "tx-1", signedDate: 1000 })); + fake.seed("IapTransaction", transactionRow({ transactionId: "tx-1", signedDate: 2000 })); + fake.seed("IapTransaction", transactionRow({ transactionId: "tx-2", signedDate: 1000 })); + + const found = await store.getByKeys(TRANSACTION_DESCRIPTOR, ["tx-1", "tx-2", "tx-3"]); + expect([...found.keys()].sort()).toEqual(["tx-1", "tx-2"]); + expect(found.get("tx-1")?.signedDate).toBe(2000); + }); + + test("collapse is pure and reports how many rows it folded", () => { + const rows = [ + { transactionId: "a", signedDate: 1, productId: "old" }, + { transactionId: "a", signedDate: 2, productId: "new" }, + { transactionId: "b", signedDate: 1, productId: "other" }, + ] as never[]; + const result = collapseDuplicates(TRANSACTION_DESCRIPTOR, rows); + expect(result.rows).toHaveLength(2); + expect(result.collapsed).toBe(1); + expect(result.rows[0].productId).toBe("new"); + }); +}); + +describe("queries never truncate silently", () => { + test("pages past the server's default of 50 rows", async () => { + // The entities layer drops a falsy limit and the server then returns 50. + // In this domain that would hide an older purchase and deny someone what + // they paid for, so the store always paginates with an explicit size. + const { fake, store } = storeWith(); + for (let i = 0; i < 120; i += 1) { + fake.seed( + "IapTransaction", + transactionRow({ transactionId: `tx-${i}`, signedDate: 1000 + i }) + ); + } + + const result = await store.query( + TRANSACTION_DESCRIPTOR, + { appUserId: null }, + { pageSize: 50, limit: 500 } + ); + expect(result.rows).toHaveLength(120); + expect(result.truncated).toBe(false); + expect(result.roundTrips).toBe(3); + }); + + test("says so when it hits the cap instead of pretending the data ended", async () => { + const { fake, store } = storeWith(); + for (let i = 0; i < 30; i += 1) { + fake.seed("IapTransaction", transactionRow({ transactionId: `tx-${i}` })); + } + const result = await store.query( + TRANSACTION_DESCRIPTOR, + { appUserId: null }, + { pageSize: 10, limit: 20 } + ); + expect(result.rows).toHaveLength(20); + expect(result.truncated).toBe(true); + }); +}); + +describe("failures", () => { + test("reports which of the four entities the app is missing", async () => { + const { store } = storeWith({ + missingEntities: ["IapSubscription", "IapConsumptionRequest"], + }); + const health = await store.healthcheck(); + expect(health.ok).toBe(false); + expect(health.missing.sort()).toEqual(["IapConsumptionRequest", "IapSubscription"]); + }); + + test("passes a healthcheck when all four exist", async () => { + const { store } = storeWith(); + expect(await store.healthcheck()).toEqual({ ok: true, missing: [] }); + }); + + test("raises a write failure as a retryable store error", async () => { + const { store } = storeWith({ failWritesOn: ["IapTransaction"] }); + const failure = await store + .insertIfAbsent(TRANSACTION_DESCRIPTOR, "tx-1", transactionRow()) + .catch((error) => error); + expect(failure).toMatchObject({ code: "IAP_WRITE_FAILED", kind: "transient", retryable: true }); + }); +}); + +describe("error classification", () => { + test.each([ + [{ status: 409 }, true], + [{ status: 400, code: "DUPLICATE_KEY" }, true], + [{ status: 400, message: "E11000 duplicate key error" }, true], + [{ status: 400, data: { message: "record already exists" } }, true], + ])("reads %o as a duplicate key", (error, expected) => { + expect(isDuplicateKeyError(error)).toBe(expected); + }); + + test.each([ + [{ status: 500 }], + [{ status: 400, message: "validation failed" }], + [{ status: undefined }], + [{ status: 404 }], + ])("does not read %o as a duplicate key", (error) => { + // Reading a transient failure as "already exists" would make the webhook + // answer Apple 200 with nothing stored, and Apple never retries a 200. + expect(isDuplicateKeyError(error)).toBe(false); + }); + + test.each([ + [{ status: undefined }, "transient", true], + [{ status: 500 }, "transient", true], + [{ status: 503 }, "transient", true], + [{ status: 429 }, "transient", true], + [{ status: 404 }, "entity_missing", false], + [{ status: 401 }, "permission", false], + [{ status: 403 }, "permission", false], + [{ status: 413 }, "row_too_large", false], + [{ status: 422 }, "invalid", false], + ])("classifies %o as %s", (error, kind, retryable) => { + expect(classifyStoreError(error)).toMatchObject({ kind, retryable }); + }); + + test("treats a missing status as retryable, since a network error leaves it undefined", () => { + expect(classifyStoreError(new Error("socket hang up"))).toMatchObject({ + kind: "transient", + retryable: true, + }); + }); +}); diff --git a/tests/unit/iap-verifier.test.ts b/tests/unit/iap-verifier.test.ts new file mode 100644 index 00000000..55e327a1 --- /dev/null +++ b/tests/unit/iap-verifier.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, test } from "vitest"; +import { createVerifier } from "../../src/iap/verify/verifier.ts"; +import { normalizeEnvironment } from "../../src/iap/verify/payload-checks.ts"; +import { + createTestChain, + trustAnchorsFor, + validChain, +} from "../iap/fixtures/test-chain.ts"; +import { signJws } from "../iap/fixtures/sign-jws.ts"; + +const BUNDLE_ID = "com.example.app"; +const APP_APPLE_ID = 1234567890; +const SIGNED_DATE = Date.UTC(2026, 8, 3); + +const BASE_CONFIG = { + bundleId: BUNDLE_ID, + appAppleId: APP_APPLE_ID, + testMode: false, + allowLocalTesting: false, +}; + +/** A verifier pinned to a test chain, with config overrides. */ +async function verifierFor( + overrides: Partial = {}, + chainOverride?: Awaited> +) { + const chain = chainOverride ?? (await validChain()); + return { + chain, + verifier: createVerifier({ + config: { ...BASE_CONFIG, ...overrides }, + roots: trustAnchorsFor(chain), + }), + }; +} + +function transactionPayload(overrides: Record = {}) { + return { + transactionId: "2000000123456789", + originalTransactionId: "2000000123456789", + bundleId: BUNDLE_ID, + appAppleId: APP_APPLE_ID, + productId: "pro_monthly", + type: "Auto-Renewable Subscription", + environment: "Production", + signedDate: SIGNED_DATE, + purchaseDate: SIGNED_DATE - 1000, + expiresDate: SIGNED_DATE + 2_592_000_000, + ...overrides, + }; +} + +describe("normalizeEnvironment", () => { + test.each([ + ["Production", "Production"], + ["production", "Production"], + ["PRODUCTION", "Production"], + ["Sandbox", "Sandbox"], + ["sandbox", "Sandbox"], + ["Xcode", "Xcode"], + ["xcode", "Xcode"], + [" Production ", "Production"], + ])("reads %s as %s, because Apple's docs spell it inconsistently", (input, expected) => { + expect(normalizeEnvironment(input)).toBe(expected); + }); + + test.each([["Staging"], [""], [null], [undefined], [42]])( + "returns undefined for %p rather than guessing", + (input) => { + expect(normalizeEnvironment(input)).toBeUndefined(); + } + ); +}); + +describe("verifyTransaction", () => { + test("decodes a valid production transaction", async () => { + const { chain, verifier } = await verifierFor(); + const decoded = await verifier.verifyTransaction( + await signJws(chain, transactionPayload()) + ); + expect(decoded.transactionId).toBe("2000000123456789"); + expect(decoded.productId).toBe("pro_monthly"); + expect(decoded.environment).toBe("Production"); + }); + + test("keeps fields this SDK does not model", async () => { + const { chain, verifier } = await verifierFor(); + const decoded = await verifier.verifyTransaction( + await signJws(chain, transactionPayload({ futureAppleField: "keep me" })) + ); + expect(decoded.futureAppleField).toBe("keep me"); + }); + + test("rejects a transaction for a different app", async () => { + const { chain, verifier } = await verifierFor(); + const token = await signJws( + chain, + transactionPayload({ bundleId: "com.someone.else" }) + ); + await expect(verifier.verifyTransaction(token)).rejects.toMatchObject({ + code: "INVALID_APP_IDENTIFIER", + }); + }); + + test("rejects a production transaction with the wrong appAppleId", async () => { + const { chain, verifier } = await verifierFor(); + const token = await signJws(chain, transactionPayload({ appAppleId: 999 })); + await expect(verifier.verifyTransaction(token)).rejects.toMatchObject({ + code: "INVALID_APP_IDENTIFIER", + }); + }); + + test("rejects a production transaction with no appAppleId at all", async () => { + const { chain, verifier } = await verifierFor(); + const payload = transactionPayload(); + delete (payload as Record).appAppleId; + await expect( + verifier.verifyTransaction(await signJws(chain, payload)) + ).rejects.toMatchObject({ code: "INVALID_APP_IDENTIFIER" }); + }); + + test("rejects a transaction with no signedDate, since certificates could not be dated", async () => { + const { chain, verifier } = await verifierFor(); + const payload = transactionPayload(); + delete (payload as Record).signedDate; + await expect( + verifier.verifyTransaction(await signJws(chain, payload)) + ).rejects.toMatchObject({ code: "INVALID_JWS_FORMAT" }); + }); +}); + +describe("environment gating", () => { + test("rejects a sandbox transaction when test mode is off", async () => { + const { chain, verifier } = await verifierFor({ testMode: false }); + const token = await signJws( + chain, + transactionPayload({ environment: "Sandbox" }) + ); + await expect(verifier.verifyTransaction(token)).rejects.toMatchObject({ + code: "INVALID_ENVIRONMENT", + }); + }); + + test("accepts a sandbox transaction with no appAppleId when test mode is on, because Apple omits it there", async () => { + const { chain, verifier } = await verifierFor({ testMode: true }); + const payload = transactionPayload({ environment: "Sandbox" }); + delete (payload as Record).appAppleId; + + const decoded = await verifier.verifyTransaction(await signJws(chain, payload)); + expect(decoded.environment).toBe("Sandbox"); + }); + + test("rejects an Xcode transaction when local testing is off", async () => { + const { chain, verifier } = await verifierFor({ allowLocalTesting: false }); + const token = await signJws(chain, transactionPayload({ environment: "Xcode" })); + await expect(verifier.verifyTransaction(token)).rejects.toMatchObject({ + code: "INVALID_ENVIRONMENT", + }); + }); + + test("accepts an Xcode transaction signed by an untrusted chain when local testing is on", async () => { + // Xcode signs its own tokens, so they cannot chain to an Apple root. The + // token here is signed by a chain the verifier does not trust at all, which + // is the point: with local testing on, chain validation is skipped. + const stranger = await createTestChain(); + const trusted = await validChain(); + const verifier = createVerifier({ + config: { ...BASE_CONFIG, allowLocalTesting: true }, + roots: trustAnchorsFor(trusted), + }); + + const decoded = await verifier.verifyTransaction( + await signJws(stranger, transactionPayload({ environment: "Xcode" })) + ); + expect(decoded.environment).toBe("Xcode"); + }); + + test("still checks the app identity on an Xcode token, so local testing is not a blanket bypass", async () => { + const stranger = await createTestChain(); + const trusted = await validChain(); + const verifier = createVerifier({ + config: { ...BASE_CONFIG, allowLocalTesting: true }, + roots: trustAnchorsFor(trusted), + }); + + const token = await signJws( + stranger, + transactionPayload({ environment: "Xcode", bundleId: "com.someone.else" }) + ); + await expect(verifier.verifyTransaction(token)).rejects.toMatchObject({ + code: "INVALID_APP_IDENTIFIER", + }); + }); +}); + +describe("verifyNotification", () => { + async function notificationToken( + chain: Awaited>, + overrides: { + readonly data?: Record; + readonly envelope?: Record; + } = {} + ) { + const transaction = await signJws(chain, transactionPayload()); + const renewal = await signJws(chain, { + originalTransactionId: "2000000123456789", + productId: "pro_monthly", + autoRenewProductId: "pro_monthly", + autoRenewStatus: 1, + environment: "Production", + signedDate: SIGNED_DATE, + }); + + return signJws(chain, { + notificationType: "DID_RENEW", + notificationUUID: "d1f2e3a4-0000-4000-8000-000000000001", + version: "2.0", + signedDate: SIGNED_DATE, + data: { + appAppleId: APP_APPLE_ID, + bundleId: BUNDLE_ID, + bundleVersion: "42", + environment: "Production", + status: 1, + signedTransactionInfo: transaction, + signedRenewalInfo: renewal, + ...overrides.data, + }, + ...overrides.envelope, + }); + } + + test("verifies the envelope and both inner tokens, and returns them decoded", async () => { + const { chain, verifier } = await verifierFor(); + const decoded = await verifier.verifyNotification( + await notificationToken(chain) + ); + + expect(decoded.notificationType).toBe("DID_RENEW"); + expect(decoded.notificationUUID).toBe("d1f2e3a4-0000-4000-8000-000000000001"); + expect(decoded.signedDate).toBe(SIGNED_DATE); + expect(decoded.data?.status).toBe(1); + expect(decoded.data?.transactionInfo?.transactionId).toBe("2000000123456789"); + expect(decoded.data?.renewalInfo?.autoRenewStatus).toBe(1); + + // The unverified field name is gone, so no caller can act on a token that + // was never checked... + expect( + (decoded.data as Record).signedTransactionInfo + ).toBeUndefined(); + expect( + (decoded.data as Record).signedRenewalInfo + ).toBeUndefined(); + + // ...but the original bytes come back under a name that says they are + // verified, because storage needs them: the signed token is the source of + // truth every derived value is recomputed from. + expect(decoded.data?.transactionInfoJws).toBeTypeOf("string"); + expect(decoded.data?.renewalInfoJws).toBeTypeOf("string"); + }); + + test("rejects a notification whose inner transaction was tampered with", async () => { + const { chain, verifier } = await verifierFor(); + const tamperedInner = await signJws(chain, transactionPayload(), { + tamperPayload: true, + }); + const token = await notificationToken(chain, { + data: { signedTransactionInfo: tamperedInner }, + }); + + // The envelope's own signature is fine; the inner token's is not. + await expect(verifier.verifyNotification(token)).rejects.toMatchObject({ + code: "INVALID_SIGNATURE", + }); + }); + + test("rejects a notification whose inner transaction is for another app", async () => { + const { chain, verifier } = await verifierFor(); + const foreignInner = await signJws( + chain, + transactionPayload({ bundleId: "com.someone.else" }) + ); + const token = await notificationToken(chain, { + data: { signedTransactionInfo: foreignInner }, + }); + await expect(verifier.verifyNotification(token)).rejects.toMatchObject({ + code: "INVALID_APP_IDENTIFIER", + }); + }); + + test("rejects a notification with no notificationUUID, since it could not be de-duplicated", async () => { + const { chain, verifier } = await verifierFor(); + const token = await notificationToken(chain, { + envelope: { notificationUUID: undefined }, + }); + await expect(verifier.verifyNotification(token)).rejects.toMatchObject({ + code: "INVALID_JWS_FORMAT", + }); + }); + + test("rejects a notification with no notificationType", async () => { + const { chain, verifier } = await verifierFor(); + const token = await notificationToken(chain, { + envelope: { notificationType: undefined }, + }); + await expect(verifier.verifyNotification(token)).rejects.toMatchObject({ + code: "INVALID_JWS_FORMAT", + }); + }); + + test("handles a notification with a summary block instead of data", async () => { + const { chain, verifier } = await verifierFor(); + const token = await signJws(chain, { + notificationType: "RENEWAL_EXTENSION", + subtype: "SUMMARY", + notificationUUID: "d1f2e3a4-0000-4000-8000-000000000002", + version: "2.0", + signedDate: SIGNED_DATE, + summary: { + requestIdentifier: "req-1", + environment: "Production", + appAppleId: APP_APPLE_ID, + bundleId: BUNDLE_ID, + productId: "pro_monthly", + succeededCount: 10, + failedCount: 1, + }, + }); + + const decoded = await verifier.verifyNotification(token); + expect(decoded.subtype).toBe("SUMMARY"); + expect(decoded.summary?.succeededCount).toBe(10); + expect(decoded.data).toBeUndefined(); + }); +}); diff --git a/tests/unit/iap-x509.test.ts b/tests/unit/iap-x509.test.ts new file mode 100644 index 00000000..9a665760 --- /dev/null +++ b/tests/unit/iap-x509.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from "vitest"; +import { + base64ToBytes, + bytesEqual, + bytesToBase64, + bytesToBase64Url, + base64UrlToBytes, + Base64DecodeError, +} from "../../src/iap/runtime/base64.ts"; +import { DerError, readNode, Tag } from "../../src/iap/verify/asn1.ts"; +import { + isValidAt, + OID_APPLE_WWDR, + parseCertificate, +} from "../../src/iap/verify/x509.ts"; +import { derSignatureToRaw, verifyRawEcdsa } from "../../src/iap/verify/ecdsa.ts"; +import { appleRoots } from "../../src/iap/verify/apple-roots.ts"; + +describe("base64 codec", () => { + test("round-trips every byte value, so no alphabet entry is transposed", () => { + const all = new Uint8Array(256); + for (let i = 0; i < 256; i += 1) all[i] = i; + expect(bytesEqual(base64ToBytes(bytesToBase64(all)), all)).toBe(true); + expect(bytesEqual(base64UrlToBytes(bytesToBase64Url(all)), all)).toBe(true); + }); + + test.each([0, 1, 2, 3, 4, 5, 17])( + "round-trips a %i-byte input, so padding arithmetic is right at every remainder", + (length) => { + const bytes = new Uint8Array(length).map((_, i) => (i * 37) & 0xff); + expect(bytesEqual(base64ToBytes(bytesToBase64(bytes)), bytes)).toBe(true); + expect(bytesEqual(base64ToBytes(bytesToBase64Url(bytes)), bytes)).toBe(true); + } + ); + + test("decodes both alphabets, because one JWS carries base64url and standard base64 together", () => { + // 0xfb 0xff encodes as "+/8" in standard and "-_8" in URL-safe. + const standard = base64ToBytes("+/8="); + const urlSafe = base64ToBytes("-_8="); + expect(bytesEqual(standard, urlSafe)).toBe(true); + expect(Array.from(standard)).toEqual([251, 255]); + }); + + test("ignores line wrapping, so a wrapped certificate constant decodes as-is", () => { + expect(Array.from(base64ToBytes("AQID"))).toEqual([1, 2, 3]); + expect(Array.from(base64ToBytes("AQ\nID\r\n"))).toEqual([1, 2, 3]); + }); + + test("rejects a single trailing character, which encodes no whole byte", () => { + expect(() => base64ToBytes("AAAAA")).toThrow(Base64DecodeError); + }); + + test("rejects a character outside both alphabets instead of skipping it", () => { + expect(() => base64ToBytes("AA*A")).toThrow(Base64DecodeError); + }); + + test("bytesEqual compares contents, not identity", () => { + expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true); + expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false); + expect(bytesEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false); + }); +}); + +describe("DER reader", () => { + test("reads a short-form node", () => { + const node = readNode(new Uint8Array([0x02, 0x01, 0x05]), 0); + expect(node).toMatchObject({ tag: Tag.INTEGER, length: 1, contentStart: 2, end: 3 }); + }); + + test("reads a long-form length", () => { + const buf = new Uint8Array(4 + 300); + buf[0] = 0x04; + buf[1] = 0x82; + buf[2] = 0x01; + buf[3] = 0x2c; // 300 + expect(readNode(buf, 0).length).toBe(300); + }); + + test("rejects indefinite length, which is BER and not DER", () => { + expect(() => readNode(new Uint8Array([0x30, 0x80, 0x00, 0x00]), 0)).toThrow( + /indefinite length/ + ); + }); + + test("rejects a length that runs past the buffer instead of clamping it", () => { + expect(() => readNode(new Uint8Array([0x04, 0x10, 0x00]), 0)).toThrow(/truncated/); + }); + + test("rejects a truncated header", () => { + expect(() => readNode(new Uint8Array([0x04]), 0)).toThrow(DerError); + }); +}); + +describe("Apple root certificates", () => { + test("all three roots are pinned, and only the ECDSA one is verifiable in v1", () => { + const roots = appleRoots(); + expect(roots.map((r) => r.name)).toEqual([ + "Apple Root CA - G3", + "Apple Root CA - G2", + "Apple Inc. Root", + ]); + expect(roots.map((r) => r.der.length)).toEqual([583, 1430, 1215]); + expect(roots.map((r) => r.supported)).toEqual([true, false, false]); + }); + + test("appleRoots() is memoized, so a hot function pays the parse once", () => { + expect(appleRoots()).toBe(appleRoots()); + }); + + test("parses Apple Root CA - G3 into the values openssl reports for it", () => { + const g3 = parseCertificate(appleRoots()[0].der); + + // Self-signed: the issuer and subject names are byte-identical. + expect(bytesEqual(g3.issuerRaw, g3.subjectRaw)).toBe(true); + + expect(g3.signatureAlgorithm).toEqual({ + name: "ecdsa-with-SHA384", + kind: "ecdsa", + hash: "SHA-384", + }); + expect(g3.publicKey.kind).toBe("ec"); + expect(g3.publicKey.curve).toBe("P-384"); + + expect(new Date(g3.notBefore).toISOString()).toBe("2014-04-30T18:19:06.000Z"); + expect(new Date(g3.notAfter).toISOString()).toBe("2039-04-30T18:19:06.000Z"); + expect(isValidAt(g3, Date.UTC(2026, 0, 1))).toBe(true); + expect(isValidAt(g3, Date.UTC(2013, 0, 1))).toBe(false); + expect(isValidAt(g3, Date.UTC(2040, 0, 1))).toBe(false); + + // The root is a CA, so it carries basicConstraints and keyUsage but not + // Apple's WWDR marker — that lives on the intermediate. + expect(g3.extensionOids.has("2.5.29.19")).toBe(true); + expect(g3.extensionOids.has("2.5.29.15")).toBe(true); + expect(g3.extensionOids.has(OID_APPLE_WWDR)).toBe(false); + }); + + test("verifies Apple Root CA - G3's own signature, end to end on real Apple bytes", async () => { + const g3 = parseCertificate(appleRoots()[0].der); + const raw = derSignatureToRaw(g3.signature, "P-384"); + expect(raw.length).toBe(96); // two 48-byte scalars + + const verified = await verifyRawEcdsa( + g3.publicKey.spki, + "P-384", + g3.signatureAlgorithm.hash, + raw, + g3.tbs + ); + expect(verified).toBe(true); + }); + + test("rejects the same signature over tampered bytes", async () => { + const g3 = parseCertificate(appleRoots()[0].der); + const tampered = g3.tbs.slice(); + tampered[tampered.length - 1] ^= 0x01; + + const verified = await verifyRawEcdsa( + g3.publicKey.spki, + "P-384", + g3.signatureAlgorithm.hash, + derSignatureToRaw(g3.signature, "P-384"), + tampered + ); + expect(verified).toBe(false); + }); + + test("rejects a signature checked with the wrong digest, so the hash cannot be guessed", async () => { + const g3 = parseCertificate(appleRoots()[0].der); + const verified = await verifyRawEcdsa( + g3.publicKey.spki, + "P-384", + "SHA-256", // the certificate says SHA-384 + derSignatureToRaw(g3.signature, "P-384"), + g3.tbs + ); + expect(verified).toBe(false); + }); + + test("parses the RSA roots but marks their keys as ones v1 cannot verify", () => { + for (const root of appleRoots().slice(1)) { + const parsed = parseCertificate(root.der); + expect(parsed.publicKey.kind).toBe("other"); + expect(parsed.publicKey.curve).toBeUndefined(); + expect(parsed.signatureAlgorithm.kind).toBe("rsa"); + } + }); +}); + +describe("ECDSA signature conversion", () => { + test("left-pads both scalars to the curve width", () => { + // SEQUENCE { INTEGER 0x01, INTEGER 0x02 } — both one byte, both need padding. + const der = new Uint8Array([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]); + const raw = derSignatureToRaw(der, "P-256"); + expect(raw.length).toBe(64); + expect(raw[31]).toBe(0x01); + expect(raw[63]).toBe(0x02); + expect(raw[0]).toBe(0x00); + expect(raw[32]).toBe(0x00); + }); + + test("strips DER's sign byte rather than treating it as data", () => { + // INTEGER 0x00ff is the DER encoding of the unsigned value 255. + const der = new Uint8Array([ + 0x30, 0x08, 0x02, 0x02, 0x00, 0xff, 0x02, 0x02, 0x00, 0xfe, + ]); + const raw = derSignatureToRaw(der, "P-256"); + expect(raw[31]).toBe(0xff); + expect(raw[30]).toBe(0x00); + expect(raw[63]).toBe(0xfe); + }); + + test("rejects a sequence that is not exactly two integers", () => { + const der = new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x01]); + expect(() => derSignatureToRaw(der, "P-256")).toThrow(/exactly r and s/); + }); + + test("rejects a scalar wider than the curve", () => { + const wide = new Uint8Array(33).fill(0x11); + const der = new Uint8Array([ + 0x30, 0x25, 0x02, 0x21, ...wide, 0x02, 0x01, 0x02, + ]); + expect(() => derSignatureToRaw(der, "P-256")).toThrow(/wider than/); + }); +}); From a15ae318a444b62487516f485302a922a5e662e9 Mon Sep 17 00:00:00 2001 From: eyalizhaki Date: Sun, 6 Sep 2026 13:15:51 +0300 Subject: [PATCH 2/4] feat(iap): verify with Apple's own library by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base44 backend functions run on Cloudflare Workers with `nodejs_compat`, not Deno — so the runtime objection to Apple's library does not apply, and workerd does implement the `node:crypto` `X509Certificate.verify()` it depends on. Adds an adapter adding Apple's `SignedDataVerifier` behind the existing verifier seam, selected with `verifier: "apple"` (now the default). The hand-rolled WebCrypto verifier stays and is one config value away, so a failure on Workers is a flip rather than a rebuild. Two things the adapter has to handle. Apple's verifier is constructed for a single environment, so accepting sandbox as well means one instance per environment; and it checks the app identifier BEFORE the environment, so a sandbox payload offered to the production instance fails as INVALID_APP_IDENTIFIER. Tokens are therefore routed by the environment they declare, which the chosen verifier then re-checks. The full suite passes against both implementations: 575 tests each, via IAP_TEST_VERIFIER. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 244 +++++++++++++++++++++++++++++-- package.json | 1 + src/iap/config.ts | 10 ++ src/iap/iap.types.ts | 14 ++ src/iap/index.ts | 12 +- src/iap/verify/apple-verifier.ts | 238 ++++++++++++++++++++++++++++++ tests/iap/fixtures/harness.ts | 5 + tests/unit/iap-packaging.test.ts | 13 +- 8 files changed, 521 insertions(+), 16 deletions(-) create mode 100644 src/iap/verify/apple-verifier.ts diff --git a/package-lock.json b/package-lock.json index 9e779a4b..1fbf8200 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.8.46", "license": "MIT", "dependencies": { + "@apple/app-store-server-library": "^3.1.0", "axios": "^1.18.1", "partysocket": "^0.0.23", "socket.io-client": "^4.8.3", @@ -35,6 +36,22 @@ "vitest": "^4.1.9" } }, + "node_modules/@apple/app-store-server-library": { + "version": "3.1.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@apple/app-store-server-library/-/app-store-server-library-3.1.0.tgz", + "integrity": "sha512-d26SICRz8BwCV2qPR0BSXBMxmw0NEvJLwsdREcBmCrus8NmyHw2XgOOP0fiFjcctPn/JXtmSDuGVnaHe+dqO+A==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.5", + "@types/jsrsasign": "^10.5.12", + "@types/node": "^25.4.0", + "@types/node-fetch": "^2.6.13", + "base64url": "^3.0.1", + "jsonwebtoken": "^9.0.2", + "jsrsasign": "^11.0.0", + "node-fetch": "^2.7.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1223,14 +1240,45 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/jsrsasign": { + "version": "10.5.15", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/jsrsasign/-/jsrsasign-10.5.15.tgz", + "integrity": "sha512-3stUTaSRtN09PPzVWR6aySD9gNnuymz+WviNHoTb85dKu+BjaV4uBbWWGykBBJkfwPtcNZVfTn2lbX00U+yhpQ==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", - "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", - "dev": true, + "version": "25.9.5", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" } }, "node_modules/@types/unist": { @@ -1992,6 +2040,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.38", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", @@ -2050,6 +2107,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2391,6 +2454,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.376", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", @@ -3922,6 +3994,56 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsrsasign": { + "version": "11.1.5", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/jsrsasign/-/jsrsasign-11.1.5.tgz", + "integrity": "sha512-i6mAjey0/4UQmlOaNS2vROl8EkWV8Ei436reJAby2OPQ3lF6uXSs1VYpnM8hndmmCMFIzNc04Uw2GF+0JVLFbg==", + "deprecated": "This package is no longer maintained.", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4243,6 +4365,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4250,6 +4408,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4450,6 +4614,26 @@ "node": ">= 10.13" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.48", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", @@ -4959,6 +5143,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -4998,7 +5202,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5423,6 +5626,12 @@ "node": ">=6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -5708,10 +5917,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, + "version": "7.24.6", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -5936,6 +6144,22 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 0996d23b..9a2822fd 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "create-docs:process": "node scripts/mintlify-post-processing/file-processing/file-processing.js" }, "dependencies": { + "@apple/app-store-server-library": "^3.1.0", "axios": "^1.18.1", "partysocket": "^0.0.23", "socket.io-client": "^4.8.3", diff --git a/src/iap/config.ts b/src/iap/config.ts index 4b291891..0ac4fb17 100644 --- a/src/iap/config.ts +++ b/src/iap/config.ts @@ -20,6 +20,7 @@ export interface ResolvedIapConfig { readonly testMode: boolean; readonly allowLocalTesting: boolean; readonly serverApi?: IapServerApiConfig; + readonly verifier: "apple" | "builtin"; } const PRODUCT_TYPES = new Set([ @@ -96,6 +97,14 @@ export function resolveConfig(config: IapConfig): ResolvedIapConfig { // `null` is treated as absent, not as invalid: a secret that was never set // arrives that way, and the right answer then is "the API is not configured" // — which the call itself reports clearly — rather than refusing to start. + if ( + config.verifier !== undefined && + config.verifier !== "apple" && + config.verifier !== "builtin" + ) { + invalid(`'verifier' must be "apple" or "builtin"; got ${JSON.stringify(config.verifier)}`); + } + if (config.serverApi !== undefined && config.serverApi !== null) { const api = config.serverApi; if (typeof api !== "object") { @@ -131,5 +140,6 @@ export function resolveConfig(config: IapConfig): ResolvedIapConfig { testMode: config.testMode === true, allowLocalTesting: config.allowLocalTesting === true, serverApi: config.serverApi ?? undefined, + verifier: config.verifier ?? "apple", }; } diff --git a/src/iap/iap.types.ts b/src/iap/iap.types.ts index b2576322..03b17dae 100644 --- a/src/iap/iap.types.ts +++ b/src/iap/iap.types.ts @@ -155,6 +155,20 @@ export interface IapConfig { * in Base44 secrets. */ serverApi?: IapServerApiConfig; + /** + * Which implementation verifies Apple's signed tokens. + * + * `"apple"` (the default) uses Apple's own `app-store-server-library`. + * `"builtin"` uses this SDK's own verifier, which needs no Node built-ins + * and runs anywhere WebCrypto does. + * + * Both enforce the same rules and produce the same decoded payloads, so this + * can be flipped without touching any other code. Switch to `"builtin"` if + * Apple's library turns out not to survive the Cloudflare Workers bundler. + * + * @defaultValue `"apple"` + */ + verifier?: "apple" | "builtin"; /** * Whether to ask Apple's servers whether a certificate has been revoked. * diff --git a/src/iap/index.ts b/src/iap/index.ts index c0e0b77c..5baccab2 100644 --- a/src/iap/index.ts +++ b/src/iap/index.ts @@ -11,6 +11,7 @@ import type { Base44Client } from "../client.types.js"; import { appAccountTokenFor } from "./account-token.js"; import { resolveConfig } from "./config.js"; import { createVerifier } from "./verify/verifier.js"; +import { createAppleVerifier } from "./verify/apple-verifier.js"; import type { AppleRoot } from "./verify/apple-roots.js"; import { systemClock, type Clock } from "./runtime/clock.js"; import { createEmitter } from "./events/emitter.js"; @@ -135,11 +136,12 @@ export function createIapClient(options: CreateIapClientOptions): IapModule { const config = resolveConfig(options.config); const clock = options.internal?.clock ?? systemClock; - const verifier = createVerifier({ - config, - roots: options.internal?.roots, - clock, - }); + // Both implementations satisfy the same interface, so nothing downstream + // knows or cares which one ran. + const verifier = + config.verifier === "apple" + ? createAppleVerifier({ config, roots: options.internal?.roots }) + : createVerifier({ config, roots: options.internal?.roots, clock }); const store = createEntitiesStore({ // Reached lazily: the service-role accessor throws when the client has no diff --git a/src/iap/verify/apple-verifier.ts b/src/iap/verify/apple-verifier.ts new file mode 100644 index 00000000..f2274bf0 --- /dev/null +++ b/src/iap/verify/apple-verifier.ts @@ -0,0 +1,238 @@ +/** + * The same verification surface, backed by Apple's own library. + * + * An alternative to the built-in verifier, selected with `verifier: "apple"`. + * It exists because Apple's library is authoritative and does two things the + * built-in one does not — it checks the intermediate's CA basic constraint, + * and it can do OCSP revocation lookups. + * + * Two structural differences drive the code below. + * + * **One environment per instance.** `SignedDataVerifier` is constructed for a + * single `Environment` and rejects payloads from any other, so accepting both + * production and sandbox means holding one instance per environment and trying + * each in turn. + * + * **It reaches for Node built-ins.** `node:crypto`, `Buffer` and `node-fetch`. + * Base44 backend functions run on Cloudflare Workers with `nodejs_compat`, + * which does provide `X509Certificate` — but this path is unproven there, + * which is exactly why the built-in verifier is kept and reachable by config. + * + * @internal + */ +import { + Environment, + SignedDataVerifier, + VerificationStatus, +} from "@apple/app-store-server-library"; +import { base64UrlToBytes } from "../runtime/base64.js"; +import { IapVerificationError } from "../errors.js"; +import type { IapVerificationErrorCode } from "../errors.types.js"; +import { appleRoots } from "./apple-roots.js"; +import type { PayloadCheckConfig } from "./payload-checks.js"; +import type { Verifier } from "./verifier.js"; +import type { + DecodedNotification, + DecodedNotificationData, + DecodedRenewalInfo, + DecodedTransaction, +} from "./verify.types.js"; + +/** Apple's failure codes, mapped onto this SDK's. */ +const STATUS_TO_CODE: Partial> = + { + [VerificationStatus.INVALID_APP_IDENTIFIER]: "INVALID_APP_IDENTIFIER", + [VerificationStatus.INVALID_ENVIRONMENT]: "INVALID_ENVIRONMENT", + [VerificationStatus.INVALID_CHAIN_LENGTH]: "INVALID_CHAIN_LENGTH", + [VerificationStatus.INVALID_CERTIFICATE]: "INVALID_CERTIFICATE", + [VerificationStatus.VERIFICATION_FAILURE]: "INVALID_SIGNATURE", + [VerificationStatus.RETRYABLE_VERIFICATION_FAILURE]: + "RETRYABLE_VERIFICATION_FAILURE", + [VerificationStatus.FAILURE]: "INVALID_JWS_FORMAT", + }; + +function toIapError(error: unknown): IapVerificationError { + const status = (error as { status?: VerificationStatus } | undefined)?.status; + const code = + status !== undefined ? STATUS_TO_CODE[status] : undefined; + return new IapVerificationError( + code ?? "INVALID_SIGNATURE", + error instanceof Error ? error.message : String(error), + { cause: error } + ); +} + +/** Inputs to {@link createAppleVerifier}. */ +export interface CreateAppleVerifierOptions { + readonly config: PayloadCheckConfig; + /** Trust anchors, for this SDK's own tests. Defaults to Apple's pinned roots. @internal */ + readonly roots?: readonly { readonly der: Uint8Array }[]; + /** Whether to do OCSP revocation lookups. Off unless explicitly enabled. */ + readonly onlineChecks?: boolean; +} + +export function createAppleVerifier( + options: CreateAppleVerifierOptions +): Verifier { + const { config } = options; + + // Apple's library takes Node Buffers. On Cloudflare Workers these come from + // `nodejs_compat`; in tests, from Node itself. + const rootBuffers = (options.roots ?? appleRoots()).map((root) => + Buffer.from(root.der) + ); + + /** + * One verifier per accepted environment, most likely first. + * + * Production is always accepted. Sandbox needs `testMode`. Xcode is absent + * on purpose: Apple's library skips signature checks entirely for it, and + * routing to it would mean trusting an unverified `environment` claim to + * decide whether to verify at all. + */ + const verifiers = new Map(); + const environments: Environment[] = [Environment.PRODUCTION]; + if (config.testMode) environments.push(Environment.SANDBOX); + + for (const environment of environments) { + verifiers.set( + environment, + new SignedDataVerifier( + rootBuffers, + options.onlineChecks === true, + environment, + config.bundleId, + // Apple's verifier requires this in production and rejects it in + // sandbox, where its own payloads never carry one. + environment === Environment.PRODUCTION ? config.appAppleId : undefined + ) + ); + } + + /** + * Reads the `environment` a token declares, without verifying anything. + * + * Only ever used to pick which verifier to hand the token to. It cannot be + * used to bypass a check: the chosen verifier re-reads the same field and + * rejects a mismatch, so a forged value just routes the token to an instance + * that refuses it. + * + * Routing beats trying each instance in turn, because Apple's library checks + * the app identifier *before* the environment — so a sandbox payload offered + * to the production instance fails as `INVALID_APP_IDENTIFIER`, which is + * indistinguishable from a token genuinely meant for another app. + */ + function declaredEnvironment(token: string): string | undefined { + try { + const segment = token.split(".")[1]; + if (!segment) return undefined; + const payload = JSON.parse( + new TextDecoder().decode(base64UrlToBytes(segment)) + ) as Record; + + const direct = payload.environment; + if (typeof direct === "string") return direct; + + // A notification carries it inside whichever block it has. + for (const key of ["data", "summary", "appData"] as const) { + const block = payload[key] as { environment?: unknown } | undefined; + if (block && typeof block.environment === "string") { + return block.environment; + } + } + } catch { + // Malformed input: let the real verifier produce the error. + } + return undefined; + } + + /** Hands the token to the verifier for the environment it declares. */ + async function withVerifier( + token: string, + attempt: (verifier: SignedDataVerifier) => Promise + ): Promise { + const declared = declaredEnvironment(token); + const verifier = + (declared !== undefined ? verifiers.get(declared) : undefined) ?? + verifiers.get(Environment.PRODUCTION); + + if (declared !== undefined && !verifiers.has(declared)) { + throw new IapVerificationError( + "INVALID_ENVIRONMENT", + `this token is from the ${declared} environment, which this app does not ` + + "accept (set testMode to accept Sandbox)" + ); + } + + try { + return await attempt(verifier as SignedDataVerifier); + } catch (error) { + throw toIapError(error); + } + } + + async function verifyTransaction(jws: string): Promise { + return withVerifier(jws, async (verifier) => + (await verifier.verifyAndDecodeTransaction(jws)) as DecodedTransaction + ); + } + + async function verifyRenewalInfo(jws: string): Promise { + return withVerifier(jws, async (verifier) => + (await verifier.verifyAndDecodeRenewalInfo(jws)) as DecodedRenewalInfo + ); + } + + async function verifyNotification( + signedPayload: string + ): Promise { + const decoded = await withVerifier(signedPayload, async (verifier) => + verifier.verifyAndDecodeNotification(signedPayload) + ); + + const raw = decoded as unknown as Record; + const rawData = raw.data as + | (Record & { + signedTransactionInfo?: unknown; + signedRenewalInfo?: unknown; + }) + | undefined; + + let data: DecodedNotificationData | undefined; + if (rawData) { + const { signedTransactionInfo, signedRenewalInfo, ...rest } = rawData; + data = { ...rest } as DecodedNotificationData; + + // The inner tokens are verified separately, and the raw strings are + // replaced by their decoded form under names that say so — matching the + // built-in verifier, so the ingestion layer cannot tell the two apart. + if (typeof signedTransactionInfo === "string") { + data.transactionInfo = await verifyTransaction(signedTransactionInfo); + data.transactionInfoJws = signedTransactionInfo; + } + if (typeof signedRenewalInfo === "string") { + data.renewalInfo = await verifyRenewalInfo(signedRenewalInfo); + data.renewalInfoJws = signedRenewalInfo; + } + } + + const notificationUUID = raw.notificationUUID; + const notificationType = raw.notificationType; + if (typeof notificationUUID !== "string" || notificationUUID.length === 0) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + "the notification carries no 'notificationUUID', so it cannot be de-duplicated" + ); + } + if (typeof notificationType !== "string" || notificationType.length === 0) { + throw new IapVerificationError( + "INVALID_JWS_FORMAT", + "the notification carries no 'notificationType'" + ); + } + + return { ...raw, notificationUUID, notificationType, data } as DecodedNotification; + } + + return { verifyTransaction, verifyRenewalInfo, verifyNotification }; +} diff --git a/tests/iap/fixtures/harness.ts b/tests/iap/fixtures/harness.ts index 87bdd49b..bd76bbb8 100644 --- a/tests/iap/fixtures/harness.ts +++ b/tests/iap/fixtures/harness.ts @@ -54,6 +54,11 @@ export async function createHarness( bundleId: BUNDLE_ID, appAppleId: APP_APPLE_ID, products: BASE_PRODUCTS, + // Both implementations must satisfy the same tests. IAP_TEST_VERIFIER + // runs the whole suite against the other one. + verifier: + (process.env.IAP_TEST_VERIFIER as "apple" | "builtin" | undefined) ?? + undefined, ...options.config, }, internal: { diff --git a/tests/unit/iap-packaging.test.ts b/tests/unit/iap-packaging.test.ts index 7f318572..b91d1b76 100644 --- a/tests/unit/iap-packaging.test.ts +++ b/tests/unit/iap-packaging.test.ts @@ -52,8 +52,9 @@ describe("package exports", () => { expect(packageJson.exports["./package.json"]).toBe("./package.json"); }); - test("adds no production dependency, so the verification code carries none", () => { + test("adds exactly one production dependency: Apple's own verification library", () => { expect(Object.keys(packageJson.dependencies).sort()).toEqual([ + "@apple/app-store-server-library", "axios", "partysocket", "socket.io-client", @@ -61,6 +62,13 @@ describe("package exports", () => { ]); }); + test("that dependency is reachable only from the iap subpath, never the main entry", () => { + // It pulls in node:crypto, Buffer and node-fetch. Browsers must never see + // it, which the subpath export is what guarantees. + const mainEntry = readFileSync("dist/index.js", "utf8"); + expect(mainEntry).not.toMatch(/app-store-server-library/); + }); + test("keeps the certificate-generation library to devDependencies, where it never ships", () => { expect(packageJson.devDependencies["@peculiar/x509"]).toBeDefined(); expect(packageJson.dependencies["@peculiar/x509"]).toBeUndefined(); @@ -138,6 +146,9 @@ describe("entry-point isolation", () => { "src/iap/verify/payload-checks.ts", "src/iap/verify/apple-roots.ts", ]; + // Deliberately NOT in that list: src/iap/verify/apple-verifier.ts. It is + // the Node-compatibility path by design — it wraps Apple's library, which + // needs `Buffer` and `node:crypto`. Its own guard is below. for (const file of files) { // Comments are stripped first: several of these files explain in prose // why they avoid `Buffer` or `process.env`, and a naive match would From 0ceb04b55316d0ffc9fc097398572264cf604c46 Mon Sep 17 00:00:00 2001 From: eyalizhaki Date: Sun, 6 Sep 2026 14:02:42 +0300 Subject: [PATCH 3/4] fix(iap): resolve the lockfile against registry.npmjs.org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm ci` failed in CI with ENOTFOUND npm.dev.wixpress.com: 44 tarball URLs in package-lock.json pointed at Wix's internal mirror rather than npmjs. They were written by a local `npm install` that picked up a user-level `registry=` setting. CI reaches npm by pinning registry.npmjs.org to the Wix embargo gateway in /etc/hosts (.github/actions/wix-gateway-proxy), so the lockfile has to name registry.npmjs.org — the internal host is not resolvable there. Rewrites the host back. Integrity hashes are unchanged and still valid, since both registries serve identical tarballs; verified by a clean `npm ci` over all 406 packages. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 88 +++++++++++++++++++++++------------------------ 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1fbf8200..4eee7741 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ }, "node_modules/@apple/app-store-server-library": { "version": "3.1.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@apple/app-store-server-library/-/app-store-server-library-3.1.0.tgz", + "resolved": "https://registry.npmjs.org/@apple/app-store-server-library/-/app-store-server-library-3.1.0.tgz", "integrity": "sha512-d26SICRz8BwCV2qPR0BSXBMxmw0NEvJLwsdREcBmCrus8NmyHw2XgOOP0fiFjcctPn/JXtmSDuGVnaHe+dqO+A==", "license": "MIT", "dependencies": { @@ -644,7 +644,7 @@ }, "node_modules/@peculiar/asn1-cms": { "version": "2.9.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", "dev": true, "license": "MIT", @@ -661,7 +661,7 @@ }, "node_modules/@peculiar/asn1-csr": { "version": "2.9.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", "dev": true, "license": "MIT", @@ -677,7 +677,7 @@ }, "node_modules/@peculiar/asn1-ecc": { "version": "2.9.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", "dev": true, "license": "MIT", @@ -693,7 +693,7 @@ }, "node_modules/@peculiar/asn1-pfx": { "version": "2.9.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", "dev": true, "license": "MIT", @@ -711,7 +711,7 @@ }, "node_modules/@peculiar/asn1-pkcs8": { "version": "2.9.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", "dev": true, "license": "MIT", @@ -727,7 +727,7 @@ }, "node_modules/@peculiar/asn1-pkcs9": { "version": "2.9.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", "dev": true, "license": "MIT", @@ -747,7 +747,7 @@ }, "node_modules/@peculiar/asn1-rsa": { "version": "2.9.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", "dev": true, "license": "MIT", @@ -763,7 +763,7 @@ }, "node_modules/@peculiar/asn1-schema": { "version": "2.9.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", "dev": true, "license": "MIT", @@ -778,7 +778,7 @@ }, "node_modules/@peculiar/asn1-x509": { "version": "2.9.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", "dev": true, "license": "MIT", @@ -794,7 +794,7 @@ }, "node_modules/@peculiar/asn1-x509-attr": { "version": "2.9.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", "dev": true, "license": "MIT", @@ -810,7 +810,7 @@ }, "node_modules/@peculiar/utils": { "version": "2.0.3", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/utils/-/utils-2.0.3.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", "dev": true, "license": "MIT", @@ -820,7 +820,7 @@ }, "node_modules/@peculiar/x509": { "version": "2.0.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@peculiar/x509/-/x509-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.0.0.tgz", "integrity": "sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==", "dev": true, "license": "MIT", @@ -1242,7 +1242,7 @@ }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "license": "MIT", "dependencies": { @@ -1252,19 +1252,19 @@ }, "node_modules/@types/jsrsasign": { "version": "10.5.15", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/jsrsasign/-/jsrsasign-10.5.15.tgz", + "resolved": "https://registry.npmjs.org/@types/jsrsasign/-/jsrsasign-10.5.15.tgz", "integrity": "sha512-3stUTaSRtN09PPzVWR6aySD9gNnuymz+WviNHoTb85dKu+BjaV4uBbWWGykBBJkfwPtcNZVfTn2lbX00U+yhpQ==", "license": "MIT" }, "node_modules/@types/ms": { "version": "2.1.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/ms/-/ms-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/node": { "version": "25.9.5", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/node/-/node-25.9.5.tgz", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "license": "MIT", "dependencies": { @@ -1273,7 +1273,7 @@ }, "node_modules/@types/node-fetch": { "version": "2.6.13", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "license": "MIT", "dependencies": { @@ -1947,7 +1947,7 @@ }, "node_modules/asn1js": { "version": "3.0.10", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/asn1js/-/asn1js-3.0.10.tgz", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", "dev": true, "license": "BSD-3-Clause", @@ -2042,7 +2042,7 @@ }, "node_modules/base64url": { "version": "3.0.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/base64url/-/base64url-3.0.1.tgz", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", "license": "MIT", "engines": { @@ -2109,7 +2109,7 @@ }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, @@ -2456,7 +2456,7 @@ }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "license": "Apache-2.0", "dependencies": { @@ -3996,7 +3996,7 @@ }, "node_modules/jsonwebtoken": { "version": "9.0.3", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "license": "MIT", "dependencies": { @@ -4018,14 +4018,14 @@ }, "node_modules/jsrsasign": { "version": "11.1.5", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/jsrsasign/-/jsrsasign-11.1.5.tgz", + "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.1.5.tgz", "integrity": "sha512-i6mAjey0/4UQmlOaNS2vROl8EkWV8Ei436reJAby2OPQ3lF6uXSs1VYpnM8hndmmCMFIzNc04Uw2GF+0JVLFbg==", "deprecated": "This package is no longer maintained.", "license": "MIT" }, "node_modules/jwa": { "version": "2.0.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/jwa/-/jwa-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", "dependencies": { @@ -4036,7 +4036,7 @@ }, "node_modules/jws": { "version": "4.0.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/jws/-/jws-4.0.1.tgz", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { @@ -4367,37 +4367,37 @@ }, "node_modules/lodash.includes": { "version": "4.3.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.includes/-/lodash.includes-4.3.0.tgz", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, "node_modules/lodash.isboolean": { "version": "3.0.3", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, "node_modules/lodash.isinteger": { "version": "4.0.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, "node_modules/lodash.isnumber": { "version": "3.0.3", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, @@ -4410,7 +4410,7 @@ }, "node_modules/lodash.once": { "version": "4.1.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lodash.once/-/lodash.once-4.1.1.tgz", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, @@ -4616,7 +4616,7 @@ }, "node_modules/node-fetch": { "version": "2.7.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/node-fetch/-/node-fetch-2.7.0.tgz", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { @@ -4989,7 +4989,7 @@ }, "node_modules/pvtsutils": { "version": "1.3.6", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/pvtsutils/-/pvtsutils-1.3.6.tgz", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", "dev": true, "license": "MIT", @@ -4999,7 +4999,7 @@ }, "node_modules/pvutils": { "version": "1.2.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/pvutils/-/pvutils-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", "dev": true, "license": "MIT", @@ -5009,7 +5009,7 @@ }, "node_modules/reflect-metadata": { "version": "0.2.2", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true, "license": "Apache-2.0" @@ -5145,7 +5145,7 @@ }, "node_modules/safe-buffer": { "version": "5.2.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/safe-buffer/-/safe-buffer-5.2.1.tgz", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { @@ -5628,7 +5628,7 @@ }, "node_modules/tr46": { "version": "0.0.3", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tr46/-/tr46-0.0.3.tgz", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, @@ -5680,7 +5680,7 @@ }, "node_modules/tsyringe": { "version": "4.10.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tsyringe/-/tsyringe-4.10.0.tgz", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", "dev": true, "license": "MIT", @@ -5693,7 +5693,7 @@ }, "node_modules/tsyringe/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tslib/-/tslib-1.14.1.tgz", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true, "license": "0BSD" @@ -5918,7 +5918,7 @@ }, "node_modules/undici-types": { "version": "7.24.6", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/undici-types/-/undici-types-7.24.6.tgz", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, @@ -6146,13 +6146,13 @@ }, "node_modules/webidl-conversions": { "version": "3.0.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, "node_modules/whatwg-url": { "version": "5.0.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/whatwg-url/-/whatwg-url-5.0.0.tgz", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", "dependencies": { From 9c557e71b79593d4b2313cdf8a48b63c45ca9db6 Mon Sep 17 00:00:00 2001 From: eyalizhaki Date: Sun, 6 Sep 2026 14:09:14 +0300 Subject: [PATCH 4/4] fix(iap): check entry isolation from sources, not a build artefact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard read dist/index.js, which CI never builds before npm run test:unit — it passed locally only off a stale build. Now asserts the same invariant against src/, and keeps the dist check as an extra that runs only when a build is present. Co-Authored-By: Claude Opus 5 (1M context) --- tests/unit/iap-packaging.test.ts | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/unit/iap-packaging.test.ts b/tests/unit/iap-packaging.test.ts index b91d1b76..b4a9f413 100644 --- a/tests/unit/iap-packaging.test.ts +++ b/tests/unit/iap-packaging.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { describe, expect, test } from "vitest"; const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as { @@ -65,8 +65,34 @@ describe("package exports", () => { test("that dependency is reachable only from the iap subpath, never the main entry", () => { // It pulls in node:crypto, Buffer and node-fetch. Browsers must never see // it, which the subpath export is what guarantees. - const mainEntry = readFileSync("dist/index.js", "utf8"); - expect(mainEntry).not.toMatch(/app-store-server-library/); + // + // Checked against the sources rather than the build, because `npm run + // test:unit` in CI runs without building — reading dist/ here passes + // locally off a stale build and fails on a clean checkout. + for (const file of ["src/index.ts", "src/client.ts", "src/client.types.ts"]) { + expect(readFileSync(file, "utf8"), `${file} reaches Apple's library`).not.toMatch( + /app-store-server-library|apple-verifier/ + ); + } + + // Only the subpath's own verifier selection may import it. + const importers = readdirSync("src/iap/verify") + .filter((name) => name.endsWith(".ts")) + .filter((name) => + readFileSync(`src/iap/verify/${name}`, "utf8").includes( + "@apple/app-store-server-library" + ) + ); + expect(importers).toEqual(["apple-verifier.ts"]); + }); + + test("the built main entry carries none of it either, when a build is present", () => { + // The strongest form of the check, but only meaningful after `npm run + // build`, so it reports rather than failing on a clean checkout. + if (!existsSync("dist/index.js")) return; + expect(readFileSync("dist/index.js", "utf8")).not.toMatch( + /app-store-server-library/ + ); }); test("keeps the certificate-generation library to devDependencies, where it never ships", () => {