diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7bebc7b570..5aaa5ff9cc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -238,6 +238,9 @@ jobs:
- name: PostgreSQL 17, PostGIS 3.5
POSTGRES_IMAGE: postgis/postgis:17-3.5
NODE_VERSION: 22.12.0
+ - name: PostgreSQL 18, PostGIS 3.6
+ POSTGRES_IMAGE: postgis/postgis:18-3.6
+ NODE_VERSION: 22.12.0
fail-fast: false
name: ${{ matrix.name }}
timeout-minutes: 20
diff --git a/.gitignore b/.gitignore
index ce3eff2a59..b5b69e3891 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,3 +61,6 @@ lib/
# Redis Dump
dump.rdb
+
+# AI agents
+.claude
diff --git a/8.0.0.md b/8.0.0.md
index 3d7dd9d6e2..ab41c0cf29 100644
--- a/8.0.0.md
+++ b/8.0.0.md
@@ -5,6 +5,7 @@ This document only highlights specific changes that require a longer explanation
---
- [Email Verification](#email-verification)
+- [Database Indexes](#database-indexes)
---
@@ -22,6 +23,22 @@ The request to re-send a verification email changed to sending a `POST` request
> [!IMPORTANT]
> Parse Server does not keep a history of verification tokens but only stores the most recently generated verification token in the database. Every time Parse Server generates a new verification token, the currently stored token is replaced. If a user opens a link with an expired token, and that token has already been replaced in the database, Parse Server cannot associate the expired token with any user. In this case, another way has to be offered to the user to re-send a verification email. To mitigate this issue, set the Parse Server option `emailVerifyTokenReuseIfValid: true` and set `emailVerifyTokenValidityDuration` to a longer duration, which ensures that the currently stored verification token is not replaced too soon.
-Related pull requests:
+Related pull request:
- https://github.com/parse-community/parse-server/pull/8488
+
+## Database Indexes
+
+As part of the email verification and password reset improvements in Parse Server 8, the queries used for these operations have changed to use tokens instead of username/email fields. To ensure optimal query performance, Parse Server now automatically creates indexes on the following fields during server initialization:
+
+- `_User._email_verify_token`: used for email verification queries
+- `_User._perishable_token`: used for password reset queries
+
+These indexes are created automatically when Parse Server starts, similar to how indexes for `username` and `email` fields are created. No manual intervention is required.
+
+> [!WARNING]
+> If you have a large existing user base, the index creation may take some time during the first server startup after upgrading to Parse Server 8. The server logs will indicate when index creation is complete or if any errors occur. If you have any concerns regarding a potential database performance impact during index creation, you could create these indexes manually in a controlled procedure before upgrading Parse Server.
+
+Related pull request:
+
+- https://github.com/parse-community/parse-server/pull/9893
diff --git a/README.md b/README.md
index 9cd61ec59c..9103e0ecfb 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
[](https://nodejs.org)
[](https://www.mongodb.com)
-[](https://www.postgresql.org)
+[](https://www.postgresql.org)
[](https://www.npmjs.com/package/parse-server)
[](https://www.npmjs.com/package/parse-server)
@@ -152,6 +152,7 @@ Parse Server is continuously tested with the most recent releases of PostgreSQL
| Postgres 15 | 3.3, 3.4, 3.5 | November 2027 | <= 8.x (2025) |
| Postgres 16 | 3.5 | November 2028 | <= 9.x (2026) |
| Postgres 17 | 3.5 | November 2029 | <= 10.x (2027) |
+| Postgres 18 | 3.6 | November 2030 | <= 11.x (2028) |
### Locally
diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md
index 0467ddaac2..f1209f668b 100644
--- a/changelogs/CHANGELOG_alpha.md
+++ b/changelogs/CHANGELOG_alpha.md
@@ -1,3 +1,101 @@
+# [8.3.0-alpha.14](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.13...8.3.0-alpha.14) (2025-11-01)
+
+
+### Features
+
+* Add options to skip automatic creation of internal database indexes on server start ([#9897](https://github.com/parse-community/parse-server/issues/9897)) ([ea91aca](https://github.com/parse-community/parse-server/commit/ea91aca1420c33e038516a321b2640709589f886))
+
+# [8.3.0-alpha.13](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.12...8.3.0-alpha.13) (2025-11-01)
+
+
+### Bug Fixes
+
+* Indexes `_email_verify_token` for email verification and `_perishable_token` password reset are not created automatically ([#9893](https://github.com/parse-community/parse-server/issues/9893)) ([62dd3c5](https://github.com/parse-community/parse-server/commit/62dd3c565ab70765cb1c547996b616b72e9bb800))
+
+# [8.3.0-alpha.12](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.11...8.3.0-alpha.12) (2025-10-25)
+
+
+### Features
+
+* Add Parse Server option `verifyServerUrl` to disable server URL verification on server launch ([#9881](https://github.com/parse-community/parse-server/issues/9881)) ([b298ccc](https://github.com/parse-community/parse-server/commit/b298cccd9fb4f664b9d83894faad6d1ea7a3c964))
+
+# [8.3.0-alpha.11](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.10...8.3.0-alpha.11) (2025-10-24)
+
+
+### Bug Fixes
+
+* Stale data read in validation query on `Parse.Object` update causes inconsistency between validation read and subsequent update write operation ([#9859](https://github.com/parse-community/parse-server/issues/9859)) ([f49efaf](https://github.com/parse-community/parse-server/commit/f49efaf5bb1d6b19f6d6712f7cdf073855c95c6e))
+
+# [8.3.0-alpha.10](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.9...8.3.0-alpha.10) (2025-10-22)
+
+
+### Bug Fixes
+
+* Error in `afterSave` trigger for `Parse.Role` due to `name` field ([#9883](https://github.com/parse-community/parse-server/issues/9883)) ([eb052d8](https://github.com/parse-community/parse-server/commit/eb052d8e6abe1ae32505fd068d5445eaf950a770))
+
+# [8.3.0-alpha.9](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.8...8.3.0-alpha.9) (2025-10-19)
+
+
+### Bug Fixes
+
+* Server URL verification before server is ready ([#9882](https://github.com/parse-community/parse-server/issues/9882)) ([178bd5c](https://github.com/parse-community/parse-server/commit/178bd5c5e258d9501c9ac4d35a3a105ab64be67e))
+
+# [8.3.0-alpha.8](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.7...8.3.0-alpha.8) (2025-10-16)
+
+
+### Bug Fixes
+
+* Warning logged when setting option `databaseOptions.disableIndexFieldValidation` ([#9880](https://github.com/parse-community/parse-server/issues/9880)) ([1815b01](https://github.com/parse-community/parse-server/commit/1815b019b52565d2bc87be2596a49aea7600aeba))
+
+# [8.3.0-alpha.7](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.6...8.3.0-alpha.7) (2025-10-15)
+
+
+### Bug Fixes
+
+* Security upgrade to parse 7.0.1 ([#9877](https://github.com/parse-community/parse-server/issues/9877)) ([abfa94c](https://github.com/parse-community/parse-server/commit/abfa94cd6de2c4e76337931c8ea8311c4ccf2a1a))
+
+# [8.3.0-alpha.6](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.5...8.3.0-alpha.6) (2025-10-14)
+
+
+### Features
+
+* Add request context middleware for config and dependency injection in hooks ([#8480](https://github.com/parse-community/parse-server/issues/8480)) ([64f104e](https://github.com/parse-community/parse-server/commit/64f104e5c5f8863098e801eee632c14fcbd9b6f9))
+
+# [8.3.0-alpha.5](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.4...8.3.0-alpha.5) (2025-10-14)
+
+
+### Features
+
+* Allow returning objects in `Parse.Cloud.beforeFind` without invoking database query ([#9770](https://github.com/parse-community/parse-server/issues/9770)) ([0b47407](https://github.com/parse-community/parse-server/commit/0b4740714c29ba99672bc535619ee3516abd356f))
+
+# [8.3.0-alpha.4](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.3...8.3.0-alpha.4) (2025-10-09)
+
+
+### Features
+
+* Disable index-field validation to create index for fields that don't yet exist ([#8137](https://github.com/parse-community/parse-server/issues/8137)) ([1b23475](https://github.com/parse-community/parse-server/commit/1b2347524ca84ade0f6badf175a815fc8a7bef49))
+
+# [8.3.0-alpha.3](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.2...8.3.0-alpha.3) (2025-10-07)
+
+
+### Features
+
+* Add support for Postgres 18 ([#9870](https://github.com/parse-community/parse-server/issues/9870)) ([d275c18](https://github.com/parse-community/parse-server/commit/d275c1806e0a5a037cc06cde7eefff3e12c91d7d))
+
+# [8.3.0-alpha.2](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.1...8.3.0-alpha.2) (2025-10-03)
+
+
+### Features
+
+* Add regex option `u` for unicode support in `Parse.Query.matches` for MongoDB ([#9867](https://github.com/parse-community/parse-server/issues/9867)) ([7cb962a](https://github.com/parse-community/parse-server/commit/7cb962a02845f3dded61baffd84515f94b66ee50))
+
+# [8.3.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.5...8.3.0-alpha.1) (2025-10-03)
+
+
+### Features
+
+* Add option `keepUnknownIndexes` to retain indexes which are not specified in schema ([#9857](https://github.com/parse-community/parse-server/issues/9857)) ([89fad46](https://github.com/parse-community/parse-server/commit/89fad468c3a43772879c06c4d939a83b72517a8e))
+
## [8.2.5-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.4...8.2.5-alpha.1) (2025-09-21)
diff --git a/package-lock.json b/package-lock.json
index 0c4f4c4680..e83ab14141 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "parse-server",
- "version": "8.2.5",
+ "version": "8.3.0-alpha.14",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "parse-server",
- "version": "8.2.5",
+ "version": "8.3.0-alpha.14",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -39,10 +39,10 @@
"mongodb": "6.17.0",
"mustache": "4.2.0",
"otpauth": "9.4.0",
- "parse": "6.1.1",
+ "parse": "7.0.1",
"path-to-regexp": "6.3.0",
"pg-monitor": "3.0.0",
- "pg-promise": "11.14.0",
+ "pg-promise": "12.2.0",
"pluralize": "8.0.0",
"punycode": "2.3.1",
"rate-limit-redis": "4.2.0",
@@ -2502,13 +2502,12 @@
}
},
"node_modules/@babel/runtime-corejs3": {
- "version": "7.27.0",
- "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.0.tgz",
- "integrity": "sha512-UWjX6t+v+0ckwZ50Y5ShZLnlk95pP5MyW/pon9tiYzl3+18pkTHTFNTKr7rQbfRXPkowt2QAn30o1b6oswszew==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz",
+ "integrity": "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==",
"license": "MIT",
"dependencies": {
- "core-js-pure": "^3.30.2",
- "regenerator-runtime": "^0.14.0"
+ "core-js-pure": "^3.43.0"
},
"engines": {
"node": ">=6.9.0"
@@ -9036,10 +9035,11 @@
}
},
"node_modules/core-js-pure": {
- "version": "3.41.0",
- "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.41.0.tgz",
- "integrity": "sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q==",
+ "version": "3.46.0",
+ "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.46.0.tgz",
+ "integrity": "sha512-NMCW30bHNofuhwLhYPt66OLOKTMbOhgTTatKVbaQC3KRHpTCiRIBYvtshr+NBYSnBxwAFhjW/RfJ0XbIjS16rw==",
"hasInstallScript": true,
+ "license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@@ -18589,17 +18589,16 @@
}
},
"node_modules/parse": {
- "version": "6.1.1",
- "resolved": "https://registry.npmjs.org/parse/-/parse-6.1.1.tgz",
- "integrity": "sha512-zf70XcHKesDcqpO2RVKyIc1l7pngxBsYQVl0Yl/A38pftOSP8BQeampqqLEqMknzUetNZy8B+wrR3k5uTQDXOw==",
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/parse/-/parse-7.0.1.tgz",
+ "integrity": "sha512-6hCnE8EWky/MqDtlpMnztzL0BEEsU3jVI7iKl2+AlJeSAeWkCgkPcb30eBNq57FcCnqWWC6uVJAaUMmX3+zrvg==",
"license": "Apache-2.0",
"dependencies": {
- "@babel/runtime-corejs3": "7.27.0",
- "idb-keyval": "6.2.1",
+ "@babel/runtime-corejs3": "7.28.4",
+ "idb-keyval": "6.2.2",
"react-native-crypto-js": "1.0.0",
"uuid": "10.0.0",
- "ws": "8.18.1",
- "xmlhttprequest": "1.8.0"
+ "ws": "8.18.3"
},
"engines": {
"node": "18 || 19 || 20 || 22"
@@ -18635,6 +18634,12 @@
"node": ">=6"
}
},
+ "node_modules/parse/node_modules/idb-keyval": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
+ "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
+ "license": "Apache-2.0"
+ },
"node_modules/parse/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
@@ -18643,14 +18648,15 @@
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
+ "license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/parse/node_modules/ws": {
- "version": "8.18.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
- "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -18784,22 +18790,22 @@
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
},
"node_modules/pg": {
- "version": "8.14.1",
- "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz",
- "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==",
+ "version": "8.16.3",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
+ "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"dependencies": {
- "pg-connection-string": "^2.7.0",
- "pg-pool": "^3.8.0",
- "pg-protocol": "^1.8.0",
- "pg-types": "^2.1.0",
- "pgpass": "1.x"
+ "pg-connection-string": "^2.9.1",
+ "pg-pool": "^3.10.1",
+ "pg-protocol": "^1.10.3",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
},
"engines": {
- "node": ">= 8.0.0"
+ "node": ">= 16.0.0"
},
"optionalDependencies": {
- "pg-cloudflare": "^1.1.1"
+ "pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
@@ -18811,22 +18817,22 @@
}
},
"node_modules/pg-cloudflare": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz",
- "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==",
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
+ "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz",
- "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==",
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
+ "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/pg-cursor": {
- "version": "2.13.1",
- "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.13.1.tgz",
- "integrity": "sha512-t7niROd7/BVlRn2juI0S0MP/Ps87lNMpmnxMRQMOH0fboL0n7gH/MxpymSdR4rZRcPfoR3Sx47JG1u5JOJf6Gg==",
+ "version": "2.15.3",
+ "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.15.3.tgz",
+ "integrity": "sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
@@ -18843,12 +18849,12 @@
}
},
"node_modules/pg-minify": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.7.0.tgz",
- "integrity": "sha512-kFPxAWAhPMvOqnY7klP3scdU5R7bxpAYOm8vGExuIkcSIwuFkZYl4C4XIPQ8DtXY2NzVmAX1aFHpvFSXQ/qQmA==",
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.8.0.tgz",
+ "integrity": "sha512-jO/oJOununpx8DzKgvSsWm61P8JjwXlaxSlbbfTBo1nvSWoo/+I6qZYaSN96jm/KDwa5d+JMQwPGgcP6HXDRow==",
"license": "MIT",
"engines": {
- "node": ">=14.0.0"
+ "node": ">=16.0.0"
}
},
"node_modules/pg-monitor": {
@@ -18864,46 +18870,46 @@
}
},
"node_modules/pg-pool": {
- "version": "3.8.0",
- "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz",
- "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
+ "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-promise": {
- "version": "11.14.0",
- "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-11.14.0.tgz",
- "integrity": "sha512-x/HZ6hK0MxYllyfUbmN/XZc7JBYoow7KElyNW9hnlhgRHMiRZmRUtfNM/wcuElpjSoASPxkoIKi4IA5QlwOONA==",
+ "version": "12.2.0",
+ "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-12.2.0.tgz",
+ "integrity": "sha512-th+GB7ftaRv5gAjSVslURSaYr5gf8d+T9/h5dZTJ/uyMqnQV8lJ8cDo3p5Crv3rprLC8ZCav9yLFcMKnobib+g==",
"license": "MIT",
"dependencies": {
"assert-options": "0.8.3",
- "pg": "8.14.1",
- "pg-minify": "1.7.0",
- "spex": "3.4.1"
+ "pg": "8.16.3",
+ "pg-minify": "1.8.0",
+ "spex": "4.0.2"
},
"engines": {
- "node": ">=14.0"
+ "node": ">=16.0"
},
"peerDependencies": {
- "pg-query-stream": "4.8.1"
+ "pg-query-stream": "4.10.3"
}
},
"node_modules/pg-protocol": {
- "version": "1.8.0",
- "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz",
- "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==",
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
+ "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
"license": "MIT"
},
"node_modules/pg-query-stream": {
- "version": "4.8.1",
- "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.8.1.tgz",
- "integrity": "sha512-kZo6C6HSzYFF6mlwl+etDk5QZD9CMdlHUXpof6PkK9+CHHaBLvOd2lZMwErOOpC/ldg4thrAojS8sG1B8PZ9Yw==",
+ "version": "4.10.3",
+ "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.10.3.tgz",
+ "integrity": "sha512-h2utrzpOIzeT9JfaqfvBbVuvCfBjH86jNfVrGGTbyepKAIOyTfDew0lAt8bbJjs9n/I5bGDl7S2sx6h5hPyJxw==",
"license": "MIT",
"peer": true,
"dependencies": {
- "pg-cursor": "^2.13.1"
+ "pg-cursor": "^2.15.3"
},
"peerDependencies": {
"pg": "^8"
@@ -21071,12 +21077,12 @@
"dev": true
},
"node_modules/spex": {
- "version": "3.4.1",
- "resolved": "https://registry.npmjs.org/spex/-/spex-3.4.1.tgz",
- "integrity": "sha512-Br0Mu3S+c70kr4keXF+6K4B8ohR+aJjI9s7SbdsI3hliE1Riz4z+FQk7FQL+r7X1t90KPkpuKwQyITpCIQN9mg==",
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/spex/-/spex-4.0.2.tgz",
+ "integrity": "sha512-/8VnouFOkRlkfj/sDN6GYnRKCutBHjUndkg6oAgv374VvjYQRzzR2gTEXRsmFmgd1SrbI8W948iTDnkEkUieCw==",
"license": "MIT",
"engines": {
- "node": ">=14.0.0"
+ "node": ">=16.0.0"
}
},
"node_modules/split2": {
@@ -24615,12 +24621,11 @@
}
},
"@babel/runtime-corejs3": {
- "version": "7.27.0",
- "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.0.tgz",
- "integrity": "sha512-UWjX6t+v+0ckwZ50Y5ShZLnlk95pP5MyW/pon9tiYzl3+18pkTHTFNTKr7rQbfRXPkowt2QAn30o1b6oswszew==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz",
+ "integrity": "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==",
"requires": {
- "core-js-pure": "^3.30.2",
- "regenerator-runtime": "^0.14.0"
+ "core-js-pure": "^3.43.0"
}
},
"@babel/template": {
@@ -29217,9 +29222,9 @@
}
},
"core-js-pure": {
- "version": "3.41.0",
- "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.41.0.tgz",
- "integrity": "sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q=="
+ "version": "3.46.0",
+ "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.46.0.tgz",
+ "integrity": "sha512-NMCW30bHNofuhwLhYPt66OLOKTMbOhgTTatKVbaQC3KRHpTCiRIBYvtshr+NBYSnBxwAFhjW/RfJ0XbIjS16rw=="
},
"core-util-is": {
"version": "1.0.3",
@@ -35848,28 +35853,32 @@
}
},
"parse": {
- "version": "6.1.1",
- "resolved": "https://registry.npmjs.org/parse/-/parse-6.1.1.tgz",
- "integrity": "sha512-zf70XcHKesDcqpO2RVKyIc1l7pngxBsYQVl0Yl/A38pftOSP8BQeampqqLEqMknzUetNZy8B+wrR3k5uTQDXOw==",
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/parse/-/parse-7.0.1.tgz",
+ "integrity": "sha512-6hCnE8EWky/MqDtlpMnztzL0BEEsU3jVI7iKl2+AlJeSAeWkCgkPcb30eBNq57FcCnqWWC6uVJAaUMmX3+zrvg==",
"requires": {
- "@babel/runtime-corejs3": "7.27.0",
+ "@babel/runtime-corejs3": "7.28.4",
"crypto-js": "4.2.0",
- "idb-keyval": "6.2.1",
+ "idb-keyval": "6.2.2",
"react-native-crypto-js": "1.0.0",
"uuid": "10.0.0",
- "ws": "8.18.1",
- "xmlhttprequest": "1.8.0"
+ "ws": "8.18.3"
},
"dependencies": {
+ "idb-keyval": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
+ "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="
+ },
"uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="
},
"ws": {
- "version": "8.18.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
- "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"requires": {}
}
}
@@ -35985,33 +35994,33 @@
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
},
"pg": {
- "version": "8.14.1",
- "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz",
- "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==",
+ "version": "8.16.3",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
+ "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"requires": {
- "pg-cloudflare": "^1.1.1",
- "pg-connection-string": "^2.7.0",
- "pg-pool": "^3.8.0",
- "pg-protocol": "^1.8.0",
- "pg-types": "^2.1.0",
- "pgpass": "1.x"
+ "pg-cloudflare": "^1.2.7",
+ "pg-connection-string": "^2.9.1",
+ "pg-pool": "^3.10.1",
+ "pg-protocol": "^1.10.3",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
}
},
"pg-cloudflare": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz",
- "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==",
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
+ "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"optional": true
},
"pg-connection-string": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz",
- "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA=="
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
+ "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="
},
"pg-cursor": {
- "version": "2.13.1",
- "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.13.1.tgz",
- "integrity": "sha512-t7niROd7/BVlRn2juI0S0MP/Ps87lNMpmnxMRQMOH0fboL0n7gH/MxpymSdR4rZRcPfoR3Sx47JG1u5JOJf6Gg==",
+ "version": "2.15.3",
+ "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.15.3.tgz",
+ "integrity": "sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA==",
"peer": true,
"requires": {}
},
@@ -36021,9 +36030,9 @@
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="
},
"pg-minify": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.7.0.tgz",
- "integrity": "sha512-kFPxAWAhPMvOqnY7klP3scdU5R7bxpAYOm8vGExuIkcSIwuFkZYl4C4XIPQ8DtXY2NzVmAX1aFHpvFSXQ/qQmA=="
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.8.0.tgz",
+ "integrity": "sha512-jO/oJOununpx8DzKgvSsWm61P8JjwXlaxSlbbfTBo1nvSWoo/+I6qZYaSN96jm/KDwa5d+JMQwPGgcP6HXDRow=="
},
"pg-monitor": {
"version": "3.0.0",
@@ -36034,34 +36043,34 @@
}
},
"pg-pool": {
- "version": "3.8.0",
- "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz",
- "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
+ "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"requires": {}
},
"pg-promise": {
- "version": "11.14.0",
- "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-11.14.0.tgz",
- "integrity": "sha512-x/HZ6hK0MxYllyfUbmN/XZc7JBYoow7KElyNW9hnlhgRHMiRZmRUtfNM/wcuElpjSoASPxkoIKi4IA5QlwOONA==",
+ "version": "12.2.0",
+ "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-12.2.0.tgz",
+ "integrity": "sha512-th+GB7ftaRv5gAjSVslURSaYr5gf8d+T9/h5dZTJ/uyMqnQV8lJ8cDo3p5Crv3rprLC8ZCav9yLFcMKnobib+g==",
"requires": {
"assert-options": "0.8.3",
- "pg": "8.14.1",
- "pg-minify": "1.7.0",
- "spex": "3.4.1"
+ "pg": "8.16.3",
+ "pg-minify": "1.8.0",
+ "spex": "4.0.2"
}
},
"pg-protocol": {
- "version": "1.8.0",
- "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz",
- "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g=="
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
+ "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="
},
"pg-query-stream": {
- "version": "4.8.1",
- "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.8.1.tgz",
- "integrity": "sha512-kZo6C6HSzYFF6mlwl+etDk5QZD9CMdlHUXpof6PkK9+CHHaBLvOd2lZMwErOOpC/ldg4thrAojS8sG1B8PZ9Yw==",
+ "version": "4.10.3",
+ "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.10.3.tgz",
+ "integrity": "sha512-h2utrzpOIzeT9JfaqfvBbVuvCfBjH86jNfVrGGTbyepKAIOyTfDew0lAt8bbJjs9n/I5bGDl7S2sx6h5hPyJxw==",
"peer": true,
"requires": {
- "pg-cursor": "^2.13.1"
+ "pg-cursor": "^2.15.3"
}
},
"pg-types": {
@@ -37590,9 +37599,9 @@
"dev": true
},
"spex": {
- "version": "3.4.1",
- "resolved": "https://registry.npmjs.org/spex/-/spex-3.4.1.tgz",
- "integrity": "sha512-Br0Mu3S+c70kr4keXF+6K4B8ohR+aJjI9s7SbdsI3hliE1Riz4z+FQk7FQL+r7X1t90KPkpuKwQyITpCIQN9mg=="
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/spex/-/spex-4.0.2.tgz",
+ "integrity": "sha512-/8VnouFOkRlkfj/sDN6GYnRKCutBHjUndkg6oAgv374VvjYQRzzR2gTEXRsmFmgd1SrbI8W948iTDnkEkUieCw=="
},
"split2": {
"version": "4.2.0",
diff --git a/package.json b/package.json
index 1de6321911..eef863cd0d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "parse-server",
- "version": "8.2.5",
+ "version": "8.3.0-alpha.14",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
@@ -49,10 +49,10 @@
"mongodb": "6.17.0",
"mustache": "4.2.0",
"otpauth": "9.4.0",
- "parse": "6.1.1",
+ "parse": "7.0.1",
"path-to-regexp": "6.3.0",
"pg-monitor": "3.0.0",
- "pg-promise": "11.14.0",
+ "pg-promise": "12.2.0",
"pluralize": "8.0.0",
"punycode": "2.3.1",
"rate-limit-redis": "4.2.0",
diff --git a/spec/Adapters/Auth/linkedIn.spec.js b/spec/Adapters/Auth/linkedIn.spec.js
index f6c84a79af..9f5a4b37ae 100644
--- a/spec/Adapters/Auth/linkedIn.spec.js
+++ b/spec/Adapters/Auth/linkedIn.spec.js
@@ -89,12 +89,16 @@ describe('LinkedInAdapter', function () {
describe('Test getUserFromAccessToken', function () {
it('should fetch user successfully', async function () {
- global.fetch = jasmine.createSpy().and.returnValue(
- Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ id: 'validUserId' }),
- })
- );
+ mockFetch([
+ {
+ url: 'https://api.linkedin.com/v2/me',
+ method: 'GET',
+ response: {
+ ok: true,
+ json: () => Promise.resolve({ id: 'validUserId' }),
+ },
+ },
+ ]);
const user = await adapter.getUserFromAccessToken('validToken', false);
@@ -104,14 +108,21 @@ describe('LinkedInAdapter', function () {
'x-li-format': 'json',
'x-li-src': undefined,
},
+ method: 'GET',
});
expect(user).toEqual({ id: 'validUserId' });
});
it('should throw error for invalid response', async function () {
- global.fetch = jasmine.createSpy().and.returnValue(
- Promise.resolve({ ok: false })
- );
+ mockFetch([
+ {
+ url: 'https://api.linkedin.com/v2/me',
+ method: 'GET',
+ response: {
+ ok: false,
+ },
+ },
+ ]);
await expectAsync(adapter.getUserFromAccessToken('invalidToken', false)).toBeRejectedWith(
new Error('LinkedIn API request failed.')
@@ -121,12 +132,16 @@ describe('LinkedInAdapter', function () {
describe('Test getAccessTokenFromCode', function () {
it('should fetch token successfully', async function () {
- global.fetch = jasmine.createSpy().and.returnValue(
- Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ access_token: 'validToken' }),
- })
- );
+ mockFetch([
+ {
+ url: 'https://www.linkedin.com/oauth/v2/accessToken',
+ method: 'POST',
+ response: {
+ ok: true,
+ json: () => Promise.resolve({ access_token: 'validToken' }),
+ },
+ },
+ ]);
const tokenResponse = await adapter.getAccessTokenFromCode('validCode', 'http://example.com');
@@ -139,9 +154,15 @@ describe('LinkedInAdapter', function () {
});
it('should throw error for invalid response', async function () {
- global.fetch = jasmine.createSpy().and.returnValue(
- Promise.resolve({ ok: false })
- );
+ mockFetch([
+ {
+ url: 'https://www.linkedin.com/oauth/v2/accessToken',
+ method: 'POST',
+ response: {
+ ok: false,
+ },
+ },
+ ]);
await expectAsync(
adapter.getAccessTokenFromCode('invalidCode', 'http://example.com')
diff --git a/spec/Adapters/Auth/wechat.spec.js b/spec/Adapters/Auth/wechat.spec.js
index b82e3e877a..43518ec0df 100644
--- a/spec/Adapters/Auth/wechat.spec.js
+++ b/spec/Adapters/Auth/wechat.spec.js
@@ -23,7 +23,8 @@ describe('WeChatAdapter', function () {
const user = await adapter.getUserFromAccessToken('validToken', { id: 'validOpenId' });
expect(global.fetch).toHaveBeenCalledWith(
- 'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId'
+ 'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId',
+ jasmine.any(Object)
);
expect(user).toEqual({ errcode: 0, id: 'validUserId' });
});
@@ -64,7 +65,8 @@ describe('WeChatAdapter', function () {
const token = await adapter.getAccessTokenFromCode(authData);
expect(global.fetch).toHaveBeenCalledWith(
- 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code'
+ 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code',
+ jasmine.any(Object)
);
expect(token).toEqual('validToken');
});
diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js
index 0b881bef47..308c7731b8 100644
--- a/spec/CloudCode.spec.js
+++ b/spec/CloudCode.spec.js
@@ -11,8 +11,8 @@ const mockAdapter = {
name: filename,
location: `http://www.somewhere.com/${filename}`,
}),
- deleteFile: () => {},
- getFileData: () => {},
+ deleteFile: () => { },
+ getFileData: () => { },
getFileLocation: (config, filename) => `http://www.somewhere.com/${filename}`,
validateFilename: () => {
return null;
@@ -49,7 +49,7 @@ describe('Cloud Code', () => {
});
it('cloud code must be valid type', async () => {
- spyOn(console, 'error').and.callFake(() => {});
+ spyOn(console, 'error').and.callFake(() => { });
await expectAsync(reconfigureServer({ cloud: true })).toBeRejectedWith(
"argument 'cloud' must either be a string or a function"
);
@@ -114,7 +114,7 @@ describe('Cloud Code', () => {
it('show warning on duplicate cloud functions', done => {
const logger = require('../lib/logger').logger;
- spyOn(logger, 'warn').and.callFake(() => {});
+ spyOn(logger, 'warn').and.callFake(() => { });
Parse.Cloud.define('hello', () => {
return 'Hello world!';
});
@@ -202,6 +202,346 @@ describe('Cloud Code', () => {
}
});
+ describe('beforeFind without DB operations', () => {
+ let findSpy;
+
+ beforeEach(() => {
+ const config = Config.get('test');
+ const databaseAdapter = config.database.adapter;
+ findSpy = spyOn(databaseAdapter, 'find').and.callThrough();
+ });
+
+ it('beforeFind can return object without DB operation', async () => {
+ Parse.Cloud.beforeFind('TestObject', () => {
+ return new Parse.Object('TestObject', { foo: 'bar' });
+ });
+ Parse.Cloud.afterFind('TestObject', req => {
+ expect(req.objects).toBeDefined();
+ expect(req.objects[0].get('foo')).toBe('bar');
+ });
+
+ const newObj = await new Parse.Query('TestObject').first();
+ expect(newObj.className).toBe('TestObject');
+ expect(newObj.toJSON()).toEqual({ foo: 'bar' });
+ expect(findSpy).not.toHaveBeenCalled();
+ await newObj.save();
+ });
+
+ it('beforeFind can return array of objects without DB operation', async () => {
+ Parse.Cloud.beforeFind('TestObject', () => {
+ return [new Parse.Object('TestObject', { foo: 'bar' })];
+ });
+ Parse.Cloud.afterFind('TestObject', req => {
+ expect(req.objects).toBeDefined();
+ expect(req.objects[0].get('foo')).toBe('bar');
+ });
+
+ const newObj = await new Parse.Query('TestObject').first();
+ expect(newObj.className).toBe('TestObject');
+ expect(newObj.toJSON()).toEqual({ foo: 'bar' });
+ expect(findSpy).not.toHaveBeenCalled();
+ await newObj.save();
+ });
+
+ it('beforeFind can return object for get query without DB operation', async () => {
+ Parse.Cloud.beforeFind('TestObject', () => {
+ return [new Parse.Object('TestObject', { foo: 'bar' })];
+ });
+ Parse.Cloud.afterFind('TestObject', req => {
+ expect(req.objects).toBeDefined();
+ expect(req.objects[0].get('foo')).toBe('bar');
+ });
+
+ const testObj = new Parse.Object('TestObject');
+ await testObj.save();
+ findSpy.calls.reset();
+
+ const newObj = await new Parse.Query('TestObject').get(testObj.id);
+ expect(newObj.className).toBe('TestObject');
+ expect(newObj.toJSON()).toEqual({ foo: 'bar' });
+ expect(findSpy).not.toHaveBeenCalled();
+ await newObj.save();
+ });
+
+ it('beforeFind can return empty array without DB operation', async () => {
+ Parse.Cloud.beforeFind('TestObject', () => {
+ return [];
+ });
+ Parse.Cloud.afterFind('TestObject', req => {
+ expect(req.objects.length).toBe(0);
+ });
+
+ const obj = new Parse.Object('TestObject');
+ await obj.save();
+ findSpy.calls.reset();
+
+ const newObj = await new Parse.Query('TestObject').first();
+ expect(newObj).toBeUndefined();
+ expect(findSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('beforeFind security with returned objects', () => {
+ let userA;
+ let userB;
+ let secret;
+
+ beforeEach(async () => {
+ userA = new Parse.User();
+ userA.setUsername('userA_' + Date.now());
+ userA.setPassword('passA');
+ await userA.signUp();
+
+ userB = new Parse.User();
+ userB.setUsername('userB_' + Date.now());
+ userB.setPassword('passB');
+ await userB.signUp();
+
+ // Create an object readable only by userB
+ const acl = new Parse.ACL();
+ acl.setPublicReadAccess(false);
+ acl.setPublicWriteAccess(false);
+ acl.setReadAccess(userB.id, true);
+ acl.setWriteAccess(userB.id, true);
+
+ secret = new Parse.Object('SecretDoc');
+ secret.set('title', 'top');
+ secret.set('content', 'classified');
+ secret.setACL(acl);
+ await secret.save(null, { sessionToken: userB.getSessionToken() });
+
+ Parse.Cloud.beforeFind('SecretDoc', () => {
+ return [secret];
+ });
+ });
+
+ it('should not expose objects not readable by current user', async () => {
+ const q = new Parse.Query('SecretDoc');
+ const results = await q.find({ sessionToken: userA.getSessionToken() });
+ expect(results.length).toBe(0);
+ });
+
+ it('should allow authorized user to see their objects', async () => {
+ const q = new Parse.Query('SecretDoc');
+ const results = await q.find({ sessionToken: userB.getSessionToken() });
+ expect(results.length).toBe(1);
+ expect(results[0].id).toBe(secret.id);
+ expect(results[0].get('title')).toBe('top');
+ expect(results[0].get('content')).toBe('classified');
+ });
+
+ it('should return OBJECT_NOT_FOUND on get() for unauthorized user', async () => {
+ const q = new Parse.Query('SecretDoc');
+ await expectAsync(
+ q.get(secret.id, { sessionToken: userA.getSessionToken() })
+ ).toBeRejectedWith(jasmine.objectContaining({ code: Parse.Error.OBJECT_NOT_FOUND }));
+ });
+
+ it('should allow master key to bypass ACL filtering when returning objects', async () => {
+ const q = new Parse.Query('SecretDoc');
+ const results = await q.find({ useMasterKey: true });
+ expect(results.length).toBe(1);
+ expect(results[0].id).toBe(secret.id);
+ });
+
+ it('should apply protectedFields masking after re-filtering', async () => {
+ // Configure protectedFields for SecretMask: mask `secretField` for everyone
+ const protectedFields = { SecretMask: { '*': ['secretField'] } };
+ await reconfigureServer({ protectedFields });
+
+ const user = new Parse.User();
+ user.setUsername('pfUser');
+ user.setPassword('pfPass');
+ await user.signUp();
+
+ // Object is publicly readable but has a protected field
+ const doc = new Parse.Object('SecretMask');
+ doc.set('name', 'visible');
+ doc.set('secretField', 'hiddenValue');
+ await doc.save(null, { useMasterKey: true });
+
+ Parse.Cloud.beforeFind('SecretMask', () => {
+ return [doc];
+ });
+
+ // Query as normal user; after re-filtering, secretField should be removed
+ const res = await new Parse.Query('SecretMask').first({ sessionToken: user.getSessionToken() });
+ expect(res).toBeDefined();
+ expect(res.get('name')).toBe('visible');
+ expect(res.get('secretField')).toBeUndefined();
+ const json = res.toJSON();
+ expect(Object.prototype.hasOwnProperty.call(json, 'secretField')).toBeFalse();
+ });
+ });
+ const { maybeRunAfterFindTrigger } = require('../lib/triggers');
+
+ describe('maybeRunAfterFindTrigger - direct function tests', () => {
+ const testConfig = {
+ applicationId: 'test',
+ logLevels: { triggerBeforeSuccess: 'info', triggerAfter: 'info' },
+ };
+
+ it('should convert Parse.Object instances to JSON when no trigger defined', async () => {
+ const className = 'TestParseObjectDirect_' + Date.now();
+
+ const parseObj1 = new Parse.Object(className);
+ parseObj1.set('name', 'test1');
+ parseObj1.id = 'obj1';
+
+ const parseObj2 = new Parse.Object(className);
+ parseObj2.set('name', 'test2');
+ parseObj2.id = 'obj2';
+
+ const result = await maybeRunAfterFindTrigger(
+ 'afterFind',
+ null,
+ className,
+ [parseObj1, parseObj2],
+ testConfig,
+ null,
+ {}
+ );
+
+ expect(result).toBeDefined();
+ expect(Array.isArray(result)).toBe(true);
+ expect(result.length).toBe(2);
+ expect(result[0].name).toBe('test1');
+ expect(result[1].name).toBe('test2');
+ });
+
+ it('should handle null/undefined objectsInput when no trigger', async () => {
+ const className = 'TestNullDirect_' + Date.now();
+
+ const resultNull = await maybeRunAfterFindTrigger(
+ 'afterFind',
+ null,
+ className,
+ null,
+ testConfig,
+ null,
+ {}
+ );
+ expect(resultNull).toEqual([]);
+
+ const resultUndefined = await maybeRunAfterFindTrigger(
+ 'afterFind',
+ null,
+ className,
+ undefined,
+ testConfig,
+ null,
+ {}
+ );
+ expect(resultUndefined).toEqual([]);
+
+ const resultEmpty = await maybeRunAfterFindTrigger(
+ 'afterFind',
+ null,
+ className,
+ [],
+ testConfig,
+ null,
+ {}
+ );
+ expect(resultEmpty).toEqual([]);
+ });
+
+ it('should handle plain object query with where clause', async () => {
+ const className = 'TestQueryWhereDirect_' + Date.now();
+ let receivedQuery = null;
+
+ Parse.Cloud.afterFind(className, req => {
+ receivedQuery = req.query;
+ return req.objects;
+ });
+
+ const mockObject = { id: 'test123', className: className, name: 'test' };
+
+ const result = await maybeRunAfterFindTrigger(
+ 'afterFind',
+ null,
+ className,
+ [mockObject],
+ testConfig,
+ { where: { name: 'test' }, limit: 10 },
+ {}
+ );
+
+ expect(receivedQuery).toBeInstanceOf(Parse.Query);
+ expect(result).toBeDefined();
+ });
+
+ it('should handle plain object query without where clause', async () => {
+ const className = 'TestQueryNoWhereDirect_' + Date.now();
+ let receivedQuery = null;
+
+ Parse.Cloud.afterFind(className, req => {
+ receivedQuery = req.query;
+ return req.objects;
+ });
+
+ const mockObject = { id: 'test456', className: className, name: 'test' };
+ const pq = new Parse.Query(className).withJSON({ limit: 5, skip: 1 });
+
+ const result = await maybeRunAfterFindTrigger(
+ 'afterFind',
+ null,
+ className,
+ [mockObject],
+ testConfig,
+ pq,
+ {}
+ );
+
+ expect(receivedQuery).toBeInstanceOf(Parse.Query);
+ const qJSON = receivedQuery.toJSON();
+ expect(qJSON.limit).toBe(5);
+ expect(qJSON.skip).toBe(1);
+ expect(qJSON.where).toEqual({});
+ expect(result).toBeDefined();
+ });
+
+ it('should create default query for invalid query parameter', async () => {
+ const className = 'TestInvalidQueryDirect_' + Date.now();
+ let receivedQuery = null;
+
+ Parse.Cloud.afterFind(className, req => {
+ receivedQuery = req.query;
+ return req.objects;
+ });
+
+ const mockObject = { id: 'test789', className: className, name: 'test' };
+
+ await maybeRunAfterFindTrigger(
+ 'afterFind',
+ null,
+ className,
+ [mockObject],
+ testConfig,
+ 'invalid_query_string',
+ {}
+ );
+
+ expect(receivedQuery).toBeInstanceOf(Parse.Query);
+ expect(receivedQuery.className).toBe(className);
+
+ receivedQuery = null;
+
+ await maybeRunAfterFindTrigger(
+ 'afterFind',
+ null,
+ className,
+ [mockObject],
+ testConfig,
+ null,
+ {}
+ );
+
+ expect(receivedQuery).toBeInstanceOf(Parse.Query);
+ expect(receivedQuery.className).toBe(className);
+ });
+ });
+
it('beforeSave rejection with custom error code', function (done) {
Parse.Cloud.beforeSave('BeforeSaveFailWithErrorCode', function () {
throw new Parse.Error(999, 'Nope');
@@ -1332,7 +1672,7 @@ describe('Cloud Code', () => {
});
it('trivial beforeSave should not affect fetched pointers (regression test for #1238)', done => {
- Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => {});
+ Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => { });
const TestObject = Parse.Object.extend('TestObject');
const NoBeforeSaveObject = Parse.Object.extend('NoBeforeSave');
@@ -1405,7 +1745,7 @@ describe('Cloud Code', () => {
});
it('beforeSave should not affect fetched pointers', done => {
- Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => {});
+ Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => { });
Parse.Cloud.beforeSave('BeforeSaveChanged', function (req) {
req.object.set('foo', 'baz');
@@ -1719,7 +2059,7 @@ describe('Cloud Code', () => {
});
it('pointer should not be cleared by triggers', async () => {
- Parse.Cloud.afterSave('MyObject', () => {});
+ Parse.Cloud.afterSave('MyObject', () => { });
const foo = await new Parse.Object('Test', { foo: 'bar' }).save();
const obj = await new Parse.Object('MyObject', { foo }).save();
const foo2 = obj.get('foo');
@@ -1727,7 +2067,7 @@ describe('Cloud Code', () => {
});
it('can set a pointer in triggers', async () => {
- Parse.Cloud.beforeSave('MyObject', () => {});
+ Parse.Cloud.beforeSave('MyObject', () => { });
Parse.Cloud.afterSave(
'MyObject',
async ({ object }) => {
@@ -1828,7 +2168,7 @@ describe('Cloud Code', () => {
it('should not run without master key', done => {
expect(() => {
- Parse.Cloud.job('myJob', () => {});
+ Parse.Cloud.job('myJob', () => { });
}).not.toThrow();
request({
@@ -2014,6 +2354,14 @@ describe('cloud functions', () => {
Parse.Cloud.run('myFunction', {}).then(() => done());
});
+
+ it('should have request config', async () => {
+ Parse.Cloud.define('myConfigFunction', req => {
+ expect(req.config).toBeDefined();
+ return 'success';
+ });
+ await Parse.Cloud.run('myConfigFunction', {});
+ });
});
describe('beforeSave hooks', () => {
@@ -2037,6 +2385,16 @@ describe('beforeSave hooks', () => {
myObject.save().then(() => done());
});
+ it('should have request config', async () => {
+ Parse.Cloud.beforeSave('MyObject', req => {
+ expect(req.config).toBeDefined();
+ });
+
+ const MyObject = Parse.Object.extend('MyObject');
+ const myObject = new MyObject();
+ await myObject.save();
+ });
+
it('should respect custom object ids (#6733)', async () => {
Parse.Cloud.beforeSave('TestObject', req => {
expect(req.object.id).toEqual('test_6733');
@@ -2092,6 +2450,16 @@ describe('afterSave hooks', () => {
myObject.save().then(() => done());
});
+ it('should have request config', async () => {
+ Parse.Cloud.afterSave('MyObject', req => {
+ expect(req.config).toBeDefined();
+ });
+
+ const MyObject = Parse.Object.extend('MyObject');
+ const myObject = new MyObject();
+ await myObject.save();
+ });
+
it('should unset in afterSave', async () => {
Parse.Cloud.afterSave(
'MyObject',
@@ -2149,6 +2517,17 @@ describe('beforeDelete hooks', () => {
.then(myObj => myObj.destroy())
.then(() => done());
});
+
+ it('should have request config', async () => {
+ Parse.Cloud.beforeDelete('MyObject', req => {
+ expect(req.config).toBeDefined();
+ });
+
+ const MyObject = Parse.Object.extend('MyObject');
+ const myObject = new MyObject();
+ await myObject.save();
+ await myObject.destroy();
+ });
});
describe('afterDelete hooks', () => {
@@ -2177,6 +2556,17 @@ describe('afterDelete hooks', () => {
.then(myObj => myObj.destroy())
.then(() => done());
});
+
+ it('should have request config', async () => {
+ Parse.Cloud.afterDelete('MyObject', req => {
+ expect(req.config).toBeDefined();
+ });
+
+ const MyObject = Parse.Object.extend('MyObject');
+ const myObject = new MyObject();
+ await myObject.save();
+ await myObject.destroy();
+ });
});
describe('beforeFind hooks', () => {
@@ -2484,6 +2874,18 @@ describe('beforeFind hooks', () => {
.then(() => done());
});
+ it('should have request config', async () => {
+ Parse.Cloud.beforeFind('MyObject', req => {
+ expect(req.config).toBeDefined();
+ });
+
+ const MyObject = Parse.Object.extend('MyObject');
+ const myObject = new MyObject();
+ await myObject.save();
+ const query = new Parse.Query('MyObject');
+ query.equalTo('objectId', myObject.id);
+ await Promise.all([query.get(myObject.id), query.first(), query.find()]);
+ })
it('should run beforeFind on pointers and array of pointers from an object', async () => {
const obj1 = new Parse.Object('TestObject');
const obj2 = new Parse.Object('TestObject2');
@@ -2868,54 +3270,67 @@ describe('afterFind hooks', () => {
.catch(done.fail);
});
+ it('should have request config', async () => {
+ Parse.Cloud.afterFind('MyObject', req => {
+ expect(req.config).toBeDefined();
+ });
+
+ const MyObject = Parse.Object.extend('MyObject');
+ const myObject = new MyObject();
+ await myObject.save();
+ const query = new Parse.Query('MyObject');
+ query.equalTo('objectId', myObject.id);
+ await Promise.all([query.get(myObject.id), query.first(), query.find()]);
+ });
+
it('should validate triggers correctly', () => {
expect(() => {
- Parse.Cloud.beforeSave('_Session', () => {});
+ Parse.Cloud.beforeSave('_Session', () => { });
}).toThrow('Only the afterLogout trigger is allowed for the _Session class.');
expect(() => {
- Parse.Cloud.afterSave('_Session', () => {});
+ Parse.Cloud.afterSave('_Session', () => { });
}).toThrow('Only the afterLogout trigger is allowed for the _Session class.');
expect(() => {
- Parse.Cloud.beforeSave('_PushStatus', () => {});
+ Parse.Cloud.beforeSave('_PushStatus', () => { });
}).toThrow('Only afterSave is allowed on _PushStatus');
expect(() => {
- Parse.Cloud.afterSave('_PushStatus', () => {});
+ Parse.Cloud.afterSave('_PushStatus', () => { });
}).not.toThrow();
expect(() => {
- Parse.Cloud.beforeLogin(() => {});
+ Parse.Cloud.beforeLogin(() => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
expect(() => {
- Parse.Cloud.beforeLogin('_User', () => {});
+ Parse.Cloud.beforeLogin('_User', () => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
expect(() => {
- Parse.Cloud.beforeLogin(Parse.User, () => {});
+ Parse.Cloud.beforeLogin(Parse.User, () => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
expect(() => {
- Parse.Cloud.beforeLogin('SomeClass', () => {});
+ Parse.Cloud.beforeLogin('SomeClass', () => { });
}).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
expect(() => {
- Parse.Cloud.afterLogin(() => {});
+ Parse.Cloud.afterLogin(() => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
expect(() => {
- Parse.Cloud.afterLogin('_User', () => {});
+ Parse.Cloud.afterLogin('_User', () => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
expect(() => {
- Parse.Cloud.afterLogin(Parse.User, () => {});
+ Parse.Cloud.afterLogin(Parse.User, () => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
expect(() => {
- Parse.Cloud.afterLogin('SomeClass', () => {});
+ Parse.Cloud.afterLogin('SomeClass', () => { });
}).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
expect(() => {
- Parse.Cloud.afterLogout(() => {});
+ Parse.Cloud.afterLogout(() => { });
}).not.toThrow();
expect(() => {
- Parse.Cloud.afterLogout('_Session', () => {});
+ Parse.Cloud.afterLogout('_Session', () => { });
}).not.toThrow();
expect(() => {
- Parse.Cloud.afterLogout('_User', () => {});
+ Parse.Cloud.afterLogout('_User', () => { });
}).toThrow('Only the _Session class is allowed for the afterLogout trigger.');
expect(() => {
- Parse.Cloud.afterLogout('SomeClass', () => {});
+ Parse.Cloud.afterLogout('SomeClass', () => { });
}).toThrow('Only the _Session class is allowed for the afterLogout trigger.');
});
@@ -3355,6 +3770,7 @@ describe('beforeLogin hook', () => {
expect(req.ip).toBeDefined();
expect(req.installationId).toBeDefined();
expect(req.context).toBeDefined();
+ expect(req.config).toBeDefined();
});
await Parse.User.signUp('tupac', 'shakur');
@@ -3472,6 +3888,7 @@ describe('afterLogin hook', () => {
expect(req.ip).toBeDefined();
expect(req.installationId).toBeDefined();
expect(req.context).toBeDefined();
+ expect(req.config).toBeDefined();
});
await Parse.User.signUp('testuser', 'p@ssword');
@@ -3674,6 +4091,15 @@ describe('saveFile hooks', () => {
}
});
+ it('beforeSaveFile should have config', async () => {
+ await reconfigureServer({ filesAdapter: mockAdapter });
+ Parse.Cloud.beforeSave(Parse.File, req => {
+ expect(req.config).toBeDefined();
+ });
+ const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
+ await file.save({ useMasterKey: true });
+ });
+
it('beforeSave(Parse.File) should change values of uploaded file by editing fileObject directly', async () => {
await reconfigureServer({ filesAdapter: mockAdapter });
const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough();
@@ -3976,7 +4402,7 @@ describe('Parse.File hooks', () => {
beforeFind() {
throw 'unauthorized';
},
- afterFind() {},
+ afterFind() { },
};
for (const hook in hooks) {
spyOn(hooks, hook).and.callThrough();
@@ -4004,7 +4430,7 @@ describe('Parse.File hooks', () => {
await file.save({ useMasterKey: true });
const user = await Parse.User.signUp('username', 'password');
const hooks = {
- beforeFind() {},
+ beforeFind() { },
afterFind() {
throw 'unauthorized';
},
@@ -4223,7 +4649,7 @@ describe('sendEmail', () => {
it('cannot send email without adapter', async () => {
const logger = require('../lib/logger').logger;
- spyOn(logger, 'error').and.callFake(() => {});
+ spyOn(logger, 'error').and.callFake(() => { });
await Parse.Cloud.sendEmail({});
expect(logger.error).toHaveBeenCalledWith(
'Failed to send email because no mail adapter is configured for Parse Server.'
diff --git a/spec/CloudCodeLogger.spec.js b/spec/CloudCodeLogger.spec.js
index a16b52365a..bcbc4f91cf 100644
--- a/spec/CloudCodeLogger.spec.js
+++ b/spec/CloudCodeLogger.spec.js
@@ -25,7 +25,7 @@ describe('Cloud Code Logger', () => {
})
.then(() => {
return Parse.User.signUp('tester', 'abc')
- .catch(() => {})
+ .catch(() => { })
.then(loggedInUser => (user = loggedInUser))
.then(() => Parse.User.logIn(user.get('username'), 'abc'));
})
@@ -139,7 +139,7 @@ describe('Cloud Code Logger', () => {
});
it_id('9857e15d-bb18-478d-8a67-fdaad3e89565')(it)('should log an afterSave', done => {
- Parse.Cloud.afterSave('MyObject', () => {});
+ Parse.Cloud.afterSave('MyObject', () => { });
new Parse.Object('MyObject')
.save()
.then(() => {
@@ -189,7 +189,7 @@ describe('Cloud Code Logger', () => {
});
});
- it_id('8088de8a-7cba-4035-8b05-4a903307e674')(it)('should log cloud function execution using the custom log level', async done => {
+ it_id('8088de8a-7cba-4035-8b05-4a903307e674')(it)('should log cloud function execution using the custom log level', async () => {
Parse.Cloud.define('aFunction', () => {
return 'it worked!';
});
@@ -203,6 +203,7 @@ describe('Cloud Code Logger', () => {
expect(log).toEqual('info');
});
+ Parse.Cloud._removeAllHooks();
await reconfigureServer({
silent: true,
logLevels: {
@@ -211,6 +212,10 @@ describe('Cloud Code Logger', () => {
},
});
+ Parse.Cloud.define('bFunction', () => {
+ throw new Error('Failed');
+ });
+
spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough();
try {
@@ -221,15 +226,12 @@ describe('Cloud Code Logger', () => {
.allArgs()
.find(log => log[1].startsWith('Failed running cloud function bFunction for '))?.[0];
expect(log).toEqual('info');
- done();
}
});
it('should log cloud function triggers using the custom log level', async () => {
- Parse.Cloud.beforeSave('TestClass', () => {});
- Parse.Cloud.afterSave('TestClass', () => {});
-
const execTest = async (logLevel, triggerBeforeSuccess, triggerAfter) => {
+ Parse.Cloud._removeAllHooks();
await reconfigureServer({
silent: true,
logLevel,
@@ -239,6 +241,9 @@ describe('Cloud Code Logger', () => {
},
});
+ Parse.Cloud.beforeSave('TestClass', () => { });
+ Parse.Cloud.afterSave('TestClass', () => { });
+
spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough();
const obj = new Parse.Object('TestClass');
await obj.save();
@@ -266,7 +271,7 @@ describe('Cloud Code Logger', () => {
});
Parse.Cloud.run('aFunction', { foo: 'bar' })
- .catch(() => {})
+ .catch(() => { })
.then(() => {
const logs = spy.calls.all().reverse();
expect(logs[0].args[1]).toBe('Parse error: ');
@@ -344,6 +349,7 @@ describe('Cloud Code Logger', () => {
});
it('should log cloud function execution using the silent log level', async () => {
+ Parse.Cloud._removeAllHooks();
await reconfigureServer({
logLevels: {
cloudFunctionSuccess: 'silent',
@@ -367,6 +373,7 @@ describe('Cloud Code Logger', () => {
});
it('should log cloud function triggers using the silent log level', async () => {
+ Parse.Cloud._removeAllHooks();
await reconfigureServer({
logLevels: {
triggerAfter: 'silent',
@@ -377,8 +384,8 @@ describe('Cloud Code Logger', () => {
Parse.Cloud.beforeSave('TestClassError', () => {
throw new Error('Failed');
});
- Parse.Cloud.beforeSave('TestClass', () => {});
- Parse.Cloud.afterSave('TestClass', () => {});
+ Parse.Cloud.beforeSave('TestClass', () => { });
+ Parse.Cloud.afterSave('TestClass', () => { });
spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough();
diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js
index e1b50a5a52..b1ccc0d586 100644
--- a/spec/DatabaseController.spec.js
+++ b/spec/DatabaseController.spec.js
@@ -413,6 +413,8 @@ describe('DatabaseController', function () {
case_insensitive_username: { username: 1 },
case_insensitive_email: { email: 1 },
email_1: { email: 1 },
+ _email_verify_token: { _email_verify_token: 1 },
+ _perishable_token: { _perishable_token: 1 },
});
}
);
@@ -437,9 +439,153 @@ describe('DatabaseController', function () {
_id_: { _id: 1 },
username_1: { username: 1 },
email_1: { email: 1 },
+ _email_verify_token: { _email_verify_token: 1 },
+ _perishable_token: { _perishable_token: 1 },
});
}
);
+
+ it_only_db('mongo')(
+ 'should use _email_verify_token index in email verification',
+ async () => {
+ const TestUtils = require('../lib/TestUtils');
+ let emailVerificationLink;
+ const emailSentPromise = TestUtils.resolvingPromise();
+ const emailAdapter = {
+ sendVerificationEmail: options => {
+ emailVerificationLink = options.link;
+ emailSentPromise.resolve();
+ },
+ sendPasswordResetEmail: () => Promise.resolve(),
+ sendMail: () => {},
+ };
+ await reconfigureServer({
+ databaseURI: 'mongodb://localhost:27017/testEmailVerifyTokenIndexStats',
+ databaseAdapter: undefined,
+ appName: 'test',
+ verifyUserEmails: true,
+ emailAdapter: emailAdapter,
+ publicServerURL: 'http://localhost:8378/1',
+ });
+
+ // Create a user to trigger email verification
+ const user = new Parse.User();
+ user.setUsername('statsuser');
+ user.setPassword('password');
+ user.set('email', 'stats@example.com');
+ await user.signUp();
+ await emailSentPromise;
+
+ // Get index stats before the query
+ const config = Config.get(Parse.applicationId);
+ const collection = await config.database.adapter._adaptiveCollection('_User');
+ const statsBefore = await collection._mongoCollection.aggregate([
+ { $indexStats: {} },
+ ]).toArray();
+ const emailVerifyIndexBefore = statsBefore.find(
+ stat => stat.name === '_email_verify_token'
+ );
+ const accessesBefore = emailVerifyIndexBefore?.accesses?.ops || 0;
+
+ // Perform email verification (this should use the index)
+ const request = require('../lib/request');
+ await request({
+ url: emailVerificationLink,
+ followRedirects: false,
+ });
+
+ // Get index stats after the query
+ const statsAfter = await collection._mongoCollection.aggregate([
+ { $indexStats: {} },
+ ]).toArray();
+ const emailVerifyIndexAfter = statsAfter.find(
+ stat => stat.name === '_email_verify_token'
+ );
+ const accessesAfter = emailVerifyIndexAfter?.accesses?.ops || 0;
+
+ // Verify the index was actually used
+ expect(accessesAfter).toBeGreaterThan(accessesBefore);
+ expect(emailVerifyIndexAfter).toBeDefined();
+
+ // Verify email verification succeeded
+ await user.fetch();
+ expect(user.get('emailVerified')).toBe(true);
+ }
+ );
+
+ it_only_db('mongo')(
+ 'should use _perishable_token index in password reset',
+ async () => {
+ const TestUtils = require('../lib/TestUtils');
+ let passwordResetLink;
+ const emailSentPromise = TestUtils.resolvingPromise();
+ const emailAdapter = {
+ sendVerificationEmail: () => Promise.resolve(),
+ sendPasswordResetEmail: options => {
+ passwordResetLink = options.link;
+ emailSentPromise.resolve();
+ },
+ sendMail: () => {},
+ };
+ await reconfigureServer({
+ databaseURI: 'mongodb://localhost:27017/testPerishableTokenIndexStats',
+ databaseAdapter: undefined,
+ appName: 'test',
+ emailAdapter: emailAdapter,
+ publicServerURL: 'http://localhost:8378/1',
+ });
+
+ // Create a user
+ const user = new Parse.User();
+ user.setUsername('statsuser2');
+ user.setPassword('oldpassword');
+ user.set('email', 'stats2@example.com');
+ await user.signUp();
+
+ // Request password reset
+ await Parse.User.requestPasswordReset('stats2@example.com');
+ await emailSentPromise;
+
+ const url = new URL(passwordResetLink);
+ const token = url.searchParams.get('token');
+
+ // Get index stats before the query
+ const config = Config.get(Parse.applicationId);
+ const collection = await config.database.adapter._adaptiveCollection('_User');
+ const statsBefore = await collection._mongoCollection.aggregate([
+ { $indexStats: {} },
+ ]).toArray();
+ const perishableTokenIndexBefore = statsBefore.find(
+ stat => stat.name === '_perishable_token'
+ );
+ const accessesBefore = perishableTokenIndexBefore?.accesses?.ops || 0;
+
+ // Perform password reset (this should use the index)
+ const request = require('../lib/request');
+ await request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/apps/test/request_password_reset',
+ body: { new_password: 'newpassword', token, username: 'statsuser2' },
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ followRedirects: false,
+ });
+
+ // Get index stats after the query
+ const statsAfter = await collection._mongoCollection.aggregate([
+ { $indexStats: {} },
+ ]).toArray();
+ const perishableTokenIndexAfter = statsAfter.find(
+ stat => stat.name === '_perishable_token'
+ );
+ const accessesAfter = perishableTokenIndexAfter?.accesses?.ops || 0;
+
+ // Verify the index was actually used
+ expect(accessesAfter).toBeGreaterThan(accessesBefore);
+ expect(perishableTokenIndexAfter).toBeDefined();
+ }
+ );
});
describe('convertEmailToLowercase', () => {
@@ -615,6 +761,76 @@ describe('DatabaseController', function () {
expect(result2.length).toEqual(1);
});
});
+
+ describe('update with validateOnly', () => {
+ const mockStorageAdapter = {
+ findOneAndUpdate: () => Promise.resolve({}),
+ find: () => Promise.resolve([{ objectId: 'test123', testField: 'initialValue' }]),
+ watch: () => Promise.resolve(),
+ getAllClasses: () =>
+ Promise.resolve([
+ {
+ className: 'TestObject',
+ fields: { testField: 'String' },
+ indexes: {},
+ classLevelPermissions: { protectedFields: {} },
+ },
+ ]),
+ };
+
+ it('should use primary readPreference when validateOnly is true', async () => {
+ const databaseController = new DatabaseController(mockStorageAdapter, {});
+ const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough();
+ const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough();
+
+ try {
+ // Call update with validateOnly: true (same as RestWrite.runBeforeSaveTrigger)
+ await databaseController.update(
+ 'TestObject',
+ { objectId: 'test123' },
+ { testField: 'newValue' },
+ {},
+ true, // skipSanitization: true (matches RestWrite behavior)
+ true // validateOnly: true
+ );
+ } catch (error) {
+ // validateOnly may throw, but we're checking the find call options
+ }
+
+ // Verify that find was called with primary readPreference
+ expect(findSpy).toHaveBeenCalled();
+ const findCall = findSpy.calls.mostRecent();
+ expect(findCall.args[3]).toEqual({ readPreference: 'primary' }); // options parameter
+
+ // Verify that findOneAndUpdate was NOT called (only validation, no actual update)
+ expect(findOneAndUpdateSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not use primary readPreference when validateOnly is false', async () => {
+ const databaseController = new DatabaseController(mockStorageAdapter, {});
+ const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough();
+ const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough();
+
+ try {
+ // Call update with validateOnly: false
+ await databaseController.update(
+ 'TestObject',
+ { objectId: 'test123' },
+ { testField: 'newValue' },
+ {},
+ false, // skipSanitization
+ false // validateOnly
+ );
+ } catch (error) {
+ // May throw for other reasons, but we're checking the call pattern
+ }
+
+ // When validateOnly is false, find should not be called for validation
+ // Instead, findOneAndUpdate should be called
+ expect(findSpy).not.toHaveBeenCalled();
+ expect(findOneAndUpdateSpy).toHaveBeenCalled();
+ });
+ });
});
function buildCLP(pointerNames) {
diff --git a/spec/DefinedSchemas.spec.js b/spec/DefinedSchemas.spec.js
index e3d6fd51fe..b2cae864c1 100644
--- a/spec/DefinedSchemas.spec.js
+++ b/spec/DefinedSchemas.spec.js
@@ -371,7 +371,7 @@ describe('DefinedSchemas', () => {
expect(schema.indexes).toEqual(indexes);
});
- it('should delete removed indexes', async () => {
+ it('should delete unknown indexes when keepUnknownIndexes is not set', async () => {
const server = await reconfigureServer();
let indexes = { complex: { createdAt: 1, updatedAt: 1 } };
@@ -393,6 +393,53 @@ describe('DefinedSchemas', () => {
cleanUpIndexes(schema);
expect(schema.indexes).toBeUndefined();
});
+
+ it('should delete unknown indexes when keepUnknownIndexes is set to false', async () => {
+ const server = await reconfigureServer();
+
+ let indexes = { complex: { createdAt: 1, updatedAt: 1 } };
+
+ let schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: false };
+ await new DefinedSchemas(schemas, server.config).execute();
+
+ indexes = {};
+ schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: false };
+ // Change indexes
+ await new DefinedSchemas(schemas, server.config).execute();
+ let schema = await new Parse.Schema('Test').get();
+ cleanUpIndexes(schema);
+ expect(schema.indexes).toBeUndefined();
+
+ // Update
+ await new DefinedSchemas(schemas, server.config).execute();
+ schema = await new Parse.Schema('Test').get();
+ cleanUpIndexes(schema);
+ expect(schema.indexes).toBeUndefined();
+ });
+
+ it('should not delete unknown indexes when keepUnknownIndexes is set to true', async () => {
+ const server = await reconfigureServer();
+
+ const indexes = { complex: { createdAt: 1, updatedAt: 1 } };
+
+ let schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: true };
+ await new DefinedSchemas(schemas, server.config).execute();
+
+ schemas = { definitions: [{ className: 'Test', indexes: {} }], keepUnknownIndexes: true };
+
+ // Change indexes
+ await new DefinedSchemas(schemas, server.config).execute();
+ let schema = await new Parse.Schema('Test').get();
+ cleanUpIndexes(schema);
+ expect(schema.indexes).toEqual({ complex: { createdAt: 1, updatedAt: 1 } });
+
+ // Update
+ await new DefinedSchemas(schemas, server.config).execute();
+ schema = await new Parse.Schema('Test').get();
+ cleanUpIndexes(schema);
+ expect(schema.indexes).toEqual(indexes);
+ });
+
xit('should keep protected indexes', async () => {
const server = await reconfigureServer();
diff --git a/spec/GridFSBucketStorageAdapter.spec.js b/spec/GridFSBucketStorageAdapter.spec.js
index 7e9c84a59e..d30415edf3 100644
--- a/spec/GridFSBucketStorageAdapter.spec.js
+++ b/spec/GridFSBucketStorageAdapter.spec.js
@@ -421,6 +421,14 @@ describe_only_db('mongo')('GridFSBucket', () => {
expect(gfsResult.toString('utf8')).toBe(twoMegabytesFile);
});
+ it('properly upload a file when disableIndexFieldValidation exist in databaseOptions', async () => {
+ const gfsAdapter = new GridFSBucketAdapter(databaseURI, { disableIndexFieldValidation: true });
+ const twoMegabytesFile = randomString(2048 * 1024);
+ await gfsAdapter.createFile('myFileName', twoMegabytesFile);
+ const gfsResult = await gfsAdapter.getFileData('myFileName');
+ expect(gfsResult.toString('utf8')).toBe(twoMegabytesFile);
+ });
+
it('properly deletes a file from GridFS', async () => {
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
await gfsAdapter.createFile('myFileName', 'a simple file');
diff --git a/spec/MongoStorageAdapter.spec.js b/spec/MongoStorageAdapter.spec.js
index b026fc0961..7d0d220cff 100644
--- a/spec/MongoStorageAdapter.spec.js
+++ b/spec/MongoStorageAdapter.spec.js
@@ -649,4 +649,179 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
});
});
}
+
+ describe('index creation options', () => {
+ beforeEach(async () => {
+ await new MongoStorageAdapter({ uri: databaseURI }).deleteAllClasses();
+ });
+
+ async function getIndexes(collectionName) {
+ const adapter = Config.get(Parse.applicationId).database.adapter;
+ const collections = await adapter.database.listCollections({ name: collectionName }).toArray();
+ if (collections.length === 0) {
+ return [];
+ }
+ return await adapter.database.collection(collectionName).indexes();
+ }
+
+ it('should skip username index when createIndexUserUsername is false', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserUsername: false },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === 'username_1')).toBeUndefined();
+ });
+
+ it('should create username index when createIndexUserUsername is true', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserUsername: true },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === 'username_1')).toBeDefined();
+ });
+
+ it('should skip case-insensitive username index when createIndexUserUsernameCaseInsensitive is false', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserUsernameCaseInsensitive: false },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === 'case_insensitive_username')).toBeUndefined();
+ });
+
+ it('should create case-insensitive username index when createIndexUserUsernameCaseInsensitive is true', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserUsernameCaseInsensitive: true },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === 'case_insensitive_username')).toBeDefined();
+ });
+
+ it('should skip email index when createIndexUserEmail is false', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserEmail: false },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === 'email_1')).toBeUndefined();
+ });
+
+ it('should create email index when createIndexUserEmail is true', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserEmail: true },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === 'email_1')).toBeDefined();
+ });
+
+ it('should skip case-insensitive email index when createIndexUserEmailCaseInsensitive is false', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserEmailCaseInsensitive: false },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === 'case_insensitive_email')).toBeUndefined();
+ });
+
+ it('should create case-insensitive email index when createIndexUserEmailCaseInsensitive is true', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserEmailCaseInsensitive: true },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === 'case_insensitive_email')).toBeDefined();
+ });
+
+ it('should skip email verify token index when createIndexUserEmailVerifyToken is false', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserEmailVerifyToken: false },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeUndefined();
+ });
+
+ it('should create email verify token index when createIndexUserEmailVerifyToken is true', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserEmailVerifyToken: true },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeDefined();
+ });
+
+ it('should skip password reset token index when createIndexUserPasswordResetToken is false', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserPasswordResetToken: false },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeUndefined();
+ });
+
+ it('should create password reset token index when createIndexUserPasswordResetToken is true', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexUserPasswordResetToken: true },
+ });
+ const indexes = await getIndexes('_User');
+ expect(indexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeDefined();
+ });
+
+ it('should skip role name index when createIndexRoleName is false', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexRoleName: false },
+ });
+ const indexes = await getIndexes('_Role');
+ expect(indexes.find(idx => idx.name === 'name_1')).toBeUndefined();
+ });
+
+ it('should create role name index when createIndexRoleName is true', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: { createIndexRoleName: true },
+ });
+ const indexes = await getIndexes('_Role');
+ expect(indexes.find(idx => idx.name === 'name_1')).toBeDefined();
+ });
+
+ it('should create all indexes by default when options are undefined', async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI,
+ databaseOptions: {},
+ });
+
+ const userIndexes = await getIndexes('_User');
+ const roleIndexes = await getIndexes('_Role');
+
+ // Verify all indexes are created with default behavior (backward compatibility)
+ expect(userIndexes.find(idx => idx.name === 'username_1')).toBeDefined();
+ expect(userIndexes.find(idx => idx.name === 'case_insensitive_username')).toBeDefined();
+ expect(userIndexes.find(idx => idx.name === 'email_1')).toBeDefined();
+ expect(userIndexes.find(idx => idx.name === 'case_insensitive_email')).toBeDefined();
+ expect(userIndexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeDefined();
+ expect(userIndexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeDefined();
+ expect(roleIndexes.find(idx => idx.name === 'name_1')).toBeDefined();
+ });
+ });
});
diff --git a/spec/ParseConfigKey.spec.js b/spec/ParseConfigKey.spec.js
index a171032087..ae31ff954d 100644
--- a/spec/ParseConfigKey.spec.js
+++ b/spec/ParseConfigKey.spec.js
@@ -81,7 +81,8 @@ describe('Config Keys', () => {
connectTimeoutMS: 5000,
socketTimeoutMS: 5000,
autoSelectFamily: true,
- autoSelectFamilyAttemptTimeout: 3000
+ autoSelectFamilyAttemptTimeout: 3000,
+ disableIndexFieldValidation: true
},
})).toBeResolved();
expect(loggerErrorSpy.calls.all().reduce((s, call) => s += call.args[0], '')).not.toMatch(invalidKeyErrorMessage);
diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js
index cac3f448ce..aee3575079 100644
--- a/spec/ParseGraphQLServer.spec.js
+++ b/spec/ParseGraphQLServer.spec.js
@@ -607,6 +607,41 @@ describe('ParseGraphQLServer', () => {
]);
};
+ describe('Context', () => {
+ it('should support dependency injection on graphql api', async () => {
+ const requestContextMiddleware = (req, res, next) => {
+ req.config.aCustomController = 'aCustomController';
+ next();
+ };
+
+ let called;
+ const parseServer = await reconfigureServer({ requestContextMiddleware });
+ await createGQLFromParseServer(parseServer);
+ Parse.Cloud.beforeSave('_User', request => {
+ expect(request.config.aCustomController).toEqual('aCustomController');
+ called = true;
+ });
+
+ await apolloClient.query({
+ query: gql`
+ mutation {
+ createUser(input: { fields: { username: "test", password: "test" } }) {
+ user {
+ objectId
+ }
+ }
+ }
+ `,
+ context: {
+ headers: {
+ 'X-Parse-Master-Key': 'test',
+ },
+ }
+ })
+ expect(called).toBe(true);
+ })
+ })
+
describe('Introspection', () => {
it('should have public introspection disabled by default without master key', async () => {
diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js
index 10558b209d..cf65e2df47 100644
--- a/spec/ParseObject.spec.js
+++ b/spec/ParseObject.spec.js
@@ -1395,10 +1395,10 @@ describe('Parse.Object testing', () => {
.save()
.then(function () {
const query = new Parse.Query(TestObject);
- return query.find(object.id);
+ return query.get(object.id);
})
- .then(function (results) {
- updatedObject = results[0];
+ .then(function (result) {
+ updatedObject = result;
updatedObject.set('x', 11);
return updatedObject.save();
})
@@ -1409,7 +1409,8 @@ describe('Parse.Object testing', () => {
equal(object.createdAt.getTime(), updatedObject.createdAt.getTime());
equal(object.updatedAt.getTime(), updatedObject.updatedAt.getTime());
done();
- });
+ })
+ .catch(done.fail);
});
xit('fetchAll backbone-style callbacks', function (done) {
diff --git a/spec/ParseQuery.Comment.spec.js b/spec/ParseQuery.Comment.spec.js
index 7b37f2a2c2..df5b4aeac6 100644
--- a/spec/ParseQuery.Comment.spec.js
+++ b/spec/ParseQuery.Comment.spec.js
@@ -46,7 +46,7 @@ describe_only_db('mongo')('Parse.Query with comment testing', () => {
});
it('send comment with query through REST', async () => {
- const comment = 'Hello Parse';
+ const comment = `Hello Parse ${Date.now()}`;
const object = new TestObject();
object.set('name', 'object');
await object.save();
@@ -58,23 +58,55 @@ describe_only_db('mongo')('Parse.Query with comment testing', () => {
},
});
await request(options);
- const result = await database.collection('system.profile').findOne({}, { sort: { ts: -1 } });
+
+ // Wait for profile entry to appear with retry logic
+ let result;
+ const maxRetries = 10;
+ const retryDelay = 100;
+ for (let i = 0; i < maxRetries; i++) {
+ result = await database.collection('system.profile').findOne(
+ { 'command.explain.comment': comment },
+ { sort: { ts: -1 } }
+ );
+ if (result) {
+ break;
+ }
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
+ }
+
+ expect(result).toBeDefined();
expect(result.command.explain.comment).toBe(comment);
});
it('send comment with query', async () => {
- const comment = 'Hello Parse';
+ const comment = `Hello Parse ${Date.now()}`;
const object = new TestObject();
object.set('name', 'object');
await object.save();
const collection = await config.database.adapter._adaptiveCollection('TestObject');
await collection._rawFind({ name: 'object' }, { comment: comment });
- const result = await database.collection('system.profile').findOne({}, { sort: { ts: -1 } });
+
+ // Wait for profile entry to appear with retry logic
+ let result;
+ const maxRetries = 10;
+ const retryDelay = 100;
+ for (let i = 0; i < maxRetries; i++) {
+ result = await database.collection('system.profile').findOne(
+ { 'command.comment': comment },
+ { sort: { ts: -1 } }
+ );
+ if (result) {
+ break;
+ }
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
+ }
+
+ expect(result).toBeDefined();
expect(result.command.comment).toBe(comment);
});
it('send a comment with a count query', async () => {
- const comment = 'Hello Parse';
+ const comment = `Hello Parse ${Date.now()}`;
const object = new TestObject();
object.set('name', 'object');
await object.save();
@@ -86,12 +118,28 @@ describe_only_db('mongo')('Parse.Query with comment testing', () => {
const collection = await config.database.adapter._adaptiveCollection('TestObject');
const countResult = await collection.count({ name: 'object' }, { comment: comment });
expect(countResult).toEqual(2);
- const result = await database.collection('system.profile').findOne({}, { sort: { ts: -1 } });
+
+ // Wait for profile entry to appear with retry logic
+ let result;
+ const maxRetries = 10;
+ const retryDelay = 100;
+ for (let i = 0; i < maxRetries; i++) {
+ result = await database.collection('system.profile').findOne(
+ { 'command.comment': comment },
+ { sort: { ts: -1 } }
+ );
+ if (result) {
+ break;
+ }
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
+ }
+
+ expect(result).toBeDefined();
expect(result.command.comment).toBe(comment);
});
it('attach a comment to an aggregation', async () => {
- const comment = 'Hello Parse';
+ const comment = `Hello Parse ${Date.now()}`;
const object = new TestObject();
object.set('name', 'object');
await object.save();
@@ -100,7 +148,23 @@ describe_only_db('mongo')('Parse.Query with comment testing', () => {
explain: true,
comment: comment,
});
- const result = await database.collection('system.profile').findOne({}, { sort: { ts: -1 } });
+
+ // Wait for profile entry to appear with retry logic
+ let result;
+ const maxRetries = 10;
+ const retryDelay = 100;
+ for (let i = 0; i < maxRetries; i++) {
+ result = await database.collection('system.profile').findOne(
+ { 'command.explain.comment': comment },
+ { sort: { ts: -1 } }
+ );
+ if (result) {
+ break;
+ }
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
+ }
+
+ expect(result).toBeDefined();
expect(result.command.explain.comment).toBe(comment);
});
});
diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js
index 0e4039979a..98ef70564f 100644
--- a/spec/ParseQuery.spec.js
+++ b/spec/ParseQuery.spec.js
@@ -2113,6 +2113,16 @@ describe('Parse.Query testing', () => {
.then(done);
});
+ it_id('351f57a8-e00a-4da2-887d-6e25c9e359fc')(it)('regex with unicode option', async function () {
+ const thing = new TestObject();
+ thing.set('myString', 'hello 世界');
+ await Parse.Object.saveAll([thing]);
+ const query = new Parse.Query(TestObject);
+ query.matches('myString', '世界', 'u');
+ const results = await query.find();
+ equal(results.length, 1);
+ });
+
it_id('823852f6-1de5-45ba-a2b9-ed952fcc6012')(it)('Use a regex that requires all modifiers', function (done) {
const thing = new TestObject();
thing.set('myString', 'PArSe\nCom');
diff --git a/spec/ParseRelation.spec.js b/spec/ParseRelation.spec.js
index f0c746065d..98b4938433 100644
--- a/spec/ParseRelation.spec.js
+++ b/spec/ParseRelation.spec.js
@@ -517,7 +517,7 @@ describe('Parse.Relation testing', () => {
// Parent object is un-fetched, so this will call /1/classes/Car instead
// of /1/classes/Wheel and pass { "redirectClassNameForKey":"wheels" }.
- return query.find(origWheel.id);
+ return query.find();
})
.then(function (results) {
// Make sure this is Wheel and not Car.
diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js
index 35a91c6c15..95e6189a6a 100644
--- a/spec/ParseRole.spec.js
+++ b/spec/ParseRole.spec.js
@@ -601,4 +601,78 @@ describe('Parse Role testing', () => {
});
});
});
+
+ it('should trigger afterSave hook when using Parse.Role', async () => {
+ const afterSavePromise = new Promise(resolve => {
+ Parse.Cloud.afterSave(Parse.Role, req => {
+ expect(req.object).toBeDefined();
+ expect(req.object.get('name')).toBe('AnotherTestRole');
+ resolve();
+ });
+ });
+
+ const acl = new Parse.ACL();
+ acl.setPublicReadAccess(true);
+ const role = new Parse.Role('AnotherTestRole', acl);
+
+ const savedRole = await role.save({}, { useMasterKey: true });
+ expect(savedRole.id).toBeDefined();
+
+ await afterSavePromise;
+ });
+
+ it('should trigger beforeSave hook and allow modifying role in beforeSave', async () => {
+ Parse.Cloud.beforeSave(Parse.Role, req => {
+ // Add a custom field in beforeSave
+ req.object.set('customField', 'addedInBeforeSave');
+ });
+
+ const acl = new Parse.ACL();
+ acl.setPublicReadAccess(true);
+ const role = new Parse.Role('ModifiedRole', acl);
+
+ const savedRole = await role.save({}, { useMasterKey: true });
+ expect(savedRole.id).toBeDefined();
+ expect(savedRole.get('customField')).toBe('addedInBeforeSave');
+ });
+
+ it('should trigger beforeSave hook using Parse.Role', async () => {
+ let beforeSaveCalled = false;
+
+ Parse.Cloud.beforeSave(Parse.Role, req => {
+ beforeSaveCalled = true;
+ expect(req.object).toBeDefined();
+ expect(req.object.get('name')).toBe('BeforeSaveWithClassRef');
+ });
+
+ const acl = new Parse.ACL();
+ acl.setPublicReadAccess(true);
+ const role = new Parse.Role('BeforeSaveWithClassRef', acl);
+
+ const savedRole = await role.save({}, { useMasterKey: true });
+ expect(savedRole.id).toBeDefined();
+ expect(beforeSaveCalled).toBe(true);
+ });
+
+ it('should allow modifying role name in beforeSave hook', async () => {
+ Parse.Cloud.beforeSave(Parse.Role, req => {
+ // Modify the role name in beforeSave
+ if (req.object.get('name') === 'OriginalName') {
+ req.object.set('name', 'ModifiedName');
+ }
+ });
+
+ const acl = new Parse.ACL();
+ acl.setPublicReadAccess(true);
+ const role = new Parse.Role('OriginalName', acl);
+
+ const savedRole = await role.save({}, { useMasterKey: true });
+ expect(savedRole.id).toBeDefined();
+ expect(savedRole.get('name')).toBe('ModifiedName');
+
+ // Verify the name was actually saved to the database
+ const query = new Parse.Query(Parse.Role);
+ const fetchedRole = await query.get(savedRole.id, { useMasterKey: true });
+ expect(fetchedRole.get('name')).toBe('ModifiedName');
+ });
});
diff --git a/spec/eslint.config.js b/spec/eslint.config.js
index 6d280d08e3..4d23d3b649 100644
--- a/spec/eslint.config.js
+++ b/spec/eslint.config.js
@@ -25,6 +25,7 @@ module.exports = [
it_id: "readonly",
fit_id: "readonly",
it_only_db: "readonly",
+ fit_only_db: "readonly",
it_only_mongodb_version: "readonly",
it_only_postgres_version: "readonly",
it_only_node_version: "readonly",
diff --git a/spec/helper.js b/spec/helper.js
index 9c31053421..43b5ceeb81 100644
--- a/spec/helper.js
+++ b/spec/helper.js
@@ -7,6 +7,15 @@ const { SpecReporter } = require('jasmine-spec-reporter');
const SchemaCache = require('../lib/Adapters/Cache/SchemaCache').default;
const { sleep, Connections } = require('../lib/TestUtils');
+const originalFetch = global.fetch;
+let fetchWasMocked = false;
+
+global.restoreFetch = () => {
+ global.fetch = originalFetch;
+ fetchWasMocked = false;
+}
+
+
// Ensure localhost resolves to ipv4 address first on node v17+
if (dns.setDefaultResultOrder) {
dns.setDefaultResultOrder('ipv4first');
@@ -205,6 +214,7 @@ const reconfigureServer = async (changedConfiguration = {}) => {
};
beforeAll(async () => {
+ global.restoreFetch();
await reconfigureServer();
Parse.initialize('test', 'test', 'test');
Parse.serverURL = serverURL;
@@ -212,7 +222,18 @@ beforeAll(async () => {
Parse.CoreManager.set('REQUEST_ATTEMPT_LIMIT', 1);
});
+beforeEach(async () => {
+ if(fetchWasMocked) {
+ global.restoreFetch();
+ }
+});
+
global.afterEachFn = async () => {
+ // Restore fetch to prevent mock pollution between tests (only if it was mocked)
+ if (fetchWasMocked) {
+ global.restoreFetch();
+ }
+
Parse.Cloud._removeAllHooks();
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient();
defaults.protectedFields = { _User: { '*': ['email'] } };
@@ -251,6 +272,7 @@ global.afterEachFn = async () => {
afterEach(global.afterEachFn);
afterAll(() => {
+ global.restoreFetch();
global.displayTestStats();
});
@@ -388,9 +410,22 @@ function mockShortLivedAuth() {
}
function mockFetch(mockResponses) {
- global.fetch = jasmine.createSpy('fetch').and.callFake((url, options = { }) => {
+ const spy = jasmine.createSpy('fetch');
+ fetchWasMocked = true; // Track that fetch was mocked for cleanup
+
+ global.fetch = (url, options = {}) => {
+ // Allow requests to the Parse Server to pass through WITHOUT recording in spy
+ // This prevents tests from failing when they check that fetch wasn't called
+ // but the Parse SDK makes internal requests to the Parse Server
+ if (typeof url === 'string' && url.includes(serverURL)) {
+ return originalFetch(url, options);
+ }
+
+ // Record non-Parse-Server calls in the spy
+ spy(url, options);
+
options.method ||= 'GET';
- const mockResponse = mockResponses.find(
+ const mockResponse = mockResponses?.find(
(mock) => mock.url === url && mock.method === options.method
);
@@ -402,7 +437,11 @@ function mockFetch(mockResponses) {
ok: false,
statusText: 'Unknown URL or method',
});
- });
+ };
+
+ // Expose spy methods for test assertions
+ global.fetch.calls = spy.calls;
+ global.fetch.and = spy.and;
}
@@ -476,6 +515,17 @@ global.it_only_db = db => {
}
};
+global.fit_only_db = db => {
+ if (
+ process.env.PARSE_SERVER_TEST_DB === db ||
+ (!process.env.PARSE_SERVER_TEST_DB && db == 'mongo')
+ ) {
+ return fit;
+ } else {
+ return xit;
+ }
+};
+
global.it_only_mongodb_version = version => {
if (!semver.validRange(version)) {
throw new Error('Invalid version range');
diff --git a/spec/rest.spec.js b/spec/rest.spec.js
index fed64c988b..1fff4fad59 100644
--- a/spec/rest.spec.js
+++ b/spec/rest.spec.js
@@ -1139,3 +1139,25 @@ describe('read-only masterKey', () => {
});
});
});
+
+describe('rest context', () => {
+ it('should support dependency injection on rest api', async () => {
+ const requestContextMiddleware = (req, res, next) => {
+ req.config.aCustomController = 'aCustomController';
+ next();
+ };
+
+ let called
+ await reconfigureServer({ requestContextMiddleware });
+ Parse.Cloud.beforeSave('_User', request => {
+ expect(request.config.aCustomController).toEqual('aCustomController');
+ called = true;
+ });
+ const user = new Parse.User();
+ user.setUsername('test');
+ user.setPassword('test');
+ await user.signUp();
+
+ expect(called).toBe(true);
+ });
+});
diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js
index 167f3ff19a..7891fa847e 100644
--- a/spec/schemas.spec.js
+++ b/spec/schemas.spec.js
@@ -3008,6 +3008,7 @@ describe('schemas', () => {
beforeEach(async () => {
await TestUtils.destroyAllDataPermanently(false);
await config.database.adapter.performInitialization({ VolatileClassesSchemas: [] });
+ databaseAdapter.disableIndexFieldValidation = false;
});
it('cannot create index if field does not exist', done => {
@@ -3036,6 +3037,29 @@ describe('schemas', () => {
});
});
+ it('can create index if field does not exist with disableIndexFieldValidation true ', async () => {
+ databaseAdapter.disableIndexFieldValidation = true;
+ await request({
+ url: 'http://localhost:8378/1/schemas/NewClass',
+ method: 'POST',
+ headers: masterKeyHeaders,
+ json: true,
+ body: {},
+ });
+ const response = await request({
+ url: 'http://localhost:8378/1/schemas/NewClass',
+ method: 'PUT',
+ headers: masterKeyHeaders,
+ json: true,
+ body: {
+ indexes: {
+ name1: { aString: 1 },
+ },
+ },
+ });
+ expect(response.data.indexes.name1).toEqual({ aString: 1 });
+ });
+
it('can create index on default field', done => {
request({
url: 'http://localhost:8378/1/schemas/NewClass',
diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js
index f7a94cd221..0d66c0a135 100644
--- a/spec/vulnerabilities.spec.js
+++ b/spec/vulnerabilities.spec.js
@@ -175,12 +175,10 @@ describe('Vulnerabilities', () => {
},
});
});
- await expectAsync(new Parse.Object('TestObject').save()).toBeRejectedWith(
- new Parse.Error(
- Parse.Error.INVALID_KEY_NAME,
- 'Prohibited keyword in request data: {"key":"constructor"}.'
- )
- );
+ // The new Parse SDK handles prototype pollution prevention in .set()
+ // so no error is thrown, but the object prototype should not be polluted
+ await new Parse.Object('TestObject').save();
+ expect(Object.prototype.dummy).toBeUndefined();
});
it('denies creating global config with polluted data', async () => {
@@ -270,12 +268,10 @@ describe('Vulnerabilities', () => {
res.json({ success: object });
});
await Parse.Hooks.createTrigger('TestObject', 'beforeSave', hookServerURL + '/BeforeSave');
- await expectAsync(new Parse.Object('TestObject').save()).toBeRejectedWith(
- new Parse.Error(
- Parse.Error.INVALID_KEY_NAME,
- 'Prohibited keyword in request data: {"key":"constructor"}.'
- )
- );
+ // The new Parse SDK handles prototype pollution prevention in .set()
+ // so no error is thrown, but the object prototype should not be polluted
+ await new Parse.Object('TestObject').save();
+ expect(Object.prototype.dummy).toBeUndefined();
await new Promise(resolve => server.close(resolve));
});
diff --git a/src/Adapters/Files/GridFSBucketAdapter.js b/src/Adapters/Files/GridFSBucketAdapter.js
index 242fc08a0d..45a585ecc2 100644
--- a/src/Adapters/Files/GridFSBucketAdapter.js
+++ b/src/Adapters/Files/GridFSBucketAdapter.js
@@ -37,7 +37,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
const defaultMongoOptions = {
};
const _mongoOptions = Object.assign(defaultMongoOptions, mongoOptions);
- for (const key of ['enableSchemaHooks', 'schemaCacheTtl', 'maxTimeMS']) {
+ for (const key of ['enableSchemaHooks', 'schemaCacheTtl', 'maxTimeMS', 'disableIndexFieldValidation']) {
delete _mongoOptions[key];
}
this._mongoOptions = _mongoOptions;
diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js
index 2c82a2019c..39b335d52e 100644
--- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js
+++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js
@@ -140,6 +140,7 @@ export class MongoStorageAdapter implements StorageAdapter {
canSortOnJoinTables: boolean;
enableSchemaHooks: boolean;
schemaCacheTtl: ?number;
+ disableIndexFieldValidation: boolean;
constructor({ uri = defaults.DefaultMongoURI, collectionPrefix = '', mongoOptions = {} }: any) {
this._uri = uri;
@@ -152,8 +153,23 @@ export class MongoStorageAdapter implements StorageAdapter {
this.canSortOnJoinTables = true;
this.enableSchemaHooks = !!mongoOptions.enableSchemaHooks;
this.schemaCacheTtl = mongoOptions.schemaCacheTtl;
- for (const key of ['enableSchemaHooks', 'schemaCacheTtl', 'maxTimeMS']) {
- delete mongoOptions[key];
+ this.disableIndexFieldValidation = !!mongoOptions.disableIndexFieldValidation;
+ // Remove Parse Server-specific options that should not be passed to MongoDB client
+ // Note: We only delete from this._mongoOptions, not from the original mongoOptions object,
+ // because other components (like DatabaseController) need access to these options
+ for (const key of [
+ 'enableSchemaHooks',
+ 'schemaCacheTtl',
+ 'maxTimeMS',
+ 'disableIndexFieldValidation',
+ 'createIndexUserUsername',
+ 'createIndexUserUsernameCaseInsensitive',
+ 'createIndexUserEmail',
+ 'createIndexUserEmailCaseInsensitive',
+ 'createIndexUserEmailVerifyToken',
+ 'createIndexUserPasswordResetToken',
+ 'createIndexRoleName',
+ ]) {
delete this._mongoOptions[key];
}
}
@@ -289,6 +305,7 @@ export class MongoStorageAdapter implements StorageAdapter {
} else {
Object.keys(field).forEach(key => {
if (
+ !this.disableIndexFieldValidation &&
!Object.prototype.hasOwnProperty.call(
fields,
key.indexOf('_p_') === 0 ? key.replace('_p_', '') : key
@@ -684,6 +701,7 @@ export class MongoStorageAdapter implements StorageAdapter {
const defaultOptions: Object = { background: true, sparse: true };
const indexNameOptions: Object = indexName ? { name: indexName } : {};
const ttlOptions: Object = options.ttl !== undefined ? { expireAfterSeconds: options.ttl } : {};
+ const sparseOptions: Object = options.sparse !== undefined ? { sparse: options.sparse } : {};
const caseInsensitiveOptions: Object = caseInsensitive
? { collation: MongoCollection.caseInsensitiveCollation() }
: {};
@@ -692,6 +710,7 @@ export class MongoStorageAdapter implements StorageAdapter {
...caseInsensitiveOptions,
...indexNameOptions,
...ttlOptions,
+ ...sparseOptions,
};
return this._adaptiveCollection(className)
diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
index b13179553f..7eaafcbde2 100644
--- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
+++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
@@ -627,13 +627,11 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus
const distance = fieldValue.$maxDistance;
const distanceInKM = distance * 6371 * 1000;
patterns.push(
- `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${
- index + 2
+ `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${index + 2
})::geometry) <= $${index + 3}`
);
sorts.push(
- `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${
- index + 2
+ `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${index + 2
})::geometry) ASC`
);
values.push(fieldName, point.longitude, point.latitude, distanceInKM);
@@ -681,8 +679,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus
}
const distanceInKM = distance * 6371 * 1000;
patterns.push(
- `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${
- index + 2
+ `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${index + 2
})::geometry) <= $${index + 3}`
);
values.push(fieldName, point.longitude, point.latitude, distanceInKM);
@@ -862,19 +859,22 @@ export class PostgresStorageAdapter implements StorageAdapter {
_stream: any;
_uuid: any;
schemaCacheTtl: ?number;
+ disableIndexFieldValidation: boolean;
constructor({ uri, collectionPrefix = '', databaseOptions = {} }: any) {
const options = { ...databaseOptions };
this._collectionPrefix = collectionPrefix;
this.enableSchemaHooks = !!databaseOptions.enableSchemaHooks;
+ this.disableIndexFieldValidation = !!databaseOptions.disableIndexFieldValidation;
+
this.schemaCacheTtl = databaseOptions.schemaCacheTtl;
- for (const key of ['enableSchemaHooks', 'schemaCacheTtl']) {
+ for (const key of ['enableSchemaHooks', 'schemaCacheTtl', 'disableIndexFieldValidation']) {
delete options[key];
}
const { client, pgp } = createClient(uri, options);
this._client = client;
- this._onchange = () => {};
+ this._onchange = () => { };
this._pgp = pgp;
this._uuid = uuidv4();
this.canSortOnJoinTables = false;
@@ -991,7 +991,10 @@ export class PostgresStorageAdapter implements StorageAdapter {
delete existingIndexes[name];
} else {
Object.keys(field).forEach(key => {
- if (!Object.prototype.hasOwnProperty.call(fields, key)) {
+ if (
+ !this.disableIndexFieldValidation &&
+ !Object.prototype.hasOwnProperty.call(fields, key)
+ ) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
`Field ${key} does not exist, cannot add index.`
@@ -1006,8 +1009,22 @@ export class PostgresStorageAdapter implements StorageAdapter {
}
});
await conn.tx('set-indexes-with-schema-format', async t => {
- if (insertedIndexes.length > 0) {
- await self.createIndexes(className, insertedIndexes, t);
+ try {
+ if (insertedIndexes.length > 0) {
+ await self.createIndexes(className, insertedIndexes, t);
+ }
+ } catch (e) {
+ // pg-promise use Batch error see https://github.com/vitaly-t/spex/blob/e572030f261be1a8e9341fc6f637e36ad07f5231/src/errors/batch.js#L59
+ const columnDoesNotExistError = e.getErrors && e.getErrors()[0] && e.getErrors()[0].code === '42703';
+ // Specific case when the column does not exist
+ if (columnDoesNotExistError) {
+ // If the disableIndexFieldValidation is true, we should ignore the error
+ if (!this.disableIndexFieldValidation) {
+ throw e;
+ }
+ } else {
+ throw e;
+ }
}
if (deletedIndexes.length > 0) {
await self.dropIndexes(className, deletedIndexes, t);
@@ -1625,16 +1642,14 @@ export class PostgresStorageAdapter implements StorageAdapter {
index += 2;
} else if (fieldValue.__op === 'Remove') {
updatePatterns.push(
- `$${index}:name = array_remove(COALESCE($${index}:name, '[]'::jsonb), $${
- index + 1
+ `$${index}:name = array_remove(COALESCE($${index}:name, '[]'::jsonb), $${index + 1
}::jsonb)`
);
values.push(fieldName, JSON.stringify(fieldValue.objects));
index += 2;
} else if (fieldValue.__op === 'AddUnique') {
updatePatterns.push(
- `$${index}:name = array_add_unique(COALESCE($${index}:name, '[]'::jsonb), $${
- index + 1
+ `$${index}:name = array_add_unique(COALESCE($${index}:name, '[]'::jsonb), $${index + 1
}::jsonb)`
);
values.push(fieldName, JSON.stringify(fieldValue.objects));
@@ -1745,8 +1760,7 @@ export class PostgresStorageAdapter implements StorageAdapter {
updateObject = `COALESCE($${index}:name, '{}'::jsonb)`;
}
updatePatterns.push(
- `$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${
- index + 1 + keysToDelete.length
+ `$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + 1 + keysToDelete.length
}::jsonb )`
);
values.push(fieldName, ...keysToDelete, JSON.stringify(fieldValue));
@@ -2185,8 +2199,7 @@ export class PostgresStorageAdapter implements StorageAdapter {
groupByFields.push(`"${source}"`);
}
columns.push(
- `EXTRACT(${
- mongoAggregateToPostgres[operation]
+ `EXTRACT(${mongoAggregateToPostgres[operation]
} FROM $${index}:name AT TIME ZONE 'UTC')::integer AS $${index + 1}:name`
);
values.push(source, alias);
diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js
index 0050216e2c..f08bface5a 100644
--- a/src/Controllers/DatabaseController.js
+++ b/src/Controllers/DatabaseController.js
@@ -111,7 +111,7 @@ const validateQuery = (
Object.keys(query).forEach(key => {
if (query && query[key] && query[key].$regex) {
if (typeof query[key].$options === 'string') {
- if (!query[key].$options.match(/^[imxs]+$/)) {
+ if (!query[key].$options.match(/^[imxsu]+$/)) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
`Bad $options value for query: ${query[key].$options}`
@@ -593,7 +593,7 @@ class DatabaseController {
convertUsernameToLowercase(update, className, this.options);
transformAuthData(className, update, schema);
if (validateOnly) {
- return this.adapter.find(className, schema, query, {}).then(result => {
+ return this.adapter.find(className, schema, query, { readPreference: 'primary' }).then(result => {
if (!result || !result.length) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.');
}
@@ -1738,36 +1738,66 @@ class DatabaseController {
await this.loadSchema().then(schema => schema.enforceClassExists('_Role'));
await this.loadSchema().then(schema => schema.enforceClassExists('_Idempotency'));
- await this.adapter.ensureUniqueness('_User', requiredUserFields, ['username']).catch(error => {
- logger.warn('Unable to ensure uniqueness for usernames: ', error);
- throw error;
- });
+ const databaseOptions = this.options.databaseOptions || {};
+
+ if (databaseOptions.createIndexUserUsername !== false) {
+ await this.adapter.ensureUniqueness('_User', requiredUserFields, ['username']).catch(error => {
+ logger.warn('Unable to ensure uniqueness for usernames: ', error);
+ throw error;
+ });
+ }
if (!this.options.enableCollationCaseComparison) {
+ if (databaseOptions.createIndexUserUsernameCaseInsensitive !== false) {
+ await this.adapter
+ .ensureIndex('_User', requiredUserFields, ['username'], 'case_insensitive_username', true)
+ .catch(error => {
+ logger.warn('Unable to create case insensitive username index: ', error);
+ throw error;
+ });
+ }
+
+ if (databaseOptions.createIndexUserEmailCaseInsensitive !== false) {
+ await this.adapter
+ .ensureIndex('_User', requiredUserFields, ['email'], 'case_insensitive_email', true)
+ .catch(error => {
+ logger.warn('Unable to create case insensitive email index: ', error);
+ throw error;
+ });
+ }
+ }
+
+ if (databaseOptions.createIndexUserEmail !== false) {
+ await this.adapter.ensureUniqueness('_User', requiredUserFields, ['email']).catch(error => {
+ logger.warn('Unable to ensure uniqueness for user email addresses: ', error);
+ throw error;
+ });
+ }
+
+ if (databaseOptions.createIndexUserEmailVerifyToken !== false) {
await this.adapter
- .ensureIndex('_User', requiredUserFields, ['username'], 'case_insensitive_username', true)
+ .ensureIndex('_User', requiredUserFields, ['_email_verify_token'], '_email_verify_token', false)
.catch(error => {
- logger.warn('Unable to create case insensitive username index: ', error);
+ logger.warn('Unable to create index for email verification token: ', error);
throw error;
});
+ }
+ if (databaseOptions.createIndexUserPasswordResetToken !== false) {
await this.adapter
- .ensureIndex('_User', requiredUserFields, ['email'], 'case_insensitive_email', true)
+ .ensureIndex('_User', requiredUserFields, ['_perishable_token'], '_perishable_token', false)
.catch(error => {
- logger.warn('Unable to create case insensitive email index: ', error);
+ logger.warn('Unable to create index for password reset token: ', error);
throw error;
});
}
- await this.adapter.ensureUniqueness('_User', requiredUserFields, ['email']).catch(error => {
- logger.warn('Unable to ensure uniqueness for user email addresses: ', error);
- throw error;
- });
-
- await this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name']).catch(error => {
- logger.warn('Unable to ensure uniqueness for role name: ', error);
- throw error;
- });
+ if (databaseOptions.createIndexRoleName !== false) {
+ await this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name']).catch(error => {
+ logger.warn('Unable to ensure uniqueness for role name: ', error);
+ throw error;
+ });
+ }
await this.adapter
.ensureUniqueness('_Idempotency', requiredIdempotencyFields, ['reqId'])
diff --git a/src/Controllers/HooksController.js b/src/Controllers/HooksController.js
index 277104ef32..fef01946f6 100644
--- a/src/Controllers/HooksController.js
+++ b/src/Controllers/HooksController.js
@@ -188,6 +188,8 @@ function wrapToHTTPRequest(hook, key) {
return req => {
const jsonBody = {};
for (var i in req) {
+ // Parse Server config is not serializable
+ if (i === 'config') { continue; }
jsonBody[i] = req[i];
}
if (req.object) {
diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js
index 5e17b62642..bf7e14f7e2 100644
--- a/src/GraphQL/ParseGraphQLServer.js
+++ b/src/GraphQL/ParseGraphQLServer.js
@@ -128,6 +128,19 @@ class ParseGraphQLServer {
);
}
+ /**
+ * @static
+ * Allow developers to customize each request with inversion of control/dependency injection
+ */
+ applyRequestContextMiddleware(api, options) {
+ if (options.requestContextMiddleware) {
+ if (typeof options.requestContextMiddleware !== 'function') {
+ throw new Error('requestContextMiddleware must be a function');
+ }
+ api.use(this.config.graphQLPath, options.requestContextMiddleware);
+ }
+ }
+
applyGraphQL(app) {
if (!app || !app.use) {
requiredParameter('You must provide an Express.js app instance!');
@@ -135,6 +148,7 @@ class ParseGraphQLServer {
app.use(this.config.graphQLPath, corsMiddleware());
app.use(this.config.graphQLPath, handleParseHeaders);
app.use(this.config.graphQLPath, handleParseSession);
+ this.applyRequestContextMiddleware(app, this.parseServer.config);
app.use(this.config.graphQLPath, handleParseErrors);
app.use(
this.config.graphQLPath,
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index a23a0de3e5..d2f1e6eef1 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -28,6 +28,13 @@ module.exports.SchemaOptions = {
action: parsers.booleanParser,
default: false,
},
+ keepUnknownIndexes: {
+ env: 'PARSE_SERVER_SCHEMA_KEEP_UNKNOWN_INDEXES',
+ help:
+ "(Optional) Keep indexes that are present in the database but not defined in the schema. Set this to `true` if you are adding indexes manually, so that they won't be removed when running schema migration. Default is `false`.",
+ action: parsers.booleanParser,
+ default: false,
+ },
lockSchemas: {
env: 'PARSE_SERVER_SCHEMA_LOCK_SCHEMAS',
help:
@@ -289,7 +296,8 @@ module.exports.ParseServerOptions = {
},
graphQLPath: {
env: 'PARSE_SERVER_GRAPHQL_PATH',
- help: 'Mount path for the GraphQL endpoint, defaults to /graphql',
+ help:
+ 'The mount path for the GraphQL endpoint
\u26A0\uFE0F File upload inside the GraphQL mutation system requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.
Defaults is `/graphql`.',
default: '/graphql',
},
graphQLPublicIntrospection: {
@@ -507,6 +515,11 @@ module.exports.ParseServerOptions = {
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY',
help: 'Read-only key, which has the same capabilities as MasterKey without writes',
},
+ requestContextMiddleware: {
+ env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE',
+ help:
+ 'Options to customize the request context using inversion of control/dependency injection.',
+ },
requestKeywordDenylist: {
env: 'PARSE_SERVER_REQUEST_KEYWORD_DENYLIST',
help:
@@ -567,7 +580,8 @@ module.exports.ParseServerOptions = {
},
serverURL: {
env: 'PARSE_SERVER_URL',
- help: 'URL to your parse server with http:// or https://.',
+ help:
+ 'The URL to Parse Server.
\u26A0\uFE0F Certain server features or adapters may require Parse Server to be able to call itself by making requests to the URL set in `serverURL`. If a feature requires this, it is mentioned in the documentation. In that case ensure that the URL is accessible from the server itself.',
required: true,
},
sessionLength: {
@@ -604,6 +618,13 @@ module.exports.ParseServerOptions = {
help: 'Set the logging to verbose',
action: parsers.booleanParser,
},
+ verifyServerUrl: {
+ env: 'PARSE_SERVER_VERIFY_SERVER_URL',
+ help:
+ 'Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.
\u26A0\uFE0F Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.
Default is `true`.',
+ action: parsers.booleanParser,
+ default: true,
+ },
verifyUserEmails: {
env: 'PARSE_SERVER_VERIFY_USER_EMAILS',
help:
@@ -1080,6 +1101,61 @@ module.exports.DatabaseOptions = {
'The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.',
action: parsers.numberParser('connectTimeoutMS'),
},
+ createIndexRoleName: {
+ env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_ROLE_NAME',
+ help:
+ 'Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.
\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.',
+ action: parsers.booleanParser,
+ default: true,
+ },
+ createIndexUserEmail: {
+ env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_EMAIL',
+ help:
+ 'Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.',
+ action: parsers.booleanParser,
+ default: true,
+ },
+ createIndexUserEmailCaseInsensitive: {
+ env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_EMAIL_CASE_INSENSITIVE',
+ help:
+ 'Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.',
+ action: parsers.booleanParser,
+ default: true,
+ },
+ createIndexUserEmailVerifyToken: {
+ env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_EMAIL_VERIFY_TOKEN',
+ help:
+ 'Set to `true` to automatically create an index on the _email_verify_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.',
+ action: parsers.booleanParser,
+ default: true,
+ },
+ createIndexUserPasswordResetToken: {
+ env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_PASSWORD_RESET_TOKEN',
+ help:
+ 'Set to `true` to automatically create an index on the _perishable_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.',
+ action: parsers.booleanParser,
+ default: true,
+ },
+ createIndexUserUsername: {
+ env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_USERNAME',
+ help:
+ 'Set to `true` to automatically create indexes on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.',
+ action: parsers.booleanParser,
+ default: true,
+ },
+ createIndexUserUsernameCaseInsensitive: {
+ env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_USERNAME_CASE_INSENSITIVE',
+ help:
+ 'Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.',
+ action: parsers.booleanParser,
+ default: true,
+ },
+ disableIndexFieldValidation: {
+ env: 'PARSE_SERVER_DATABASE_DISABLE_INDEX_FIELD_VALIDATION',
+ help:
+ 'Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later.',
+ action: parsers.booleanParser,
+ },
enableSchemaHooks: {
env: 'PARSE_SERVER_DATABASE_ENABLE_SCHEMA_HOOKS',
help:
diff --git a/src/Options/docs.js b/src/Options/docs.js
index bfba129bb2..9e650b1038 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -4,6 +4,7 @@
* @property {Function} beforeMigration Execute a callback before running schema migrations.
* @property {Any} definitions Rest representation on Parse.Schema https://docs.parseplatform.org/rest/guide/#adding-a-schema
* @property {Boolean} deleteExtraFields Is true if Parse Server should delete any fields not defined in a schema definition. This should only be used during development.
+ * @property {Boolean} keepUnknownIndexes (Optional) Keep indexes that are present in the database but not defined in the schema. Set this to `true` if you are adding indexes manually, so that they won't be removed when running schema migration. Default is `false`.
* @property {Boolean} lockSchemas Is true if Parse Server will reject any attempts to modify the schema while the server is running.
* @property {Boolean} recreateModifiedFields Is true if Parse Server should recreate any fields that are different between the current database schema and theschema definition. This should only be used during development.
* @property {Boolean} strict Is true if Parse Server should exit if schema update fail.
@@ -52,7 +53,7 @@
* @property {String} fileKey Key for your files
* @property {Adapter} filesAdapter Adapter module for the files sub-system
* @property {FileUploadOptions} fileUpload Options for file uploads
- * @property {String} graphQLPath Mount path for the GraphQL endpoint, defaults to /graphql
+ * @property {String} graphQLPath The mount path for the GraphQL endpoint
⚠️ File upload inside the GraphQL mutation system requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.
Defaults is `/graphql`.
* @property {Boolean} graphQLPublicIntrospection Enable public introspection for the GraphQL endpoint, defaults to false
* @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
@@ -90,6 +91,7 @@
* @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications
* @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.
ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case.
* @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes
+ * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection.
* @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.
@@ -98,13 +100,14 @@
* @property {SecurityOptions} security The security options to identify and report weak security settings.
* @property {Boolean} sendUserEmailVerification Set to `false` to prevent sending of verification email. Supports a function with a return value of `true` or `false` for conditional email sending.
Default is `true`.
* @property {Function} serverCloseComplete Callback when server has closed
- * @property {String} serverURL URL to your parse server with http:// or https://.
+ * @property {String} serverURL The URL to Parse Server.
⚠️ Certain server features or adapters may require Parse Server to be able to call itself by making requests to the URL set in `serverURL`. If a feature requires this, it is mentioned in the documentation. In that case ensure that the URL is accessible from the server itself.
* @property {Number} sessionLength Session duration, in seconds, defaults to 1 year
* @property {Boolean} silent Disables console output
* @property {Boolean} startLiveQueryServer Starts the liveQuery server
* @property {Any} trustProxy The trust proxy settings. It is important to understand the exact setup of the reverse proxy, since this setting will trust values provided in the Parse Server API request. See the express trust proxy settings documentation. Defaults to `false`.
* @property {String[]} userSensitiveFields Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields
* @property {Boolean} verbose Set the logging to verbose
+ * @property {Boolean} verifyServerUrl Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.
⚠️ Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.
Default is `true`.
* @property {Boolean} verifyUserEmails Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification.
Default is `false`.
* @property {String} webhookKey Key sent with outgoing webhook calls
*/
@@ -240,6 +243,14 @@
* @property {Boolean} autoSelectFamily The MongoDB driver option to set whether the socket attempts to connect to IPv6 and IPv4 addresses until a connection is established. If available, the driver will select the first IPv6 address.
* @property {Number} autoSelectFamilyAttemptTimeout The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead.
* @property {Number} connectTimeoutMS The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.
+ * @property {Boolean} createIndexRoleName Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ * @property {Boolean} createIndexUserEmail Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ * @property {Boolean} createIndexUserEmailCaseInsensitive Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ * @property {Boolean} createIndexUserEmailVerifyToken Set to `true` to automatically create an index on the _email_verify_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ * @property {Boolean} createIndexUserPasswordResetToken Set to `true` to automatically create an index on the _perishable_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ * @property {Boolean} createIndexUserUsername Set to `true` to automatically create indexes on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ * @property {Boolean} createIndexUserUsernameCaseInsensitive Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ * @property {Boolean} disableIndexFieldValidation Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later.
* @property {Boolean} enableSchemaHooks Enables database real-time hooks to update single schema cache. Set to `true` if using multiple Parse Servers instances connected to the same database. Failing to do so will cause a schema change to not propagate to all instances and re-syncing will only happen when the instances restart. To use this feature with MongoDB, a replica set cluster with [change stream](https://docs.mongodb.com/manual/changeStreams/#availability) support is required.
* @property {Number} maxPoolSize The MongoDB driver option to set the maximum number of opened, cached, ready-to-use database connections maintained by the driver.
* @property {Number} maxStalenessSeconds The MongoDB driver option to set the maximum replication lag for reads from secondary nodes.
diff --git a/src/Options/index.js b/src/Options/index.js
index b1827d808a..42da7b2237 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -25,6 +25,9 @@ export interface SchemaOptions {
/* Is true if Parse Server will reject any attempts to modify the schema while the server is running.
:DEFAULT: false */
lockSchemas: ?boolean;
+ /* (Optional) Keep indexes that are present in the database but not defined in the schema. Set this to `true` if you are adding indexes manually, so that they won't be removed when running schema migration. Default is `false`.
+ :DEFAULT: false */
+ keepUnknownIndexes: ?boolean;
/* Execute a callback before running schema migrations. */
beforeMigration: ?() => void | Promise;
/* Execute a callback after running schema migrations. */
@@ -51,9 +54,12 @@ export interface ParseServerOptions {
masterKeyTtl: ?number;
/* (Optional) The maintenance key is used for modifying internal and read-only fields of Parse Server.
⚠️ This key is not intended to be used as part of a regular operation of Parse Server. This key is intended to conduct out-of-band changes such as one-time migrations or data correction tasks. Internal fields are not officially documented and may change at any time without publication in release changelogs. We strongly advice not to rely on internal fields as part of your regular operation and to investigate the implications of any planned changes *directly in the source code* of your current version of Parse Server. */
maintenanceKey: string;
- /* URL to your parse server with http:// or https://.
+ /* The URL to Parse Server.
⚠️ Certain server features or adapters may require Parse Server to be able to call itself by making requests to the URL set in `serverURL`. If a feature requires this, it is mentioned in the documentation. In that case ensure that the URL is accessible from the server itself.
:ENV: PARSE_SERVER_URL */
serverURL: string;
+ /* Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.
⚠️ Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.
Default is `true`.
+ :DEFAULT: true */
+ verifyServerUrl: ?boolean;
/* (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.
This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.
Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.
Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.
Defaults to `['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[]);
@@ -302,7 +308,7 @@ export interface ParseServerOptions {
:ENV: PARSE_SERVER_MOUNT_GRAPHQL
:DEFAULT: false */
mountGraphQL: ?boolean;
- /* Mount path for the GraphQL endpoint, defaults to /graphql
+ /* The mount path for the GraphQL endpoint
⚠️ File upload inside the GraphQL mutation system requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.
Defaults is `/graphql`.
:ENV: PARSE_SERVER_GRAPHQL_PATH
:DEFAULT: /graphql */
graphQLPath: ?string;
@@ -339,6 +345,8 @@ export interface ParseServerOptions {
/* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.
ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case.
:DEFAULT: [] */
rateLimit: ?(RateLimitOptions[]);
+ /* Options to customize the request context using inversion of control/dependency injection.*/
+ requestContextMiddleware: ?(req: any, res: any, next: any) => void;
}
export interface RateLimitOptions {
@@ -624,6 +632,29 @@ export interface DatabaseOptions {
autoSelectFamily: ?boolean;
/* The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead. */
autoSelectFamilyAttemptTimeout: ?number;
+ /* Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ :DEFAULT: true */
+ createIndexUserEmail: ?boolean;
+ /* Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ :DEFAULT: true */
+ createIndexUserEmailCaseInsensitive: ?boolean;
+ /* Set to `true` to automatically create an index on the _email_verify_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ :DEFAULT: true */
+ createIndexUserEmailVerifyToken: ?boolean;
+ /* Set to `true` to automatically create an index on the _perishable_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ :DEFAULT: true */
+ createIndexUserPasswordResetToken: ?boolean;
+ /* Set to `true` to automatically create indexes on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ :DEFAULT: true */
+ createIndexUserUsername: ?boolean;
+ /* Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ :DEFAULT: true */
+ createIndexUserUsernameCaseInsensitive: ?boolean;
+ /* Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.
⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+ :DEFAULT: true */
+ createIndexRoleName: ?boolean;
+ /* Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later. */
+ disableIndexFieldValidation: ?boolean;
}
export interface AuthAdapter {
diff --git a/src/ParseServer.ts b/src/ParseServer.ts
index c0df4f3431..04543ac1c3 100644
--- a/src/ParseServer.ts
+++ b/src/ParseServer.ts
@@ -279,12 +279,30 @@ class ParseServer {
}
}
+ /**
+ * @static
+ * Allow developers to customize each request with inversion of control/dependency injection
+ */
+ static applyRequestContextMiddleware(api, options) {
+ if (options.requestContextMiddleware) {
+ if (typeof options.requestContextMiddleware !== 'function') {
+ throw new Error('requestContextMiddleware must be a function');
+ }
+ api.use(options.requestContextMiddleware);
+ }
+ }
/**
* @static
* Create an express app for the parse server
* @param {Object} options let you specify the maxUploadSize when creating the express app */
static app(options) {
- const { maxUploadSize = '20mb', appId, directAccess, pages, rateLimit = [] } = options;
+ const {
+ maxUploadSize = '20mb',
+ appId,
+ directAccess,
+ pages,
+ rateLimit = [],
+ } = options;
// This app serves the Parse API directly.
// It's the equivalent of https://api.parse.com/1 in the hosted Parse API.
var api = express();
@@ -326,7 +344,7 @@ class ParseServer {
middlewares.addRateLimit(route, options);
}
api.use(middlewares.handleParseSession);
-
+ this.applyRequestContextMiddleware(api, options);
const appRouter = ParseServer.promiseRouter({ appId });
api.use(appRouter.expressRouter());
@@ -353,12 +371,6 @@ class ParseServer {
process.exit(1);
}
});
- // verify the server url after a 'mount' event is received
- /* istanbul ignore next */
- api.on('mount', async function () {
- await new Promise(resolve => setTimeout(resolve, 1000));
- ParseServer.verifyServerUrl();
- });
}
if (process.env.PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS === '1' || directAccess) {
Parse.CoreManager.setRESTController(ParseServerRESTController(appId, appRouter));
@@ -475,6 +487,9 @@ class ParseServer {
/* istanbul ignore next */
if (!process.env.TESTING) {
configureListeners(this);
+ if (options.verifyServerUrl !== false) {
+ await ParseServer.verifyServerUrl();
+ }
}
this.expressApp = app;
return this;
diff --git a/src/RestQuery.js b/src/RestQuery.js
index 621700984b..dd226f249c 100644
--- a/src/RestQuery.js
+++ b/src/RestQuery.js
@@ -50,6 +50,7 @@ async function RestQuery({
if (![RestQuery.Method.find, RestQuery.Method.get].includes(method)) {
throw new Parse.Error(Parse.Error.INVALID_QUERY, 'bad query type');
}
+ const isGet = method === RestQuery.Method.get;
enforceRoleSecurity(method, className, auth);
const result = runBeforeFind
? await triggers.maybeRunQueryTrigger(
@@ -60,7 +61,7 @@ async function RestQuery({
config,
auth,
context,
- method === RestQuery.Method.get
+ isGet
)
: Promise.resolve({ restWhere, restOptions });
@@ -72,7 +73,8 @@ async function RestQuery({
result.restOptions || restOptions,
clientSDK,
runAfterFind,
- context
+ context,
+ isGet
);
}
@@ -101,7 +103,8 @@ function _UnsafeRestQuery(
restOptions = {},
clientSDK,
runAfterFind = true,
- context
+ context,
+ isGet
) {
this.config = config;
this.auth = auth;
@@ -113,6 +116,7 @@ function _UnsafeRestQuery(
this.response = null;
this.findOptions = {};
this.context = context || {};
+ this.isGet = isGet;
if (!this.auth.isMaster) {
if (this.className == '_Session') {
if (!this.auth.user) {
@@ -914,7 +918,8 @@ _UnsafeRestQuery.prototype.runAfterFindTrigger = function () {
this.response.results,
this.config,
parseQuery,
- this.context
+ this.context,
+ this.isGet
)
.then(results => {
// Ensure we properly set the className back
diff --git a/src/RestWrite.js b/src/RestWrite.js
index 78dd8c8878..41b6c23468 100644
--- a/src/RestWrite.js
+++ b/src/RestWrite.js
@@ -1743,6 +1743,14 @@ RestWrite.prototype.buildParseObjects = function () {
const readOnlyAttributes = className.constructor.readOnlyAttributes
? className.constructor.readOnlyAttributes()
: [];
+
+ // For _Role class, 'name' cannot be set after the role has an objectId.
+ // In afterSave context, _handleSaveResponse has already set the objectId,
+ // so we treat 'name' as read-only to avoid Parse SDK validation errors.
+ const isRoleAfterSave = this.className === '_Role' && this.response && !this.query;
+ if (isRoleAfterSave && this.data.name && !readOnlyAttributes.includes('name')) {
+ readOnlyAttributes.push('name');
+ }
if (!this.originalData) {
for (const attribute of readOnlyAttributes) {
extraData[attribute] = this.data[attribute];
diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js
index 4c90ac2810..2e56a1b426 100644
--- a/src/Routers/FunctionsRouter.js
+++ b/src/Routers/FunctionsRouter.js
@@ -73,6 +73,7 @@ export class FunctionsRouter extends PromiseRouter {
headers: req.config.headers,
ip: req.config.ip,
jobName,
+ config: req.config,
message: jobHandler.setMessage.bind(jobHandler),
};
@@ -129,6 +130,7 @@ export class FunctionsRouter extends PromiseRouter {
params = parseParams(params, req.config);
const request = {
params: params,
+ config: req.config,
master: req.auth && req.auth.isMaster,
user: req.auth && req.auth.user,
installationId: req.info.installationId,
diff --git a/src/SchemaMigrations/DefinedSchemas.js b/src/SchemaMigrations/DefinedSchemas.js
index cf2b1761f4..0a94a5e4ad 100644
--- a/src/SchemaMigrations/DefinedSchemas.js
+++ b/src/SchemaMigrations/DefinedSchemas.js
@@ -349,7 +349,10 @@ export class DefinedSchemas {
Object.keys(cloudSchema.indexes).forEach(indexName => {
if (!this.isProtectedIndex(localSchema.className, indexName)) {
if (!localSchema.indexes || !localSchema.indexes[indexName]) {
- newLocalSchema.deleteIndex(indexName);
+ // If keepUnknownIndex is falsy, then delete all unknown indexes from the db.
+ if(!this.schemaOptions.keepUnknownIndexes){
+ newLocalSchema.deleteIndex(indexName);
+ }
} else if (
!this.paramsAreEquals(localSchema.indexes[indexName], cloudSchema.indexes[indexName])
) {
diff --git a/src/SchemaMigrations/Migrations.js b/src/SchemaMigrations/Migrations.js
index 8768911189..23499bdba7 100644
--- a/src/SchemaMigrations/Migrations.js
+++ b/src/SchemaMigrations/Migrations.js
@@ -6,6 +6,7 @@ export interface SchemaOptions {
deleteExtraFields: ?boolean;
recreateModifiedFields: ?boolean;
lockSchemas: ?boolean;
+ keepUnknownIndexes: ?boolean;
beforeMigration: ?() => void | Promise;
afterMigration: ?() => void | Promise;
}
diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js
index 3f33e5100d..fa982de8f3 100644
--- a/src/cloud-code/Parse.Cloud.js
+++ b/src/cloud-code/Parse.Cloud.js
@@ -670,6 +670,7 @@ module.exports = ParseCloud;
* @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...)
* @property {Object} log The current logger inside Parse Server.
* @property {Parse.Object} original If set, the object, as currently stored.
+ * @property {Object} config The Parse Server config.
*/
/**
@@ -684,6 +685,7 @@ module.exports = ParseCloud;
* @property {Object} headers The original HTTP headers for the request.
* @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`)
* @property {Object} log The current logger inside Parse Server.
+ * @property {Object} config The Parse Server config.
*/
/**
@@ -721,6 +723,7 @@ module.exports = ParseCloud;
* @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...)
* @property {Object} log The current logger inside Parse Server.
* @property {Boolean} isGet wether the query a `get` or a `find`
+ * @property {Object} config The Parse Server config.
*/
/**
@@ -734,6 +737,7 @@ module.exports = ParseCloud;
* @property {Object} headers The original HTTP headers for the request.
* @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...)
* @property {Object} log The current logger inside Parse Server.
+ * @property {Object} config The Parse Server config.
*/
/**
@@ -742,12 +746,14 @@ module.exports = ParseCloud;
* @property {Boolean} master If true, means the master key was used.
* @property {Parse.User} user If set, the user that made the request.
* @property {Object} params The params passed to the cloud function.
+ * @property {Object} config The Parse Server config.
*/
/**
* @interface Parse.Cloud.JobRequest
* @property {Object} params The params passed to the background job.
* @property {function} message If message is called with a string argument, will update the current message to be stored in the job status.
+ * @property {Object} config The Parse Server config.
*/
/**
diff --git a/src/rest.js b/src/rest.js
index 1f9dbacb73..8297121a68 100644
--- a/src/rest.js
+++ b/src/rest.js
@@ -23,11 +23,91 @@ function checkTriggers(className, config, types) {
function checkLiveQuery(className, config) {
return config.liveQueryController && config.liveQueryController.hasLiveQuery(className);
}
+async function runFindTriggers(
+ config,
+ auth,
+ className,
+ restWhere,
+ restOptions,
+ clientSDK,
+ context,
+ options = {}
+) {
+ const { isGet } = options;
-// Returns a promise for an object with optional keys 'results' and 'count'.
-const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => {
+ // Run beforeFind trigger - may modify query or return objects directly
+ const result = await triggers.maybeRunQueryTrigger(
+ triggers.Types.beforeFind,
+ className,
+ restWhere,
+ restOptions,
+ config,
+ auth,
+ context,
+ isGet
+ );
+
+ restWhere = result.restWhere || restWhere;
+ restOptions = result.restOptions || restOptions;
+
+ // Short-circuit path: beforeFind returned objects directly
+ // Security risk: These objects may have been fetched with master privileges
+ if (result?.objects) {
+ const objectsFromBeforeFind = result.objects;
+
+ let objectsForAfterFind = objectsFromBeforeFind;
+
+ // Security check: Re-filter objects if not master to ensure ACL/CLP compliance
+ if (!auth?.isMaster && !auth?.isMaintenance) {
+ const ids = (Array.isArray(objectsFromBeforeFind) ? objectsFromBeforeFind : [objectsFromBeforeFind])
+ .map(o => (o && (o.id || o.objectId)) || null)
+ .filter(Boolean);
+
+ // Objects without IDs are(normally) unsaved objects
+ // For unsaved objects, the ACL security does not apply, so no need to redo the query.
+ // For saved objects, we need to re-query to ensure proper ACL/CLP enforcement
+ if (ids.length > 0) {
+ const refilterWhere = isGet ? { objectId: ids[0] } : { objectId: { $in: ids } };
+
+ // Re-query with proper security: no triggers to avoid infinite loops
+ const refilterQuery = await RestQuery({
+ method: isGet ? RestQuery.Method.get : RestQuery.Method.find,
+ config,
+ auth,
+ className,
+ restWhere: refilterWhere,
+ restOptions,
+ clientSDK,
+ context,
+ runBeforeFind: false,
+ runAfterFind: false,
+ });
+
+ const refiltered = await refilterQuery.execute();
+ objectsForAfterFind = (refiltered && refiltered.results) || [];
+ }
+ }
+
+ // Run afterFind trigger on security-filtered objects
+ const afterFindProcessedObjects = await triggers.maybeRunAfterFindTrigger(
+ triggers.Types.afterFind,
+ auth,
+ className,
+ objectsForAfterFind,
+ config,
+ new Parse.Query(className).withJSON({ where: restWhere, ...restOptions }),
+ context,
+ isGet
+ );
+
+ return {
+ results: afterFindProcessedObjects,
+ };
+ }
+
+ // Normal path: execute database query with modified conditions
const query = await RestQuery({
- method: RestQuery.Method.find,
+ method: isGet ? RestQuery.Method.get : RestQuery.Method.find,
config,
auth,
className,
@@ -35,24 +115,40 @@ const find = async (config, auth, className, restWhere, restOptions, clientSDK,
restOptions,
clientSDK,
context,
+ runBeforeFind: false,
});
+
return query.execute();
+}
+
+// Returns a promise for an object with optional keys 'results' and 'count'.
+const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => {
+ enforceRoleSecurity('find', className, auth);
+ return runFindTriggers(
+ config,
+ auth,
+ 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) => {
- var restWhere = { objectId };
- const query = await RestQuery({
- method: RestQuery.Method.get,
+ enforceRoleSecurity('get', className, auth);
+ return runFindTriggers(
config,
auth,
className,
- restWhere,
+ { objectId },
restOptions,
clientSDK,
context,
- });
- return query.execute();
+ { isGet: true }
+ );
};
// Returns a promise that doesn't resolve to any useful value.
diff --git a/src/triggers.js b/src/triggers.js
index 2dfbeff7ac..26b107f062 100644
--- a/src/triggers.js
+++ b/src/triggers.js
@@ -182,8 +182,11 @@ export function toJSONwithObjects(object, className) {
}
toJSON[key] = val._toFullJSON();
}
+ // Preserve original object's className if no override className is provided
if (className) {
toJSON.className = className;
+ } else if (object.className && !toJSON.className) {
+ toJSON.className = object.className;
}
return toJSON;
}
@@ -257,7 +260,8 @@ export function getRequestObject(
parseObject,
originalParseObject,
config,
- context
+ context,
+ isGet
) {
const request = {
triggerName: triggerType,
@@ -266,8 +270,13 @@ export function getRequestObject(
log: config.loggerController,
headers: config.headers,
ip: config.ip,
+ config,
};
+ if (isGet !== undefined) {
+ request.isGet = !!isGet;
+ }
+
if (originalParseObject) {
request.original = originalParseObject;
}
@@ -312,6 +321,7 @@ export function getRequestQueryObject(triggerType, auth, query, count, config, c
headers: config.headers,
ip: config.ip,
context: context || {},
+ config,
};
if (!auth) {
@@ -437,69 +447,93 @@ function logTriggerErrorBeforeHook(triggerType, className, input, auth, error, l
export function maybeRunAfterFindTrigger(
triggerType,
auth,
- className,
- objects,
+ classNameQuery,
+ objectsInput,
config,
query,
- context
+ context,
+ isGet
) {
return new Promise((resolve, reject) => {
- const trigger = getTrigger(className, triggerType, config.applicationId);
+ const trigger = getTrigger(classNameQuery, triggerType, config.applicationId);
+
if (!trigger) {
- return resolve();
+ if (objectsInput && objectsInput.length > 0 && objectsInput[0] instanceof Parse.Object) {
+ return resolve(objectsInput.map(obj => toJSONwithObjects(obj)));
+ }
+ return resolve(objectsInput || []);
}
- const request = getRequestObject(triggerType, auth, null, null, config, context);
- if (query) {
+
+ const request = getRequestObject(triggerType, auth, null, null, config, context, isGet);
+ // Convert query parameter to Parse.Query instance
+ if (query instanceof Parse.Query) {
request.query = query;
+ } else if (typeof query === 'object' && query !== null) {
+ const parseQueryInstance = new Parse.Query(classNameQuery);
+ if (query.where) {
+ parseQueryInstance.withJSON(query);
+ }
+ request.query = parseQueryInstance;
+ } else {
+ request.query = new Parse.Query(classNameQuery);
}
+
const { success, error } = getResponseObject(
request,
- object => {
- resolve(object);
+ processedObjectsJSON => {
+ resolve(processedObjectsJSON);
},
- error => {
- reject(error);
+ errorData => {
+ reject(errorData);
}
);
logTriggerSuccessBeforeHook(
triggerType,
- className,
- 'AfterFind',
- JSON.stringify(objects),
+ classNameQuery,
+ 'AfterFind Input (Pre-Transform)',
+ JSON.stringify(
+ objectsInput.map(o => (o instanceof Parse.Object ? o.id + ':' + o.className : o))
+ ),
auth,
config.logLevels.triggerBeforeSuccess
);
- request.objects = objects.map(object => {
- //setting the class name to transform into parse object
- object.className = className;
- return Parse.Object.fromJSON(object);
+
+ // Convert plain objects to Parse.Object instances for trigger
+ request.objects = objectsInput.map(currentObject => {
+ if (currentObject instanceof Parse.Object) {
+ return currentObject;
+ }
+ // Preserve the original className if it exists, otherwise use the query className
+ const originalClassName = currentObject.className || classNameQuery;
+ const tempObjectWithClassName = { ...currentObject, className: originalClassName };
+ return Parse.Object.fromJSON(tempObjectWithClassName);
});
return Promise.resolve()
.then(() => {
- return maybeRunValidator(request, `${triggerType}.${className}`, auth);
+ return maybeRunValidator(request, `${triggerType}.${classNameQuery}`, auth);
})
.then(() => {
if (request.skipWithMasterKey) {
return request.objects;
}
- const response = trigger(request);
- if (response && typeof response.then === 'function') {
- return response.then(results => {
+ const responseFromTrigger = trigger(request);
+ if (responseFromTrigger && typeof responseFromTrigger.then === 'function') {
+ return responseFromTrigger.then(results => {
return results;
});
}
- return response;
+ return responseFromTrigger;
})
.then(success, error);
- }).then(results => {
+ }).then(resultsAsJSON => {
logTriggerAfterHook(
triggerType,
- className,
- JSON.stringify(results),
+ classNameQuery,
+ JSON.stringify(resultsAsJSON),
auth,
config.logLevels.triggerAfter
);
- return results;
+ return resultsAsJSON;
});
}
@@ -607,9 +641,19 @@ export function maybeRunQueryTrigger(
restOptions = restOptions || {};
restOptions.subqueryReadPreference = requestObject.subqueryReadPreference;
}
+ let objects = undefined;
+ if (result instanceof Parse.Object) {
+ objects = [result];
+ } else if (
+ Array.isArray(result) &&
+ (!result.length || result.every(obj => obj instanceof Parse.Object))
+ ) {
+ objects = result;
+ }
return {
restWhere,
restOptions,
+ objects,
};
},
err => {
@@ -976,6 +1020,7 @@ export function getRequestFileObject(triggerType, auth, fileObject, config) {
log: config.loggerController,
headers: config.headers,
ip: config.ip,
+ config,
};
if (!auth) {
diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts
index ac1c71e886..7a572a2f10 100644
--- a/types/Options/index.d.ts
+++ b/types/Options/index.d.ts
@@ -122,6 +122,7 @@ export interface ParseServerOptions {
allowExpiredAuthDataToken?: boolean;
requestKeywordDenylist?: (RequestKeywordDenylist[]);
rateLimit?: (RateLimitOptions[]);
+ verifyServerUrl?: boolean;
}
export interface RateLimitOptions {
requestPath: string;