mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c692c3e6e6 | ||
|
|
1e0d6cee9b | ||
|
|
552c6dd754 | ||
|
|
0ae0ed382b | ||
|
|
828d0e0e45 | ||
|
|
155123ade9 | ||
|
|
216cf8335b | ||
|
|
56c159ec96 | ||
|
|
4f90c5e107 | ||
|
|
eb6441362a | ||
|
|
6667900f94 | ||
|
|
50c37a4ea3 | ||
|
|
9fee1a0708 | ||
|
|
35207c2796 | ||
|
|
725be0d602 | ||
|
|
0980ab1a40 | ||
|
|
f26700e39d | ||
|
|
fc53270dbe | ||
|
|
f303f752c6 | ||
|
|
163d7be605 | ||
|
|
cbd1da7be3 | ||
|
|
31f70a3269 | ||
|
|
60a58ec11a | ||
|
|
7c3b43d263 | ||
|
|
b3dedd0482 | ||
|
|
1464b8c582 |
@@ -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."
|
||||
|
||||
@@ -72,6 +72,12 @@ 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)
|
||||
- [Options](#options)
|
||||
- [`duplicateDeviceTokenActionEnforceAuth`](#duplicatedevicetokenactionenforceauth)
|
||||
- [`duplicateDeviceTokenAction`](#duplicatedevicetokenaction)
|
||||
- [`duplicateDeviceTokenMergePriority`](#duplicatedevicetokenmergepriority)
|
||||
- [Configuration example](#configuration-example)
|
||||
- [Localization](#localization)
|
||||
- [Pages](#pages)
|
||||
- [Localization with Directory Structure](#localization-with-directory-structure)
|
||||
@@ -313,7 +319,7 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo
|
||||
|
||||
## Route Allow List
|
||||
|
||||
The `routeAllowList` option restricts which API routes are accessible to external clients. When set, all external requests are denied by default unless the route matches one of the configured regex patterns. This is useful for apps where all logic runs in Cloud Code and clients should not access the API directly.
|
||||
The `routeAllowList` option restricts which REST API routes are accessible to external clients. When set, all external REST API requests are denied by default unless the route matches one of the configured regex patterns. This is useful for apps where all logic runs in Cloud Code and clients should not access the REST API directly.
|
||||
|
||||
Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected. Master key and maintenance key requests bypass the restriction.
|
||||
|
||||
@@ -333,7 +339,7 @@ const server = ParseServer({
|
||||
|
||||
Each entry is a regex pattern matched against the normalized route identifier. Patterns are auto-anchored with `^` and `$` for full-match semantics. For example, `classes/Chat` matches only `classes/Chat`, not `classes/ChatRoom`. Use `classes/Chat.*` to match both.
|
||||
|
||||
Setting an empty array `[]` blocks all external non-master-key requests (full lockdown). Not setting the option preserves current behavior (all routes accessible).
|
||||
Setting an empty array `[]` blocks all external non-master-key REST API requests (full lockdown of REST API routes). Not setting the option preserves current behavior (all routes accessible).
|
||||
|
||||
### Covered Routes
|
||||
|
||||
@@ -394,6 +400,9 @@ The following table lists all route groups covered by `routeAllowList` with exam
|
||||
> [!NOTE]
|
||||
> File routes are not covered by `routeAllowList`. File upload access is controlled via the `fileUpload` option. File download and metadata access is controlled via the `fileDownload` option.
|
||||
|
||||
> [!NOTE]
|
||||
> The GraphQL API is not covered by `routeAllowList`. `routeAllowList` gates the REST API per route, while every GraphQL operation is transported over a single endpoint with the operation, target class, and field set encoded in the request body — so per-route allow-list semantics do not compose with it.
|
||||
|
||||
## Email Verification and Password Reset
|
||||
|
||||
Verifying user email addresses and enabling password reset via email requires an email adapter. There are many email adapters provided and maintained by the community. The following is an example configuration with an example email adapter. See the [Parse Server Options][server-options] for more details and a full list of available options.
|
||||
@@ -658,6 +667,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
|
||||
|
||||
@@ -1,3 +1,52 @@
|
||||
## [9.9.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.2...9.9.1-alpha.3) (2026-05-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Server option routeAllowList is bypassable through batch sub-requests ([GHSA-p84r-h6rx-f2xr](https://github.com/parse-community/parse-server/security/advisories/GHSA-p84r-h6rx-f2xr)) ([#10482](https://github.com/parse-community/parse-server/issues/10482)) ([552c6dd](https://github.com/parse-community/parse-server/commit/552c6dd754638c9f546fbceecd2ba0f7225a95d1))
|
||||
|
||||
## [9.9.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.1...9.9.1-alpha.2) (2026-05-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL "Did you mean" validation suggestions disclose schema to unauthenticated callers ([GHSA-8cph-rgr4-g5vj](https://github.com/parse-community/parse-server/security/advisories/GHSA-8cph-rgr4-g5vj)) ([#10467](https://github.com/parse-community/parse-server/issues/10467)) ([155123a](https://github.com/parse-community/parse-server/commit/155123ade9bc88cdf4807cf267ea1196f9274773))
|
||||
|
||||
## [9.9.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.9.0...9.9.1-alpha.1) (2026-05-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Pre-authentication denial of service via client version header regex backtracking ([GHSA-38m6-82c8-4xfm](https://github.com/parse-community/parse-server/security/advisories/GHSA-38m6-82c8-4xfm)) ([#10463](https://github.com/parse-community/parse-server/issues/10463)) ([56c159e](https://github.com/parse-community/parse-server/commit/56c159ec962d729df09ccaa5cc2537751511e375))
|
||||
|
||||
# [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)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,42 @@
|
||||
# [9.9.0](https://github.com/parse-community/parse-server/compare/9.8.0...9.9.0) (2026-05-01)
|
||||
|
||||
|
||||
### 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))
|
||||
* 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))
|
||||
|
||||
### 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))
|
||||
* 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.8.0](https://github.com/parse-community/parse-server/compare/9.7.0...9.8.0) (2026-04-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Bump lodash from 4.17.23 to 4.18.1 ([#10393](https://github.com/parse-community/parse-server/issues/10393)) ([19716ad](https://github.com/parse-community/parse-server/commit/19716ad9afe9400ad2440c0ed3c5fbfe376a8585))
|
||||
* Endpoint `/sessions/me` bypasses `_Session` `protectedFields` ([GHSA-g4v2-qx3q-4p64](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4v2-qx3q-4p64)) ([#10406](https://github.com/parse-community/parse-server/issues/10406)) ([d507575](https://github.com/parse-community/parse-server/commit/d5075758f6c3ae9d806671de196fd8b419bc517e))
|
||||
* Endpoint `/upgradeToRevocableSession` ignores `_Session` `protectedFields` ([#10408](https://github.com/parse-community/parse-server/issues/10408)) ([c136e2b](https://github.com/parse-community/parse-server/commit/c136e2b7ab74609a5127fb68fc5ba40fef440f48))
|
||||
* Endpoints `/login` and `/verifyPassword` ignore `_User` `protectedFields` ([#10409](https://github.com/parse-community/parse-server/issues/10409)) ([8a3db3b](https://github.com/parse-community/parse-server/commit/8a3db3b9666ea998a8843c629e1af55b105e22e0))
|
||||
* Facebook Standard Login missing app ID validation ([#10429](https://github.com/parse-community/parse-server/issues/10429)) ([fd31159](https://github.com/parse-community/parse-server/commit/fd31159859ed90f57eb3713f82c9f5b04b20a28c))
|
||||
* File upload Content-Type override via extension mismatch ([GHSA-vr5f-2r24-w5hc](https://github.com/parse-community/parse-server/security/advisories/GHSA-vr5f-2r24-w5hc)) ([#10383](https://github.com/parse-community/parse-server/issues/10383)) ([dd7cc41](https://github.com/parse-community/parse-server/commit/dd7cc41a952b9ec6fa655a5655f106cca27d65c7))
|
||||
* Login timing side-channel reveals user existence ([GHSA-mmpq-5hcv-hf2v](https://github.com/parse-community/parse-server/security/advisories/GHSA-mmpq-5hcv-hf2v)) ([#10398](https://github.com/parse-community/parse-server/issues/10398)) ([531b9ab](https://github.com/parse-community/parse-server/commit/531b9ab6dda4268ede365367fcdc6d98e737ccc3))
|
||||
* Maintenance key IP mismatch silently downgrades to regular auth instead of rejecting ([#10391](https://github.com/parse-community/parse-server/issues/10391)) ([7d8b367](https://github.com/parse-community/parse-server/commit/7d8b367e0b3ef9e9dd6735408068895ead873a0c))
|
||||
* Master key does not bypass `protectedFields` on various endpoints ([#10412](https://github.com/parse-community/parse-server/issues/10412)) ([c0889c8](https://github.com/parse-community/parse-server/commit/c0889c8575ee6c6ee01c79cd1ae457124e2a08b3))
|
||||
* Nested batch sub-requests cause unclear error ([#10371](https://github.com/parse-community/parse-server/issues/10371)) ([6635096](https://github.com/parse-community/parse-server/commit/66350964c8a200eb9e4540f6fcdc0fe0099c5ff6))
|
||||
* Session field guard bypass via falsy values for ACL and user fields ([#10382](https://github.com/parse-community/parse-server/issues/10382)) ([ead12bd](https://github.com/parse-community/parse-server/commit/ead12bd1df7f11013d9266e41014dcb143351341))
|
||||
* Streaming file download bypasses afterFind file trigger authorization ([GHSA-hpm8-9qx6-jvwv](https://github.com/parse-community/parse-server/security/advisories/GHSA-hpm8-9qx6-jvwv)) ([#10361](https://github.com/parse-community/parse-server/issues/10361)) ([a0b0c69](https://github.com/parse-community/parse-server/commit/a0b0c69fc44f87f80d793d257344e7dcbf676e22))
|
||||
|
||||
### Features
|
||||
|
||||
* Add `requestComplexity.allowRegex` option to disable `$regex` query operator ([#10418](https://github.com/parse-community/parse-server/issues/10418)) ([18482e3](https://github.com/parse-community/parse-server/commit/18482e386c1e723da2df3137f61fa5e2bc8983a6))
|
||||
* Add `requestComplexity.subqueryLimit` option to limit subquery results ([#10420](https://github.com/parse-community/parse-server/issues/10420)) ([bf40004](https://github.com/parse-community/parse-server/commit/bf40004d258f114c06a3085052ca094384b52b43))
|
||||
* Add route block with new server option `routeAllowList` ([#10389](https://github.com/parse-community/parse-server/issues/10389)) ([f2d06e7](https://github.com/parse-community/parse-server/commit/f2d06e7b95242268607bfa5205b4e86ba7c7698e))
|
||||
* Add server option `fileDownload` to restrict file download ([#10394](https://github.com/parse-community/parse-server/issues/10394)) ([fc117ef](https://github.com/parse-community/parse-server/commit/fc117efa4dc233ad6dfee6f46d80991b10927ba8))
|
||||
* Add support for invoking Cloud Function with `multipart/form-data` protocol ([#10395](https://github.com/parse-community/parse-server/issues/10395)) ([a3f36a2](https://github.com/parse-community/parse-server/commit/a3f36a2ddb981d9868ddf26b128e24b2d58214bd))
|
||||
|
||||
# [9.7.0](https://github.com/parse-community/parse-server/compare/9.6.1...9.7.0) (2026-03-30)
|
||||
|
||||
|
||||
|
||||
Generated
+171
-459
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.8.0-alpha.13",
|
||||
"version": "9.9.1-alpha.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "9.8.0-alpha.13",
|
||||
"version": "9.9.1-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,49 +4121,6 @@
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@parse/push-adapter": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@parse/push-adapter/-/push-adapter-8.4.0.tgz",
|
||||
@@ -4204,6 +4138,65 @@
|
||||
"node": "20 || 22 || 24"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"core-js-pure": "^3.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"@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.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": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -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": {
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.8.0-alpha.13",
|
||||
"version": "9.9.1-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"
|
||||
|
||||
@@ -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_',
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
const ClientSDK = require('../lib/ClientSDK');
|
||||
|
||||
describe('ClientSDK', () => {
|
||||
it('should properly parse the SDK versions', () => {
|
||||
const clientSDKFromVersion = ClientSDK.fromString;
|
||||
expect(clientSDKFromVersion('i1.1.1')).toEqual({
|
||||
sdk: 'i',
|
||||
version: '1.1.1',
|
||||
});
|
||||
expect(clientSDKFromVersion('i1')).toEqual({
|
||||
sdk: 'i',
|
||||
version: '1',
|
||||
});
|
||||
expect(clientSDKFromVersion('apple-tv1.13.0')).toEqual({
|
||||
sdk: 'apple-tv',
|
||||
version: '1.13.0',
|
||||
});
|
||||
expect(clientSDKFromVersion('js1.9.0')).toEqual({
|
||||
sdk: 'js',
|
||||
version: '1.9.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should properly sastisfy', () => {
|
||||
expect(
|
||||
ClientSDK.compatible({
|
||||
js: '>=1.9.0',
|
||||
})('js1.9.0')
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
ClientSDK.compatible({
|
||||
js: '>=1.9.0',
|
||||
})('js2.0.0')
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
ClientSDK.compatible({
|
||||
js: '>=1.9.0',
|
||||
})('js1.8.0')
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
ClientSDK.compatible({
|
||||
js: '>=1.9.0',
|
||||
})(undefined)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -113,7 +113,6 @@ describe('middlewares', () => {
|
||||
});
|
||||
|
||||
const BodyParams = {
|
||||
clientVersion: '_ClientVersion',
|
||||
installationId: '_InstallationId',
|
||||
sessionToken: '_SessionToken',
|
||||
masterKey: '_MasterKey',
|
||||
@@ -468,12 +467,6 @@ describe('middlewares', () => {
|
||||
expect(fakeRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should reject non-string _ClientVersion in body', async () => {
|
||||
fakeReq.body._ClientVersion = { toLowerCase: 'evil' };
|
||||
await middlewares.handleParseHeaders(fakeReq, fakeRes);
|
||||
expect(fakeRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should reject non-string _InstallationId in body', async () => {
|
||||
fakeReq.body._InstallationId = { toString: 'evil' };
|
||||
await middlewares.handleParseHeaders(fakeReq, fakeRes);
|
||||
@@ -502,7 +495,6 @@ describe('middlewares', () => {
|
||||
// Each request should be handled independently without affecting server stability.
|
||||
const payloads = [
|
||||
{ _SessionToken: { toString: 'evil' } },
|
||||
{ _ClientVersion: { toLowerCase: 'evil' } },
|
||||
{ _InstallationId: [1, 2, 3] },
|
||||
{ _ContentType: { toString: 'evil' } },
|
||||
];
|
||||
@@ -539,12 +531,10 @@ describe('middlewares', () => {
|
||||
|
||||
it('should still accept valid string body fields', done => {
|
||||
fakeReq.body._SessionToken = 'r:validtoken';
|
||||
fakeReq.body._ClientVersion = 'js1.0.0';
|
||||
fakeReq.body._InstallationId = 'install123';
|
||||
fakeReq.body._ContentType = 'application/json';
|
||||
middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
|
||||
expect(fakeReq.info.sessionToken).toEqual('r:validtoken');
|
||||
expect(fakeReq.info.clientVersion).toEqual('js1.0.0');
|
||||
expect(fakeReq.info.installationId).toEqual('install123');
|
||||
expect(fakeReq.headers['content-type']).toEqual('application/json');
|
||||
done();
|
||||
|
||||
@@ -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');
|
||||
@@ -1012,6 +1016,115 @@ describe('ParseGraphQLServer', () => {
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.data.__type).toBeDefined();
|
||||
});
|
||||
|
||||
it('should strip "Did you mean" field suggestions from validation errors without master or maintenance key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Typo {
|
||||
healt
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const message = e.networkError.result.errors[0].message;
|
||||
expect(message).toContain('Cannot query field "healt"');
|
||||
expect(message).not.toMatch(/Did you mean/);
|
||||
expect(message).not.toContain('health');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip "Did you mean" argument suggestions from validation errors without master or maintenance key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query UnknownArg {
|
||||
users(wher: {}) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const message = e.networkError.result.errors[0].message;
|
||||
expect(message).toContain('Unknown argument "wher"');
|
||||
expect(message).not.toMatch(/Did you mean/);
|
||||
expect(message).not.toContain('"where"');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep "Did you mean" suggestions with master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Typo {
|
||||
healt
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const message = e.networkError.result.errors[0].message;
|
||||
expect(message).toContain('Cannot query field "healt"');
|
||||
expect(message).toMatch(/Did you mean/);
|
||||
expect(message).toContain('health');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep "Did you mean" suggestions with maintenance key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Typo {
|
||||
healt
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Maintenance-Key': 'test2',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const message = e.networkError.result.errors[0].message;
|
||||
expect(message).toContain('Cannot query field "healt"');
|
||||
expect(message).toMatch(/Did you mean/);
|
||||
expect(message).toContain('health');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep "Did you mean" suggestions when public introspection is enabled', async () => {
|
||||
const parseServer = await reconfigureServer();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Typo {
|
||||
healt
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const message = e.networkError.result.errors[0].message;
|
||||
expect(message).toContain('Cannot query field "healt"');
|
||||
expect(message).toMatch(/Did you mean/);
|
||||
expect(message).toContain('health');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -314,6 +314,174 @@ describe('routeAllowList', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('GraphQL exemption', () => {
|
||||
// routeAllowList is a path-based REST API control. The GraphQL endpoint
|
||||
// collapses every operation onto a single URL (graphQLPath), so a
|
||||
// per-route allow-list cannot meaningfully gate individual GraphQL
|
||||
// operations.
|
||||
const gqlRequest = body =>
|
||||
require('../lib/request')({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/graphql',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Javascript-Key': 'test',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
it('reaches GraphQL endpoint when routeAllowList is empty array', async () => {
|
||||
await reconfigureServer({ mountGraphQL: true, routeAllowList: [] });
|
||||
const restRequest = require('../lib/request');
|
||||
await expectAsync(
|
||||
restRequest({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/classes/GameScore',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
data: jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }),
|
||||
})
|
||||
);
|
||||
const response = await gqlRequest({ query: '{ health }' });
|
||||
expect(response.data.data.health).toBeTrue();
|
||||
});
|
||||
|
||||
it('reaches GraphQL endpoint when routeAllowList contains only REST routes', async () => {
|
||||
await reconfigureServer({
|
||||
mountGraphQL: true,
|
||||
routeAllowList: ['classes/AllowedClass'],
|
||||
});
|
||||
const response = await gqlRequest({ query: '{ health }' });
|
||||
expect(response.data.data.health).toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps class CLP enforced through GraphQL when routeAllowList is empty array', async () => {
|
||||
await reconfigureServer({ mountGraphQL: true, routeAllowList: [] });
|
||||
const { updateCLP } = require('./support/dev');
|
||||
const obj = new Parse.Object('CLPGuarded');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
await updateCLP({ find: {}, get: {}, create: {}, update: {}, delete: {} }, 'CLPGuarded');
|
||||
const response = await gqlRequest({
|
||||
query: '{ cLPGuardeds { edges { node { objectId } } } }',
|
||||
});
|
||||
expect(response.data.errors).toBeDefined();
|
||||
expect(response.data.errors[0].extensions.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch sub-requests', () => {
|
||||
// routeAllowList must be enforced per batch sub-request. The outer
|
||||
// enforceRouteAllowList middleware runs only on the outer /batch URL,
|
||||
// so without per-sub-request enforcement an operator who allowlists
|
||||
// `batch` would accidentally expose every REST route reachable through
|
||||
// batch sub-request dispatch.
|
||||
const restRequest = require('../lib/request');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
|
||||
it('blocks a batch GET sub-request whose path is not allowlisted', async () => {
|
||||
await reconfigureServer({ routeAllowList: ['batch'] });
|
||||
await new Parse.Object('Blocked').save({ secret: 'x' }, { useMasterKey: true });
|
||||
try {
|
||||
await restRequest({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [{ method: 'GET', path: '/1/classes/Blocked' }],
|
||||
}),
|
||||
});
|
||||
fail('batch sub-request to a blocked route should have been rejected');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks a batch POST sub-request whose path is not allowlisted', async () => {
|
||||
await reconfigureServer({ routeAllowList: ['batch'] });
|
||||
try {
|
||||
await restRequest({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [{ method: 'POST', path: '/1/classes/Blocked', body: { x: 1 } }],
|
||||
}),
|
||||
});
|
||||
fail('batch sub-request POST to a blocked route should have been rejected');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
}
|
||||
const query = new Parse.Query('Blocked');
|
||||
const results = await query.find({ useMasterKey: true });
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('allows a batch sub-request whose path matches the allow list', async () => {
|
||||
await reconfigureServer({ routeAllowList: ['batch', 'classes/Allowed'] });
|
||||
const response = await restRequest({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [{ method: 'POST', path: '/1/classes/Allowed', body: { x: 1 } }],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(1);
|
||||
expect(response.data[0].success.objectId).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects the entire batch if any sub-request is not allowlisted', async () => {
|
||||
await reconfigureServer({ routeAllowList: ['batch', 'classes/Allowed'] });
|
||||
try {
|
||||
await restRequest({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/Allowed', body: { x: 1 } },
|
||||
{ method: 'POST', path: '/1/classes/Blocked', body: { y: 2 } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
fail('batch with any disallowed sub-request should have been rejected');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
}
|
||||
const allowedQuery = new Parse.Query('Allowed');
|
||||
const allowedResults = await allowedQuery.find({ useMasterKey: true });
|
||||
expect(allowedResults.length).toBe(0);
|
||||
});
|
||||
|
||||
it('allows master key to bypass sub-request allow-list check', async () => {
|
||||
await reconfigureServer({ routeAllowList: ['batch'] });
|
||||
const response = await restRequest({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [{ method: 'POST', path: '/1/classes/Blocked', body: { x: 1 } }],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(1);
|
||||
expect(response.data[0].success.objectId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it_id('229cab22-dad3-4d08-8de5-64d813658596')(it)('should block all route groups when not in allow list', async () => {
|
||||
await reconfigureServer({
|
||||
routeAllowList: ['classes/GameScore'],
|
||||
|
||||
@@ -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',
|
||||
@@ -5772,4 +5885,102 @@ describe('Vulnerabilities', () => {
|
||||
expect(meResponse.data.sessionToken).toBe(sessionToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-38m6-82c8-4xfm) Pre-auth polynomial ReDoS via client version parsing', () => {
|
||||
const middlewares = require('../lib/middlewares');
|
||||
const AppCache = require('../lib/cache').AppCache;
|
||||
|
||||
const AppCachePut = (appId, config) =>
|
||||
AppCache.put(appId, {
|
||||
...config,
|
||||
maintenanceKeyIpsStore: new Map(),
|
||||
masterKeyIpsStore: new Map(),
|
||||
readOnlyMasterKeyIpsStore: new Map(),
|
||||
});
|
||||
|
||||
const buildFakeReq = ({ headers = {}, body = {} } = {}) => {
|
||||
const req = {
|
||||
ip: '127.0.0.1',
|
||||
originalUrl: 'http://example.com/parse/',
|
||||
url: 'http://example.com/',
|
||||
body: { _ApplicationId: 'FakeAppId', ...body },
|
||||
headers,
|
||||
get: key => req.headers[key.toLowerCase()],
|
||||
};
|
||||
return req;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
AppCachePut('FakeAppId', {
|
||||
masterKeyIps: ['0.0.0.0/0'],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
AppCache.del('FakeAppId');
|
||||
});
|
||||
|
||||
it('does not capture client version from X-Parse-Client-Version header into req.info', async () => {
|
||||
const req = buildFakeReq({ headers: { 'x-parse-client-version': 'js5.0.0' } });
|
||||
const res = jasmine.createSpyObj('res', ['end', 'status']);
|
||||
let nextCalled = false;
|
||||
await middlewares.handleParseHeaders(req, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(req.info.clientVersion).toBeUndefined();
|
||||
expect(req.info.clientSDK).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not capture client version from _ClientVersion body field into req.info', async () => {
|
||||
const req = buildFakeReq({ body: { _ClientVersion: 'js5.0.0' } });
|
||||
const res = jasmine.createSpyObj('res', ['end', 'status']);
|
||||
let nextCalled = false;
|
||||
await middlewares.handleParseHeaders(req, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(req.info.clientVersion).toBeUndefined();
|
||||
expect(req.info.clientSDK).toBeUndefined();
|
||||
expect(req.body._ClientVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not invoke any regex on adversarial X-Parse-Client-Version header (16 KB of dashes)', async () => {
|
||||
const adversarial = '-'.repeat(16000);
|
||||
const req = buildFakeReq({ headers: { 'x-parse-client-version': adversarial } });
|
||||
const res = jasmine.createSpyObj('res', ['end', 'status']);
|
||||
await middlewares.handleParseHeaders(req, res, () => {});
|
||||
expect(req.info.clientVersion).toBeUndefined();
|
||||
expect(req.info.clientSDK).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not invoke any regex on adversarial _ClientVersion body field (200 KB of dashes)', async () => {
|
||||
const adversarial = '-'.repeat(200000);
|
||||
const req = buildFakeReq({ body: { _ClientVersion: adversarial } });
|
||||
const res = jasmine.createSpyObj('res', ['end', 'status']);
|
||||
const t0 = process.hrtime.bigint();
|
||||
await middlewares.handleParseHeaders(req, res, () => {});
|
||||
const elapsedMs = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
expect(elapsedMs).toBeLessThan(3000);
|
||||
expect(req.info.clientVersion).toBeUndefined();
|
||||
expect(req.info.clientSDK).toBeUndefined();
|
||||
expect(req.body._ClientVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips _ClientVersion from req.body even when value is non-string (no rejection, no capture)', async () => {
|
||||
const req = buildFakeReq({ body: { _ClientVersion: { toLowerCase: 'evil' } } });
|
||||
const res = jasmine.createSpyObj('res', ['end', 'status']);
|
||||
let nextCalled = false;
|
||||
await middlewares.handleParseHeaders(req, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(req.body._ClientVersion).toBeUndefined();
|
||||
expect(req.info.clientVersion).toBeUndefined();
|
||||
expect(req.info.clientSDK).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <expression> could be a column name, prefixed with the '$' character. We'll look for
|
||||
// these <expression> 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}`;
|
||||
|
||||
@@ -125,7 +125,9 @@ export interface StorageAdapter {
|
||||
readPreference: ?string,
|
||||
hint: ?mixed,
|
||||
explain?: boolean,
|
||||
comment?: string
|
||||
comment?: string,
|
||||
rawValues?: boolean,
|
||||
rawFieldNames?: boolean
|
||||
): Promise<any>;
|
||||
performInitialization(options: ?any): Promise<void>;
|
||||
watch(callback: () => void): void;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
var semver = require('semver');
|
||||
|
||||
function compatible(compatibleSDK) {
|
||||
return function (clientSDK) {
|
||||
if (typeof clientSDK === 'string') {
|
||||
clientSDK = fromString(clientSDK);
|
||||
}
|
||||
// REST API, or custom SDK
|
||||
if (!clientSDK) {
|
||||
return true;
|
||||
}
|
||||
const clientVersion = clientSDK.version;
|
||||
const compatiblityVersion = compatibleSDK[clientSDK.sdk];
|
||||
return semver.satisfies(clientVersion, compatiblityVersion);
|
||||
};
|
||||
}
|
||||
|
||||
function supportsForwardDelete(clientSDK) {
|
||||
return compatible({
|
||||
js: '>=1.9.0',
|
||||
})(clientSDK);
|
||||
}
|
||||
|
||||
function fromString(version) {
|
||||
const versionRE = /([-a-zA-Z]+)([0-9\.]+)/;
|
||||
const match = version.toLowerCase().match(versionRE);
|
||||
if (match && match.length === 3) {
|
||||
return {
|
||||
sdk: match[1],
|
||||
version: match[2],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compatible,
|
||||
supportsForwardDelete,
|
||||
fromString,
|
||||
};
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -90,6 +90,33 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({
|
||||
|
||||
});
|
||||
|
||||
// graphql-js validation rules (FieldsOnCorrectTypeRule, KnownArgumentNamesRule,
|
||||
// KnownTypeNamesRule, ...) embed "Did you mean ...?" hints sourced from the live
|
||||
// schema in their error messages. Those messages are returned to the caller
|
||||
// before didResolveOperation runs, so they sidestep IntrospectionControlPlugin
|
||||
// and disclose schema identifiers the introspection guard is meant to hide.
|
||||
// Strip the hint suffix for callers that are not allowed to introspect.
|
||||
const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({
|
||||
requestDidStart: async (requestContext) => ({
|
||||
validationDidStart: async () => {
|
||||
if (publicIntrospection) {
|
||||
return;
|
||||
}
|
||||
const isMasterOrMaintenance =
|
||||
requestContext.contextValue.auth?.isMaster ||
|
||||
requestContext.contextValue.auth?.isMaintenance;
|
||||
if (isMasterOrMaintenance) {
|
||||
return;
|
||||
}
|
||||
return async (validationErrors) => {
|
||||
validationErrors?.forEach(error => {
|
||||
error.message = error.message.replace(/ ?Did you mean(.+?)\?$/, '');
|
||||
});
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
class ParseGraphQLServer {
|
||||
parseGraphQLController: ParseGraphQLController;
|
||||
|
||||
@@ -153,7 +180,7 @@ class ParseGraphQLServer {
|
||||
// We need always true introspection because apollo server have changing behavior based on the NODE_ENV variable
|
||||
// we delegate the introspection control to the IntrospectionControlPlugin
|
||||
introspection: true,
|
||||
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
|
||||
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), SchemaSuggestionsControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
|
||||
schema,
|
||||
});
|
||||
await apollo.start();
|
||||
|
||||
@@ -5,8 +5,7 @@ const createObject = async (className, fields, config, auth, info) => {
|
||||
fields = {};
|
||||
}
|
||||
|
||||
return (await rest.create(config, auth, className, fields, info.clientSDK, info.context))
|
||||
.response;
|
||||
return (await rest.create(config, auth, className, fields, info.context)).response;
|
||||
};
|
||||
|
||||
const updateObject = async (className, objectId, fields, config, auth, info) => {
|
||||
@@ -14,9 +13,7 @@ const updateObject = async (className, objectId, fields, config, auth, info) =>
|
||||
fields = {};
|
||||
}
|
||||
|
||||
return (
|
||||
await rest.update(config, auth, className, { objectId }, fields, info.clientSDK, info.context)
|
||||
).response;
|
||||
return (await rest.update(config, auth, className, { objectId }, fields, info.context)).response;
|
||||
};
|
||||
|
||||
const deleteObject = async (className, objectId, config, auth, info) => {
|
||||
|
||||
@@ -69,7 +69,6 @@ const getObject = async (
|
||||
className,
|
||||
objectId,
|
||||
options,
|
||||
info.clientSDK,
|
||||
info.context
|
||||
);
|
||||
|
||||
@@ -131,9 +130,8 @@ const findObjects = async (
|
||||
if (Object.keys(where).length > 0 && subqueryReadPreference) {
|
||||
preCountOptions.subqueryReadPreference = subqueryReadPreference;
|
||||
}
|
||||
preCount = (
|
||||
await rest.find(config, auth, className, where, preCountOptions, info.clientSDK, info.context)
|
||||
).count;
|
||||
preCount = (await rest.find(config, auth, className, where, preCountOptions, info.context))
|
||||
.count;
|
||||
if ((skip || 0) + limit < preCount) {
|
||||
skip = preCount - limit;
|
||||
}
|
||||
@@ -199,7 +197,6 @@ const findObjects = async (
|
||||
className,
|
||||
where,
|
||||
options,
|
||||
info.clientSDK,
|
||||
info.context
|
||||
);
|
||||
results = findResult.results;
|
||||
|
||||
@@ -59,7 +59,6 @@ const getUserFromSessionToken = async (context, queryInfo, keysPrefix, userId) =
|
||||
// Get the user it self from auth object
|
||||
{ objectId: context.auth.user.id },
|
||||
options,
|
||||
info.clientVersion,
|
||||
info.context
|
||||
);
|
||||
if (!response.results || response.results.length == 0) {
|
||||
|
||||
@@ -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<string>} 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,
|
||||
};
|
||||
@@ -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.<br><br>\u2139\uFE0F Mind the following limitations:<br>- 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<br>- 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<br>- 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.",
|
||||
@@ -569,7 +583,7 @@ module.exports.ParseServerOptions = {
|
||||
},
|
||||
routeAllowList: {
|
||||
env: 'PARSE_SERVER_ROUTE_ALLOW_LIST',
|
||||
help: '(Optional) Restricts external client access to a list of allowed API routes.<br><br>When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.<br><br>Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.<br><br><b>Examples of normalized route identifiers:</b><ul><li>`classes/GameScore` (class CRUD)</li><li>`classes/GameScore/abc123` (object by ID)</li><li>`users` (user operations)</li><li>`login` (login endpoint)</li><li>`functions/sendEmail` (Cloud Function)</li><li>`jobs/cleanup` (Cloud Job)</li><li>`push` (push notifications)</li><li>`config` (client config)</li><li>`installations` (installations)</li><li>`files/picture.jpg` (file operations)</li></ul><b>Example patterns:</b><ul><li>`classes/ChatMessage` matches only `classes/ChatMessage`</li><li>`classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.</li><li>`functions/.*` matches all Cloud Functions</li></ul>Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).<br><br>When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.<br><br>Defaults to `undefined` which means the feature is inactive and all routes are accessible.',
|
||||
help: '(Optional) Restricts external client access to a list of allowed REST API routes.<br><br>When this option is set, all external non-master-key REST API requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.<br><br>Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.<br><br><b>Examples of normalized route identifiers:</b><ul><li>`classes/GameScore` (class CRUD)</li><li>`classes/GameScore/abc123` (object by ID)</li><li>`users` (user operations)</li><li>`login` (login endpoint)</li><li>`functions/sendEmail` (Cloud Function)</li><li>`jobs/cleanup` (Cloud Job)</li><li>`push` (push notifications)</li><li>`config` (client config)</li><li>`installations` (installations)</li></ul><b>Example patterns:</b><ul><li>`classes/ChatMessage` matches only `classes/ChatMessage`</li><li>`classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.</li><li>`functions/.*` matches all Cloud Functions</li></ul>Setting an empty array `[]` blocks all external non-master-key REST API requests (full lockdown of REST API routes).<br><br>When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.<br><br>Defaults to `undefined` which means the feature is inactive and all routes are accessible.<br><br><b>Note:</b> File routes and the GraphQL API are not covered by this option.',
|
||||
action: parsers.arrayParser,
|
||||
},
|
||||
scheduledPush: {
|
||||
@@ -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',
|
||||
|
||||
+16
-1
@@ -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.<br><br>ℹ️ Mind the following limitations:<br>- 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<br>- 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<br>- 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.<br><br>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']`.<br><br><b>Special scenarios:</b><br>- 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.<br>- 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.<br><br><b>Considerations:</b><br>- 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.<br>- 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.<br>- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.<br>- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.<br><br>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`.
|
||||
@@ -103,7 +105,7 @@
|
||||
* @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns.
|
||||
* @property {String} restAPIKey Key for REST calls
|
||||
* @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions.
|
||||
* @property {String[]} routeAllowList (Optional) Restricts external client access to a list of allowed API routes.<br><br>When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.<br><br>Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.<br><br><b>Examples of normalized route identifiers:</b><ul><li>`classes/GameScore` (class CRUD)</li><li>`classes/GameScore/abc123` (object by ID)</li><li>`users` (user operations)</li><li>`login` (login endpoint)</li><li>`functions/sendEmail` (Cloud Function)</li><li>`jobs/cleanup` (Cloud Job)</li><li>`push` (push notifications)</li><li>`config` (client config)</li><li>`installations` (installations)</li><li>`files/picture.jpg` (file operations)</li></ul><b>Example patterns:</b><ul><li>`classes/ChatMessage` matches only `classes/ChatMessage`</li><li>`classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.</li><li>`functions/.*` matches all Cloud Functions</li></ul>Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).<br><br>When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.<br><br>Defaults to `undefined` which means the feature is inactive and all routes are accessible.
|
||||
* @property {String[]} routeAllowList (Optional) Restricts external client access to a list of allowed REST API routes.<br><br>When this option is set, all external non-master-key REST API requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.<br><br>Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.<br><br><b>Examples of normalized route identifiers:</b><ul><li>`classes/GameScore` (class CRUD)</li><li>`classes/GameScore/abc123` (object by ID)</li><li>`users` (user operations)</li><li>`login` (login endpoint)</li><li>`functions/sendEmail` (Cloud Function)</li><li>`jobs/cleanup` (Cloud Job)</li><li>`push` (push notifications)</li><li>`config` (client config)</li><li>`installations` (installations)</li></ul><b>Example patterns:</b><ul><li>`classes/ChatMessage` matches only `classes/ChatMessage`</li><li>`classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.</li><li>`functions/.*` matches all Cloud Functions</li></ul>Setting an empty array `[]` blocks all external non-master-key REST API requests (full lockdown of REST API routes).<br><br>When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.<br><br>Defaults to `undefined` which means the feature is inactive and all routes are accessible.<br><br><b>Note:</b> File routes and the GraphQL API are not covered by this option.
|
||||
* @property {Boolean} scheduledPush Configuration for push scheduling, defaults to false.
|
||||
* @property {SchemaOptions} schema Defined schema
|
||||
* @property {SecurityOptions} security The security options to identify and report weak security settings.
|
||||
@@ -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.
|
||||
|
||||
+32
-1
@@ -79,7 +79,7 @@ export interface ParseServerOptions {
|
||||
/* (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.<br><br>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']`.<br><br><b>Special scenarios:</b><br>- Setting an empty array `[]` means that the 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.<br>- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.<br><br><b>Considerations:</b><br>- 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.<br>- 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.<br>- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.<br>- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.<br><br>Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key.
|
||||
:DEFAULT: ["127.0.0.1","::1"] */
|
||||
masterKeyIps: ?(string[]);
|
||||
/* (Optional) Restricts external client access to a list of allowed API routes.<br><br>When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.<br><br>Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.<br><br><b>Examples of normalized route identifiers:</b><ul><li>`classes/GameScore` (class CRUD)</li><li>`classes/GameScore/abc123` (object by ID)</li><li>`users` (user operations)</li><li>`login` (login endpoint)</li><li>`functions/sendEmail` (Cloud Function)</li><li>`jobs/cleanup` (Cloud Job)</li><li>`push` (push notifications)</li><li>`config` (client config)</li><li>`installations` (installations)</li><li>`files/picture.jpg` (file operations)</li></ul><b>Example patterns:</b><ul><li>`classes/ChatMessage` matches only `classes/ChatMessage`</li><li>`classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.</li><li>`functions/.*` matches all Cloud Functions</li></ul>Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).<br><br>When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.<br><br>Defaults to `undefined` which means the feature is inactive and all routes are accessible. */
|
||||
/* (Optional) Restricts external client access to a list of allowed REST API routes.<br><br>When this option is set, all external non-master-key REST API requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.<br><br>Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.<br><br><b>Examples of normalized route identifiers:</b><ul><li>`classes/GameScore` (class CRUD)</li><li>`classes/GameScore/abc123` (object by ID)</li><li>`users` (user operations)</li><li>`login` (login endpoint)</li><li>`functions/sendEmail` (Cloud Function)</li><li>`jobs/cleanup` (Cloud Job)</li><li>`push` (push notifications)</li><li>`config` (client config)</li><li>`installations` (installations)</li></ul><b>Example patterns:</b><ul><li>`classes/ChatMessage` matches only `classes/ChatMessage`</li><li>`classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.</li><li>`functions/.*` matches all Cloud Functions</li></ul>Setting an empty array `[]` blocks all external non-master-key REST API requests (full lockdown of REST API routes).<br><br>When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.<br><br>Defaults to `undefined` which means the feature is inactive and all routes are accessible.<br><br><b>Note:</b> File routes and the GraphQL API are not covered by this option.*/
|
||||
routeAllowList: ?(string[]);
|
||||
/* (Optional) Restricts the use of maintenance key permissions to a list of IP addresses or ranges.<br><br>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']`.<br><br><b>Special scenarios:</b><br>- Setting an empty array `[]` means that the maintenance 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.<br>- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the maintenance key and effectively disables the IP filter.<br><br><b>Considerations:</b><br>- 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.<br>- 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.<br>- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.<br>- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.<br><br>Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the maintenance key.
|
||||
:DEFAULT: ["127.0.0.1","::1"] */
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+3
-8
@@ -31,7 +31,6 @@ const { createSanitizedError } = require('./Error');
|
||||
* @param options.className {string} The name of the class to query
|
||||
* @param options.restWhere {object} The where object for the query
|
||||
* @param options.restOptions {object} The options object for the query
|
||||
* @param options.clientSDK {string} The client SDK that is performing the query
|
||||
* @param options.runAfterFind {boolean} Whether to run the afterFind trigger
|
||||
* @param options.runBeforeFind {boolean} Whether to run the beforeFind trigger
|
||||
* @param options.context {object} The context object for the query
|
||||
@@ -44,7 +43,6 @@ async function RestQuery({
|
||||
className,
|
||||
restWhere = {},
|
||||
restOptions = {},
|
||||
clientSDK,
|
||||
runAfterFind = true,
|
||||
runBeforeFind = true,
|
||||
context,
|
||||
@@ -73,7 +71,6 @@ async function RestQuery({
|
||||
className,
|
||||
result.restWhere || restWhere,
|
||||
result.restOptions || restOptions,
|
||||
clientSDK,
|
||||
runAfterFind,
|
||||
context,
|
||||
isGet
|
||||
@@ -93,7 +90,6 @@ RestQuery.Method = Object.freeze({
|
||||
* @param className
|
||||
* @param restWhere
|
||||
* @param restOptions
|
||||
* @param clientSDK
|
||||
* @param runAfterFind
|
||||
* @param context
|
||||
*/
|
||||
@@ -103,7 +99,6 @@ function _UnsafeRestQuery(
|
||||
className,
|
||||
restWhere = {},
|
||||
restOptions = {},
|
||||
clientSDK,
|
||||
runAfterFind = true,
|
||||
context,
|
||||
isGet
|
||||
@@ -113,7 +108,6 @@ function _UnsafeRestQuery(
|
||||
this.className = className;
|
||||
this.restWhere = restWhere;
|
||||
this.restOptions = restOptions;
|
||||
this.clientSDK = clientSDK;
|
||||
this.runAfterFind = runAfterFind;
|
||||
this.response = null;
|
||||
this.findOptions = {};
|
||||
@@ -219,6 +213,8 @@ function _UnsafeRestQuery(
|
||||
case 'limit':
|
||||
case 'readPreference':
|
||||
case 'comment':
|
||||
case 'rawValues':
|
||||
case 'rawFieldNames':
|
||||
this.findOptions[option] = restOptions[option];
|
||||
break;
|
||||
case 'order':
|
||||
@@ -320,7 +316,7 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) {
|
||||
};
|
||||
|
||||
_UnsafeRestQuery.prototype.each = function (callback) {
|
||||
const { config, auth, className, restWhere, restOptions, clientSDK } = this;
|
||||
const { config, auth, className, restWhere, restOptions } = this;
|
||||
// if the limit is set, use it
|
||||
restOptions.limit = restOptions.limit || 100;
|
||||
restOptions.order = 'objectId';
|
||||
@@ -339,7 +335,6 @@ _UnsafeRestQuery.prototype.each = function (callback) {
|
||||
className,
|
||||
restWhere,
|
||||
restOptions,
|
||||
clientSDK,
|
||||
this.runAfterFind,
|
||||
this.context
|
||||
);
|
||||
|
||||
+56
-68
@@ -10,13 +10,14 @@ var cryptoUtils = require('./cryptoUtils');
|
||||
var passwordCrypto = require('./password');
|
||||
var Parse = require('parse/node');
|
||||
var triggers = require('./triggers');
|
||||
var ClientSDK = require('./ClientSDK');
|
||||
const util = require('util');
|
||||
import RestQuery from './RestQuery';
|
||||
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.
|
||||
@@ -27,7 +28,7 @@ import { createSanitizedError } from './Error';
|
||||
// RestWrite will handle objectId, createdAt, and updatedAt for
|
||||
// everything. It also knows to use triggers and special modifications
|
||||
// for the _User class.
|
||||
function RestWrite(config, auth, className, query, data, originalData, clientSDK, context, action) {
|
||||
function RestWrite(config, auth, className, query, data, originalData, context, action) {
|
||||
if (auth.isReadOnly) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
@@ -38,7 +39,6 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK
|
||||
this.config = config;
|
||||
this.auth = auth;
|
||||
this.className = className;
|
||||
this.clientSDK = clientSDK;
|
||||
this.storage = {};
|
||||
this.runOptions = {};
|
||||
this.context = context || {};
|
||||
@@ -658,20 +658,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 +683,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 +703,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 +1445,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 +1457,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 +1513,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;
|
||||
@@ -1993,7 +1983,6 @@ RestWrite.prototype._updateResponseWithData = function (response, data) {
|
||||
if (_.isEmpty(this.storage.fieldsChangedByTrigger)) {
|
||||
return response;
|
||||
}
|
||||
const clientSupportsDelete = ClientSDK.supportsForwardDelete(this.clientSDK);
|
||||
this.storage.fieldsChangedByTrigger.forEach(fieldName => {
|
||||
const dataValue = data[fieldName];
|
||||
|
||||
@@ -2001,10 +1990,9 @@ RestWrite.prototype._updateResponseWithData = function (response, data) {
|
||||
response[fieldName] = dataValue;
|
||||
}
|
||||
|
||||
// Strips operations from responses
|
||||
if (response[fieldName] && response[fieldName].__op) {
|
||||
delete response[fieldName];
|
||||
if (clientSupportsDelete && dataValue.__op == 'Delete') {
|
||||
if (dataValue.__op == 'Delete') {
|
||||
response[fieldName] = dataValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -42,12 +60,13 @@ export class AggregateRouter extends ClassesRouter {
|
||||
this.className(req),
|
||||
body.where,
|
||||
options,
|
||||
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 };
|
||||
|
||||
@@ -18,7 +18,6 @@ export class AudiencesRouter extends ClassesRouter {
|
||||
'_Audience',
|
||||
body.where,
|
||||
options,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(response => {
|
||||
|
||||
@@ -43,7 +43,6 @@ export class ClassesRouter extends PromiseRouter {
|
||||
this.className(req),
|
||||
body.where,
|
||||
options,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(response => {
|
||||
@@ -88,7 +87,6 @@ export class ClassesRouter extends PromiseRouter {
|
||||
this.className(req),
|
||||
req.params.objectId,
|
||||
options,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(response => {
|
||||
@@ -123,7 +121,6 @@ export class ClassesRouter extends PromiseRouter {
|
||||
req.auth,
|
||||
this.className(req),
|
||||
req.body || {},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
}
|
||||
@@ -136,7 +133,6 @@ export class ClassesRouter extends PromiseRouter {
|
||||
this.className(req),
|
||||
where,
|
||||
req.body || {},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ function getFileForProductIdentifier(productIdentifier, req) {
|
||||
'_Product',
|
||||
{ productIdentifier: productIdentifier },
|
||||
undefined,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(function (result) {
|
||||
|
||||
@@ -19,7 +19,6 @@ export class InstallationsRouter extends ClassesRouter {
|
||||
'_Installation',
|
||||
body.where,
|
||||
options,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(response => {
|
||||
|
||||
@@ -21,7 +21,6 @@ export class SessionsRouter extends ClassesRouter {
|
||||
'_Session',
|
||||
{ sessionToken },
|
||||
{},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
if (
|
||||
@@ -51,7 +50,6 @@ export class SessionsRouter extends ClassesRouter {
|
||||
'_Session',
|
||||
sessionObjectId,
|
||||
{},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
if (!response.results || response.results.length == 0) {
|
||||
@@ -103,7 +101,6 @@ export class SessionsRouter extends ClassesRouter {
|
||||
'_Session',
|
||||
{ sessionToken: sessionData.sessionToken },
|
||||
{},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
if (!response.results || response.results.length === 0) {
|
||||
|
||||
@@ -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() {
|
||||
@@ -200,7 +201,6 @@ export class UsersRouter extends ClassesRouter {
|
||||
'_Session',
|
||||
{ sessionToken },
|
||||
{},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
if (
|
||||
@@ -219,7 +219,6 @@ export class UsersRouter extends ClassesRouter {
|
||||
'_User',
|
||||
userId,
|
||||
{},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
if (!userResponse.results || userResponse.results.length == 0) {
|
||||
@@ -256,7 +255,6 @@ export class UsersRouter extends ClassesRouter {
|
||||
{ objectId: user.objectId },
|
||||
req.body || {},
|
||||
user,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
),
|
||||
user
|
||||
@@ -314,26 +312,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) {
|
||||
@@ -384,7 +366,6 @@ export class UsersRouter extends ClassesRouter {
|
||||
'_User',
|
||||
user.objectId,
|
||||
{},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
filteredUser = filteredUserResponse.results?.[0];
|
||||
@@ -487,7 +468,6 @@ export class UsersRouter extends ClassesRouter {
|
||||
'_User',
|
||||
user.objectId,
|
||||
{},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
filteredUser = filteredUserResponse.results?.[0];
|
||||
@@ -514,7 +494,6 @@ export class UsersRouter extends ClassesRouter {
|
||||
'_Session',
|
||||
{ sessionToken: req.info.sessionToken },
|
||||
undefined,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
if (records.results && records.results.length) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const Parse = require('parse/node').Parse;
|
||||
const path = require('path');
|
||||
const { isRouteAllowed } = require('./middlewares');
|
||||
const { createSanitizedError } = require('./Error');
|
||||
// These methods handle batch requests.
|
||||
const batchPath = '/batch';
|
||||
|
||||
@@ -104,6 +106,17 @@ async function handleBatch(router, req) {
|
||||
if ((restRequest.method || 'GET').toUpperCase() === 'POST' && routablePath === batchPath) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_JSON, 'nested batch requests are not allowed');
|
||||
}
|
||||
// Re-enforce routeAllowList on each sub-request. The enforceRouteAllowList
|
||||
// middleware runs once on the outer /batch URL, so without this check an
|
||||
// operator who allowlists `batch` would expose every route reachable via
|
||||
// sub-request dispatch.
|
||||
if (!isRouteAllowed(routablePath, req.config, req.auth)) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
`Route not allowed by routeAllowList: ${(restRequest.method || 'GET').toUpperCase()} ${routablePath}`,
|
||||
req.config
|
||||
);
|
||||
}
|
||||
for (const limit of rateLimits) {
|
||||
const pathExp = limit.path.regexp || limit.path;
|
||||
if (!pathExp.test(routablePath)) {
|
||||
|
||||
+35
-35
@@ -3,7 +3,6 @@ import Utils from './Utils';
|
||||
import Parse from 'parse/node';
|
||||
import auth from './Auth';
|
||||
import Config from './Config';
|
||||
import ClientSDK from './ClientSDK';
|
||||
import defaultLogger from './logger';
|
||||
import rest from './rest';
|
||||
import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter';
|
||||
@@ -95,7 +94,6 @@ export async function handleParseHeaders(req, res, next) {
|
||||
javascriptKey: req.get('X-Parse-Javascript-Key'),
|
||||
dotNetKey: req.get('X-Parse-Windows-Key'),
|
||||
restAPIKey: req.get('X-Parse-REST-API-Key'),
|
||||
clientVersion: req.get('X-Parse-Client-Version'),
|
||||
context: context,
|
||||
};
|
||||
|
||||
@@ -150,13 +148,7 @@ export async function handleParseHeaders(req, res, next) {
|
||||
delete req.body._JavaScriptKey;
|
||||
// TODO: test that the REST API formats generated by the other
|
||||
// SDKs are handled ok
|
||||
if (req.body._ClientVersion) {
|
||||
if (typeof req.body._ClientVersion !== 'string') {
|
||||
return invalidRequest(req, res);
|
||||
}
|
||||
info.clientVersion = req.body._ClientVersion;
|
||||
delete req.body._ClientVersion;
|
||||
}
|
||||
delete req.body._ClientVersion;
|
||||
if (req.body._InstallationId) {
|
||||
if (typeof req.body._InstallationId !== 'string') {
|
||||
return invalidRequest(req, res);
|
||||
@@ -209,10 +201,6 @@ export async function handleParseHeaders(req, res, next) {
|
||||
return invalidRequest(req, res);
|
||||
}
|
||||
|
||||
if (info.clientVersion && typeof info.clientVersion === 'string') {
|
||||
info.clientSDK = ClientSDK.fromString(info.clientVersion);
|
||||
}
|
||||
|
||||
if (fileViaJSON && req.body) {
|
||||
if (req.body.base64 && typeof req.body.base64 !== 'string') {
|
||||
return invalidRequest(req, res);
|
||||
@@ -531,41 +519,53 @@ export function handleParseHealth(options) {
|
||||
};
|
||||
}
|
||||
|
||||
export function enforceRouteAllowList(req, res, next) {
|
||||
const config = req.config;
|
||||
if (!config || config.routeAllowList === undefined || config.routeAllowList === null) {
|
||||
return next();
|
||||
}
|
||||
if (req.auth && (req.auth.isMaster || req.auth.isMaintenance)) {
|
||||
return next();
|
||||
}
|
||||
let path = req.originalUrl;
|
||||
if (config.mount) {
|
||||
const mountPath = new URL(config.mount).pathname;
|
||||
if (path.startsWith(mountPath)) {
|
||||
path = path.substring(mountPath.length);
|
||||
function normalizeRouteAllowListPath(path, mount) {
|
||||
let normalized = path;
|
||||
if (mount) {
|
||||
const mountPath = new URL(mount).pathname;
|
||||
if (normalized.startsWith(mountPath)) {
|
||||
normalized = normalized.substring(mountPath.length);
|
||||
}
|
||||
}
|
||||
if (path.startsWith('/')) {
|
||||
path = path.substring(1);
|
||||
if (normalized.startsWith('/')) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
if (path.endsWith('/')) {
|
||||
path = path.substring(0, path.length - 1);
|
||||
if (normalized.endsWith('/')) {
|
||||
normalized = normalized.substring(0, normalized.length - 1);
|
||||
}
|
||||
const queryIndex = path.indexOf('?');
|
||||
const queryIndex = normalized.indexOf('?');
|
||||
if (queryIndex !== -1) {
|
||||
path = path.substring(0, queryIndex);
|
||||
normalized = normalized.substring(0, queryIndex);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isRouteAllowed(path, config, auth) {
|
||||
if (!config || config.routeAllowList === undefined || config.routeAllowList === null) {
|
||||
return true;
|
||||
}
|
||||
if (auth && (auth.isMaster || auth.isMaintenance)) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeRouteAllowListPath(path, config.mount);
|
||||
const regexes = config._routeAllowListRegex || [];
|
||||
for (const regex of regexes) {
|
||||
if (regex.test(path)) {
|
||||
return next();
|
||||
if (regex.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function enforceRouteAllowList(req, res, next) {
|
||||
if (isRouteAllowed(req.originalUrl, req.config, req.auth)) {
|
||||
return next();
|
||||
}
|
||||
const path = normalizeRouteAllowListPath(req.originalUrl, req.config?.mount);
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
`Route not allowed by routeAllowList: ${req.method} ${path}`,
|
||||
config
|
||||
req.config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+5
-11
@@ -31,7 +31,6 @@ async function runFindTriggers(
|
||||
className,
|
||||
restWhere,
|
||||
restOptions,
|
||||
clientSDK,
|
||||
context,
|
||||
options = {}
|
||||
) {
|
||||
@@ -90,7 +89,6 @@ async function runFindTriggers(
|
||||
className,
|
||||
restWhere: refilterWhere,
|
||||
restOptions,
|
||||
clientSDK,
|
||||
context,
|
||||
runBeforeFind: false,
|
||||
runAfterFind: false,
|
||||
@@ -126,7 +124,6 @@ async function runFindTriggers(
|
||||
className,
|
||||
restWhere,
|
||||
restOptions,
|
||||
clientSDK,
|
||||
context,
|
||||
runBeforeFind: false,
|
||||
});
|
||||
@@ -135,7 +132,7 @@ async function runFindTriggers(
|
||||
}
|
||||
|
||||
// Returns a promise for an object with optional keys 'results' and 'count'.
|
||||
const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => {
|
||||
const find = async (config, auth, className, restWhere, restOptions, context) => {
|
||||
enforceRoleSecurity('find', className, auth, config);
|
||||
return runFindTriggers(
|
||||
config,
|
||||
@@ -143,14 +140,13 @@ const find = async (config, auth, className, restWhere, restOptions, clientSDK,
|
||||
className,
|
||||
restWhere,
|
||||
restOptions,
|
||||
clientSDK,
|
||||
context,
|
||||
{ isGet: false }
|
||||
);
|
||||
};
|
||||
|
||||
// get is just like find but only queries an objectId.
|
||||
const get = async (config, auth, className, objectId, restOptions, clientSDK, context) => {
|
||||
const get = async (config, auth, className, objectId, restOptions, context) => {
|
||||
enforceRoleSecurity('get', className, auth, config);
|
||||
return runFindTriggers(
|
||||
config,
|
||||
@@ -158,7 +154,6 @@ const get = async (config, auth, className, objectId, restOptions, clientSDK, co
|
||||
className,
|
||||
{ objectId },
|
||||
restOptions,
|
||||
clientSDK,
|
||||
context,
|
||||
{ isGet: true }
|
||||
);
|
||||
@@ -264,16 +259,16 @@ function del(config, auth, className, objectId, context) {
|
||||
}
|
||||
|
||||
// Returns a promise for a {response, status, location} object.
|
||||
function create(config, auth, className, restObject, clientSDK, context) {
|
||||
function create(config, auth, className, restObject, context) {
|
||||
enforceRoleSecurity('create', className, auth, config);
|
||||
var write = new RestWrite(config, auth, className, null, restObject, null, clientSDK, context);
|
||||
var write = new RestWrite(config, auth, className, null, restObject, null, context);
|
||||
return write.execute();
|
||||
}
|
||||
|
||||
// Returns a promise that contains the fields of the update that the
|
||||
// REST API is supposed to return.
|
||||
// Usually, this is just updatedAt.
|
||||
function update(config, auth, className, restWhere, restObject, clientSDK, context) {
|
||||
function update(config, auth, className, restWhere, restObject, context) {
|
||||
enforceRoleSecurity('update', className, auth, config);
|
||||
|
||||
return Promise.resolve()
|
||||
@@ -313,7 +308,6 @@ function update(config, auth, className, restWhere, restObject, clientSDK, conte
|
||||
restWhere,
|
||||
restObject,
|
||||
originalRestObject,
|
||||
clientSDK,
|
||||
context,
|
||||
'update'
|
||||
).execute();
|
||||
|
||||
Reference in New Issue
Block a user