diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index 18e8d4275d..27ccee409e 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -27,6 +27,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h | DEPPS21 | Config option `protectedFieldsOwnerExempt` defaults to `false` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS22 | Config option `protectedFieldsTriggerExempt` defaults to `true` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS23 | Config option `protectedFieldsSaveResponseExempt` defaults to `false` | | 9.7.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS24 | Config option `installation.duplicateDeviceTokenActionEnforceAuth` defaults to `true` | [#10451](https://github.com/parse-community/parse-server/pull/10451) | 9.9.0 (2026) | 10.0.0 (2027) | deprecated | - | [i_deprecation]: ## "The version and date of the deprecation." [i_change]: ## "The version and date of the planned change." diff --git a/README.md b/README.md index 6fa6cf4ab4..6e9862f898 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who - [Configuring File Adapters](#configuring-file-adapters) - [Restricting File URL Domains](#restricting-file-url-domains) - [Idempotency Enforcement](#idempotency-enforcement) + - [Installations](#installations) - [Localization](#localization) - [Pages](#pages) - [Localization with Directory Structure](#localization-with-directory-structure) @@ -658,6 +659,49 @@ Assuming the script above is named, `parse_idempotency_delete_expired_records.sh 2 * * * * /root/parse_idempotency_delete_expired_records.sh >/dev/null 2>&1 ``` +## Installations + +Parse Server deduplicates `_Installation` records when a new install collides with an existing row's `deviceToken`. The `installation` option block configures the dedup behavior. + +### Options + +| Parameter | Optional | Type | Default | Environment Variable | +|---|---|---|---|---| +| `installation.duplicateDeviceTokenActionEnforceAuth` | yes | `Boolean` | `false` | `PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION_ENFORCE_AUTH` | +| `installation.duplicateDeviceTokenAction` | yes | `String` | `'delete'` | `PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION` | +| `installation.duplicateDeviceTokenMergePriority` | yes | `String` | `'deviceToken'` | `PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_MERGE_PRIORITY` | + +#### `duplicateDeviceTokenActionEnforceAuth` + +When `true`, the dedup operation runs with the caller's auth context so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. + +#### `duplicateDeviceTokenAction` + +What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. + +- `'delete'`: destroys the conflicting row. +- `'update'`: clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. + +#### `duplicateDeviceTokenMergePriority` + +When an existing row holds the new `deviceToken` but has no `installationId` of its own, Parse Server merges the two rows. This option controls which side wins. + +- `'deviceToken'`: the deviceToken-only row survives; the request's installationId-matched row is the loser. +- `'installationId'`: the request's installationId-matched row survives; the deviceToken-only orphan is the loser. + +### Configuration example + +```javascript +const parseServer = new ParseServer({ + ...otherOptions, + installation: { + duplicateDeviceTokenActionEnforceAuth: true, + duplicateDeviceTokenAction: 'update', + duplicateDeviceTokenMergePriority: 'installationId', + }, +}); +``` + ## Localization ### Pages diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index a0e4c63537..98c0cf907f 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,31 @@ +# [9.9.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.9.0-alpha.2...9.9.0-alpha.3) (2026-04-30) + + +### Features + +* Add installation deviceToken deduplication options ([#10451](https://github.com/parse-community/parse-server/issues/10451)) ([9fee1a0](https://github.com/parse-community/parse-server/commit/9fee1a07080ab8bda2a3d4798881bcc288e5b37a)) + +# [9.9.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.9.0-alpha.1...9.9.0-alpha.2) (2026-04-26) + + +### Bug Fixes + +* MFA SMS one-time password accepted twice under concurrent login ([GHSA-jpq4-7fmq-q5fj](https://github.com/parse-community/parse-server/security/advisories/GHSA-jpq4-7fmq-q5fj)) ([#10448](https://github.com/parse-community/parse-server/issues/10448)) ([725be0d](https://github.com/parse-community/parse-server/commit/725be0d602baa619492606e7b3f6829082d93a4c)) + +# [9.9.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.8.1-alpha.1...9.9.0-alpha.1) (2026-04-17) + + +### Features + +* Add `rawValues` and `rawFieldNames` options for aggregation queries ([#10438](https://github.com/parse-community/parse-server/issues/10438)) ([f26700e](https://github.com/parse-community/parse-server/commit/f26700e39d1980940467bee0d26ca3deb88e3924)) + +## [9.8.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.8.0...9.8.1-alpha.1) (2026-04-12) + + +### Bug Fixes + +* Context mutations leak across requests in `ParseServerRESTController` ([#10291](https://github.com/parse-community/parse-server/issues/10291)) ([60a58ec](https://github.com/parse-community/parse-server/commit/60a58ec11a8bb67aaf217b1e7362b89d742b66da)) + # [9.8.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.12...9.8.0-alpha.13) (2026-04-12) diff --git a/package-lock.json b/package-lock.json index 9c73796483..eb8c76bc0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.8.0", + "version": "9.9.0-alpha.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.8.0", + "version": "9.9.0-alpha.3", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -29,7 +29,7 @@ "graphql-relay": "0.10.2", "graphql-upload": "15.0.2", "intersect": "1.0.1", - "jsonwebtoken": "9.0.2", + "jsonwebtoken": "9.0.3", "jwks-rsa": "3.2.0", "ldapjs": "3.0.7", "lodash": "4.18.1", @@ -38,8 +38,8 @@ "mongodb": "7.1.0", "mustache": "4.2.0", "otpauth": "9.5.0", - "parse": "8.5.0", - "path-to-regexp": "8.4.0", + "parse": "8.6.0", + "path-to-regexp": "8.4.2", "pg-monitor": "3.1.0", "pg-promise": "12.6.0", "pluralize": "8.0.0", @@ -94,7 +94,7 @@ "mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter", "mongodb-runner": "5.9.3", "node-abort-controller": "3.1.1", - "node-fetch": "3.2.10", + "node-fetch": "3.3.2", "nyc": "17.1.0", "prettier": "3.8.1", "semantic-release": "25.0.3", @@ -103,7 +103,7 @@ "yaml": "2.8.3" }, "engines": { - "node": ">=20.19.0 <21.0.0 || >=22.12.0 <23.0.0 || >=24.11.0 <25.0.0" + "node": ">=20.19.0 <21.0.0 || >=22.13.0 <23.0.0 || >=24.11.0 <25.0.0" }, "funding": { "type": "opencollective", @@ -2128,18 +2128,18 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.0.tgz", - "integrity": "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz", + "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==", "license": "MIT", "dependencies": { "core-js-pure": "^3.48.0" @@ -2644,29 +2644,6 @@ "node": ">=14" } }, - "node_modules/@google-cloud/storage/node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "optional": true, - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@google-cloud/storage/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", - "optional": true, - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/@google-cloud/storage/node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -4144,64 +4121,80 @@ "node": "20 || 22 || 24" } }, - "node_modules/@parse/node-apn/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==", + "node_modules/@parse/push-adapter": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@parse/push-adapter/-/push-adapter-8.4.0.tgz", + "integrity": "sha512-sWinUJZvbWIH6cJfIRuwUCcsjvi6IkoJ3zp2JoCP/mLzItt6NPNk+j73RE9UJzIKlwt3NciWXeSHoxprPnNH/A==", "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" + "@parse/node-apn": "8.0.0", + "expo-server-sdk": "6.1.0", + "firebase-admin": "13.7.0", + "npmlog": "7.0.1", + "parse": "8.5.0", + "web-push": "3.6.7" }, "engines": { - "node": ">=12", - "npm": ">=6" + "node": "20 || 22 || 24" } }, - "node_modules/@parse/node-apn/node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "node_modules/@parse/push-adapter/node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@parse/node-apn/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==", + "node_modules/@parse/push-adapter/node_modules/@babel/runtime-corejs3": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.0.tgz", + "integrity": "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" + "core-js-pure": "^3.48.0" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@parse/push-adapter": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@parse/push-adapter/-/push-adapter-8.4.0.tgz", - "integrity": "sha512-sWinUJZvbWIH6cJfIRuwUCcsjvi6IkoJ3zp2JoCP/mLzItt6NPNk+j73RE9UJzIKlwt3NciWXeSHoxprPnNH/A==", - "license": "MIT", + "node_modules/@parse/push-adapter/node_modules/parse": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-8.5.0.tgz", + "integrity": "sha512-X9gI4Yjbi9LPMPnCtKL4h0Nxe1aSCFMPWcB1zbu11qU/Be3eVSB5I5IMBunTuWlVz6Wchu3dtM5jl/1aBZ9wiQ==", + "license": "Apache-2.0", "dependencies": { - "@parse/node-apn": "8.0.0", - "expo-server-sdk": "6.1.0", - "firebase-admin": "13.7.0", - "npmlog": "7.0.1", - "parse": "8.5.0", - "web-push": "3.6.7" + "@babel/runtime": "7.28.6", + "@babel/runtime-corejs3": "7.29.0", + "crypto-js": "4.2.0", + "idb-keyval": "6.2.2", + "react-native-crypto-js": "1.0.0", + "ws": "8.19.0" }, "engines": { - "node": "20 || 22 || 24" + "node": ">=20.19.0 <21 || >=22.12.0 <23 || >=24.1.0 <25" + } + }, + "node_modules/@parse/push-adapter/node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/@pkgjs/parseargs": { @@ -9752,7 +9745,8 @@ "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==" + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" }, "node_modules/buffer-fill": { "version": "1.0.0", @@ -10697,9 +10691,9 @@ } }, "node_modules/core-js-pure": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.48.0.tgz", - "integrity": "sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw==", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz", + "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -13313,26 +13307,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/gcp-metadata/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/gcp-metadata/node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -13672,45 +13646,6 @@ "node": ">=18" } }, - "node_modules/google-auth-library/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/google-auth-library/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/google-auth-library/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/google-gax": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", @@ -13778,29 +13713,6 @@ "node": ">=14" } }, - "node_modules/google-gax/node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "optional": true, - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/google-gax/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", - "optional": true, - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/google-gax/node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -14000,29 +13912,6 @@ "node": ">=14.0.0" } }, - "node_modules/gtoken/node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "optional": true, - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/gtoken/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", - "optional": true, - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -15244,11 +15133,12 @@ } }, "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "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": "^3.2.2", + "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", @@ -15265,9 +15155,10 @@ } }, "node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "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", @@ -15292,11 +15183,12 @@ } }, "node_modules/jws": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", - "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "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": "^1.4.2", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -17167,10 +17059,10 @@ } }, "node_modules/node-fetch": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz", - "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==", - "dev": true, + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", @@ -20432,20 +20324,20 @@ } }, "node_modules/parse": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/parse/-/parse-8.5.0.tgz", - "integrity": "sha512-X9gI4Yjbi9LPMPnCtKL4h0Nxe1aSCFMPWcB1zbu11qU/Be3eVSB5I5IMBunTuWlVz6Wchu3dtM5jl/1aBZ9wiQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-8.6.0.tgz", + "integrity": "sha512-AZjc8yGo8/iTZFpCXWw/r1qNusiUGWtq9i92/u0jNd+Iupg3EJUSV/OOyTrCeav8NDyo92wVS5O3iKAYPlhlsA==", "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "7.28.6", - "@babel/runtime-corejs3": "7.29.0", + "@babel/runtime": "7.29.2", + "@babel/runtime-corejs3": "7.29.2", "crypto-js": "4.2.0", "idb-keyval": "6.2.2", "react-native-crypto-js": "1.0.0", - "ws": "8.19.0" + "ws": "8.20.0" }, "engines": { - "node": ">=20.19.0 <21 || >=22.12.0 <23 || >=24.1.0 <25" + "node": ">=20.19.0 <21 || >=22.13.0 <23 || >=24.1.0 <25" } }, "node_modules/parse-json": { @@ -20475,27 +20367,6 @@ "node": ">=6" } }, - "node_modules/parse/node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/parse5": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", @@ -20611,9 +20482,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -26484,25 +26355,6 @@ "node": ">= 16" } }, - "node_modules/web-push/node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/web-push/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==", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/web-streams-polyfill": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", @@ -28385,14 +28237,14 @@ } }, "@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==" + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==" }, "@babel/runtime-corejs3": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.0.tgz", - "integrity": "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz", + "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==", "requires": { "core-js-pure": "^3.48.0" } @@ -28765,27 +28617,6 @@ "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "optional": true }, - "jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "optional": true, - "requires": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "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==", - "optional": true, - "requires": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -29842,44 +29673,6 @@ "jsonwebtoken": "9.0.3", "node-forge": "1.4.0", "verror": "1.10.1" - }, - "dependencies": { - "jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "requires": { - "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" - } - }, - "jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "requires": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "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==", - "requires": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - } } }, "@parse/push-adapter": { @@ -29893,6 +29686,40 @@ "npmlog": "7.0.1", "parse": "8.5.0", "web-push": "3.6.7" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==" + }, + "@babel/runtime-corejs3": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.0.tgz", + "integrity": "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==", + "requires": { + "core-js-pure": "^3.48.0" + } + }, + "parse": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-8.5.0.tgz", + "integrity": "sha512-X9gI4Yjbi9LPMPnCtKL4h0Nxe1aSCFMPWcB1zbu11qU/Be3eVSB5I5IMBunTuWlVz6Wchu3dtM5jl/1aBZ9wiQ==", + "requires": { + "@babel/runtime": "7.28.6", + "@babel/runtime-corejs3": "7.29.0", + "crypto-js": "4.2.0", + "idb-keyval": "6.2.2", + "react-native-crypto-js": "1.0.0", + "ws": "8.19.0" + } + }, + "ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "requires": {} + } } }, "@pkgjs/parseargs": { @@ -34300,9 +34127,9 @@ } }, "core-js-pure": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.48.0.tgz", - "integrity": "sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw==" + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz", + "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==" }, "core-util-is": { "version": "1.0.3", @@ -36105,18 +35932,6 @@ "optional": true, "peer": true }, - "node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "optional": true, - "peer": true, - "requires": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - } - }, "rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -36362,35 +36177,6 @@ "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } - }, - "jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "requires": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "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==", - "requires": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "requires": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - } } } }, @@ -36445,27 +36231,6 @@ "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "optional": true }, - "jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "optional": true, - "requires": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "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==", - "optional": true, - "requires": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -36587,29 +36352,6 @@ "requires": { "gaxios": "^6.0.0", "jws": "^4.0.0" - }, - "dependencies": { - "jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "optional": true, - "requires": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "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==", - "optional": true, - "requires": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - } } }, "handlebars": { @@ -37485,11 +37227,11 @@ } }, "jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "requires": { - "jws": "^3.2.2", + "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", @@ -37502,9 +37244,9 @@ } }, "jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "requires": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -37525,11 +37267,11 @@ } }, "jws": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", - "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "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==", "requires": { - "jwa": "^1.4.2", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -38829,10 +38571,9 @@ } }, "node-fetch": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz", - "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==", - "dev": true, + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "requires": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", @@ -41049,24 +40790,16 @@ } }, "parse": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/parse/-/parse-8.5.0.tgz", - "integrity": "sha512-X9gI4Yjbi9LPMPnCtKL4h0Nxe1aSCFMPWcB1zbu11qU/Be3eVSB5I5IMBunTuWlVz6Wchu3dtM5jl/1aBZ9wiQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-8.6.0.tgz", + "integrity": "sha512-AZjc8yGo8/iTZFpCXWw/r1qNusiUGWtq9i92/u0jNd+Iupg3EJUSV/OOyTrCeav8NDyo92wVS5O3iKAYPlhlsA==", "requires": { - "@babel/runtime": "7.28.6", - "@babel/runtime-corejs3": "7.29.0", + "@babel/runtime": "7.29.2", + "@babel/runtime-corejs3": "7.29.2", "crypto-js": "4.2.0", "idb-keyval": "6.2.2", "react-native-crypto-js": "1.0.0", - "ws": "8.19.0" - }, - "dependencies": { - "ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "requires": {} - } + "ws": "8.20.0" } }, "parse-json": { @@ -41174,9 +40907,9 @@ } }, "path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==" + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" }, "path-type": { "version": "4.0.0", @@ -45130,27 +44863,6 @@ "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" - }, - "dependencies": { - "jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "requires": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "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==", - "requires": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - } } }, "web-streams-polyfill": { diff --git a/package.json b/package.json index 5ea789fa16..6bd973d503 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.8.0", + "version": "9.9.0-alpha.3", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { @@ -39,7 +39,7 @@ "graphql-relay": "0.10.2", "graphql-upload": "15.0.2", "intersect": "1.0.1", - "jsonwebtoken": "9.0.2", + "jsonwebtoken": "9.0.3", "jwks-rsa": "3.2.0", "ldapjs": "3.0.7", "lodash": "4.18.1", @@ -48,8 +48,8 @@ "mongodb": "7.1.0", "mustache": "4.2.0", "otpauth": "9.5.0", - "parse": "8.5.0", - "path-to-regexp": "8.4.0", + "parse": "8.6.0", + "path-to-regexp": "8.4.2", "pg-monitor": "3.1.0", "pg-promise": "12.6.0", "pluralize": "8.0.0", @@ -101,7 +101,7 @@ "mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter", "mongodb-runner": "5.9.3", "node-abort-controller": "3.1.1", - "node-fetch": "3.2.10", + "node-fetch": "3.3.2", "nyc": "17.1.0", "prettier": "3.8.1", "semantic-release": "25.0.3", @@ -140,7 +140,7 @@ }, "types": "types/index.d.ts", "engines": { - "node": ">=20.19.0 <21.0.0 || >=22.12.0 <23.0.0 || >=24.11.0 <25.0.0" + "node": ">=20.19.0 <21.0.0 || >=22.13.0 <23.0.0 || >=24.11.0 <25.0.0" }, "bin": { "parse-server": "bin/parse-server" diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index 8bfeb3799c..1a3cbb4b55 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -18,11 +18,13 @@ const nestedOptionTypes = [ 'FileDownloadOptions', 'FileUploadOptions', 'IdempotencyOptions', + 'InstallationOptions', 'Object', 'PagesCustomUrlsOptions', 'PagesOptions', 'PagesRoute', 'PasswordPolicyOptions', + 'QueryServerOptions', 'RequestComplexityOptions', 'SecurityOptions', 'SchemaOptions', @@ -38,6 +40,7 @@ const nestedOptionEnvPrefix = { FileDownloadOptions: 'PARSE_SERVER_FILE_DOWNLOAD_', FileUploadOptions: 'PARSE_SERVER_FILE_UPLOAD_', IdempotencyOptions: 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_', + InstallationOptions: 'PARSE_SERVER_INSTALLATION_', LiveQueryOptions: 'PARSE_SERVER_LIVEQUERY_', LiveQueryServerOptions: 'PARSE_LIVE_QUERY_SERVER_', LogClientEvent: 'PARSE_SERVER_DATABASE_LOG_CLIENT_EVENTS_', @@ -48,6 +51,7 @@ const nestedOptionEnvPrefix = { PagesRoute: 'PARSE_SERVER_PAGES_ROUTE_', ParseServerOptions: 'PARSE_SERVER_', PasswordPolicyOptions: 'PARSE_SERVER_PASSWORD_POLICY_', + QueryServerOptions: 'PARSE_SERVER_QUERY_', RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_', RequestComplexityOptions: 'PARSE_SERVER_REQUEST_COMPLEXITY_', SchemaOptions: 'PARSE_SERVER_SCHEMA_', diff --git a/spec/Deprecator.spec.js b/spec/Deprecator.spec.js index 1412795620..993d18682f 100644 --- a/spec/Deprecator.spec.js +++ b/spec/Deprecator.spec.js @@ -234,4 +234,39 @@ describe('Deprecator', () => { ); } }); + + it('registers a deprecation entry for installation.duplicateDeviceTokenActionEnforceAuth', () => { + const Deprecations = require('../lib/Deprecator/Deprecations'); + const entry = Deprecations.find( + d => d.optionKey === 'installation.duplicateDeviceTokenActionEnforceAuth' + ); + expect(entry).toBeDefined(); + expect(entry.changeNewDefault).toBe('true'); + expect(entry.solution).toContain('duplicateDeviceTokenActionEnforceAuth'); + }); + + it('logs deprecation for installation.duplicateDeviceTokenActionEnforceAuth when not set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer(); + expect(logSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'installation.duplicateDeviceTokenActionEnforceAuth', + changeNewDefault: 'true', + }) + ); + }); + + it('does not log deprecation for installation.duplicateDeviceTokenActionEnforceAuth when explicitly set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ + installation: { duplicateDeviceTokenActionEnforceAuth: false }, + }); + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'installation.duplicateDeviceTokenActionEnforceAuth', + }) + ); + }); }); diff --git a/spec/GraphQLQueryComplexity.spec.js b/spec/GraphQLQueryComplexity.spec.js index 324634e278..def95a6b51 100644 --- a/spec/GraphQLQueryComplexity.spec.js +++ b/spec/GraphQLQueryComplexity.spec.js @@ -2,7 +2,11 @@ const http = require('http'); const express = require('express'); -const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); +const fetch = (...args) => + import('node-fetch').then(({ default: fetch }) => { + const [url, options = {}] = args; + return fetch(url, { agent: new http.Agent({ keepAlive: false }), ...options }); + }); require('./helper'); const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); diff --git a/spec/InstallationDedup.spec.js b/spec/InstallationDedup.spec.js new file mode 100644 index 0000000000..c73c574cf6 --- /dev/null +++ b/spec/InstallationDedup.spec.js @@ -0,0 +1,319 @@ +'use strict'; + +const Parse = require('parse/node').Parse; + +describe('InstallationDedup', () => { + let InstallationDedup; + let logger; + let logSpy; + + beforeEach(() => { + InstallationDedup = require('../lib/InstallationDedup'); + logger = require('../lib/logger').logger; + logSpy = { + verbose: spyOn(logger, 'verbose').and.callFake(() => {}), + warn: spyOn(logger, 'warn').and.callFake(() => {}), + error: spyOn(logger, 'error').and.callFake(() => {}), + }; + }); + + describe('removeConflictingDeviceToken', () => { + it('action="delete" with no match resolves silently and logs verbose', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue(Promise.reject({ code: Parse.Error.OBJECT_NOT_FOUND })), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalled(); + expect(logSpy.verbose).toHaveBeenCalled(); + expect(logSpy.warn).not.toHaveBeenCalled(); + expect(logSpy.error).not.toHaveBeenCalled(); + }); + + it('action="delete" with matches calls destroy with empty options when enforceAuth=false', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: false, + runOptions: { acl: ['*'] }, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { deviceToken: 'X' }, + {}, + undefined + ); + expect(logSpy.verbose).toHaveBeenCalled(); + }); + + it('action="delete" with enforceAuth=true passes runOptions to destroy', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const runOptions = { acl: ['*', 'userABC'] }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: true, + runOptions, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { deviceToken: 'X' }, + runOptions, + undefined + ); + }); + + it('action="update" calls update with deviceToken cleared and many=true in options', async () => { + const database = { + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'update', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(database.update).toHaveBeenCalledWith( + '_Installation', + { deviceToken: 'X' }, + { deviceToken: { __op: 'Delete' } }, + jasmine.objectContaining({ many: true }), + false, + false, + undefined + ); + expect(logSpy.verbose).toHaveBeenCalled(); + }); + + it('OPERATION_FORBIDDEN error is swallowed and logged as warn', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue( + Promise.reject({ code: Parse.Error.OPERATION_FORBIDDEN, message: 'denied' }) + ), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: true, + runOptions: { acl: ['*'] }, + validSchemaController: undefined, + }); + expect(logSpy.warn).toHaveBeenCalled(); + expect(logSpy.error).not.toHaveBeenCalled(); + }); + + it('unexpected error is logged as error and rethrown', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue(Promise.reject(new Error('database connection lost'))), + }; + let caught; + try { + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + } catch (e) { + caught = e; + } + expect(caught).toBeDefined(); + expect(caught.message).toBe('database connection lost'); + expect(logSpy.error).toHaveBeenCalled(); + }); + }); + + describe('applyDuplicateDeviceTokenMerge', () => { + const idMatch = { objectId: 'A', installationId: 'I' }; + const deviceTokenMatch = { objectId: 'B', deviceToken: 'X' }; + + it('mergePriority="deviceToken" + action="delete" destroys idMatch and returns deviceTokenMatch.objectId', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('B'); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { objectId: 'A' }, + {}, + undefined + ); + expect(logSpy.verbose).toHaveBeenCalled(); + }); + + it('mergePriority="deviceToken" + action="update" clears installationId on idMatch and returns deviceTokenMatch.objectId', async () => { + const database = { + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'update', + mergePriority: 'deviceToken', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('B'); + expect(database.update).toHaveBeenCalledWith( + '_Installation', + { objectId: 'A' }, + { installationId: { __op: 'Delete' } }, + jasmine.objectContaining({ many: false }), + false, + false, + undefined + ); + }); + + it('mergePriority="installationId" + action="delete" destroys deviceTokenMatch and returns idMatch.objectId', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'installationId', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('A'); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { objectId: 'B' }, + {}, + undefined + ); + }); + + it('mergePriority="installationId" + action="update" clears deviceToken on deviceTokenMatch and returns idMatch.objectId', async () => { + const database = { + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'update', + mergePriority: 'installationId', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('A'); + expect(database.update).toHaveBeenCalledWith( + '_Installation', + { objectId: 'B' }, + { deviceToken: { __op: 'Delete' } }, + jasmine.objectContaining({ many: false }), + false, + false, + undefined + ); + }); + + it('OPERATION_FORBIDDEN on the merge action still returns survivor objectId (silent skip)', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue(Promise.reject({ code: Parse.Error.OPERATION_FORBIDDEN })), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: true, + runOptions: { acl: ['*'] }, + validSchemaController: undefined, + }); + expect(result).toBe('B'); + expect(logSpy.warn).toHaveBeenCalled(); + }); + + it('returns the shared objectId without calling destroy/update when idMatch and deviceTokenMatch are the same row', async () => { + const sameRow = { objectId: 'SAME', installationId: 'I', deviceToken: 'X' }; + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch: sameRow, + deviceTokenMatch: sameRow, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('SAME'); + expect(database.destroy).not.toHaveBeenCalled(); + expect(database.update).not.toHaveBeenCalled(); + }); + + it('enforceAuth=true passes runOptions to destroy', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const runOptions = { acl: ['*', 'userABC'] }; + await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: true, + runOptions, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { objectId: 'A' }, + runOptions, + undefined + ); + }); + }); +}); diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index e36067afae..07bcd4efdf 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -1,7 +1,11 @@ const http = require('http'); const express = require('express'); const req = require('../lib/request'); -const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); +const fetch = (...args) => + import('node-fetch').then(({ default: fetch }) => { + const [url, options = {}] = args; + return fetch(url, { agent: new http.Agent({ keepAlive: false }), ...options }); + }); const FormData = require('form-data'); require('./helper'); const { updateCLP } = require('./support/dev'); diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 408e8fa7bf..261733c3af 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1297,4 +1297,485 @@ describe('Installations', () => { // TODO: Look at additional tests from installation_collection_test.go:882 // TODO: Do we need to support _tombstone disabling of installations? // TODO: Test deletion, badge increments + + describe('deviceToken deduplication on new install (no installationId match)', () => { + const { randomUUID } = require('crypto'); + const installationSchema = { + fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), + }; + + async function reconfigureWithInstallationOptions(installationOpts) { + await reconfigureServer({ installation: installationOpts }); + config = Config.get('test'); + database = config.database; + } + + it('default options destroy conflicting rows', async () => { + const t = randomUUID(); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-b', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-c', + }); + + const results = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(results.length).toBe(1); + expect(results[0].installationId).toBe('iid-c'); + }); + + it('action="update" preserves channels on conflicting rows but clears deviceToken', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t = randomUUID(); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-a', + channels: ['old-news'], + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-b', + channels: ['old-sports'], + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-c', + channels: ['fresh'], + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(3); + const survivor = all.find(r => r.installationId === 'iid-c'); + expect(survivor.deviceToken).toBe(t); + const cleared = all.filter(r => r.installationId !== 'iid-c'); + cleared.forEach(r => { + expect(r.deviceToken).toBeUndefined(); + expect(r.channels).toBeDefined(); + }); + }); + + it('enforceAuth=true preserves ACL-protected rows from unauthenticated dedup', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); + const t = randomUUID(); + const user = await Parse.User.signUp('alice-' + Date.now(), 'pass'); + const aliceId = user.id; + + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-protected', + ACL: { [aliceId]: { read: true, write: true } }, + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-other', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-attacker', + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + const protectedRow = all.find(r => r.installationId === 'iid-protected'); + expect(protectedRow).toBeDefined(); + expect(protectedRow.deviceToken).toBe(t); + }); + + it('enforceAuth=true with master-key caller still bypasses ACL and dedups', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); + const t = randomUUID(); + const user = await Parse.User.signUp('bob-' + Date.now(), 'pass'); + const bobId = user.id; + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-1', + ACL: { [bobId]: { read: true, write: true } }, + }); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-2', + }); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-3', + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].installationId).toBe('iid-3'); + }); + + it('action="update" clears deviceToken on ALL matching rows (multi-row update)', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t = randomUUID(); + // First REST create ensures the storage class/table exists before direct + // adapter inserts (relevant for Postgres, which creates tables lazily). + await rest.create(config, auth.master(config), '_Installation', { + deviceType: 'ios', + deviceToken: t, + installationId: 'multi-iid-a', + channels: ['c-multi-iid-a'], + }); + // Insert two more rows directly via the storage adapter so all three hold + // the same deviceToken simultaneously — bypassing the sequential REST + // dedup that would otherwise prevent this state. + const adapter = config.database.adapter; + for (const iid of ['multi-iid-b', 'multi-iid-c']) { + await adapter.createObject( + '_Installation', + installationSchema, + { + objectId: 'oid-' + iid, + deviceType: 'ios', + deviceToken: t, + installationId: iid, + channels: ['c-' + iid], + }, + null + ); + } + // Trigger site 1: new install with same deviceToken, different installationId. + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'multi-iid-d', + channels: ['fresh'], + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + const survivor = all.find(r => r.installationId === 'multi-iid-d'); + expect(survivor).toBeDefined(); + expect(survivor.deviceToken).toBe(t); + const cleared = all.filter(r => r.installationId !== 'multi-iid-d'); + expect(cleared.length).toBe(3); + cleared.forEach(r => { + expect(r.deviceToken).toBeUndefined(); + }); + }); + }); + + describe('deviceToken deduplication on existing install update (deviceToken changes)', () => { + const { randomUUID } = require('crypto'); + const installationSchema = { + fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), + }; + + async function reconfigureWithInstallationOptions(installationOpts) { + await reconfigureServer({ installation: installationOpts }); + config = Config.get('test'); + database = config.database; + } + + it('default options destroy conflicting row when PUT sets a new deviceToken', async () => { + const t1 = randomUUID(); + const t2 = randomUUID(); + const a = await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t1, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t2, + deviceType: 'ios', + installationId: 'iid-b', + }); + await rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: a.response.objectId }, + { deviceToken: t2, installationId: 'iid-a' } + ); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].deviceToken).toBe(t2); + expect(all[0].installationId).toBe('iid-a'); + }); + + it('action="update" preserves the conflicting row and only clears its deviceToken', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t1 = randomUUID(); + const t2 = randomUUID(); + const a = await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t1, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t2, + deviceType: 'ios', + installationId: 'iid-b', + channels: ['preserve-me'], + }); + await rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: a.response.objectId }, + { deviceToken: t2, installationId: 'iid-a' } + ); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(2); + const aRow = all.find(r => r.installationId === 'iid-a'); + const bRow = all.find(r => r.installationId === 'iid-b'); + expect(aRow.deviceToken).toBe(t2); + expect(bRow.deviceToken).toBeUndefined(); + expect(bRow.channels).toEqual(['preserve-me']); + }); + + it('enforceAuth=true preserves ACL-protected conflicting rows', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); + const t1 = randomUUID(); + const t2 = randomUUID(); + const user = await Parse.User.signUp('carol-' + Date.now(), 'pass'); + const carolId = user.id; + + const a = await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t1, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t2, + deviceType: 'ios', + installationId: 'iid-b', + ACL: { [carolId]: { read: true, write: true } }, + }); + await rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: a.response.objectId }, + { deviceToken: t2, installationId: 'iid-a' } + ); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + const bRow = all.find(r => r.installationId === 'iid-b'); + expect(bRow).toBeDefined(); + expect(bRow.deviceToken).toBe(t2); + const aRow = all.find(r => r.installationId === 'iid-a'); + expect(aRow.deviceToken).toBe(t2); + }); + }); + + describe('deviceToken deduplication merge case (idMatch + deviceToken-only orphan)', () => { + const { randomUUID } = require('crypto'); + const installationSchema = { + fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), + }; + + async function reconfigureWithInstallationOptions(installationOpts) { + await reconfigureServer({ installation: installationOpts }); + config = Config.get('test'); + database = config.database; + } + + /** + * Sets up the merge fixture: + * Row A — { installationId: iid, deviceType: 'ios' } (no deviceToken) + * Row B — { deviceToken: t, deviceType: 'ios', channels } (no installationId) + * Then triggers the merge by POSTing { installationId: iid, deviceToken: t }. + */ + async function setupMergeFixture(t, iid, bChannels = ['orphan-history']) { + // Row A: matched by installationId, no deviceToken yet. + await rest.create(config, auth.master(config), '_Installation', { + deviceType: 'ios', + installationId: iid, + }); + // Row B: deviceToken-only orphan. Insert via the storage adapter to bypass + // the require-at-least-one-ID check (the orphan has only deviceToken). + const objectId = 'orph' + Math.random().toString(36).substring(2, 12); + await database.adapter.createObject( + '_Installation', + installationSchema, + { + objectId, + deviceType: 'ios', + deviceToken: t, + channels: bChannels, + }, + null + ); + return objectId; + } + + it('default options merge: deviceToken-holder wins, idMatch destroyed', async () => { + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + // POST that triggers the merge. + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].objectId).toBe(orphanObjectId); + expect(all[0].installationId).toBe('merge-iid-a'); + expect(all[0].deviceToken).toBe(t); + expect(all[0].channels).toEqual(['orphan-history']); + }); + + it('mergePriority=deviceToken, action=update clears installationId on idMatch (loser)', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(2); + const survivor = all.find(r => r.objectId === orphanObjectId); + expect(survivor.installationId).toBe('merge-iid-a'); + expect(survivor.deviceToken).toBe(t); + const loser = all.find(r => r.objectId !== orphanObjectId); + expect(loser.installationId).toBeUndefined(); + }); + + it('mergePriority=installationId, action=delete destroys orphan, idMatch wins', async () => { + await reconfigureWithInstallationOptions({ + duplicateDeviceTokenMergePriority: 'installationId', + }); + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].installationId).toBe('merge-iid-a'); + expect(all[0].deviceToken).toBe(t); + expect(all[0].objectId).not.toBe(orphanObjectId); + }); + + it('mergePriority=installationId, action=update clears deviceToken on orphan', async () => { + await reconfigureWithInstallationOptions({ + duplicateDeviceTokenMergePriority: 'installationId', + duplicateDeviceTokenAction: 'update', + }); + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(2); + const survivor = all.find(r => r.installationId === 'merge-iid-a'); + expect(survivor.deviceToken).toBe(t); + const loser = all.find(r => r.objectId === orphanObjectId); + expect(loser.deviceToken).toBeUndefined(); + expect(loser.channels).toEqual(['orphan-history']); + }); + }); + + describe('options validation', () => { + it('should accept default empty config', async () => { + await expectAsync(reconfigureServer({})).toBeResolved(); + }); + + it('should accept fully specified valid config', async () => { + await expectAsync( + reconfigureServer({ + installation: { + duplicateDeviceTokenActionEnforceAuth: true, + duplicateDeviceTokenAction: 'update', + duplicateDeviceTokenMergePriority: 'installationId', + }, + }) + ).toBeResolved(); + }); + + it('should reject non-object values', async () => { + await expectAsync( + reconfigureServer({ installation: 'invalid' }) + ).toBeRejectedWith('installation must be an object.'); + }); + + it('should reject array values', async () => { + await expectAsync( + reconfigureServer({ installation: [] }) + ).toBeRejectedWith('installation must be an object.'); + }); + + it('should reject unknown nested keys', async () => { + await expectAsync( + reconfigureServer({ + installation: { unknownKey: 'foo' }, + }) + ).toBeRejectedWith("installation contains unknown property 'unknownKey'."); + }); + + it('should reject non-boolean duplicateDeviceTokenActionEnforceAuth', async () => { + await expectAsync( + reconfigureServer({ + installation: { duplicateDeviceTokenActionEnforceAuth: 'true' }, + }) + ).toBeRejectedWith('installation.duplicateDeviceTokenActionEnforceAuth must be a boolean.'); + }); + + it('should reject invalid duplicateDeviceTokenAction value', async () => { + await expectAsync( + reconfigureServer({ + installation: { duplicateDeviceTokenAction: 'merge' }, + }) + ).toBeRejectedWith( + "installation.duplicateDeviceTokenAction must be one of: 'delete', 'update'." + ); + }); + + it('should reject invalid duplicateDeviceTokenMergePriority value', async () => { + await expectAsync( + reconfigureServer({ + installation: { duplicateDeviceTokenMergePriority: 'objectId' }, + }) + ).toBeRejectedWith( + "installation.duplicateDeviceTokenMergePriority must be one of: 'deviceToken', 'installationId'." + ); + }); + + it('should apply defaults for missing nested keys', async () => { + await reconfigureServer({ + installation: { duplicateDeviceTokenActionEnforceAuth: true }, + }); + const config = Config.get('test'); + expect(config.installation.duplicateDeviceTokenActionEnforceAuth).toBe(true); + expect(config.installation.duplicateDeviceTokenAction).toBe('delete'); + expect(config.installation.duplicateDeviceTokenMergePriority).toBe('deviceToken'); + }); + + it('should apply full defaults when block omitted', async () => { + await reconfigureServer({}); + const config = Config.get('test'); + expect(config.installation).toEqual({ + duplicateDeviceTokenActionEnforceAuth: false, + duplicateDeviceTokenAction: 'delete', + duplicateDeviceTokenMergePriority: 'deviceToken', + }); + }); + }); }); diff --git a/spec/ParseQuery.Aggregate.spec.js b/spec/ParseQuery.Aggregate.spec.js index 1085589fb4..ac615b1fde 100644 --- a/spec/ParseQuery.Aggregate.spec.js +++ b/spec/ParseQuery.Aggregate.spec.js @@ -473,6 +473,104 @@ describe('Parse.Query Aggregate testing', () => { expect(new Date(results[0].date.iso)).toEqual(obj1.get('date')); }); + it_id('8c211edc-a48e-4ab3-810a-f56897228393')(it_exclude_dbs(['postgres']))('rawValues: true converts $date EJSON marker to BSON Date in $match', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { $match: { objectId: obj.id, createdAt: { $lte: { $date: iso } } } }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].total).toBe(1); + }); + + it_id('2a79e4c8-aa16-434f-bbea-e34637eaff16')(it_exclude_dbs(['postgres']))('rawValues: true deserializes $date at any nesting depth', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { + $match: { + $and: [ + { objectId: obj.id }, + { $or: [{ createdAt: { $lte: { $date: iso } } }] }, + ], + }, + }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].total).toBe(1); + }); + + it_id('cc08f092-8f26-4f5b-81f2-769de812982f')(it_exclude_dbs(['postgres']))('rawValues: true does NOT coerce bare ISO strings', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { $match: { objectId: obj.id, createdAt: { $lte: iso } } }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + // Bare ISO string compared against BSON Date: MongoDB string-vs-date comparison yields no matches. + expect(results.length).toBe(0); + }); + + it_id('bc4cb19e-3114-40d8-8db8-0e9f5b582f33')(it_exclude_dbs(['postgres']))('rawValues: true does NOT coerce Parse Date encoding `{ __type: "Date", iso }`', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { + $match: { + objectId: obj.id, + createdAt: { $lte: { __type: 'Date', iso } }, + }, + }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + // Parse Date encoding is not interpreted in rawValues mode; comparison fails silently. + expect(results.length).toBe(0); + }); + + it_id('27c3bf01-5b4a-41b3-988e-522fdef63181')(it_exclude_dbs(['postgres']))('rawValues: true serializes BSON Date in results as `{ $date: iso }`', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { $match: { objectId: obj.id, createdAt: { $lte: { $date: iso } } } }, + { $project: { _id: 1, _created_at: 1 } }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + expect(results.length).toBe(1); + // EJSON-serialized date marker, not Parse `{ __type: 'Date', iso }` encoding. + expect(results[0]._created_at).toEqual(jasmine.objectContaining({ $date: jasmine.any(String) })); + }); + + it_id('5b6b225d-219e-480c-9241-ac3e146dda9f')(it_exclude_dbs(['postgres']))('rawValues: true deserializes EJSON in `$addFields`', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = '2026-01-01T00:00:00.000Z'; + const pipeline = [ + { $match: { objectId: obj.id } }, + { $addFields: { pinned: { $date: iso } } }, + { $project: { _id: 1, pinned: 1 } }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].pinned).toEqual(jasmine.objectContaining({ $date: jasmine.any(String) })); + }); + it_only_db('postgres')( 'can group by any date field postgres (it does not work if you have dirty data)', // rows in your collection with non date data in the field that is supposed to be a date done => { @@ -1554,4 +1652,125 @@ describe('Parse.Query Aggregate testing', () => { expect(e.code).toBe(Parse.Error.INVALID_QUERY); } }); + + it_id('e1d699e3-1389-4213-b0e6-37838bcef390')(it_exclude_dbs(['postgres']))('rawFieldNames: true lets users write _created_at directly', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { + $match: { + _id: obj.id, + _created_at: { $lte: { $date: iso } }, + }, + }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { + rawValues: true, + rawFieldNames: true, + useMasterKey: true, + }); + expect(results.length).toBe(1); + expect(results[0].total).toBe(1); + }); + + it_id('79e68a9f-ce15-44cf-9f9e-6a722f73ef1a')(it_exclude_dbs(['postgres']))('rawFieldNames: true does NOT rewrite Parse-style names', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + // Using Parse-style `createdAt` under rawFieldNames should query a field that doesn't exist in MongoDB. + const pipeline = [ + { $match: { _id: obj.id, createdAt: { $lte: { $date: iso } } } }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { + rawValues: true, + rawFieldNames: true, + useMasterKey: true, + }); + // `createdAt` is not a MongoDB field name; no documents match. + expect(results.length).toBe(0); + }); + + it_id('b69c1a5a-b1d3-4c45-adb4-bb8f74af37c6')(it_exclude_dbs(['postgres']))('rawFieldNames: true returns native field names in results', async () => { + const obj = new TestObject(); + await obj.save(); + const pipeline = [ + { $match: { _id: obj.id } }, + { $project: { _id: 1, _created_at: 1 } }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { + rawValues: true, + rawFieldNames: true, + useMasterKey: true, + }); + expect(results.length).toBe(1); + expect(results[0]._id).toBe(obj.id); + expect(Object.prototype.hasOwnProperty.call(results[0], '_created_at')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(results[0], 'objectId')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(results[0], 'createdAt')).toBe(false); + }); + + it_id('f854cc3d-2259-42bc-be88-4122f80f8568')(it_exclude_dbs(['postgres']))('server-level rawValues default applies when per-query omits it', async () => { + await reconfigureServer({ query: { aggregationRawValues: true } }); + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { $match: { objectId: obj.id, createdAt: { $lte: { $date: iso } } } }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + // No rawValues in the per-query options — should inherit from the server default. + const results = await query.aggregate(pipeline, { useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].total).toBe(1); + }); + + it_id('5be28dc9-a298-488c-8dec-893c2309f6b7')(it_exclude_dbs(['postgres']))('per-query rawValues: false overrides server-level true', async () => { + await reconfigureServer({ query: { aggregationRawValues: true } }); + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + // With server-level rawValues: true, EJSON `{ $date: iso }` would be converted to a BSON Date + // and the $match would succeed. Per-query rawValues: false overrides that, so `{ $date: iso }` + // is NOT deserialized as EJSON and the comparison fails — proving the override works. + const pipeline = [ + { $match: { objectId: obj.id, createdAt: { $lte: { $date: iso } } } }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { + rawValues: false, + useMasterKey: true, + }); + // Under rawValues: false the `{ $date: iso }` is not EJSON-deserialized; comparison yields no match. + expect(results.length).toBe(0); + }); + + it_id('e0e89b62-5ced-4610-ab16-82ea532e69c1')(it_exclude_dbs(['postgres']))('server-level rawFieldNames default applies when per-query omits it', async () => { + await reconfigureServer({ + query: { aggregationRawValues: true, aggregationRawFieldNames: true }, + }); + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { + $match: { + _id: obj.id, + _created_at: { $lte: { $date: iso } }, + }, + }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].total).toBe(1); + }); }); diff --git a/spec/ParseServerRESTController.spec.js b/spec/ParseServerRESTController.spec.js index 31d1f5aec7..061f2fcf28 100644 --- a/spec/ParseServerRESTController.spec.js +++ b/spec/ParseServerRESTController.spec.js @@ -519,6 +519,97 @@ describe('ParseServerRESTController', () => { ); }); + it('should deep copy context so mutations in beforeSave do not leak across requests', async () => { + const sharedContext = { counter: 0, nested: { value: 'original' } }; + + Parse.Cloud.beforeSave('ContextTestObject', req => { + // Mutate the context in beforeSave + req.context.counter = (req.context.counter || 0) + 1; + req.context.nested.value = 'mutated'; + req.context.addedByHook = true; + }); + + // First save — this should not affect the original sharedContext + await RESTController.request( + 'POST', + '/classes/ContextTestObject', + { key: 'value1' }, + { context: sharedContext } + ); + + // The original context object must remain unchanged + expect(sharedContext.counter).toEqual(0); + expect(sharedContext.nested.value).toEqual('original'); + expect(sharedContext.addedByHook).toBeUndefined(); + + // Second save with the same context — should also start with the original values + await RESTController.request( + 'POST', + '/classes/ContextTestObject', + { key: 'value2' }, + { context: sharedContext } + ); + + // The original context object must still remain unchanged + expect(sharedContext.counter).toEqual(0); + expect(sharedContext.nested.value).toEqual('original'); + expect(sharedContext.addedByHook).toBeUndefined(); + }); + + it('should isolate context between concurrent requests', async () => { + const contexts = []; + + Parse.Cloud.beforeSave('ConcurrentContextObject', req => { + // Each request should see its own context, not a shared one + req.context.requestId = req.object.get('requestId'); + contexts.push({ ...req.context }); + }); + + const sharedContext = { shared: true }; + + await Promise.all([ + RESTController.request( + 'POST', + '/classes/ConcurrentContextObject', + { requestId: 'req1' }, + { context: sharedContext } + ), + RESTController.request( + 'POST', + '/classes/ConcurrentContextObject', + { requestId: 'req2' }, + { context: sharedContext } + ), + ]); + + // Each hook should have seen its own requestId, not the other's + const req1Context = contexts.find(c => c.requestId === 'req1'); + const req2Context = contexts.find(c => c.requestId === 'req2'); + expect(req1Context).toBeDefined(); + expect(req2Context).toBeDefined(); + expect(req1Context.requestId).toEqual('req1'); + expect(req2Context.requestId).toEqual('req2'); + // Original context must remain unchanged + expect(sharedContext.requestId).toBeUndefined(); + }); + + it('should reject with an error when context contains non-cloneable values', async () => { + const nonCloneableContext = { fn: () => {} }; + try { + await RESTController.request( + 'POST', + '/classes/MyObject', + { key: 'value' }, + { context: nonCloneableContext } + ); + fail('should have rejected for non-cloneable context'); + } catch (error) { + expect(error).toBeDefined(); + expect(error.code).toEqual(Parse.Error.INVALID_VALUE); + expect(error.message).toContain('Context contains non-cloneable values'); + } + }); + it('ensures sessionTokens are properly handled', async () => { const user = await Parse.User.signUp('user', 'pass'); const sessionToken = user.getSessionToken(); diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 105333fd0e..c3f9296af0 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1,6 +1,10 @@ const http = require('http'); const express = require('express'); -const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); +const fetch = (...args) => + import('node-fetch').then(({ default: fetch }) => { + const [url, options = {}] = args; + return fetch(url, { agent: new http.Agent({ keepAlive: false }), ...options }); + }); const ws = require('ws'); const request = require('../lib/request'); const Config = require('../lib/Config'); @@ -4991,6 +4995,115 @@ describe('Vulnerabilities', () => { }); }); + describe('(GHSA-jpq4-7fmq-q5fj) SMS MFA single-use token reuse via concurrent requests', () => { + const mfaHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + let sentToken; + + beforeEach(async () => { + sentToken = null; + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['SMS'], + algorithm: 'SHA1', + digits: 6, + period: 30, + sendSMS: token => { + sentToken = token; + }, + }, + }, + }); + }); + + async function setupSmsMfaUser() { + const user = await Parse.User.signUp('smsmfauser', 'password123'); + // Enroll SMS MFA + await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + ...mfaHeaders, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + authData: { mfa: { mobile: '+15551234567' } }, + }), + }); + const enrollToken = sentToken; + // Confirm enrollment with the received OTP + await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + ...mfaHeaders, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + authData: { mfa: { mobile: '+15551234567', token: enrollToken } }, + }), + }); + sentToken = null; + return user; + } + + async function requestLoginOtp(username, password) { + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: mfaHeaders, + body: JSON.stringify({ + username, + password, + authData: { mfa: { token: 'request' } }, + }), + }); + } catch (_err) { + // Expected: adapter throws "Please enter the token" + } + return sentToken; + } + + it('rejects concurrent logins using the same SMS MFA OTP', async () => { + const user = await setupSmsMfaUser(); + const otp = await requestLoginOtp('smsmfauser', 'password123'); + expect(otp).toBeDefined(); + + const loginWithOtp = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: mfaHeaders, + body: JSON.stringify({ + username: 'smsmfauser', + password: 'password123', + authData: { mfa: { token: otp } }, + }), + }); + + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithOtp())); + + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); + + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); + + // Verify the OTP has been consumed + await user.fetch({ useMasterKey: true }); + const mfa = user.get('authData').mfa; + expect(mfa.token).toBeUndefined(); + }); + }); + describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field names in PostgreSQL adapter', () => { const headers = { 'Content-Type': 'application/json', diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 151264703e..9c17b2a18b 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -17,6 +17,7 @@ import { import Parse from 'parse/node'; // @flow-disable-next import _ from 'lodash'; +import { EJSON } from 'bson'; import defaults, { ParseServerDatabaseOptions } from '../../../defaults'; import logger from '../../../logger'; @@ -936,13 +937,18 @@ export class MongoStorageAdapter implements StorageAdapter { readPreference: ?string, hint: ?mixed, explain?: boolean, - comment: ?string + comment: ?string, + rawValues?: boolean, + rawFieldNames?: boolean ) { validateExplainValue(explain); + if (rawValues) { + pipeline = EJSON.deserialize(pipeline); + } let isPointerField = false; pipeline = pipeline.map(stage => { if (stage.$group) { - stage.$group = this._parseAggregateGroupArgs(schema, stage.$group); + stage.$group = this._parseAggregateGroupArgs(schema, stage.$group, rawFieldNames); if ( stage.$group._id && typeof stage.$group._id === 'string' && @@ -952,13 +958,13 @@ export class MongoStorageAdapter implements StorageAdapter { } } if (stage.$match) { - stage.$match = this._parseAggregateArgs(schema, stage.$match); + stage.$match = this._parseAggregateArgs(schema, stage.$match, rawValues, rawFieldNames); } if (stage.$project) { - stage.$project = this._parseAggregateProjectArgs(schema, stage.$project); + stage.$project = this._parseAggregateProjectArgs(schema, stage.$project, rawValues, rawFieldNames); } if (stage.$geoNear && stage.$geoNear.query) { - stage.$geoNear.query = this._parseAggregateArgs(schema, stage.$geoNear.query); + stage.$geoNear.query = this._parseAggregateArgs(schema, stage.$geoNear.query, rawValues, rawFieldNames); } return stage; }); @@ -975,6 +981,9 @@ export class MongoStorageAdapter implements StorageAdapter { }) ) .then(results => { + if (rawFieldNames) { + return results; + } results.forEach(result => { if (Object.prototype.hasOwnProperty.call(result, '_id')) { if (isPointerField && result._id) { @@ -993,7 +1002,15 @@ export class MongoStorageAdapter implements StorageAdapter { }); return results; }) - .then(objects => objects.map(object => mongoObjectToParseObject(className, object, schema))) + .then(objects => { + if (rawValues) { + return objects.map(obj => EJSON.serialize(obj)); + } + if (rawFieldNames) { + return objects; + } + return objects.map(object => mongoObjectToParseObject(className, object, schema)); + }) .catch(err => this.handleError(err)); } @@ -1016,36 +1033,41 @@ export class MongoStorageAdapter implements StorageAdapter { // // As much as I hate recursion...this seemed like a good fit for it. We're essentially traversing // down a tree to find a "leaf node" and checking to see if it needs to be converted. - _parseAggregateArgs(schema: any, pipeline: any): any { + _parseAggregateArgs(schema: any, pipeline: any, rawValues?: boolean, rawFieldNames?: boolean): any { if (pipeline === null) { return null; + } else if (Utils.isDate(pipeline)) { + return pipeline; } else if (Array.isArray(pipeline)) { - return pipeline.map(value => this._parseAggregateArgs(schema, value)); + return pipeline.map(value => this._parseAggregateArgs(schema, value, rawValues, rawFieldNames)); } else if (typeof pipeline === 'object') { const returnValue = {}; for (const field in pipeline) { - if (schema.fields[field] && schema.fields[field].type === 'Pointer') { + if (!rawFieldNames && schema.fields[field] && schema.fields[field].type === 'Pointer') { if (typeof pipeline[field] === 'object') { - // Pass objects down to MongoDB...this is more than likely an $exists operator. + returnValue[`_p_${field}`] = pipeline[field]; + } else if (rawValues) { returnValue[`_p_${field}`] = pipeline[field]; } else { returnValue[`_p_${field}`] = `${schema.fields[field].targetClass}$${pipeline[field]}`; } - } else if (schema.fields[field] && schema.fields[field].type === 'Date') { + } else if (schema.fields[field] && schema.fields[field].type === 'Date' && !rawValues) { returnValue[field] = this._convertToDate(pipeline[field]); } else { - returnValue[field] = this._parseAggregateArgs(schema, pipeline[field]); + returnValue[field] = this._parseAggregateArgs(schema, pipeline[field], rawValues, rawFieldNames); } - if (field === 'objectId') { - returnValue['_id'] = returnValue[field]; - delete returnValue[field]; - } else if (field === 'createdAt') { - returnValue['_created_at'] = returnValue[field]; - delete returnValue[field]; - } else if (field === 'updatedAt') { - returnValue['_updated_at'] = returnValue[field]; - delete returnValue[field]; + if (!rawFieldNames) { + if (field === 'objectId') { + returnValue['_id'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'createdAt') { + returnValue['_created_at'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'updatedAt') { + returnValue['_updated_at'] = returnValue[field]; + delete returnValue[field]; + } } } return returnValue; @@ -1057,24 +1079,26 @@ export class MongoStorageAdapter implements StorageAdapter { // two functions and making the code even harder to understand, I decided to split it up. The // difference with this function is we are not transforming the values, only the keys of the // pipeline. - _parseAggregateProjectArgs(schema: any, pipeline: any): any { + _parseAggregateProjectArgs(schema: any, pipeline: any, rawValues?: boolean, rawFieldNames?: boolean): any { const returnValue = {}; for (const field in pipeline) { - if (schema.fields[field] && schema.fields[field].type === 'Pointer') { + if (!rawFieldNames && schema.fields[field] && schema.fields[field].type === 'Pointer') { returnValue[`_p_${field}`] = pipeline[field]; } else { - returnValue[field] = this._parseAggregateArgs(schema, pipeline[field]); + returnValue[field] = this._parseAggregateArgs(schema, pipeline[field], rawValues, rawFieldNames); } - if (field === 'objectId') { - returnValue['_id'] = returnValue[field]; - delete returnValue[field]; - } else if (field === 'createdAt') { - returnValue['_created_at'] = returnValue[field]; - delete returnValue[field]; - } else if (field === 'updatedAt') { - returnValue['_updated_at'] = returnValue[field]; - delete returnValue[field]; + if (!rawFieldNames) { + if (field === 'objectId') { + returnValue['_id'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'createdAt') { + returnValue['_created_at'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'updatedAt') { + returnValue['_updated_at'] = returnValue[field]; + delete returnValue[field]; + } } } return returnValue; @@ -1085,16 +1109,16 @@ export class MongoStorageAdapter implements StorageAdapter { // The could be a column name, prefixed with the '$' character. We'll look for // these and check to see if it is a 'Pointer' or if it's one of createdAt, // updatedAt or objectId and change it accordingly. - _parseAggregateGroupArgs(schema: any, pipeline: any): any { + _parseAggregateGroupArgs(schema: any, pipeline: any, rawFieldNames?: boolean): any { if (Array.isArray(pipeline)) { - return pipeline.map(value => this._parseAggregateGroupArgs(schema, value)); + return pipeline.map(value => this._parseAggregateGroupArgs(schema, value, rawFieldNames)); } else if (typeof pipeline === 'object') { const returnValue = {}; for (const field in pipeline) { - returnValue[field] = this._parseAggregateGroupArgs(schema, pipeline[field]); + returnValue[field] = this._parseAggregateGroupArgs(schema, pipeline[field], rawFieldNames); } return returnValue; - } else if (typeof pipeline === 'string') { + } else if (typeof pipeline === 'string' && !rawFieldNames) { const field = pipeline.substring(1); if (schema.fields[field] && schema.fields[field].type === 'Pointer') { return `$_p_${field}`; diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js index 49e1c23d36..19c945265b 100644 --- a/src/Adapters/Storage/StorageAdapter.js +++ b/src/Adapters/Storage/StorageAdapter.js @@ -125,7 +125,9 @@ export interface StorageAdapter { readPreference: ?string, hint: ?mixed, explain?: boolean, - comment?: string + comment?: string, + rawValues?: boolean, + rawFieldNames?: boolean ): Promise; performInitialization(options: ?any): Promise; watch(callback: () => void): void; diff --git a/src/AuthDataLock.js b/src/AuthDataLock.js new file mode 100644 index 0000000000..c809a8b3d8 --- /dev/null +++ b/src/AuthDataLock.js @@ -0,0 +1,47 @@ +// Apply optimistic locking for authData provider field changes. For each lockable +// top-level field in the original authData whose value differs from the incoming +// value, add an equality constraint for the original value to the update WHERE +// clause. Concurrent requests racing the same single-use token will only allow the +// first update to match; subsequent updates miss and surface as OBJECT_NOT_FOUND. +// +// Only fields whose values round-trip cleanly through both storage adapters are +// locked: primitives (string, number, boolean) and arrays. Date values and nested +// objects are skipped because their JSON representation differs between the +// MongoDB and Postgres adapters, and because Parse Server's query-key validator +// rejects deeper paths containing characters like `+` (e.g. phone-number keys). +// Locking the consumed single-use credential (the MFA token string or the +// recovery-code array) is sufficient — its removal invalidates the WHERE clause +// for concurrent writers. +export function applyAuthDataOptimisticLock(query, originalAuthData, newAuthData) { + if (!originalAuthData) { + return; + } + for (const provider of Object.keys(newAuthData)) { + const original = originalAuthData[provider]; + if (!original || typeof original !== 'object') { + continue; + } + for (const [field, value] of Object.entries(original)) { + if (!isLockableAuthDataValue(value)) { + continue; + } + if (JSON.stringify(value) !== JSON.stringify(newAuthData[provider]?.[field])) { + query[`authData.${provider}.${field}`] = value; + } + } + } +} + +function isLockableAuthDataValue(value) { + if (value === null || value === undefined) { + return false; + } + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'boolean') { + return true; + } + if (Array.isArray(value)) { + return true; + } + return false; +} diff --git a/src/Config.js b/src/Config.js index dad033dd1d..95543c6c6b 100644 --- a/src/Config.js +++ b/src/Config.js @@ -15,6 +15,7 @@ import { FileDownloadOptions, FileUploadOptions, IdempotencyOptions, + InstallationOptions, LiveQueryOptions, LogLevels, PagesOptions, @@ -147,6 +148,7 @@ export class Config { requestComplexity, liveQuery, routeAllowList, + installation, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -197,6 +199,7 @@ export class Config { this.validateRequestComplexity(requestComplexity); this.validateLiveQueryOptions(liveQuery); this.validateRouteAllowList(routeAllowList); + this.validateInstallation(installation); } static validateCustomPages(customPages) { @@ -711,6 +714,45 @@ export class Config { } } + static validateInstallation(installation) { + if (installation === undefined) { + return; + } + if (typeof installation !== 'object' || Array.isArray(installation) || installation === null) { + throw 'installation must be an object.'; + } + const validKeys = [ + 'duplicateDeviceTokenActionEnforceAuth', + 'duplicateDeviceTokenAction', + 'duplicateDeviceTokenMergePriority', + ]; + for (const key of Object.keys(installation)) { + if (!validKeys.includes(key)) { + throw `installation contains unknown property '${key}'.`; + } + } + if (installation.duplicateDeviceTokenActionEnforceAuth === undefined) { + installation.duplicateDeviceTokenActionEnforceAuth = + InstallationOptions.duplicateDeviceTokenActionEnforceAuth.default; + } else if (typeof installation.duplicateDeviceTokenActionEnforceAuth !== 'boolean') { + throw 'installation.duplicateDeviceTokenActionEnforceAuth must be a boolean.'; + } + const validActions = ['delete', 'update']; + if (installation.duplicateDeviceTokenAction === undefined) { + installation.duplicateDeviceTokenAction = + InstallationOptions.duplicateDeviceTokenAction.default; + } else if (!validActions.includes(installation.duplicateDeviceTokenAction)) { + throw "installation.duplicateDeviceTokenAction must be one of: 'delete', 'update'."; + } + const validPriorities = ['deviceToken', 'installationId']; + if (installation.duplicateDeviceTokenMergePriority === undefined) { + installation.duplicateDeviceTokenMergePriority = + InstallationOptions.duplicateDeviceTokenMergePriority.default; + } else if (!validPriorities.includes(installation.duplicateDeviceTokenMergePriority)) { + throw "installation.duplicateDeviceTokenMergePriority must be one of: 'deviceToken', 'installationId'."; + } + } + static validateAllowHeaders(allowHeaders) { if (![null, undefined].includes(allowHeaders)) { if (Array.isArray(allowHeaders)) { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 4180893c58..6d13bf6553 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1269,6 +1269,8 @@ class DatabaseController { caseInsensitive = false, explain, comment, + rawValues, + rawFieldNames, }: any = {}, auth: any = {}, validSchemaController: SchemaController.SchemaController @@ -1409,7 +1411,9 @@ class DatabaseController { readPreference, hint, explain, - comment + comment, + rawValues, + rawFieldNames ); } } else if (explain) { diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index 8e9d47885f..5eb78c88c0 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -108,4 +108,9 @@ module.exports = [ changeNewDefault: 'false', solution: "Set 'protectedFieldsSaveResponseExempt' to 'false' to strip protected fields from write operation responses (create, update), consistent with how they are stripped from query results. Set to 'true' to keep the current behavior where protected fields are included in write responses.", }, + { + optionKey: 'installation.duplicateDeviceTokenActionEnforceAuth', + changeNewDefault: 'true', + solution: "Set 'installation.duplicateDeviceTokenActionEnforceAuth' to 'true' to enforce the caller's auth context (and the resulting ACL and CLP) when Parse Server deduplicates _Installation records sharing the same deviceToken. Set to 'false' to keep the current behavior of bypassing permissions on the dedup operation.", + }, ]; diff --git a/src/InstallationDedup.js b/src/InstallationDedup.js new file mode 100644 index 0000000000..391b29def4 --- /dev/null +++ b/src/InstallationDedup.js @@ -0,0 +1,176 @@ +import Parse from 'parse/node'; +import logger from './logger'; + +const CLASS_NAME = '_Installation'; + +function logResult(action, count, err) { + if (err && err.code === Parse.Error.OBJECT_NOT_FOUND) { + logger.verbose(`Installation dedup ${action} matched no rows; nothing to do.`); + return; + } + if (err && err.code === Parse.Error.OPERATION_FORBIDDEN) { + logger.warn( + `Installation dedup ${action} skipped: caller has no permission to ${action} the conflicting row(s). The conflicting row remains.` + ); + return; + } + if (err) { + logger.error(`Installation dedup ${action} failed: ${err.message || err}`); + return; + } + logger.verbose( + `Installation dedup ${action} applied to ${count == null ? 'matching' : count} conflicting row(s).` + ); +} + +async function performAction({ + database, + query, + action, + fieldToClear, + runOptions, + many, + validSchemaController, +}) { + if (action === 'delete') { + return database.destroy(CLASS_NAME, query, runOptions, validSchemaController); + } + if (action === 'update') { + return database.update( + CLASS_NAME, + query, + { [fieldToClear]: { __op: 'Delete' } }, + { ...runOptions, many }, + false, + false, + validSchemaController + ); + } + throw new Error(`Unknown installation dedup action: ${action}`); +} + +/** + * Removes or updates `_Installation` rows that hold a `deviceToken` matching the query, + * allowing the caller to claim that `deviceToken` exclusively. Used when a new or updated + * install collides with one or more existing rows on `deviceToken`. + * + * @param {Object} options + * @param {DatabaseController} options.database + * @param {Object} options.query e.g. { deviceToken: 'X', installationId: { $ne: 'I' } } + * @param {'delete'|'update'} options.action + * @param {boolean} options.enforceAuth + * @param {Object} options.runOptions RestWrite.runOptions + * @param {SchemaController} options.validSchemaController + */ +export async function removeConflictingDeviceToken({ + database, + query, + action, + enforceAuth, + runOptions, + validSchemaController, +}) { + const opts = enforceAuth ? runOptions : {}; + try { + await performAction({ + database, + query, + action, + fieldToClear: 'deviceToken', + runOptions: opts, + many: true, + validSchemaController, + }); + logResult(action, null, null); + } catch (err) { + if (err && err.code === Parse.Error.OBJECT_NOT_FOUND) { + logResult(action, 0, err); + return; + } + if (err && err.code === Parse.Error.OPERATION_FORBIDDEN) { + logResult(action, null, err); + return; + } + logResult(action, null, err); + throw err; + } +} + +/** + * Resolves a merge conflict between two `_Installation` rows that together represent the + * same install: one matched by `installationId`/`objectId` (`idMatch`), and another holding + * the same `deviceToken` but no `installationId` (`deviceTokenMatch`). The `mergePriority` + * determines which row survives; the loser receives the configured `action`. Returns the + * survivor's `objectId` so the save flow can target it. + * + * @param {Object} options + * @param {DatabaseController} options.database + * @param {{ objectId: string, installationId?: string, deviceToken?: string }} options.idMatch + * @param {{ objectId: string, deviceToken?: string }} options.deviceTokenMatch + * @param {'delete'|'update'} options.action + * @param {'deviceToken'|'installationId'} options.mergePriority + * @param {boolean} options.enforceAuth + * @param {Object} options.runOptions + * @param {SchemaController} options.validSchemaController + * @returns {Promise} survivor's objectId + */ +export async function applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action, + mergePriority, + enforceAuth, + runOptions, + validSchemaController, +}) { + // Self-merge guard: when both matches resolve to the same row, there's + // nothing to clean up. Skip the action so we don't destroy/update the row + // we're about to return as the survivor. + if (idMatch.objectId === deviceTokenMatch.objectId) { + return idMatch.objectId; + } + const opts = enforceAuth ? runOptions : {}; + let loser; + let survivorId; + let fieldToClear; + if (mergePriority === 'deviceToken') { + loser = idMatch; + survivorId = deviceTokenMatch.objectId; + fieldToClear = 'installationId'; + } else if (mergePriority === 'installationId') { + loser = deviceTokenMatch; + survivorId = idMatch.objectId; + fieldToClear = 'deviceToken'; + } else { + throw new Error(`Unknown installation dedup mergePriority: ${mergePriority}`); + } + + try { + await performAction({ + database, + query: { objectId: loser.objectId }, + action, + fieldToClear, + runOptions: opts, + many: false, + validSchemaController, + }); + logResult(action, 1, null); + } catch (err) { + if (err && err.code === Parse.Error.OBJECT_NOT_FOUND) { + logResult(action, 0, err); + } else if (err && err.code === Parse.Error.OPERATION_FORBIDDEN) { + logResult(action, null, err); + } else { + logResult(action, null, err); + throw err; + } + } + return survivorId; +} + +export default { + removeConflictingDeviceToken, + applyDuplicateDeviceTokenMerge, +}; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 8ebff9fa9f..277ba9a477 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -322,6 +322,13 @@ module.exports.ParseServerOptions = { type: 'IdempotencyOptions', default: {}, }, + installation: { + env: 'PARSE_SERVER_INSTALLATION', + help: 'Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`.', + action: parsers.objectParser, + type: 'InstallationOptions', + default: {}, + }, javascriptKey: { env: 'PARSE_SERVER_JAVASCRIPT_KEY', help: 'Key for the Javascript SDK', @@ -512,6 +519,13 @@ module.exports.ParseServerOptions = { help: 'Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications', action: parsers.objectParser, }, + query: { + env: 'PARSE_SERVER_QUERY', + help: 'Query-related server defaults.', + action: parsers.objectParser, + type: 'QueryServerOptions', + default: {}, + }, rateLimit: { env: 'PARSE_SERVER_RATE_LIMIT', help: "Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

\u2139\uFE0F Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.", @@ -759,6 +773,24 @@ module.exports.RequestComplexityOptions = { default: -1, }, }; +module.exports.InstallationOptions = { + duplicateDeviceTokenAction: { + env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION', + help: "What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`.", + default: 'delete', + }, + duplicateDeviceTokenActionEnforceAuth: { + env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION_ENFORCE_AUTH', + help: "Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`.", + action: parsers.booleanParser, + default: false, + }, + duplicateDeviceTokenMergePriority: { + env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_MERGE_PRIORITY', + help: "At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` \u2014 the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` \u2014 the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`.", + default: 'deviceToken', + }, +}; module.exports.SecurityOptions = { checkGroups: { env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS', @@ -778,6 +810,20 @@ module.exports.SecurityOptions = { default: false, }, }; +module.exports.QueryServerOptions = { + aggregationRawFieldNames: { + env: 'PARSE_SERVER_QUERY_AGGREGATION_RAW_FIELD_NAMES', + help: 'When `true`, all aggregation queries default to using native MongoDB field names (no automatic `createdAt` \u2192 `_created_at` rewriting). Individual queries can still override this via the `rawFieldNames` option. Default is `false`.', + action: parsers.booleanParser, + default: false, + }, + aggregationRawValues: { + env: 'PARSE_SERVER_QUERY_AGGREGATION_RAW_VALUES', + help: 'When `true`, all aggregation queries default to using MongoDB Extended JSON (EJSON) for explicit value typing and skip schema-based value coercion. Individual queries can still override this via the `rawValues` option. Default is `false`.', + action: parsers.booleanParser, + default: false, + }, +}; module.exports.PagesOptions = { customRoutes: { env: 'PARSE_SERVER_PAGES_CUSTOM_ROUTES', diff --git a/src/Options/docs.js b/src/Options/docs.js index 7035c06862..f3c454e763 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -61,6 +61,7 @@ * @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file * @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0 * @property {IdempotencyOptions} idempotencyOptions Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production. + * @property {InstallationOptions} installation Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`. * @property {String} javascriptKey Key for the Javascript SDK * @property {Boolean} jsonLogs Log as structured JSON objects * @property {LiveQueryOptions} liveQuery parse-server's LiveQuery configuration object @@ -95,6 +96,7 @@ * @property {Boolean} protectedFieldsTriggerExempt Whether Cloud Code triggers (e.g. `beforeSave`, `afterSave`) are exempt from `protectedFields`. If `true`, triggers receive the full object including protected fields in `request.object` and `request.original`, regardless of the caller's auth context. If `false`, protected fields are stripped from the original object fetch used to build trigger objects. Defaults to `false`. * @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`. * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications + * @property {QueryServerOptions} query Query-related server defaults. * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

â„šī¸ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case. * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes * @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the read-only master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the read-only master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`. @@ -147,6 +149,13 @@ * @property {Number} subqueryLimit Maximum number of results returned by a `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subquery. Set to `-1` to disable. Default is `-1`. */ +/** + * @interface InstallationOptions + * @property {String} duplicateDeviceTokenAction What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`. + * @property {Boolean} duplicateDeviceTokenActionEnforceAuth Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`. + * @property {String} duplicateDeviceTokenMergePriority At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` — the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` — the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`. + */ + /** * @interface SecurityOptions * @property {CheckGroup[]} checkGroups The security check groups to run. This allows to add custom security checks or override existing ones. Default are the groups defined in `CheckGroups.js`. @@ -154,6 +163,12 @@ * @property {Boolean} enableCheckLog Is true if the security check report should be written to logs. This should only be enabled temporarily to not expose weak security settings in logs. */ +/** + * @interface QueryServerOptions + * @property {Boolean} aggregationRawFieldNames When `true`, all aggregation queries default to using native MongoDB field names (no automatic `createdAt` → `_created_at` rewriting). Individual queries can still override this via the `rawFieldNames` option. Default is `false`. + * @property {Boolean} aggregationRawValues When `true`, all aggregation queries default to using MongoDB Extended JSON (EJSON) for explicit value typing and skip schema-based value coercion. Individual queries can still override this via the `rawValues` option. Default is `false`. + */ + /** * @interface PagesOptions * @property {PagesRoute[]} customRoutes The custom routes. diff --git a/src/Options/index.js b/src/Options/index.js index 73009f1f10..e1266d239a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -391,6 +391,14 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_REQUEST_COMPLEXITY :DEFAULT: {} */ requestComplexity: ?RequestComplexityOptions; + /* Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`. + :ENV: PARSE_SERVER_INSTALLATION + :DEFAULT: {} */ + installation: ?InstallationOptions; + /* Query-related server defaults. + :ENV: PARSE_SERVER_QUERY + :DEFAULT: {} */ + query: ?QueryServerOptions; /* The security options to identify and report weak security settings. :DEFAULT: {} */ security: ?SecurityOptions; @@ -479,6 +487,18 @@ export interface RequestComplexityOptions { batchRequestLimit: ?number; } +export interface InstallationOptions { + /* Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`. + :DEFAULT: false */ + duplicateDeviceTokenActionEnforceAuth: ?boolean; + /* What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`. + :DEFAULT: delete */ + duplicateDeviceTokenAction: ?string; + /* At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` — the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` — the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`. + :DEFAULT: deviceToken */ + duplicateDeviceTokenMergePriority: ?string; +} + export interface SecurityOptions { /* Is true if Parse Server should check for weak security settings. :DEFAULT: false */ @@ -490,6 +510,17 @@ export interface SecurityOptions { checkGroups: ?(CheckGroup[]); } +export interface QueryServerOptions { + /* When `true`, all aggregation queries default to using MongoDB Extended JSON (EJSON) for explicit value typing and skip schema-based value coercion. Individual queries can still override this via the `rawValues` option. Default is `false`. + :ENV: PARSE_SERVER_QUERY_AGGREGATION_RAW_VALUES + :DEFAULT: false */ + aggregationRawValues: ?boolean; + /* When `true`, all aggregation queries default to using native MongoDB field names (no automatic `createdAt` → `_created_at` rewriting). Individual queries can still override this via the `rawFieldNames` option. Default is `false`. + :ENV: PARSE_SERVER_QUERY_AGGREGATION_RAW_FIELD_NAMES + :DEFAULT: false */ + aggregationRawFieldNames: ?boolean; +} + export interface PagesOptions { /* Is true if pages should be localized; this has no effect on custom page redirects. :DEFAULT: false */ diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index 9ec4b6f86e..3f1c4dd4bf 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -111,6 +111,18 @@ function ParseServerRESTController(applicationId, router) { } return new Promise((resolve, reject) => { + let requestContext; + try { + requestContext = structuredClone(options.context || {}); + } catch (error) { + reject( + new Parse.Error( + Parse.Error.INVALID_VALUE, + `Context contains non-cloneable values: ${error.message}` + ) + ); + return; + } getAuth(options, config).then(auth => { const request = { body: data, @@ -120,7 +132,7 @@ function ParseServerRESTController(applicationId, router) { applicationId: applicationId, sessionToken: options.sessionToken, installationId: options.installationId, - context: options.context || {}, + context: requestContext, }, query, }; diff --git a/src/RestQuery.js b/src/RestQuery.js index 912c328a66..f94c0af2c5 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -219,6 +219,8 @@ function _UnsafeRestQuery( case 'limit': case 'readPreference': case 'comment': + case 'rawValues': + case 'rawFieldNames': this.findOptions[option] = restOptions[option]; break; case 'order': diff --git a/src/RestWrite.js b/src/RestWrite.js index c6c8db969c..6d3c0d35a9 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -17,6 +17,8 @@ import _ from 'lodash'; import logger from './logger'; import { requiredColumns } from './Controllers/SchemaController'; import { createSanitizedError } from './Error'; +import { applyAuthDataOptimisticLock } from './AuthDataLock'; +import * as InstallationDedup from './InstallationDedup'; // query and data are both provided in REST API format. So data // types are encoded by plain old objects. @@ -658,20 +660,20 @@ RestWrite.prototype.handleAuthData = async function (authData) { this.authDataResponse = res.authDataResponse; } + // Capture original authData before mutating userResult via the response reference + const originalAuthData = userResult?.authData + ? Object.fromEntries( + Object.entries(userResult.authData).map(([k, v]) => + [k, v && typeof v === 'object' ? { ...v } : v] + ) + ) + : undefined; + // IF we are in login we'll skip the database operation / beforeSave / afterSave etc... // we need to set it up there. // We are supposed to have a response only on LOGIN with authData, so we skip those // If we're not logging in, but just updating the current user, we can safely skip that part if (this.response) { - // Capture original authData before mutating userResult via the response reference - const originalAuthData = userResult?.authData - ? Object.fromEntries( - Object.entries(userResult.authData).map(([k, v]) => - [k, v && typeof v === 'object' ? { ...v } : v] - ) - ) - : undefined; - // Assign the new authData in the response Object.keys(mutatedAuthData).forEach(provider => { this.response.response.authData[provider] = mutatedAuthData[provider]; @@ -683,24 +685,11 @@ RestWrite.prototype.handleAuthData = async function (authData) { // Then we're good for the user, early exit of sorts if (Object.keys(this.data.authData).length) { const query = { objectId: this.data.objectId }; - // Optimistic locking: include the original array fields in the WHERE clause + // Optimistic locking: include each changed original field in the WHERE clause // for providers whose data is being updated. This prevents concurrent requests - // from both succeeding when consuming single-use tokens (e.g. MFA recovery codes). - if (originalAuthData) { - for (const provider of Object.keys(this.data.authData)) { - const original = originalAuthData[provider]; - if (original && typeof original === 'object') { - for (const [field, value] of Object.entries(original)) { - if ( - Array.isArray(value) && - JSON.stringify(value) !== JSON.stringify(this.data.authData[provider]?.[field]) - ) { - query[`authData.${provider}.${field}`] = value; - } - } - } - } - } + // from both succeeding when consuming single-use tokens (e.g. MFA recovery codes + // as arrays, or MFA SMS OTP tokens as strings). + applyAuthDataOptimisticLock(query, originalAuthData, this.data.authData); try { await this.config.database.update( this.className, @@ -716,6 +705,11 @@ RestWrite.prototype.handleAuthData = async function (authData) { throw error; } } + } else if (this.query && this.data.authData && Object.keys(this.data.authData).length) { + // UPDATE path (e.g. PUT /users/:id during linked-provider re-auth): apply + // the same optimistic lock to the subsequent runDatabaseOperation update so + // concurrent single-use token consumers cannot both succeed. + applyAuthDataOptimisticLock(this.query, originalAuthData, this.data.authData); } } } @@ -1453,10 +1447,10 @@ RestWrite.prototype.handleInstallation = function () { } else { // Multiple device token matches and we specified an installation ID, // or a single match where both the passed and matching objects have - // an installation ID. Try cleaning out old installations that match - // the deviceToken, and return nil to signal that a new object should - // be created. - var delQuery = { + // an installation ID. Clean out other installations that match the + // deviceToken, and return nil to signal that a new object should be + // created. + const delQuery = { deviceToken: this.data.deviceToken, installationId: { $ne: installationId, @@ -1465,35 +1459,32 @@ RestWrite.prototype.handleInstallation = function () { if (this.data.appIdentifier) { delQuery['appIdentifier'] = this.data.appIdentifier; } - this.config.database.destroy('_Installation', delQuery).catch(err => { - if (err.code == Parse.Error.OBJECT_NOT_FOUND) { - // no deletions were made. Can be ignored. - return; - } - // rethrow the error - throw err; + const installationOpts = this.config.installation || {}; + return InstallationDedup.removeConflictingDeviceToken({ + database: this.config.database, + query: delQuery, + action: installationOpts.duplicateDeviceTokenAction || 'delete', + enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, + runOptions: this.runOptions, + validSchemaController: this.validSchemaController, }); - return; } } else { if (deviceTokenMatches.length == 1 && !deviceTokenMatches[0]['installationId']) { // Exactly one device token match and it doesn't have an installation - // ID. This is the one case where we want to merge with the existing - // object. - const delQuery = { objectId: idMatch.objectId }; - return this.config.database - .destroy('_Installation', delQuery) - .then(() => { - return deviceTokenMatches[0]['objectId']; - }) - .catch(err => { - if (err.code == Parse.Error.OBJECT_NOT_FOUND) { - // no deletions were made. Can be ignored - return; - } - // rethrow the error - throw err; - }); + // ID. The two rows represent the same install; resolve the merge per + // the configured options. + const installationOpts = this.config.installation || {}; + return InstallationDedup.applyDuplicateDeviceTokenMerge({ + database: this.config.database, + idMatch, + deviceTokenMatch: deviceTokenMatches[0], + action: installationOpts.duplicateDeviceTokenAction || 'delete', + mergePriority: installationOpts.duplicateDeviceTokenMergePriority || 'deviceToken', + enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, + runOptions: this.runOptions, + validSchemaController: this.validSchemaController, + }); } else { if (this.data.deviceToken && idMatch.deviceToken != this.data.deviceToken) { // We're setting the device token on an existing installation, so @@ -1524,14 +1515,15 @@ RestWrite.prototype.handleInstallation = function () { if (this.data.appIdentifier) { delQuery['appIdentifier'] = this.data.appIdentifier; } - this.config.database.destroy('_Installation', delQuery).catch(err => { - if (err.code == Parse.Error.OBJECT_NOT_FOUND) { - // no deletions were made. Can be ignored. - return; - } - // rethrow the error - throw err; - }); + const installationOpts = this.config.installation || {}; + return InstallationDedup.removeConflictingDeviceToken({ + database: this.config.database, + query: delQuery, + action: installationOpts.duplicateDeviceTokenAction || 'delete', + enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, + runOptions: this.runOptions, + validSchemaController: this.validSchemaController, + }).then(() => idMatch.objectId); } // In non-merge scenarios, just return the installation match id return idMatch.objectId; diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js index 5b35a9fbb9..753be4e7e0 100644 --- a/src/Routers/AggregateRouter.js +++ b/src/Routers/AggregateRouter.js @@ -27,6 +27,24 @@ export class AggregateRouter extends ClassesRouter { options.readPreference = body.readPreference; delete body.readPreference; } + if (typeof body.rawValues === 'boolean') { + options.rawValues = body.rawValues; + delete body.rawValues; + } + if (typeof body.rawFieldNames === 'boolean') { + options.rawFieldNames = body.rawFieldNames; + delete body.rawFieldNames; + } + const queryOptions = (req.config && req.config.query) || {}; + if (options.rawValues === undefined && typeof queryOptions.aggregationRawValues === 'boolean') { + options.rawValues = queryOptions.aggregationRawValues; + } + if ( + options.rawFieldNames === undefined && + typeof queryOptions.aggregationRawFieldNames === 'boolean' + ) { + options.rawFieldNames = queryOptions.aggregationRawFieldNames; + } options.pipeline = AggregateRouter.getPipeline(body); if (typeof body.where === 'string') { try { @@ -45,9 +63,11 @@ export class AggregateRouter extends ClassesRouter { req.info.clientSDK, req.info.context ); - for (const result of response.results) { - if (typeof result === 'object') { - UsersRouter.removeHiddenProperties(result); + if (!options.rawValues && !options.rawFieldNames) { + for (const result of response.results) { + if (typeof result === 'object') { + UsersRouter.removeHiddenProperties(result); + } } } return { response }; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 3ac099f0b0..34271a7dd7 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -18,6 +18,7 @@ import { promiseEnsureIdempotency } from '../middlewares'; import RestWrite from '../RestWrite'; import { logger } from '../logger'; import { createSanitizedError } from '../Error'; +import { applyAuthDataOptimisticLock } from '../AuthDataLock'; export class UsersRouter extends ClassesRouter { className() { @@ -314,26 +315,10 @@ export class UsersRouter extends ClassesRouter { // If we have some new validated authData update directly if (validatedAuthData && Object.keys(validatedAuthData).length) { const query = { objectId: user.objectId }; - // Optimistic locking: include the original array fields in the WHERE clause - // for providers whose data is being updated. This prevents concurrent requests - // from both succeeding when consuming single-use tokens (e.g. MFA recovery codes). - // Only array fields need locking — element removal is vulnerable to TOCTOU; - // scalar fields are simply overwritten and don't have concurrency issues. - if (user.authData) { - for (const provider of Object.keys(validatedAuthData)) { - const original = user.authData[provider]; - if (original && typeof original === 'object') { - for (const [field, value] of Object.entries(original)) { - if ( - Array.isArray(value) && - JSON.stringify(value) !== JSON.stringify(validatedAuthData[provider]?.[field]) - ) { - query[`authData.${provider}.${field}`] = value; - } - } - } - } - } + // Prevent concurrent requests from both succeeding when consuming single-use + // tokens (e.g. MFA recovery codes or SMS OTP tokens) by extending the update + // WHERE clause with the original values of changed primitive/array fields. + applyAuthDataOptimisticLock(query, user.authData, validatedAuthData); try { await req.config.database.update('_User', query, { authData: validatedAuthData }, {}); } catch (error) {