diff --git a/package-lock.json b/package-lock.json index 001946f4..4eee7741 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "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", "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 +28,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", @@ -33,6 +36,22 @@ "vitest": "^4.1.9" } }, + "node_modules/@apple/app-store-server-library": { + "version": "3.1.0", + "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": { + "@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", @@ -623,6 +642,204 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@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://registry.npmjs.org/@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://registry.npmjs.org/@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://registry.npmjs.org/@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://registry.npmjs.org/@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://registry.npmjs.org/@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://registry.npmjs.org/@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://registry.npmjs.org/@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://registry.npmjs.org/@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://registry.npmjs.org/@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://registry.npmjs.org/@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://registry.npmjs.org/@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", @@ -1023,14 +1240,45 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@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://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://registry.npmjs.org/@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://registry.npmjs.org/@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://registry.npmjs.org/@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": { @@ -1697,6 +1945,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/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", @@ -1777,6 +2040,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/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", @@ -1835,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://registry.npmjs.org/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", @@ -2176,6 +2454,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "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": { + "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", @@ -3707,6 +3994,56 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/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://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://registry.npmjs.org/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://registry.npmjs.org/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", @@ -4028,6 +4365,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "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://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://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://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://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://registry.npmjs.org/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", @@ -4035,6 +4408,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/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", @@ -4235,6 +4614,26 @@ "node": ">= 10.13" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/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", @@ -4588,6 +4987,33 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/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://registry.npmjs.org/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://registry.npmjs.org/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", @@ -4717,6 +5143,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/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", @@ -4756,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" @@ -5181,6 +5626,12 @@ "node": ">=6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/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", @@ -5225,8 +5676,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://registry.npmjs.org/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://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -5447,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://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -5675,6 +6144,22 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "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://registry.npmjs.org/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 ef71bda2..9a2822fd 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" @@ -26,12 +53,14 @@ "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", "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 +72,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..0ac4fb17 --- /dev/null +++ b/src/iap/config.ts @@ -0,0 +1,145 @@ +/** + * 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; + readonly verifier: "apple" | "builtin"; +} + +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.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") { + 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, + verifier: config.verifier ?? "apple", + }; +} 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..03b17dae --- /dev/null +++ b/src/iap/iap.types.ts @@ -0,0 +1,687 @@ +/** + * 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; + /** + * 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. + * + * 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..5baccab2 --- /dev/null +++ b/src/iap/index.ts @@ -0,0 +1,330 @@ +/** + * 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 { 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"; +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; + + // 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 + // 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/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/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..bd76bbb8 --- /dev/null +++ b/tests/iap/fixtures/harness.ts @@ -0,0 +1,203 @@ +// 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, + // 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: { + 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..b4a9f413 --- /dev/null +++ b/tests/unit/iap-packaging.test.ts @@ -0,0 +1,212 @@ +import { existsSync, readdirSync, 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 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", + "uuid", + ]); + }); + + 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. + // + // 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", () => { + 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", + ]; + // 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 + // 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/); + }); +});