diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index bb6766d8aa..0000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,45 +0,0 @@ - - -### Issue Description - - - -### Steps to reproduce - - - -#### Expected Results - - - -#### Actual Outcome - - - -### Environment Setup - -- **Server** - - parse-server version (Be specific! Don't say 'latest'.) : [FILL THIS OUT] - - Operating System: [FILL THIS OUT] - - Hardware: [FILL THIS OUT] - - Localhost or remote server? (AWS, Heroku, Azure, Digital Ocean, etc): [FILL THIS OUT] - -- **Database** - - MongoDB version: [FILL THIS OUT] - - Storage engine: [FILL THIS OUT] - - Hardware: [FILL THIS OUT] - - Localhost or remote server? (AWS, mLab, ObjectRocket, Digital Ocean, etc): [FILL THIS OUT] - -### Logs/Trace - -Include all relevant logs. You can turn on additional logging by configuring VERBOSE=1 in your environment. diff --git a/.github/ISSUE_TEMPLATE/---1-report-an-issue.md b/.github/ISSUE_TEMPLATE/---1-report-an-issue.md new file mode 100644 index 0000000000..dbfa97106a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---1-report-an-issue.md @@ -0,0 +1,48 @@ +--- +name: "\U0001F41B Report an issue" +about: A feature of Parse Server is not working as expected. +title: '' +labels: '' +assignees: '' + +--- + +### New Issue Checklist + + +- [ ] I am not disclosing a [vulnerability](https://github.com/parse-community/parse-server/blob/master/SECURITY.md). +- [ ] I am not just asking a [question](https://github.com/parse-community/.github/blob/master/SUPPORT.md). +- [ ] I have searched through [existing issues](https://github.com/parse-community/parse-server/issues?q=is%3Aissue). +- [ ] I can reproduce the issue with the [latest version of Parse Server](https://github.com/parse-community/parse-server/releases). + +### Issue Description + + +### Steps to reproduce + + +### Actual Outcome + + +### Expected Outcome + + +### Environment + + +Server +- Parse Server version: `FILL_THIS_OUT` +- Operating system: `FILL_THIS_OUT` +- Local or remote host (AWS, Azure, Google Cloud, Heroku, Digital Ocean, etc): `FILL_THIS_OUT` + +Database +- System (MongoDB or Postgres): `FILL_THIS_OUT` +- Database version: `FILL_THIS_OUT` +- Local or remote host (MongoDB Atlas, mLab, AWS, Azure, Google Cloud, etc): `FILL_THIS_OUT` + +Client +- SDK (iOS, Android, JavaScript, PHP, Unity, etc): `FILL_THIS_OUT` +- SDK version: `FILL_THIS_OUT` + +### Logs + diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---2-feature-request.md similarity index 76% rename from .github/ISSUE_TEMPLATE/---feature-request.md rename to .github/ISSUE_TEMPLATE/---2-feature-request.md index a19d228731..c2756fb952 100644 --- a/.github/ISSUE_TEMPLATE/---feature-request.md +++ b/.github/ISSUE_TEMPLATE/---2-feature-request.md @@ -1,6 +1,9 @@ --- -name: "\U0001F4A1 Feature request" -about: Suggest an idea for this project +name: "\U0001F4A1 Request a feature" +about: Suggest new functionality or an enhancement of existing functionality. +title: '' +labels: '' +assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/---getting-help.md b/.github/ISSUE_TEMPLATE/---getting-help.md deleted file mode 100644 index 331bb3021e..0000000000 --- a/.github/ISSUE_TEMPLATE/---getting-help.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: "🙋‍Getting Help" -about: Join https://community.parseplatform.org - ---- diff --git a/.github/ISSUE_TEMPLATE/---parse-server-3-0-0.md b/.github/ISSUE_TEMPLATE/---parse-server-3-0-0.md deleted file mode 100644 index 49e9f447ff..0000000000 --- a/.github/ISSUE_TEMPLATE/---parse-server-3-0-0.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: "\U0001F525 parse-server 3.0.0" -about: Report an issue while migrating to parse-server 3.0.0 - ---- - - - -# Before opening the issue please ensure that you have: - -- [ ] [Read the migration guide](https://github.com/parse-community/parse-server/blob/master/3.0.0.md) to parse-server 3.0.0 -- [ ] [Read the migration guide](https://github.com/parse-community/Parse-SDK-JS/blob/master/2.0.0.md) to Parse SDK JS 2.0.0 - -### Issue Description - - - -### Steps to reproduce - - - -### Expected Results - - - -### Actual Outcome - - - -### Environment Setup - -- **Server** - - parse-server version (Be specific! Don't say 'latest'.) : [FILL THIS OUT] - - Operating System: [FILL THIS OUT] - - Hardware: [FILL THIS OUT] - - Localhost or remote server? (AWS, Heroku, Azure, Digital Ocean, etc): [FILL THIS OUT] - -- **Database** - - MongoDB version: [FILL THIS OUT] - - Storage engine: [FILL THIS OUT] - - Hardware: [FILL THIS OUT] - - Localhost or remote server? (AWS, mLab, ObjectRocket, Digital Ocean, etc): [FILL THIS OUT] - -### Logs/Trace - - diff --git a/.github/ISSUE_TEMPLATE/---push-notifications.md b/.github/ISSUE_TEMPLATE/---push-notifications.md deleted file mode 100644 index 43998b70f8..0000000000 --- a/.github/ISSUE_TEMPLATE/---push-notifications.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: "\U0001F4F2 Push Notifications" -about: Issues with setting up or delivering push notifications - ---- - - - -### Issue Description - - - -### Push Configuration - -Please provide a copy of your `push` configuration here, obfuscating any sensitive part. - -### Environment Setup - -- **Server** - - parse-server version (Be specific! Don't say 'latest'.) : [FILL THIS OUT] - - Operating System: [FILL THIS OUT] - - Hardware: [FILL THIS OUT] - - Localhost or remote server? (AWS, Heroku, Azure, Digital Ocean, etc): [FILL THIS OUT] - -- **Database** - - MongoDB version: [FILL THIS OUT] - - Storage engine: [FILL THIS OUT] - - Hardware: [FILL THIS OUT] - - Localhost or remote server? (AWS, mLab, ObjectRocket, Digital Ocean, etc): [FILL THIS OUT] - -### Logs/Trace - - - diff --git a/.github/ISSUE_TEMPLATE/---report-an-issue.md b/.github/ISSUE_TEMPLATE/---report-an-issue.md deleted file mode 100644 index 78583e73d3..0000000000 --- a/.github/ISSUE_TEMPLATE/---report-an-issue.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: "\U0001F41B Report an issue" -about: Report an issue on parse-server - ---- - - -### Issue Description - - - -### Steps to reproduce - - - -### Expected Results - - - -### Actual Outcome - - - -### Environment Setup - -- **Server** - - parse-server version (Be specific! Don't say 'latest'.) : [FILL THIS OUT] - - Operating System: [FILL THIS OUT] - - Hardware: [FILL THIS OUT] - - Localhost or remote server? (AWS, Heroku, Azure, Digital Ocean, etc): [FILL THIS OUT] - -- **Database** - - MongoDB version: [FILL THIS OUT] - - Storage engine: [FILL THIS OUT] - - Hardware: [FILL THIS OUT] - - Localhost or remote server? (AWS, mLab, ObjectRocket, Digital Ocean, etc): [FILL THIS OUT] - -### Logs/Trace - - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..e5a8c3caa9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: 🙋🏽‍♀️ Getting help with code + url: https://stackoverflow.com/questions/tagged/parse-platform + about: Get help with code-level questions on Stack Overflow. + - name: 🙋 Getting general help + url: https://community.parseplatform.org + about: Get help with other questions on our Community Forum. diff --git a/.github/MigrationPhases.png b/.github/MigrationPhases.png deleted file mode 100644 index dfaca26604..0000000000 Binary files a/.github/MigrationPhases.png and /dev/null differ diff --git a/.github/stale.yml b/.github/stale.yml index e08c0e16f9..6807ed061f 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -17,7 +17,7 @@ exemptLabels: - security - up-for-grabs # Label to use when marking an issue as stale -staleLabel: wontfix +staleLabel: stale # Limit to only `issues` not `pulls` only: issues # Comment to post when marking an issue as stale. Set to `false` to disable diff --git a/CHANGELOG.md b/CHANGELOG.md index d719f9fca2..4f54fdaf7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,47 @@ ## Parse Server Changelog ### master -[Full Changelog](https://github.com/parse-community/parse-server/compare/4.2.0...master) +[Full Changelog](https://github.com/parse-community/parse-server/compare/4.3.0...master) +- IMPROVE: Optimized deletion of class field from schema by using an index if available to do an index scan instead of a collection scan. [#6815](https://github.com/parse-community/parse-server/issues/6815). Thanks to [Manuel Trezza](https://github.com/mtrezza). + +### 4.3.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/4.2.0...4.3.0) +- PERFORMANCE: Optimizing pointer CLP query decoration done by DatabaseController#addPointerPermissions [#6747](https://github.com/parse-community/parse-server/pull/6747). Thanks to [mess-lelouch](https://github.com/mess-lelouch). +- SECURITY: Fix security breach on GraphQL viewer [78239ac](https://github.com/parse-community/parse-server/commit/78239ac9071167fdf243c55ae4bc9a2c0b0d89aa), [secuity advisory](https://github.com/parse-community/parse-server/security/advisories/GHSA-236h-rqv8-8q73). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: Save context not present if direct access enabled [#6764](https://github.com/parse-community/parse-server/pull/6764). Thanks to [Omair Vaiyani](https://github.com/omairvaiyani). +- NEW: Before Connect + Before Subscribe [#6793](https://github.com/parse-community/parse-server/pull/6793). Thanks to [dblythy](https://github.com/dblythy). +- FIX: Add version to playground to fix CDN [#6804](https://github.com/parse-community/parse-server/pull/6804). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- NEW (EXPERIMENTAL): Idempotency enforcement for client requests. This deduplicates requests where the client intends to send one request to Parse Server but due to network issues the server receives the request multiple times. **Caution, this is an experimental feature that may not be appropriate for production.** [#6748](https://github.com/parse-community/parse-server/issues/6748). Thanks to [Manuel Trezza](https://github.com/mtrezza). +- FIX: Add production Google Auth Adapter instead of using the development url [#6734](https://github.com/parse-community/parse-server/pull/6734). Thanks to [SebC.](https://github.com/SebC99). +- IMPROVE: Run Prettier JS Again Without requiring () on arrow functions [#6796](https://github.com/parse-community/parse-server/pull/6796). Thanks to [Diamond Lewis](https://github.com/dplewis). +- IMPROVE: Run Prettier JS [#6795](https://github.com/parse-community/parse-server/pull/6795). Thanks to [Diamond Lewis](https://github.com/dplewis). +- IMPROVE: Replace bcrypt with @node-rs/bcrypt [#6794](https://github.com/parse-community/parse-server/pull/6794). Thanks to [LongYinan](https://github.com/Brooooooklyn). +- IMPROVE: Make clear description of anonymous user [#6655](https://github.com/parse-community/parse-server/pull/6655). Thanks to [Jerome De Leon](https://github.com/JeromeDeLeon). +- IMPROVE: Simplify GraphQL merge system to avoid js ref bugs [#6791](https://github.com/parse-community/parse-server/pull/6791). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- NEW: Pass context in beforeDelete, afterDelete, beforeFind and Parse.Cloud.run [#6666](https://github.com/parse-community/parse-server/pull/6666). Thanks to [yog27ray](https://github.com/yog27ray). +- NEW: Allow passing custom gql schema function to ParseServer#start options [#6762](https://github.com/parse-community/parse-server/pull/6762). Thanks to [Luca](https://github.com/lucatk). +- NEW: Allow custom cors origin header [#6772](https://github.com/parse-community/parse-server/pull/6772). Thanks to [Kevin Yao](https://github.com/kzmeyao). +- FIX: Fix context for cascade-saving and saving existing object [#6735](https://github.com/parse-community/parse-server/pull/6735). Thanks to [Manuel](https://github.com/mtrezza). +- NEW: Add file bucket encryption using fileKey [#6765](https://github.com/parse-community/parse-server/pull/6765). Thanks to [Corey Baker](https://github.com/cbaker6). +- FIX: Removed gaze from dev dependencies and removed not working dev script [#6745](https://github.com/parse-community/parse-server/pull/6745). Thanks to [Vincent Semrau](https://github.com/vince1995). +- IMPROVE: Upgrade graphql-tools to v6 [#6701](https://github.com/parse-community/parse-server/pull/6701). Thanks to [Yaacov Rydzinski](https://github.com/yaacovCR). +- NEW: Support Metadata in GridFSAdapter [#6660](https://github.com/parse-community/parse-server/pull/6660). Thanks to [Diamond Lewis](https://github.com/dplewis). +- NEW: Allow to unset file from graphql [#6651](https://github.com/parse-community/parse-server/pull/6651). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- NEW: Handle shutdown for RedisCacheAdapter [#6658](https://github.com/parse-community/parse-server/pull/6658). Thanks to [promisenxu](https://github.com/promisenxu). +- FIX: Fix explain on user class [#6650](https://github.com/parse-community/parse-server/pull/6650). Thanks to [Manuel](https://github.com/mtrezza). +- FIX: Fix read preference for aggregate [#6585](https://github.com/parse-community/parse-server/pull/6585). Thanks to [Manuel](https://github.com/mtrezza). +- NEW: Add context to Parse.Object.save [#6626](https://github.com/parse-community/parse-server/pull/6626). Thanks to [Manuel](https://github.com/mtrezza). +- NEW: Adding ssl config params to Postgres URI [#6580](https://github.com/parse-community/parse-server/pull/6580). Thanks to [Corey Baker](https://github.com/cbaker6). +- FIX: Travis postgres update: removing unnecessary start of mongo-runner [#6594](https://github.com/parse-community/parse-server/pull/6594). Thanks to [Corey Baker](https://github.com/cbaker6). +- FIX: ObjectId size for Pointer in Postgres [#6619](https://github.com/parse-community/parse-server/pull/6619). Thanks to [Corey Baker](https://github.com/cbaker6). +- IMPROVE: Improve a test case [#6629](https://github.com/parse-community/parse-server/pull/6629). Thanks to [Gordon Sun](https://github.com/sunshineo). +- NEW: Allow to resolve automatically Parse Type fields from Custom Schema [#6562](https://github.com/parse-community/parse-server/pull/6562). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: Remove wrong console log in test [#6627](https://github.com/parse-community/parse-server/pull/6627). Thanks to [Gordon Sun](https://github.com/sunshineo). +- IMPROVE: Graphql tools v5 [#6611](https://github.com/parse-community/parse-server/pull/6611). Thanks to [Yaacov Rydzinski](https://github.com/yaacovCR). +- FIX: Catch JSON.parse and return 403 properly [#6589](https://github.com/parse-community/parse-server/pull/6589). Thanks to [Gordon Sun](https://github.com/sunshineo). +- PERFORMANCE: Allow covering relation queries with minimal index [#6581](https://github.com/parse-community/parse-server/pull/6581). Thanks to [Noah Silas](https://github.com/noahsilas). +- FIX: Fix Postgres group aggregation [#6522](https://github.com/parse-community/parse-server/pull/6522). Thanks to [Siddharth Ramesh](https://github.com/srameshr). +- NEW: Allow set user mapped from JWT directly on request [#6411](https://github.com/parse-community/parse-server/pull/6411). Thanks to [Gordon Sun](https://github.com/sunshineo). ### 4.2.0 [Full Changelog](https://github.com/parse-community/parse-server/compare/4.1.0...4.2.0) diff --git a/README.md b/README.md index 51338bcc31..ed3dab8f86 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,39 @@ Parse Server allows developers to choose from several options when hosting files `GridFSBucketAdapter` is used by default and requires no setup, but if you're interested in using S3 or Google Cloud Storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters). +### Idempodency Enforcement + +**Caution, this is an experimental feature that may not be appropriate for production.** + +This feature deduplicates identical requests that are received by Parse Server mutliple times, typically due to network issues or network adapter access restrictions on mobile operating systems. + +Identical requests are identified by their request header `X-Parse-Request-Id`. Therefore a client request has to include this header for deduplication to be applied. Requests that do not contain this header cannot be deduplicated and are processed normally by Parse Server. This means rolling out this feature to clients is seamless as Parse Server still processes request without this header when this feature is enbabled. + +> This feature needs to be enabled on the client side to send the header and on the server to process the header. Refer to the specific Parse SDK docs to see whether the feature is supported yet. + +Deduplication is only done for object creation and update (`POST` and `PUT` requests). Deduplication is not done for object finding and deletion (`GET` and `DELETE` requests), as these operations are already idempotent by definition. + +#### Configuration example +``` +let api = new ParseServer({ + idempotencyOptions: { + paths: [".*"], // enforce for all requests + ttl: 120 // keep request IDs for 120s + } +} +``` +#### Parameters + +| Parameter | Optional | Type | Default value | Example values | Environment variable | Description | +|-----------|----------|--------|---------------|-----------|-----------|-------------| +| `idempotencyOptions` | yes | `Object` | `undefined` | | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS | Setting this enables idempotency enforcement for the specified paths. | +| `idempotencyOptions.paths`| yes | `Array` | `[]` | `.*` (all paths, includes the examples below),
`functions/.*` (all functions),
`jobs/.*` (all jobs),
`classes/.*` (all classes),
`functions/.*` (all functions),
`users` (user creation / update),
`installations` (installation creation / update) | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_PATHS | An array of path patterns that have to match the request path for request deduplication to be enabled. The mount path must not be included, for example to match the request path `/parse/functions/myFunction` specifiy the path pattern `functions/myFunction`. A trailing slash of the request path is ignored, for example the path pattern `functions/myFunction` matches both `/parse/functions/myFunction` and `/parse/functions/myFunction/`. | +| `idempotencyOptions.ttl` | yes | `Integer` | `300` | `60` (60 seconds) | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_TTL | The duration in seconds after which a request record is discarded from the database. Duplicate requests due to network issues can be expected to arrive within milliseconds up to several seconds. This value must be greater than `0`. | + +#### Notes + +- This feature is currently only available for MongoDB and not for Postgres. + ### Logging Parse Server will, by default, log: @@ -733,18 +766,6 @@ All the Cloud Code interfaces also have been updated to reflect those changes, a We have written up a [migration guide](3.0.0.md), hoping this will help you transition to the next major release. -# Support - -Please take a look at our [support document](https://github.com/parse-community/.github/blob/master/SUPPORT.md). - -If you believe you've found an issue with Parse Server, make sure these boxes are checked before [reporting an issue](https://github.com/parse-community/parse-server/issues): - -- [ ] You've met the [prerequisites](http://docs.parseplatform.org/parse-server/guide/#prerequisites). - -- [ ] You're running the [latest version](https://github.com/parse-community/parse-server/releases) of Parse Server. - -- [ ] You've searched through [existing issues](https://github.com/parse-community/parse-server/issues?utf8=%E2%9C%93&q=). Chances are that your issue has been reported or resolved before. - # Want to ride the bleeding edge? It is recommend to use builds deployed npm for many reasons, but if you want to use diff --git a/package-lock.json b/package-lock.json index c94de248dc..a9220cf3ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "4.2.0", + "version": "4.3.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -47,6 +47,11 @@ "xss": "^1.0.6" } }, + "@ardatan/aggregate-error": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@ardatan/aggregate-error/-/aggregate-error-0.0.1.tgz", + "integrity": "sha512-UQ9BequOTIavs0pTHLMwQwKQF8tTV1oezY/H2O9chA+JNPFZSua55xpU5dPSjAU9/jLJ1VwU+HJuTVN8u7S6Fg==" + }, "@babel/cli": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.10.0.tgz", @@ -2715,16 +2720,25 @@ } }, "@graphql-tools/delegate": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-6.0.10.tgz", - "integrity": "sha512-FBHrmpSI9QpNbvqc5D4wdQW0WrNVUA2ylFhzsNRk9yvlKzcVKqiTrOpb++j7TLB+tG06dpSkfAssPcgZvU60fw==", + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-6.0.12.tgz", + "integrity": "sha512-52bac1Ct1s0c8aSTVCbnc5FI2LC+NqUFSs+5/mP1k5hIEW2GROGBeZdbRs2GQaHir1vKUYIyHzlZIIBMzOZ/gA==", "requires": { - "@graphql-tools/schema": "6.0.10", - "@graphql-tools/utils": "6.0.10", - "aggregate-error": "3.0.1", + "@ardatan/aggregate-error": "0.0.1", + "@graphql-tools/schema": "6.0.12", + "@graphql-tools/utils": "6.0.12", "tslib": "~2.0.0" }, "dependencies": { + "@graphql-tools/utils": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-6.0.12.tgz", + "integrity": "sha512-MuFSkxXCe2QoD5QJPJ/1WIm0YnBzzXpkq9d/XznVAWptHFRwtwIbZ1xcREjYquFvoZ7ddsjZfyvUN/5ulmHhhg==", + "requires": { + "@ardatan/aggregate-error": "0.0.1", + "camel-case": "4.1.1" + } + }, "tslib": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz", @@ -2733,15 +2747,24 @@ } }, "@graphql-tools/merge": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-6.0.10.tgz", - "integrity": "sha512-fnz9h5vdA8LXc9TvmhnRXykwFZWZ4FdBeo4g3R1KqcQCp65ByCMcBuCJtYf4VxPrcgTLGlWtVOHrItCi0kdioA==", + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-6.0.12.tgz", + "integrity": "sha512-GGvdIoTad6PJk/d1omPlGQ25pCFWmjuGkARYZ71qWI/c4FEA8EdGoOoPz3shhaKXyLdRiu84S758z4ZtDQiYVw==", "requires": { - "@graphql-tools/schema": "6.0.10", - "@graphql-tools/utils": "6.0.10", + "@graphql-tools/schema": "6.0.12", + "@graphql-tools/utils": "6.0.12", "tslib": "~2.0.0" }, "dependencies": { + "@graphql-tools/utils": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-6.0.12.tgz", + "integrity": "sha512-MuFSkxXCe2QoD5QJPJ/1WIm0YnBzzXpkq9d/XznVAWptHFRwtwIbZ1xcREjYquFvoZ7ddsjZfyvUN/5ulmHhhg==", + "requires": { + "@ardatan/aggregate-error": "0.0.1", + "camel-case": "4.1.1" + } + }, "tslib": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz", @@ -2750,14 +2773,23 @@ } }, "@graphql-tools/schema": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-6.0.10.tgz", - "integrity": "sha512-g8iy36dgf/Cpyz7bHSE2axkE8PdM5VYdS2tntmytLvPaN3Krb8IxBpZBJhmiICwyAAkruQE7OjDfYr8vP8jY4A==", + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-6.0.12.tgz", + "integrity": "sha512-XUmKJ+ipENaxuXIX4GapsLAUl1dFQBUg+S4ZbgtKVlwrPhZJ9bkjIqnUHk3wg4S4VXqzLX97ol1e4g9N6XLkYg==", "requires": { - "@graphql-tools/utils": "6.0.10", + "@graphql-tools/utils": "6.0.12", "tslib": "~2.0.0" }, "dependencies": { + "@graphql-tools/utils": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-6.0.12.tgz", + "integrity": "sha512-MuFSkxXCe2QoD5QJPJ/1WIm0YnBzzXpkq9d/XznVAWptHFRwtwIbZ1xcREjYquFvoZ7ddsjZfyvUN/5ulmHhhg==", + "requires": { + "@ardatan/aggregate-error": "0.0.1", + "camel-case": "4.1.1" + } + }, "tslib": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz", @@ -2766,18 +2798,27 @@ } }, "@graphql-tools/stitch": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@graphql-tools/stitch/-/stitch-6.0.10.tgz", - "integrity": "sha512-45xgk/ggXEkj6Ys4Hf1sV0ngzzvPhcGvA23/NG6E5LSkt4GM0TjtRpqwWMMoKJps9+1JX9/RSbHBAchC+zZj3w==", - "requires": { - "@graphql-tools/delegate": "6.0.10", - "@graphql-tools/merge": "6.0.10", - "@graphql-tools/schema": "6.0.10", - "@graphql-tools/utils": "6.0.10", - "@graphql-tools/wrap": "6.0.10", + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/stitch/-/stitch-6.0.12.tgz", + "integrity": "sha512-I+9l5Ws30Fn3nx0CIDUDMGP0nhexMEJyzfQn1t9DuOTy2QHPQ5YpaZ8hxv6y5+X23EJBU9AebqvNSvWNEO6XJQ==", + "requires": { + "@graphql-tools/delegate": "6.0.12", + "@graphql-tools/merge": "6.0.12", + "@graphql-tools/schema": "6.0.12", + "@graphql-tools/utils": "6.0.12", + "@graphql-tools/wrap": "6.0.12", "tslib": "~2.0.0" }, "dependencies": { + "@graphql-tools/utils": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-6.0.12.tgz", + "integrity": "sha512-MuFSkxXCe2QoD5QJPJ/1WIm0YnBzzXpkq9d/XznVAWptHFRwtwIbZ1xcREjYquFvoZ7ddsjZfyvUN/5ulmHhhg==", + "requires": { + "@ardatan/aggregate-error": "0.0.1", + "camel-case": "4.1.1" + } + }, "tslib": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz", @@ -2786,26 +2827,35 @@ } }, "@graphql-tools/utils": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-6.0.10.tgz", - "integrity": "sha512-1s3vBnYUIDLBGEaV1VF3lv1Xq54lT8Oz7tNNypv7K7cv3auKX7idRtjP8RM6hKpGod46JNZgu3NNOshMUEyEyA==", + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-6.0.12.tgz", + "integrity": "sha512-MuFSkxXCe2QoD5QJPJ/1WIm0YnBzzXpkq9d/XznVAWptHFRwtwIbZ1xcREjYquFvoZ7ddsjZfyvUN/5ulmHhhg==", "requires": { - "aggregate-error": "3.0.1", + "@ardatan/aggregate-error": "0.0.1", "camel-case": "4.1.1" } }, "@graphql-tools/wrap": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-6.0.10.tgz", - "integrity": "sha512-260f+eks3pSltokwueFJXQSwf7QdsjccphXINBIa0hwPyF8mPanyJlqd5GxkkG+C2K/oOXm8qaxc6pp7lpaomQ==", + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-6.0.12.tgz", + "integrity": "sha512-x/t6004aNLzTbOFzZiau15fY2+TBy0wbFqP2du+I+yh8j6KmAU1YkPolBJ4bAI04WD3qcLNh7Rai+VhOxidOkw==", "requires": { - "@graphql-tools/delegate": "6.0.10", - "@graphql-tools/schema": "6.0.10", - "@graphql-tools/utils": "6.0.10", + "@graphql-tools/delegate": "6.0.12", + "@graphql-tools/schema": "6.0.12", + "@graphql-tools/utils": "6.0.12", "aggregate-error": "3.0.1", "tslib": "~2.0.0" }, "dependencies": { + "@graphql-tools/utils": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-6.0.12.tgz", + "integrity": "sha512-MuFSkxXCe2QoD5QJPJ/1WIm0YnBzzXpkq9d/XznVAWptHFRwtwIbZ1xcREjYquFvoZ7ddsjZfyvUN/5ulmHhhg==", + "requires": { + "@ardatan/aggregate-error": "0.0.1", + "camel-case": "4.1.1" + } + }, "tslib": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz", @@ -3509,12 +3559,12 @@ } }, "apollo-engine-reporting": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/apollo-engine-reporting/-/apollo-engine-reporting-2.2.1.tgz", - "integrity": "sha512-HPwf70p4VbxKEagHYWTwldqfYNekBE33BXcryHI9owxMm5B8/vutQfx67+4Bf351kOpndCG9I91aOiFBfC2/iQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/apollo-engine-reporting/-/apollo-engine-reporting-2.3.0.tgz", + "integrity": "sha512-SbcPLFuUZcRqDEZ6mSs8uHM9Ftr8yyt2IEu0JA8c3LNBmYXSLM7MHqFe80SVcosYSTBgtMz8mLJO8orhYoSYZw==", "requires": { "apollo-engine-reporting-protobuf": "^0.5.2", - "apollo-graphql": "^0.4.0", + "apollo-graphql": "^0.5.0", "apollo-server-caching": "^0.5.2", "apollo-server-env": "^2.4.5", "apollo-server-errors": "^2.4.2", @@ -3544,9 +3594,9 @@ } }, "apollo-graphql": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.4.5.tgz", - "integrity": "sha512-0qa7UOoq7E71kBYE7idi6mNQhHLVdMEDInWk6TNw3KsSWZE2/I68gARP84Mj+paFTO5NYuw1Dht66PVX76Cc2w==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.5.0.tgz", + "integrity": "sha512-YSdF/BKPbsnQpxWpmCE53pBJX44aaoif31Y22I/qKpB6ZSGzYijV5YBoCL5Q15H2oA/v/02Oazh9lbp4ek3eig==", "requires": { "apollo-env": "^0.6.5", "lodash.sortby": "^4.7.0" @@ -3617,9 +3667,9 @@ } }, "apollo-server-core": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.15.1.tgz", - "integrity": "sha512-ZRSK3uVPS6YkIV3brm2CjzVphg6NHY0PRhFojZD8BjoQlGo3+pPRP1IHFDvC3UzybGWfyCelcfF4YiVqh4GJHw==", + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.16.0.tgz", + "integrity": "sha512-mnvg2cPvsQtjFXIqIhEAbPqGyiSXDSbiBgNQ8rY8g7r2eRMhHKZePqGF03gP1/w87yVaSDRAZBDk6o+jiBXjVQ==", "requires": { "@apollographql/apollo-tools": "^0.4.3", "@apollographql/graphql-playground-html": "1.6.26", @@ -3627,7 +3677,7 @@ "@types/ws": "^7.0.0", "apollo-cache-control": "^0.11.1", "apollo-datasource": "^0.7.2", - "apollo-engine-reporting": "^2.2.1", + "apollo-engine-reporting": "^2.3.0", "apollo-server-caching": "^0.5.2", "apollo-server-env": "^2.4.5", "apollo-server-errors": "^2.4.2", @@ -3681,9 +3731,9 @@ "integrity": "sha512-FeGxW3Batn6sUtX3OVVUm7o56EgjxDlmgpTLNyWcLb0j6P8mw9oLNyAm3B+deHA4KNdNHO5BmHS2g1SJYjqPCQ==" }, "apollo-server-express": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.15.0.tgz", - "integrity": "sha512-ECptVIrOVW2cmMWvqtpkZfyZrQL8yTSgbVvP4M8qcPV/3XxDJa6444zy7vxqN7lyYl8IJAsg/IwC0vodoXe//A==", + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.15.1.tgz", + "integrity": "sha512-anNb9HJo+KTpgvUqiPOjEl4wPq8y8NmWaIUz/QqPZlhIEDdf7wd/kQo3Sdbov++7J9JNJx6Ownnvw+wxfogUgA==", "requires": { "@apollographql/graphql-playground-html": "1.6.26", "@types/accepts": "^1.3.5", @@ -3691,8 +3741,8 @@ "@types/cors": "^2.8.4", "@types/express": "4.17.4", "accepts": "^1.3.5", - "apollo-server-core": "^2.15.0", - "apollo-server-types": "^0.5.0", + "apollo-server-core": "^2.15.1", + "apollo-server-types": "^0.5.1", "body-parser": "^1.18.3", "cors": "^2.8.4", "express": "^4.17.1", @@ -6911,9 +6961,9 @@ "dev": true }, "graphql": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.1.0.tgz", - "integrity": "sha512-0TVyfOlCGhv/DBczQkJmwXOK6fjWkjzY3Pt7wY8i0gcYXq8aogG3weCsg48m72lywKSeOqedEHvVPOvZvSD51Q==" + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz", + "integrity": "sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w==" }, "graphql-extensions": { "version": "0.12.4", @@ -8479,9 +8529,9 @@ } }, "lodash": { - "version": "4.17.16", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.16.tgz", - "integrity": "sha512-mzxOTaU4AsJhnIujhngm+OnA6JX4fTI8D5H26wwGd+BJ57bW70oyRwTqo6EFJm1jTZ7hCo7yVzH1vB8TMFd2ww==" + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" }, "lodash.assignin": { "version": "4.2.0", @@ -10067,6 +10117,11 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + }, + "ws": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.0.tgz", + "integrity": "sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w==" } } }, @@ -10172,15 +10227,15 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "pg": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.2.1.tgz", - "integrity": "sha512-DKzffhpkWRr9jx7vKxA+ur79KG+SKw+PdjMb1IRhMiKI9zqYUGczwFprqy+5Veh/DCcFs1Y6V8lRLN5I1DlleQ==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.2.2.tgz", + "integrity": "sha512-Uni50U0W2CNPM68+zfC/1WWjSO3q/uBSF/Nl7D+1npZGsPSM4/EZt0xSMW2jox1Bn0EfDlnTWnTsM/TrSOtBEA==", "requires": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", "pg-connection-string": "^2.2.3", "pg-pool": "^3.2.1", - "pg-protocol": "^1.2.4", + "pg-protocol": "^1.2.5", "pg-types": "^2.1.0", "pgpass": "1.x", "semver": "4.3.2" @@ -10214,14 +10269,14 @@ "integrity": "sha512-BQDPWUeKenVrMMDN9opfns/kZo4lxmSWhIqo+cSAF7+lfi9ZclQbr9vfnlNaPr8wYF3UYjm5X0yPAhbcgqNOdA==" }, "pg-promise": { - "version": "10.5.7", - "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-10.5.7.tgz", - "integrity": "sha512-feCpn4J4MsNnR5Ve3fpbIlmbohwRirvZEI1Dcy72zwKvIKKRHPk7TJZFQHP4YQhaZ3sT3VGgg0o1/I+uhht/1g==", + "version": "10.5.8", + "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-10.5.8.tgz", + "integrity": "sha512-EdLyPd/XlmNsfA2uRKHuCnyLhk5DHPdKGPZmjzpcKfdx6dDZB+nEfSuaNSjReRrM7BmPaV/hSGppt9kG/W2Umw==", "requires": { "assert-options": "0.6.2", - "pg": "8.2.1", + "pg": "8.2.2", "pg-minify": "1.6.1", - "spex": "3.0.1" + "spex": "3.0.2" } }, "pg-protocol": { @@ -11327,9 +11382,9 @@ } }, "spex": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spex/-/spex-3.0.1.tgz", - "integrity": "sha512-priWZUrXBmVPHTOmtUeS7gZzCOUwRK87OHJw5K8bTC6MLOq93mQocx+vWccNyKPT2EY+goZvKGguGn2lx8TBDA==" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/spex/-/spex-3.0.2.tgz", + "integrity": "sha512-ZNCrOso+oNv5P01HCO4wuxV9Og5rS6ms7gGAqugfBPjx1QwfNXJI3T02ldfaap1O0dlT1sB0Rk+mhDqxt3Z27w==" }, "split": { "version": "1.0.1", @@ -11513,9 +11568,9 @@ } }, "subscriptions-transport-ws": { - "version": "0.9.16", - "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.16.tgz", - "integrity": "sha512-pQdoU7nC+EpStXnCfh/+ho0zE0Z+ma+i7xvj7bkXKb1dvYHSZxgRPaU6spRP+Bjzow67c/rRDoix5RT0uU9omw==", + "version": "0.9.17", + "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.17.tgz", + "integrity": "sha512-hNHi2N80PBz4T0V0QhnnsMGvG3XDFDS9mS6BhZ3R12T6EBywC8d/uJscsga0cVO4DKtXCkCRrWm2sOYrbOdhEA==", "requires": { "backo2": "^1.0.2", "eventemitter3": "^3.1.0", @@ -12128,9 +12183,9 @@ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz", - "integrity": "sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg==" + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.2.0.tgz", + "integrity": "sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q==" }, "v8-compile-cache": { "version": "2.1.0", @@ -12354,9 +12409,9 @@ } }, "ws": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.0.tgz", - "integrity": "sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w==" + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz", + "integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==" }, "xml2js": { "version": "0.4.17", diff --git a/package.json b/package.json index 2fa08872e1..3b4182e9f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "4.2.0", + "version": "4.3.0", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { @@ -20,13 +20,13 @@ "license": "BSD-3-Clause", "dependencies": { "@apollographql/graphql-playground-html": "1.6.26", - "@graphql-tools/stitch": "6.0.10", - "@graphql-tools/utils": "6.0.10", + "@graphql-tools/stitch": "6.0.12", + "@graphql-tools/utils": "6.0.12", "@parse/fs-files-adapter": "1.0.1", "@parse/push-adapter": "3.2.0", "@parse/s3-files-adapter": "1.4.0", "@parse/simple-mailgun-adapter": "1.1.0", - "apollo-server-express": "2.15.0", + "apollo-server-express": "2.15.1", "bcryptjs": "2.4.3", "body-parser": "1.19.0", "commander": "5.1.0", @@ -34,7 +34,7 @@ "deepcopy": "2.0.0", "express": "4.17.1", "follow-redirects": "1.12.1", - "graphql": "15.1.0", + "graphql": "15.3.0", "graphql-list-fields": "2.0.2", "graphql-relay": "0.6.0", "graphql-upload": "11.0.0", @@ -42,21 +42,21 @@ "jsonwebtoken": "8.5.1", "jwks-rsa": "1.8.1", "ldapjs": "2.0.0", - "lodash": "4.17.16", + "lodash": "4.17.19", "lru-cache": "5.1.1", "mime": "2.4.6", "mongodb": "3.5.9", "parse": "2.15.0", - "pg-promise": "10.5.7", + "pg-promise": "10.5.8", "pluralize": "8.0.0", "redis": "3.0.2", "semver": "7.3.2", - "subscriptions-transport-ws": "0.9.16", + "subscriptions-transport-ws": "0.9.17", "tv4": "1.3.0", - "uuid": "8.1.0", + "uuid": "8.2.0", "winston": "3.2.1", "winston-daily-rotate-file": "4.5.0", - "ws": "7.3.0" + "ws": "7.3.1" }, "devDependencies": { "@babel/cli": "7.10.0", @@ -122,9 +122,6 @@ "url": "https://opencollective.com/parse-server", "logo": "https://opencollective.com/parse-server/logo.txt?reverse=true&variant=binary" }, - "publishConfig": { - "registry": "https://npm.pkg.github.com/" - }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parse-server" diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index 8215792a82..a640e1c3c7 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -52,6 +52,9 @@ function getENVPrefix(iface) { if (iface.id.name === 'LiveQueryOptions') { return 'PARSE_SERVER_LIVEQUERY_'; } + if (iface.id.name === 'IdempotencyOptions') { + return 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_'; + } } function processProperty(property, iface) { @@ -170,6 +173,13 @@ function parseDefaultValue(elt, value, t) { }); literalValue = t.objectExpression(props); } + if (type == 'IdempotencyOptions') { + const object = parsers.objectParser(value); + const props = Object.keys(object).map((key) => { + return t.objectProperty(key, object[value]); + }); + literalValue = t.objectExpression(props); + } if (type == 'ProtectedFields') { const prop = t.objectProperty( t.stringLiteral('_User'), t.objectPattern([ diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 1dfd190e7c..a42017769b 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -19,7 +19,7 @@ const responses = { microsoft: { id: 'userId', mail: 'userMail' }, }; -describe('AuthenticationProviders', function() { +describe('AuthenticationProviders', function () { [ 'apple', 'gcenter', @@ -42,8 +42,8 @@ describe('AuthenticationProviders', function() { 'weibo', 'phantauth', 'microsoft', - ].map(function(providerName) { - it('Should validate structure of ' + providerName, done => { + ].map(function (providerName) { + it('Should validate structure of ' + providerName, (done) => { const provider = require('../lib/Adapters/Auth/' + providerName); jequal(typeof provider.validateAuthData, 'function'); jequal(typeof provider.validateAppId, 'function'); @@ -66,12 +66,12 @@ describe('AuthenticationProviders', function() { }); it(`should provide the right responses for adapter ${providerName}`, async () => { - const noResponse = ['twitter', 'apple', 'gcenter']; + const noResponse = ['twitter', 'apple', 'gcenter', 'google']; if (noResponse.includes(providerName)) { return; } spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'get').and.callFake( - options => { + (options) => { if ( options === 'https://oauth.vk.com/access_token?client_id=appId&client_secret=appSecret&v=5.59&grant_type=client_credentials' @@ -101,7 +101,7 @@ describe('AuthenticationProviders', function() { }); }); - const getMockMyOauthProvider = function() { + const getMockMyOauthProvider = function () { return { authData: { id: '12345', @@ -114,7 +114,7 @@ describe('AuthenticationProviders', function() { synchronizedAuthToken: null, synchronizedExpiration: null, - authenticate: function(options) { + authenticate: function (options) { if (this.shouldError) { options.error(this, 'An error occurred'); } else if (this.shouldCancel) { @@ -123,7 +123,7 @@ describe('AuthenticationProviders', function() { options.success(this, this.authData); } }, - restoreAuthentication: function(authData) { + restoreAuthentication: function (authData) { if (!authData) { this.synchronizedUserId = null; this.synchronizedAuthToken = null; @@ -135,10 +135,10 @@ describe('AuthenticationProviders', function() { this.synchronizedExpiration = authData.expiration_date; return true; }, - getAuthType: function() { + getAuthType: function () { return 'myoauth'; }, - deauthenticate: function() { + deauthenticate: function () { this.loggedOut = true; this.restoreAuthentication(null); }, @@ -146,16 +146,16 @@ describe('AuthenticationProviders', function() { }; Parse.User.extend({ - extended: function() { + extended: function () { return true; }, }); - const createOAuthUser = function(callback) { + const createOAuthUser = function (callback) { return createOAuthUserWithSessionToken(undefined, callback); }; - const createOAuthUserWithSessionToken = function(token, callback) { + const createOAuthUserWithSessionToken = function (token, callback) { const jsonBody = { authData: { myoauth: getMockMyOauthProvider().authData, @@ -175,7 +175,7 @@ describe('AuthenticationProviders', function() { body: jsonBody, }; return request(options) - .then(response => { + .then((response) => { if (callback) { callback(null, response, response.data); } @@ -184,7 +184,7 @@ describe('AuthenticationProviders', function() { body: response.data, }; }) - .catch(error => { + .catch((error) => { if (callback) { callback(error); } @@ -192,7 +192,7 @@ describe('AuthenticationProviders', function() { }); }; - it('should create user with REST API', done => { + it('should create user with REST API', (done) => { createOAuthUser((error, response, body) => { expect(error).toBe(null); const b = body; @@ -203,7 +203,7 @@ describe('AuthenticationProviders', function() { const q = new Parse.Query('_Session'); q.equalTo('sessionToken', sessionToken); q.first({ useMasterKey: true }) - .then(res => { + .then((res) => { if (!res) { fail('should not fail fetching the session'); done(); @@ -219,7 +219,7 @@ describe('AuthenticationProviders', function() { }); }); - it('should only create a single user with REST API', done => { + it('should only create a single user with REST API', (done) => { let objectId; createOAuthUser((error, response, body) => { expect(error).toBe(null); @@ -239,9 +239,9 @@ describe('AuthenticationProviders', function() { }); }); - it("should fail to link if session token don't match user", done => { + it("should fail to link if session token don't match user", (done) => { Parse.User.signUp('myUser', 'password') - .then(user => { + .then((user) => { return createOAuthUserWithSessionToken(user.getSessionToken()); }) .then(() => { @@ -250,7 +250,7 @@ describe('AuthenticationProviders', function() { .then(() => { return Parse.User.signUp('myUser2', 'password'); }) - .then(user => { + .then((user) => { return createOAuthUserWithSessionToken(user.getSessionToken()); }) .then(fail, ({ data }) => { @@ -330,16 +330,16 @@ describe('AuthenticationProviders', function() { expect(typeof authAdapter.validateAppId).toBe('function'); } - it('properly loads custom adapter', done => { + it('properly loads custom adapter', (done) => { const validAuthData = { id: 'hello', token: 'world', }; const adapter = { - validateAppId: function() { + validateAppId: function () { return Promise.resolve(); }, - validateAuthData: function(authData) { + validateAuthData: function (authData) { if ( authData.id == validAuthData.id && authData.token == validAuthData.token @@ -370,14 +370,14 @@ describe('AuthenticationProviders', function() { expect(appIdSpy).not.toHaveBeenCalled(); done(); }, - err => { + (err) => { jfail(err); done(); } ); }); - it('properly loads custom adapter module object', done => { + it('properly loads custom adapter module object', (done) => { const authenticationHandler = authenticationLoader({ customAuthentication: path.resolve('./spec/support/CustomAuth.js'), }); @@ -394,14 +394,14 @@ describe('AuthenticationProviders', function() { () => { done(); }, - err => { + (err) => { jfail(err); done(); } ); }); - it('properly loads custom adapter module object (again)', done => { + it('properly loads custom adapter module object (again)', (done) => { const authenticationHandler = authenticationLoader({ customAuthentication: { module: path.resolve('./spec/support/CustomAuthFunction.js'), @@ -421,7 +421,7 @@ describe('AuthenticationProviders', function() { () => { done(); }, - err => { + (err) => { jfail(err); done(); } @@ -530,7 +530,7 @@ describe('AuthenticationProviders', function() { expect(providerOptions.appSecret).toEqual('secret'); }); - it('should fail if Facebook appIds is not configured properly', done => { + it('should fail if Facebook appIds is not configured properly', (done) => { const options = { facebookaccountkit: { appIds: [], @@ -540,13 +540,13 @@ describe('AuthenticationProviders', function() { 'facebookaccountkit', options ); - adapter.validateAppId(appIds).then(done.fail, err => { + adapter.validateAppId(appIds).then(done.fail, (err) => { expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); done(); }); }); - it('should fail to validate Facebook accountkit auth with bad token', done => { + it('should fail to validate Facebook accountkit auth with bad token', (done) => { const options = { facebookaccountkit: { appIds: ['a', 'b'], @@ -560,14 +560,14 @@ describe('AuthenticationProviders', function() { 'facebookaccountkit', options ); - adapter.validateAuthData(authData).then(done.fail, err => { + adapter.validateAuthData(authData).then(done.fail, (err) => { expect(err.code).toBe(190); expect(err.type).toBe('OAuthException'); done(); }); }); - it('should fail to validate Facebook accountkit auth with bad token regardless of app secret proof', done => { + it('should fail to validate Facebook accountkit auth with bad token regardless of app secret proof', (done) => { const options = { facebookaccountkit: { appIds: ['a', 'b'], @@ -582,11 +582,13 @@ describe('AuthenticationProviders', function() { 'facebookaccountkit', options ); - adapter.validateAuthData(authData, providerOptions).then(done.fail, err => { - expect(err.code).toBe(190); - expect(err.type).toBe('OAuthException'); - done(); - }); + adapter + .validateAuthData(authData, providerOptions) + .then(done.fail, (err) => { + expect(err.code).toBe(190); + expect(err.type).toBe('OAuthException'); + done(); + }); }); }); @@ -627,66 +629,124 @@ describe('instagram auth adapter', () => { describe('google auth adapter', () => { const google = require('../lib/Adapters/Auth/google'); - const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + const jwt = require('jsonwebtoken'); - it('should use id_token for validation is passed', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ sub: 'userId' }); - }); - await google.validateAuthData({ id: 'userId', id_token: 'the_token' }, {}); + it('should throw error with missing id_token', async () => { + try { + await google.validateAuthData({}, {}); + fail(); + } catch (e) { + expect(e.message).toBe('id token is invalid for this user.'); + } }); - it('should use id_token for validation is passed and responds with user_id', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ user_id: 'userId' }); - }); - await google.validateAuthData({ id: 'userId', id_token: 'the_token' }, {}); + it('should not decode invalid id_token', async () => { + try { + await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + {} + ); + fail(); + } catch (e) { + expect(e.message).toBe('provided token does not decode as JWT'); + } }); - it('should use access_token for validation is passed and responds with user_id', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ user_id: 'userId' }); - }); - await google.validateAuthData( - { id: 'userId', access_token: 'the_token' }, - {} + // it('should throw error if public key used to encode token is not available', async () => { + // const fakeDecodedToken = { header: { kid: '789', alg: 'RS256' } }; + // try { + // spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + + // await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {}); + // fail(); + // } catch (e) { + // expect(e.message).toBe( + // `Unable to find matching key for Key ID: ${fakeDecodedToken.header.kid}` + // ); + // } + // }); + + it('(using client id as string) should verify id_token', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } ); + expect(result).toEqual(fakeClaim); }); - it('should use access_token for validation is passed with sub', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ sub: 'userId' }); - }); - await google.validateAuthData({ id: 'userId', id_token: 'the_token' }, {}); + it('(using client id as string) should throw error with with invalid jwt issuer', async () => { + const fakeClaim = { + iss: 'https://not.google.com', + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'id token not issued by correct provider - expected: accounts.google.com or https://accounts.google.com | from: https://not.google.com' + ); + } }); - it('should fail when the id_token is invalid', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ sub: 'badId' }); - }); + xit('(using client id as string) should throw error with invalid jwt client_id', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + try { await google.validateAuthData( - { id: 'userId', id_token: 'the_token' }, - {} + { id: 'INSERT ID HERE', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: 'secret' } ); fail(); } catch (e) { - expect(e.message).toBe('Google auth is invalid for this user.'); + expect(e.message).toBe('jwt audience invalid. expected: secret'); } }); - it('should fail when the access_token is invalid', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ sub: 'badId' }); - }); + xit('should throw error with invalid user id', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + try { await google.validateAuthData( - { id: 'userId', access_token: 'the_token' }, - {} + { id: 'invalid user', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: 'INSERT CLIENT ID HERE' } ); fail(); } catch (e) { - expect(e.message).toBe('Google auth is invalid for this user.'); + expect(e.message).toBe('auth data is invalid for this user.'); } }); }); @@ -1593,13 +1653,13 @@ describe('microsoft graph auth adapter', () => { }); }); - it('should fail to validate Microsoft Graph auth with bad token', done => { + it('should fail to validate Microsoft Graph auth with bad token', (done) => { const authData = { id: 'fake-id', mail: 'fake@mail.com', access_token: 'very.long.bad.token', }; - microsoft.validateAuthData(authData).then(done.fail, err => { + microsoft.validateAuthData(authData).then(done.fail, (err) => { expect(err.code).toBe(101); expect(err.message).toBe( 'Microsoft Graph auth is invalid for this user.' diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index af373e64f2..77cd6d035a 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -1,8 +1,8 @@ const DatabaseController = require('../lib/Controllers/DatabaseController.js'); const validateQuery = DatabaseController._validateQuery; -describe('DatabaseController', function() { - describe('validateQuery', function() { +describe('DatabaseController', function () { + describe('validateQuery', function () { it('should not restructure simple cases of SERVER-13732', done => { const query = { $or: [{ a: 1 }, { a: 2 }], @@ -55,4 +55,264 @@ describe('DatabaseController', function() { done(); }); }); + + describe('addPointerPermissions', function () { + const CLASS_NAME = 'Foo'; + const USER_ID = 'userId'; + const ACL_GROUP = [USER_ID]; + const OPERATION = 'find'; + + const databaseController = new DatabaseController(); + const schemaController = jasmine.createSpyObj('SchemaController', [ + 'testPermissionsForClassName', + 'getClassLevelPermissions', + 'getExpectedType', + ]); + + it('should not decorate query if no pointer CLPs are present', done => { + const clp = buildCLP(); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(true); + schemaController.getClassLevelPermissions + .withArgs(CLASS_NAME) + .and.returnValue(clp); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ ...query }); + + done(); + }); + + it('should decorate query if a pointer CLP entry is present', done => { + const clp = buildCLP(['user']); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions + .withArgs(CLASS_NAME) + .and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Pointer' }); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ ...query, user: createUserPointer(USER_ID) }); + + done(); + }); + + it('should decorate query if an array CLP entry is present', done => { + const clp = buildCLP(['users']); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions + .withArgs(CLASS_NAME) + .and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'users') + .and.returnValue({ type: 'Array' }); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ + ...query, + users: { $all: [createUserPointer(USER_ID)] }, + }); + + done(); + }); + + it('should decorate query if an object CLP entry is present', done => { + const clp = buildCLP(['user']); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions + .withArgs(CLASS_NAME) + .and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Object' }); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ + ...query, + user: createUserPointer(USER_ID), + }); + + done(); + }); + + it('should decorate query if a pointer CLP is present and the same field is part of the query', done => { + const clp = buildCLP(['user']); + const query = { a: 'b', user: 'a' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions + .withArgs(CLASS_NAME) + .and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Pointer' }); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ + $and: [{ user: createUserPointer(USER_ID) }, { ...query }], + }); + + done(); + }); + + it('should transform the query to an $or query if multiple array/pointer CLPs are present', done => { + const clp = buildCLP(['user', 'users', 'userObject']); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions + .withArgs(CLASS_NAME) + .and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Pointer' }); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'users') + .and.returnValue({ type: 'Array' }); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'userObject') + .and.returnValue({ type: 'Object' }); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ + $or: [ + { ...query, user: createUserPointer(USER_ID) }, + { ...query, users: { $all: [createUserPointer(USER_ID)] } }, + { ...query, userObject: createUserPointer(USER_ID) }, + ], + }); + + done(); + }); + + it('should throw an error if for some unexpected reason the property specified in the CLP is neither a pointer nor an array', done => { + const clp = buildCLP(['user']); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions + .withArgs(CLASS_NAME) + .and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Number' }); + + expect(() => { + databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + }).toThrow( + Error( + `An unexpected condition occurred when resolving pointer permissions: ${CLASS_NAME} user` + ) + ); + + done(); + }); + }); }); + +function buildCLP(pointerNames) { + const OPERATIONS = [ + 'count', + 'find', + 'get', + 'create', + 'update', + 'delete', + 'addField', + ]; + + const clp = OPERATIONS.reduce((acc, op) => { + acc[op] = {}; + + if (pointerNames && pointerNames.length) { + acc[op].pointerFields = pointerNames; + } + + return acc; + }, {}); + + clp.protectedFields = {}; + clp.writeUserFields = []; + clp.readUserFields = []; + + return clp; +} + +function createUserPointer(userId) { + return { + __type: 'Pointer', + className: '_User', + objectId: userId, + }; +} diff --git a/spec/Idempotency.spec.js b/spec/Idempotency.spec.js new file mode 100644 index 0000000000..2d0e99aa19 --- /dev/null +++ b/spec/Idempotency.spec.js @@ -0,0 +1,247 @@ +'use strict'; +const Config = require('../lib/Config'); +const Definitions = require('../lib/Options/Definitions'); +const request = require('../lib/request'); +const rest = require('../lib/rest'); +const auth = require('../lib/Auth'); +const uuid = require('uuid'); + +describe_only_db('mongo')('Idempotency', () => { + // Parameters + /** Enable TTL expiration simulated by removing entry instead of waiting for MongoDB TTL monitor which + runs only every 60s, so it can take up to 119s until entry removal - ain't nobody got time for that */ + const SIMULATE_TTL = true; + // Helpers + async function deleteRequestEntry(reqId) { + const config = Config.get(Parse.applicationId); + const res = await rest.find( + config, + auth.master(config), + '_Idempotency', + { reqId: reqId }, + { limit: 1 } + ); + await rest.del( + config, + auth.master(config), + '_Idempotency', + res.results[0].objectId); + } + async function setup(options) { + await reconfigureServer({ + appId: Parse.applicationId, + masterKey: Parse.masterKey, + serverURL: Parse.serverURL, + idempotencyOptions: options, + }); + } + // Setups + beforeEach(async () => { + if (SIMULATE_TTL) { jasmine.DEFAULT_TIMEOUT_INTERVAL = 200000; } + await setup({ + paths: [ + "functions/.*", + "jobs/.*", + "classes/.*", + "users", + "installations" + ], + ttl: 30, + }); + }); + // Tests + it('should enforce idempotency for cloud code function', async () => { + let counter = 0; + Parse.Cloud.define('myFunction', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/functions/myFunction', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + expect(Config.get(Parse.applicationId).idempotencyOptions.ttl).toBe(30); + await request(params); + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("Duplicate request"); + }); + expect(counter).toBe(1); + }); + + it('should delete request entry after TTL', async () => { + let counter = 0; + Parse.Cloud.define('myFunction', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/functions/myFunction', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await expectAsync(request(params)).toBeResolved(); + if (SIMULATE_TTL) { + await deleteRequestEntry('abc-123'); + } else { + await new Promise(resolve => setTimeout(resolve, 130000)); + } + await expectAsync(request(params)).toBeResolved(); + expect(counter).toBe(2); + }); + + it('should enforce idempotency for cloud code jobs', async () => { + let counter = 0; + Parse.Cloud.job('myJob', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/jobs/myJob', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await expectAsync(request(params)).toBeResolved(); + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("Duplicate request"); + }); + expect(counter).toBe(1); + }); + + it('should enforce idempotency for class object creation', async () => { + let counter = 0; + Parse.Cloud.afterSave('MyClass', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/classes/MyClass', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await expectAsync(request(params)).toBeResolved(); + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("Duplicate request"); + }); + expect(counter).toBe(1); + }); + + it('should enforce idempotency for user object creation', async () => { + let counter = 0; + Parse.Cloud.afterSave('_User', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/users', + body: { + username: "user", + password: "pass" + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await expectAsync(request(params)).toBeResolved(); + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("Duplicate request"); + }); + expect(counter).toBe(1); + }); + + it('should enforce idempotency for installation object creation', async () => { + let counter = 0; + Parse.Cloud.afterSave('_Installation', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/installations', + body: { + installationId: "1", + deviceType: "ios" + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await expectAsync(request(params)).toBeResolved(); + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("Duplicate request"); + }); + expect(counter).toBe(1); + }); + + it('should not interfere with calls of different request ID', async () => { + let counter = 0; + Parse.Cloud.afterSave('MyClass', () => { + counter++; + }); + const promises = [...Array(100).keys()].map(() => { + const params = { + method: 'POST', + url: 'http://localhost:8378/1/classes/MyClass', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': uuid.v4() + } + }; + return request(params); + }); + await expectAsync(Promise.all(promises)).toBeResolved(); + expect(counter).toBe(100); + }); + + it('should re-throw any other error unchanged when writing request entry fails for any other reason', async () => { + spyOn(rest, 'create').and.rejectWith(new Parse.Error(0, "some other error")); + Parse.Cloud.define('myFunction', () => {}); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/functions/myFunction', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("some other error"); + }); + }); + + it('should use default configuration when none is set', async () => { + await setup({}); + expect(Config.get(Parse.applicationId).idempotencyOptions.ttl).toBe(Definitions.IdempotencyOptions.ttl.default); + expect(Config.get(Parse.applicationId).idempotencyOptions.paths).toBe(Definitions.IdempotencyOptions.paths.default); + }); + + it('should throw on invalid configuration', async () => { + await expectAsync(setup({ paths: 1 })).toBeRejected(); + await expectAsync(setup({ ttl: 'a' })).toBeRejected(); + await expectAsync(setup({ ttl: 0 })).toBeRejected(); + await expectAsync(setup({ ttl: -1 })).toBeRejected(); + }); +}); diff --git a/spec/MongoStorageAdapter.spec.js b/spec/MongoStorageAdapter.spec.js index 45c7068341..239f50b888 100644 --- a/spec/MongoStorageAdapter.spec.js +++ b/spec/MongoStorageAdapter.spec.js @@ -8,6 +8,7 @@ const databaseURI = const request = require('../lib/request'); const Config = require('../lib/Config'); const TestUtils = require('../lib/TestUtils'); +const semver = require('semver'); const fakeClient = { s: { options: { dbName: null } }, @@ -350,8 +351,45 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { expect(postIndexPlan.executionStats.executionStages.stage).toBe('FETCH'); }); + it('should delete field without index', async () => { + const database = Config.get(Parse.applicationId).database; + const obj = new Parse.Object('MyObject'); + obj.set("test", 1); + await obj.save(); + const schemaBeforeDeletion = await new Parse.Schema('MyObject').get(); + await database.adapter.deleteFields( + "MyObject", + schemaBeforeDeletion, + ["test"] + ); + const schemaAfterDeletion = await new Parse.Schema('MyObject').get(); + expect(schemaBeforeDeletion.fields.test).toBeDefined(); + expect(schemaAfterDeletion.fields.test).toBeUndefined(); + }); + + it('should delete field with index', async () => { + const database = Config.get(Parse.applicationId).database; + const obj = new Parse.Object('MyObject'); + obj.set("test", 1); + await obj.save(); + const schemaBeforeDeletion = await new Parse.Schema('MyObject').get(); + await database.adapter.ensureIndex( + 'MyObject', + schemaBeforeDeletion, + ['test'] + ); + await database.adapter.deleteFields( + "MyObject", + schemaBeforeDeletion, + ["test"] + ); + const schemaAfterDeletion = await new Parse.Schema('MyObject').get(); + expect(schemaBeforeDeletion.fields.test).toBeDefined(); + expect(schemaAfterDeletion.fields.test).toBeUndefined(); + }); + if ( - process.env.MONGODB_VERSION === '4.0.4' && + semver.satisfies(process.env.MONGODB_VERSION, '>=4.0.4') && process.env.MONGODB_TOPOLOGY === 'replicaset' && process.env.MONGODB_STORAGE_ENGINE === 'wiredTiger' ) { diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index b805cabe96..c1f23bc733 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -170,7 +170,7 @@ describe('ParseGraphQLServer', () => { new ParseGraphQLServer(parseServer, { graphQLPath: 'somepath', }).applyGraphQL({ - use: (path) => { + use: path => { useCount++; expect(path).toEqual('somepath'); }, @@ -208,7 +208,7 @@ describe('ParseGraphQLServer', () => { graphQLPath: 'graphQL', playgroundPath: 'somepath', }).applyPlayground({ - get: (path) => { + get: path => { useCount++; expect(path).toEqual('somepath'); }, @@ -436,9 +436,7 @@ describe('ParseGraphQLServer', () => { parseGraphQLServer.applyGraphQL(expressApp); parseGraphQLServer.applyPlayground(expressApp); parseGraphQLServer.createSubscriptions(httpServer); - await new Promise((resolve) => - httpServer.listen({ port: 13377 }, resolve) - ); + await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve)); const subscriptionClient = new SubscriptionClient( 'ws://localhost:13377/subscriptions', @@ -506,7 +504,7 @@ describe('ParseGraphQLServer', () => { let checked = false; const apolloClient = new ApolloClient({ link: new ApolloLink((operation, forward) => { - return forward(operation).map((response) => { + return forward(operation).map(response => { const context = operation.getContext(); const { response: { headers }, @@ -541,7 +539,7 @@ describe('ParseGraphQLServer', () => { it('should handle Parse headers', async () => { let checked = false; const originalGetGraphQLOptions = parseGraphQLServer._getGraphQLOptions; - parseGraphQLServer._getGraphQLOptions = async (req) => { + parseGraphQLServer._getGraphQLOptions = async req => { expect(req.info).toBeDefined(); expect(req.config).toBeDefined(); expect(req.auth).toBeDefined(); @@ -643,7 +641,7 @@ describe('ParseGraphQLServer', () => { }) ).data['__type']; expect(fileType.kind).toEqual('OBJECT'); - expect(fileType.fields.map((field) => field.name).sort()).toEqual([ + expect(fileType.fields.map(field => field.name).sort()).toEqual([ 'name', 'url', ]); @@ -665,7 +663,7 @@ describe('ParseGraphQLServer', () => { }) ).data['__type']; expect(classType.kind).toEqual('INTERFACE'); - expect(classType.fields.map((field) => field.name).sort()).toEqual([ + expect(classType.fields.map(field => field.name).sort()).toEqual([ 'ACL', 'createdAt', 'objectId', @@ -690,7 +688,7 @@ describe('ParseGraphQLServer', () => { ).data['__type']; expect(readPreferenceType.kind).toEqual('ENUM'); expect( - readPreferenceType.enumValues.map((value) => value.name).sort() + readPreferenceType.enumValues.map(value => value.name).sort() ).toEqual([ 'NEAREST', 'PRIMARY', @@ -731,7 +729,7 @@ describe('ParseGraphQLServer', () => { } `, }) - ).data['__schema'].types.map((type) => type.name); + ).data['__schema'].types.map(type => type.name); const expectedTypes = [ 'ParseObject', @@ -741,7 +739,7 @@ describe('ParseGraphQLServer', () => { 'Upload', ]; expect( - expectedTypes.every((type) => schemaTypes.indexOf(type) !== -1) + expectedTypes.every(type => schemaTypes.indexOf(type) !== -1) ).toBeTruthy(JSON.stringify(schemaTypes.types)); }); }); @@ -768,7 +766,7 @@ describe('ParseGraphQLServer', () => { } `, }) - ).data['__schema'].types.map((type) => type.name); + ).data['__schema'].types.map(type => type.name); expect(schemaTypes).toContain('Node'); }); @@ -786,7 +784,7 @@ describe('ParseGraphQLServer', () => { } `, }) - ).data['__type'].fields.map((field) => field.name); + ).data['__type'].fields.map(field => field.name); expect(queryFields).toContain('node'); }); @@ -804,7 +802,7 @@ describe('ParseGraphQLServer', () => { } `, }) - ).data['__type'].fields.map((field) => field.name); + ).data['__type'].fields.map(field => field.name); expect(userFields).toContain('id'); expect(userFields).toContain('objectId'); @@ -824,7 +822,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].inputFields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(createFileInputFields).toEqual(['clientMutationId', 'upload']); @@ -844,7 +842,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].fields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(createFilePayloadFields).toEqual([ @@ -869,7 +867,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].inputFields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(callFunctionInputFields).toEqual([ @@ -895,7 +893,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].fields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(callFunctionPayloadFields).toEqual([ @@ -918,7 +916,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].inputFields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(inputFields).toEqual(['clientMutationId', 'fields']); @@ -938,7 +936,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].fields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(payloadFields).toEqual(['clientMutationId', 'viewer']); @@ -958,7 +956,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].inputFields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(inputFields).toEqual([ @@ -982,7 +980,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].fields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(payloadFields).toEqual(['clientMutationId', 'viewer']); @@ -1002,7 +1000,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].inputFields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(inputFields).toEqual(['clientMutationId']); @@ -1022,7 +1020,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].fields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(payloadFields).toEqual(['clientMutationId', 'viewer']); @@ -1042,7 +1040,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].inputFields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(inputFields).toEqual([ @@ -1066,7 +1064,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].fields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(payloadFields).toEqual(['class', 'clientMutationId']); @@ -1086,7 +1084,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].inputFields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(inputFields).toEqual([ @@ -1110,7 +1108,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].fields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(payloadFields).toEqual(['class', 'clientMutationId']); @@ -1130,7 +1128,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].inputFields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(inputFields).toEqual(['clientMutationId', 'name']); @@ -1150,7 +1148,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].fields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(payloadFields).toEqual(['class', 'clientMutationId']); @@ -1175,7 +1173,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].inputFields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(createObjectInputFields).toEqual([ @@ -1203,7 +1201,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].fields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(createObjectPayloadFields).toEqual([ @@ -1231,7 +1229,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].inputFields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(createObjectInputFields).toEqual([ @@ -1260,7 +1258,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].fields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(createObjectPayloadFields).toEqual([ @@ -1288,7 +1286,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].inputFields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(createObjectInputFields).toEqual(['clientMutationId', 'id']); @@ -1313,7 +1311,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type'].fields - .map((field) => field.name) + .map(field => field.name) .sort(); expect(createObjectPayloadFields).toEqual([ @@ -1339,7 +1337,7 @@ describe('ParseGraphQLServer', () => { } `, }) - ).data['__schema'].types.map((type) => type.name); + ).data['__schema'].types.map(type => type.name); const expectedTypes = [ 'Role', @@ -1354,7 +1352,7 @@ describe('ParseGraphQLServer', () => { 'UpdateUserFieldsInput', ]; expect( - expectedTypes.every((type) => schemaTypes.indexOf(type) !== -1) + expectedTypes.every(type => schemaTypes.indexOf(type) !== -1) ).toBeTruthy(JSON.stringify(schemaTypes)); }); @@ -1373,7 +1371,7 @@ describe('ParseGraphQLServer', () => { `, }) ).data['__type']; - const possibleTypes = objectType.possibleTypes.map((o) => o.name); + const possibleTypes = objectType.possibleTypes.map(o => o.name); expect(possibleTypes).toContain('User'); expect(possibleTypes).toContain('Role'); expect(possibleTypes).toContain('Element'); @@ -1397,7 +1395,7 @@ describe('ParseGraphQLServer', () => { } `, }) - ).data['__type'].fields.map((field) => field.name); + ).data['__type'].fields.map(field => field.name); expect(userFields.indexOf('foo') !== -1).toBeTruthy(); }); @@ -1414,7 +1412,7 @@ describe('ParseGraphQLServer', () => { } `, }) - ).data['__type'].fields.map((field) => field.name); + ).data['__type'].fields.map(field => field.name); expect(userFields.includes('password')).toBeFalsy(); }); }); @@ -1896,13 +1894,13 @@ describe('ParseGraphQLServer', () => { `, }); expect( - __type.inputFields.find((o) => o.name === 'price').type.kind + __type.inputFields.find(o => o.name === 'price').type.kind ).toEqual('SCALAR'); expect( - __type.inputFields.find((o) => o.name === 'engine').type.kind + __type.inputFields.find(o => o.name === 'engine').type.kind ).toEqual('NON_NULL'); expect( - __type.inputFields.find((o) => o.name === 'doors').type.kind + __type.inputFields.find(o => o.name === 'doors').type.kind ).toEqual('NON_NULL'); const { @@ -1922,13 +1920,13 @@ describe('ParseGraphQLServer', () => { `, }); expect( - __type2.fields.find((o) => o.name === 'price').type.kind + __type2.fields.find(o => o.name === 'price').type.kind ).toEqual('SCALAR'); expect( - __type2.fields.find((o) => o.name === 'engine').type.kind + __type2.fields.find(o => o.name === 'engine').type.kind ).toEqual('NON_NULL'); expect( - __type2.fields.find((o) => o.name === 'doors').type.kind + __type2.fields.find(o => o.name === 'doors').type.kind ).toEqual('NON_NULL'); }); @@ -2787,7 +2785,7 @@ describe('ParseGraphQLServer', () => { ).toEqual(2); expect( findSecondaryObjectsResult.data.secondaryObjects.edges - .map((value) => value.node.someField) + .map(value => value.node.someField) .sort() ).toEqual(['some value 22', 'some value 44']); expect( @@ -2954,7 +2952,7 @@ describe('ParseGraphQLServer', () => { ).toEqual('some value 22'); expect( createPrimaryObjectResult.data.createPrimaryObject.primaryObject.relationField.edges - .map((value) => value.node.someField) + .map(value => value.node.someField) .sort() ).toEqual(['some value 22', 'some value 44']); expect( @@ -3193,7 +3191,7 @@ describe('ParseGraphQLServer', () => { }, }, }); - const classes = Object.keys(result.data).map((fieldName) => ({ + const classes = Object.keys(result.data).map(fieldName => ({ clientMutationId: result.data[fieldName].clientMutationId, class: { name: result.data[fieldName].class.name, @@ -3358,9 +3356,9 @@ describe('ParseGraphQLServer', () => { }, }); findResult.data.classes = findResult.data.classes - .filter((schemaClass) => !schemaClass.name.startsWith('_')) + .filter(schemaClass => !schemaClass.name.startsWith('_')) .sort((a, b) => (a.name > b.name ? 1 : -1)); - findResult.data.classes.forEach((schemaClass) => { + findResult.data.classes.forEach(schemaClass => { schemaClass.schemaFields = schemaClass.schemaFields.sort((a, b) => a.name > b.name ? 1 : -1 ); @@ -4277,10 +4275,10 @@ describe('ParseGraphQLServer', () => { expect(result.manyRelations.length).toEqual(2); const customerSubObject = result.manyRelations.find( - (o) => o.objectId === obj1.id + o => o.objectId === obj1.id ); const someClassSubObject = result.manyRelations.find( - (o) => o.objectId === obj2.id + o => o.objectId === obj2.id ); expect(customerSubObject).toBeDefined(); @@ -4289,7 +4287,7 @@ describe('ParseGraphQLServer', () => { 'imCustomerOne' ); const formatedArrayField = customerSubObject.arrayField.map( - (elem) => elem.value + elem => elem.value ); expect(formatedArrayField).toEqual(arrayField); expect(someClassSubObject.someClassField).toEqual( @@ -4445,7 +4443,7 @@ describe('ParseGraphQLServer', () => { await Promise.all( objects .slice(0, 3) - .map((obj) => + .map(obj => expectAsync( getObject(obj.className, obj.id) ).toBeRejectedWith(jasmine.stringMatching('Object not found')) @@ -4456,7 +4454,7 @@ describe('ParseGraphQLServer', () => { .someField ).toEqual('someValue4'); await Promise.all( - objects.map(async (obj) => + objects.map(async obj => expect( ( await getObject(obj.className, obj.id, { @@ -4467,7 +4465,7 @@ describe('ParseGraphQLServer', () => { ) ); await Promise.all( - objects.map(async (obj) => + objects.map(async obj => expect( ( await getObject(obj.className, obj.id, { @@ -4478,7 +4476,7 @@ describe('ParseGraphQLServer', () => { ) ); await Promise.all( - objects.map(async (obj) => + objects.map(async obj => expect( ( await getObject(obj.className, obj.id, { @@ -4494,7 +4492,7 @@ describe('ParseGraphQLServer', () => { }) ).toBeRejectedWith(jasmine.stringMatching('Object not found')); await Promise.all( - [object1, object3, object4].map(async (obj) => + [object1, object3, object4].map(async obj => expect( ( await getObject(obj.className, obj.id, { @@ -4505,7 +4503,7 @@ describe('ParseGraphQLServer', () => { ) ); await Promise.all( - objects.slice(0, 3).map((obj) => + objects.slice(0, 3).map(obj => expectAsync( getObject(obj.className, obj.id, { 'X-Parse-Session-Token': user4.getSessionToken(), @@ -4521,7 +4519,7 @@ describe('ParseGraphQLServer', () => { ).data.get.someField ).toEqual('someValue4'); await Promise.all( - objects.slice(0, 2).map((obj) => + objects.slice(0, 2).map(obj => expectAsync( getObject(obj.className, obj.id, { 'X-Parse-Session-Token': user5.getSessionToken(), @@ -4646,7 +4644,7 @@ describe('ParseGraphQLServer', () => { ).toBeDefined(); }); - it('should respect protectedFields', async (done) => { + it('should respect protectedFields', async done => { await prepareData(); await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); @@ -4762,7 +4760,7 @@ describe('ParseGraphQLServer', () => { let foundUserClassReadPreference = false; databaseAdapter.database.serverConfig.cursor.calls .all() - .forEach((call) => { + .forEach(call => { if ( call.args[0].ns.collection.indexOf('GraphQLClass') >= 0 ) { @@ -4826,7 +4824,7 @@ describe('ParseGraphQLServer', () => { let foundUserClassReadPreference = false; databaseAdapter.database.serverConfig.cursor.calls .all() - .forEach((call) => { + .forEach(call => { if (call.args[0].ns.collection.indexOf('GraphQLClass') >= 0) { foundGraphQLClassReadPreference = true; expect(call.args[0].options.readPreference.mode).toBe( @@ -4886,7 +4884,7 @@ describe('ParseGraphQLServer', () => { let foundUserClassReadPreference = false; databaseAdapter.database.serverConfig.cursor.calls .all() - .forEach((call) => { + .forEach(call => { if (call.args[0].ns.collection.indexOf('GraphQLClass') >= 0) { foundGraphQLClassReadPreference = true; expect(call.args[0].options.readPreference.mode).toBe( @@ -4936,7 +4934,7 @@ describe('ParseGraphQLServer', () => { expect(result.data.customers.edges.length).toEqual(2); - result.data.customers.edges.forEach((resultObj) => { + result.data.customers.edges.forEach(resultObj => { const obj = resultObj.node.objectId === obj1.id ? obj1 : obj2; expect(resultObj.node.objectId).toEqual(obj.id); expect(resultObj.node.someField).toEqual(obj.get('someField')); @@ -4977,12 +4975,12 @@ describe('ParseGraphQLServer', () => { expect( (await findObjects('GraphQLClass')).data.find.edges.map( - (object) => object.node.someField + object => object.node.someField ) ).toEqual([]); expect( (await findObjects('PublicClass')).data.find.edges.map( - (object) => object.node.someField + object => object.node.someField ) ).toEqual(['someValue4']); expect( @@ -4991,7 +4989,7 @@ describe('ParseGraphQLServer', () => { 'X-Parse-Master-Key': 'test', }) ).data.find.edges - .map((object) => object.node.someField) + .map(object => object.node.someField) .sort() ).toEqual(['someValue1', 'someValue2', 'someValue3']); expect( @@ -4999,7 +4997,7 @@ describe('ParseGraphQLServer', () => { await findObjects('PublicClass', { 'X-Parse-Master-Key': 'test', }) - ).data.find.edges.map((object) => object.node.someField) + ).data.find.edges.map(object => object.node.someField) ).toEqual(['someValue4']); expect( ( @@ -5007,7 +5005,7 @@ describe('ParseGraphQLServer', () => { 'X-Parse-Session-Token': user1.getSessionToken(), }) ).data.find.edges - .map((object) => object.node.someField) + .map(object => object.node.someField) .sort() ).toEqual(['someValue1', 'someValue2', 'someValue3']); expect( @@ -5015,7 +5013,7 @@ describe('ParseGraphQLServer', () => { await findObjects('PublicClass', { 'X-Parse-Session-Token': user1.getSessionToken(), }) - ).data.find.edges.map((object) => object.node.someField) + ).data.find.edges.map(object => object.node.someField) ).toEqual(['someValue4']); expect( ( @@ -5023,7 +5021,7 @@ describe('ParseGraphQLServer', () => { 'X-Parse-Session-Token': user2.getSessionToken(), }) ).data.find.edges - .map((object) => object.node.someField) + .map(object => object.node.someField) .sort() ).toEqual(['someValue1', 'someValue2', 'someValue3']); expect( @@ -5032,7 +5030,7 @@ describe('ParseGraphQLServer', () => { 'X-Parse-Session-Token': user3.getSessionToken(), }) ).data.find.edges - .map((object) => object.node.someField) + .map(object => object.node.someField) .sort() ).toEqual(['someValue1', 'someValue3']); expect( @@ -5040,14 +5038,14 @@ describe('ParseGraphQLServer', () => { await findObjects('GraphQLClass', { 'X-Parse-Session-Token': user4.getSessionToken(), }) - ).data.find.edges.map((object) => object.node.someField) + ).data.find.edges.map(object => object.node.someField) ).toEqual([]); expect( ( await findObjects('GraphQLClass', { 'X-Parse-Session-Token': user5.getSessionToken(), }) - ).data.find.edges.map((object) => object.node.someField) + ).data.find.edges.map(object => object.node.someField) ).toEqual(['someValue3']); }); @@ -5100,7 +5098,7 @@ describe('ParseGraphQLServer', () => { expect( result.data.graphQLClasses.edges - .map((object) => object.node.someField) + .map(object => object.node.someField) .sort() ).toEqual(['someValue1', 'someValue3']); }); @@ -5178,7 +5176,7 @@ describe('ParseGraphQLServer', () => { expect( result.data.graphQLClasses.edges - .map((object) => object.node.someField) + .map(object => object.node.someField) .sort() ).toEqual(['someValue1', 'someValue2']); }); @@ -5345,7 +5343,7 @@ describe('ParseGraphQLServer', () => { }); expect( - result.data.find.edges.map((obj) => obj.node.someField) + result.data.find.edges.map(obj => obj.node.someField) ).toEqual(['someValue14', 'someValue17']); }); @@ -5416,7 +5414,7 @@ describe('ParseGraphQLServer', () => { let result = await find(); expect( - result.data.someClasses.edges.map((edge) => edge.node.numberField) + result.data.someClasses.edges.map(edge => edge.node.numberField) ).toEqual(numberArray(0, 99)); expect(result.data.someClasses.count).toEqual(100); expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual( @@ -5432,7 +5430,7 @@ describe('ParseGraphQLServer', () => { result = await find({ first: 10 }); expect( - result.data.someClasses.edges.map((edge) => edge.node.numberField) + result.data.someClasses.edges.map(edge => edge.node.numberField) ).toEqual(numberArray(0, 9)); expect(result.data.someClasses.count).toEqual(100); expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual( @@ -5451,7 +5449,7 @@ describe('ParseGraphQLServer', () => { after: result.data.someClasses.pageInfo.endCursor, }); expect( - result.data.someClasses.edges.map((edge) => edge.node.numberField) + result.data.someClasses.edges.map(edge => edge.node.numberField) ).toEqual(numberArray(10, 19)); expect(result.data.someClasses.count).toEqual(100); expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual( @@ -5467,7 +5465,7 @@ describe('ParseGraphQLServer', () => { result = await find({ last: 10 }); expect( - result.data.someClasses.edges.map((edge) => edge.node.numberField) + result.data.someClasses.edges.map(edge => edge.node.numberField) ).toEqual(numberArray(90, 99)); expect(result.data.someClasses.count).toEqual(100); expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual( @@ -5486,7 +5484,7 @@ describe('ParseGraphQLServer', () => { before: result.data.someClasses.pageInfo.startCursor, }); expect( - result.data.someClasses.edges.map((edge) => edge.node.numberField) + result.data.someClasses.edges.map(edge => edge.node.numberField) ).toEqual(numberArray(80, 89)); expect(result.data.someClasses.count).toEqual(100); expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual( @@ -5820,7 +5818,7 @@ describe('ParseGraphQLServer', () => { let foundUserClassReadPreference = false; databaseAdapter.database.serverConfig.cursor.calls .all() - .forEach((call) => { + .forEach(call => { if (call.args[0].ns.collection.indexOf('GraphQLClass') >= 0) { foundGraphQLClassReadPreference = true; expect(call.args[0].options.readPreference.mode).toBe( @@ -5877,7 +5875,7 @@ describe('ParseGraphQLServer', () => { let foundUserClassReadPreference = false; databaseAdapter.database.serverConfig.cursor.calls .all() - .forEach((call) => { + .forEach(call => { if (call.args[0].ns.collection.indexOf('GraphQLClass') >= 0) { foundGraphQLClassReadPreference = true; expect(call.args[0].options.readPreference.mode).toBe( @@ -5937,7 +5935,7 @@ describe('ParseGraphQLServer', () => { let foundUserClassReadPreference = false; databaseAdapter.database.serverConfig.cursor.calls .all() - .forEach((call) => { + .forEach(call => { if (call.args[0].ns.collection.indexOf('GraphQLClass') >= 0) { foundGraphQLClassReadPreference = true; expect(call.args[0].options.readPreference.mode).toBe( @@ -6008,7 +6006,7 @@ describe('ParseGraphQLServer', () => { let foundUserClassReadPreference = false; databaseAdapter.database.serverConfig.cursor.calls .all() - .forEach((call) => { + .forEach(call => { if ( call.args[0].ns.collection.indexOf('GraphQLClass') >= 0 ) { @@ -6067,7 +6065,7 @@ describe('ParseGraphQLServer', () => { } expect( - result.data.graphQLClasses.edges.map((edge) => edge.node.objectId) + result.data.graphQLClasses.edges.map(edge => edge.node.objectId) ).toEqual([object3.id, object1.id, object2.id]); }); @@ -6120,7 +6118,7 @@ describe('ParseGraphQLServer', () => { expect( result.data.parentClass.graphQLClasses.edges.map( - (edge) => edge.node.objectId + edge => edge.node.objectId ) ).toEqual([object3.id, object1.id, object2.id]); } @@ -6384,7 +6382,7 @@ describe('ParseGraphQLServer', () => { } await Promise.all( - objects.slice(0, 3).map(async (obj) => { + objects.slice(0, 3).map(async obj => { const originalFieldValue = obj.get('someField'); await expectAsync( updateObject(obj.className, obj.id, { @@ -6405,7 +6403,7 @@ describe('ParseGraphQLServer', () => { await object4.fetch({ useMasterKey: true }); expect(object4.get('someField')).toEqual('changedValue1'); await Promise.all( - objects.map(async (obj) => { + objects.map(async obj => { expect( ( await updateObject( @@ -6421,7 +6419,7 @@ describe('ParseGraphQLServer', () => { }) ); await Promise.all( - objects.map(async (obj) => { + objects.map(async obj => { expect( ( await updateObject( @@ -6437,7 +6435,7 @@ describe('ParseGraphQLServer', () => { }) ); await Promise.all( - objects.map(async (obj) => { + objects.map(async obj => { expect( ( await updateObject( @@ -6453,7 +6451,7 @@ describe('ParseGraphQLServer', () => { }) ); await Promise.all( - [object1, object3, object4].map(async (obj) => { + [object1, object3, object4].map(async obj => { expect( ( await updateObject( @@ -6480,7 +6478,7 @@ describe('ParseGraphQLServer', () => { await object2.fetch({ useMasterKey: true }); expect(object2.get('someField')).toEqual(originalFieldValue); await Promise.all( - objects.slice(0, 3).map(async (obj) => { + objects.slice(0, 3).map(async obj => { const originalFieldValue = obj.get('someField'); await expectAsync( updateObject( @@ -6507,7 +6505,7 @@ describe('ParseGraphQLServer', () => { await object4.fetch({ useMasterKey: true }); expect(object4.get('someField')).toEqual('changedValue6'); await Promise.all( - objects.slice(0, 2).map(async (obj) => { + objects.slice(0, 2).map(async obj => { const originalFieldValue = obj.get('someField'); await expectAsync( updateObject( @@ -6583,7 +6581,7 @@ describe('ParseGraphQLServer', () => { } await Promise.all( - objects.slice(0, 3).map(async (obj) => { + objects.slice(0, 3).map(async obj => { const originalFieldValue = obj.get('someField'); await expectAsync( updateObject(obj.className, obj.id, { @@ -6607,7 +6605,7 @@ describe('ParseGraphQLServer', () => { await object4.fetch({ useMasterKey: true }); expect(object4.get('someField')).toEqual('changedValue1'); await Promise.all( - objects.map(async (obj) => { + objects.map(async obj => { expect( ( await updateObject( @@ -6626,7 +6624,7 @@ describe('ParseGraphQLServer', () => { }) ); await Promise.all( - objects.map(async (obj) => { + objects.map(async obj => { expect( ( await updateObject( @@ -6645,7 +6643,7 @@ describe('ParseGraphQLServer', () => { }) ); await Promise.all( - objects.map(async (obj) => { + objects.map(async obj => { expect( ( await updateObject( @@ -6664,7 +6662,7 @@ describe('ParseGraphQLServer', () => { }) ); await Promise.all( - [object1, object3, object4].map(async (obj) => { + [object1, object3, object4].map(async obj => { expect( ( await updateObject( @@ -6694,7 +6692,7 @@ describe('ParseGraphQLServer', () => { await object2.fetch({ useMasterKey: true }); expect(object2.get('someField')).toEqual(originalFieldValue); await Promise.all( - objects.slice(0, 3).map(async (obj) => { + objects.slice(0, 3).map(async obj => { const originalFieldValue = obj.get('someField'); await expectAsync( updateObject( @@ -6724,7 +6722,7 @@ describe('ParseGraphQLServer', () => { await object4.fetch({ useMasterKey: true }); expect(object4.get('someField')).toEqual('changedValue6'); await Promise.all( - objects.slice(0, 2).map(async (obj) => { + objects.slice(0, 2).map(async obj => { const originalFieldValue = obj.get('someField'); await expectAsync( updateObject( @@ -6851,7 +6849,7 @@ describe('ParseGraphQLServer', () => { } await Promise.all( - objects.slice(0, 3).map(async (obj) => { + objects.slice(0, 3).map(async obj => { const originalFieldValue = obj.get('someField'); await expectAsync( deleteObject(obj.className, obj.id) @@ -6861,7 +6859,7 @@ describe('ParseGraphQLServer', () => { }) ); await Promise.all( - objects.slice(0, 3).map(async (obj) => { + objects.slice(0, 3).map(async obj => { const originalFieldValue = obj.get('someField'); await expectAsync( deleteObject(obj.className, obj.id, { @@ -6952,7 +6950,7 @@ describe('ParseGraphQLServer', () => { } await Promise.all( - objects.slice(0, 3).map(async (obj) => { + objects.slice(0, 3).map(async obj => { const originalFieldValue = obj.get('someField'); await expectAsync( deleteObject(obj.className, obj.id) @@ -6962,7 +6960,7 @@ describe('ParseGraphQLServer', () => { }) ); await Promise.all( - objects.slice(0, 3).map(async (obj) => { + objects.slice(0, 3).map(async obj => { const originalFieldValue = obj.get('someField'); await expectAsync( deleteObject(obj.className, obj.id, { @@ -7185,6 +7183,56 @@ describe('ParseGraphQLServer', () => { expect(resultFoo).toBeDefined(); expect(resultFoo.bar).toEqual('hello'); }); + it('should return logged user and do not by pass pointer security', async () => { + const masterKeyOnlyACL = new Parse.ACL(); + masterKeyOnlyACL.setPublicReadAccess(false); + masterKeyOnlyACL.setPublicWriteAccess(false); + const foo = new Parse.Object('Foo'); + foo.setACL(masterKeyOnlyACL); + foo.set('bar', 'hello'); + await foo.save(null, { useMasterKey: true }); + const userName = 'userx1', + password = 'user1', + email = 'emailUserx1@parse.com'; + + const user = new Parse.User(); + user.setUsername(userName); + user.setPassword(password); + user.setEmail(email); + user.set('userFoo', foo); + await user.signUp(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const session = await Parse.Session.current(); + const result = await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + sessionToken + user { + id + objectId + userFoo { + bar + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': session.getSessionToken(), + }, + }, + }); + + const sessionToken = result.data.viewer.sessionToken; + const { objectId, userFoo: resultFoo } = result.data.viewer.user; + expect(objectId).toEqual(user.id); + expect(sessionToken).toBeDefined(); + expect(resultFoo).toEqual(null); + }); }); describe('Users Mutations', () => { @@ -7635,8 +7683,8 @@ describe('ParseGraphQLServer', () => { } }); - it('should accept different params', (done) => { - Parse.Cloud.define('hello', async (req) => { + it('should accept different params', done => { + Parse.Cloud.define('hello', async req => { expect(req.params.date instanceof Date).toBe(true); expect(req.params.date.getTime()).toBe(1463907600000); expect(req.params.dateList[0] instanceof Date).toBe(true); @@ -7772,7 +7820,7 @@ describe('ParseGraphQLServer', () => { ).data['__type']; expect(functionEnum.kind).toEqual('ENUM'); expect( - functionEnum.enumValues.map((value) => value.name).sort() + functionEnum.enumValues.map(value => value.name).sort() ).toEqual(['_underscored', 'a', 'b', 'contains1Number']); } catch (e) { handleError(e); @@ -7814,12 +7862,12 @@ describe('ParseGraphQLServer', () => { ).data['__type']; expect(functionEnum.kind).toEqual('ENUM'); expect( - functionEnum.enumValues.map((value) => value.name).sort() + functionEnum.enumValues.map(value => value.name).sort() ).toEqual(['a']); expect( parseGraphQLServer.parseGraphQLSchema.log.warn.calls .all() - .map((call) => call.args[0]) + .map(call => call.args[0]) .sort() ).toEqual([ 'Function 1NumberInTheBeggning could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.', @@ -8715,13 +8763,13 @@ describe('ParseGraphQLServer', () => { expect(result.name).toEqual('imACountry2'); expect(result.companies.edges.length).toEqual(3); expect( - result.companies.edges.some((o) => o.node.objectId === company.id) + result.companies.edges.some(o => o.node.objectId === company.id) ).toBeTruthy(); expect( - result.companies.edges.some((o) => o.node.name === 'imACompany2') + result.companies.edges.some(o => o.node.name === 'imACompany2') ).toBeTruthy(); expect( - result.companies.edges.some((o) => o.node.name === 'imACompany3') + result.companies.edges.some(o => o.node.name === 'imACompany3') ).toBeTruthy(); } ); @@ -8806,16 +8854,16 @@ describe('ParseGraphQLServer', () => { expect(result.companies.edges.length).toEqual(2); expect( result.companies.edges.some( - (c) => + c => c.node.name === 'imACompany2' && - c.node.teams.edges.some((t) => t.node.name === 'imATeam2') + c.node.teams.edges.some(t => t.node.name === 'imATeam2') ) ).toBeTruthy(); expect( result.companies.edges.some( - (c) => + c => c.node.name === 'imACompany3' && - c.node.teams.edges.some((t) => t.node.name === 'imATeam3') + c.node.teams.edges.some(t => t.node.name === 'imATeam3') ) ).toBeTruthy(); }); @@ -8884,17 +8932,13 @@ describe('ParseGraphQLServer', () => { expect(result.objectId).toEqual(country.id); expect(result.companies.edges.length).toEqual(2); expect( - result.companies.edges.some( - (o) => o.node.objectId === company2.id - ) + result.companies.edges.some(o => o.node.objectId === company2.id) ).toBeTruthy(); expect( - result.companies.edges.some((o) => o.node.name === 'imACompany3') + result.companies.edges.some(o => o.node.name === 'imACompany3') ).toBeTruthy(); expect( - result.companies.edges.some( - (o) => o.node.objectId === company1.id - ) + result.companies.edges.some(o => o.node.objectId === company1.id) ).toBeFalsy(); } ); @@ -8966,7 +9010,7 @@ describe('ParseGraphQLServer', () => { expect(result.name).toEqual('imACountry2'); expect(result.companies.edges.length).toEqual(1); expect( - result.companies.edges.some((o) => o.node.name === 'imACompany2') + result.companies.edges.some(o => o.node.name === 'imACompany2') ).toBeTruthy(); } ); @@ -9017,10 +9061,10 @@ describe('ParseGraphQLServer', () => { expect(result1.objectId).toEqual(country.id); expect(result1.companies.edges.length).toEqual(2); expect( - result1.companies.edges.some((o) => o.node.objectId === company1.id) + result1.companies.edges.some(o => o.node.objectId === company1.id) ).toBeTruthy(); expect( - result1.companies.edges.some((o) => o.node.objectId === company2.id) + result1.companies.edges.some(o => o.node.objectId === company2.id) ).toBeTruthy(); // With where @@ -9762,12 +9806,12 @@ describe('ParseGraphQLServer', () => { const { edges } = someClasses; expect(edges.length).toEqual(2); expect( - edges.find((result) => result.node.id === create1.someClass.id) - .node.someField + edges.find(result => result.node.id === create1.someClass.id).node + .someField ).toEqual(someFieldValue); expect( - edges.find((result) => result.node.id === create2.someClass.id) - .node.someField + edges.find(result => result.node.id === create2.someClass.id).node + .someField ).toEqual(someFieldValue2); } catch (e) { handleError(e); @@ -9859,7 +9903,7 @@ describe('ParseGraphQLServer', () => { const { someField } = getResult.data.someClass; expect(Array.isArray(someField)).toBeTruthy(); - expect(someField.map((element) => element.value)).toEqual( + expect(someField.map(element => element.value)).toEqual( someFieldValue ); expect(getResult.data.someClasses.edges.length).toEqual(1); @@ -10276,7 +10320,7 @@ describe('ParseGraphQLServer', () => { [46, 47], [48, 49], [44, 45], - ].map((point) => ({ + ].map(point => ({ latitude: point[0], longitude: point[1], })); @@ -10356,7 +10400,7 @@ describe('ParseGraphQLServer', () => { 'object' ); expect(getResult.data.someClass.somePolygonField).toEqual( - somePolygonFieldValue.map((geoPoint) => ({ + somePolygonFieldValue.map(geoPoint => ({ ...geoPoint, __typename: 'GeoPoint', })) @@ -10672,7 +10716,7 @@ describe('ParseGraphQLServer', () => { `, }); parseGraphQLServer.applyGraphQL(expressApp); - await new Promise((resolve) => + await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve) ); const httpLink = createUploadLink({ @@ -10797,7 +10841,7 @@ describe('ParseGraphQLServer', () => { fields: { nameUpperCase: { type: new GraphQLNonNull(GraphQLString), - resolve: (p) => p.name.toUpperCase(), + resolve: p => p.name.toUpperCase(), }, type: { type: TypeEnum }, language: { @@ -10858,7 +10902,7 @@ describe('ParseGraphQLServer', () => { }); parseGraphQLServer.applyGraphQL(expressApp); - await new Promise((resolve) => + await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve) ); const httpLink = createUploadLink({ @@ -10992,7 +11036,7 @@ describe('ParseGraphQLServer', () => { }); parseGraphQLServer.applyGraphQL(expressApp); - await new Promise((resolve) => + await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve) ); const httpLink = createUploadLink({ diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index 80eebaa733..92a90b5064 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -16,10 +16,287 @@ describe('ParseLiveQuery', function () { const query = new Parse.Query(TestObject); query.equalTo('objectId', object.id); const subscription = await query.subscribe(); - subscription.on('update', async object => { + subscription.on('update', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + it('expect afterEvent create', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Create'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + }); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + subscription.on('create', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + }); + + it('expect afterEvent payload', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Update'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + expect(req.original.get('foo')).toBeUndefined(); + done(); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + await query.subscribe(); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('expect afterEvent enter', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Enter'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + expect(req.original.get('foo')).toBeUndefined(); + }); + + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + subscription.on('enter', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + + object.set('foo', 'bar'); + await object.save(); + }); + + it('expect afterEvent leave', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Leave'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBeUndefined(); + expect(req.original.get('foo')).toBe('bar'); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + subscription.on('leave', object => { + expect(object.get('foo')).toBeUndefined(); + done(); + }); + + object.unset('foo'); + await object.save(); + }); + + it('expect afterEvent delete', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Delete'); + expect(req.user).toBeUndefined(); + req.object.set('foo', 'bar'); + }); + + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + + const subscription = await query.subscribe(); + subscription.on('delete', object => { expect(object.get('foo')).toBe('bar'); done(); }); + + await object.destroy(); + }); + + it('can handle afterEvent modification', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + const current = req.object; + current.set('foo', 'yolo'); + + const original = req.original; + original.set('yolo', 'foo'); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', (object, original) => { + expect(object.get('foo')).toBe('yolo'); + expect(original.get('yolo')).toBe('foo'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('can return different object in afterEvent', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', () => { + const object = new Parse.Object('Yolo'); + return object; + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', object => { + expect(object.className).toBe('Yolo'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('can handle async afterEvent modification', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const parent = new TestObject(); + const child = new TestObject(); + child.set('bar', 'foo'); + await Parse.Object.saveAll([parent, child]); + + Parse.Cloud.afterLiveQueryEvent('TestObject', async req => { + const current = req.object; + const pointer = current.get('child'); + await pointer.fetch(); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', parent.id); + const subscription = await query.subscribe(); + subscription.on('update', object => { + expect(object.get('child')).toBeDefined(); + expect(object.get('child').get('bar')).toBe('foo'); + done(); + }); + parent.set('child', child); + await parent.save(); + }); + + it('can handle afterEvent throw', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + const current = req.object; + const original = req.original; + + setTimeout(() => { + done(); + }, 2000); + + if (current.get('foo') != original.get('foo')) { + throw "Don't pass an update trigger, or message"; + } + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', () => { + fail('update should not have been called.'); + }); + subscription.on('error', () => { + fail('error should not have been called.'); + }); object.set({ foo: 'bar' }); await object.save(); }); @@ -56,7 +333,8 @@ describe('ParseLiveQuery', function () { const query = new Parse.Query(TestObject); query.equalTo('objectId', object.id); const subscription = await query.subscribe(); - subscription.on('update', async object => { + + subscription.on('update', object => { expect(object.get('foo')).toBe('bar'); done(); }); diff --git a/spec/ParseQuery.Aggregate.spec.js b/spec/ParseQuery.Aggregate.spec.js index b7dd291d1c..d9a684d0a9 100644 --- a/spec/ParseQuery.Aggregate.spec.js +++ b/spec/ParseQuery.Aggregate.spec.js @@ -1440,7 +1440,7 @@ describe('Parse.Query Aggregate testing', () => { ['location'], 'geoIndex', false, - '2dsphere' + { indexType: '2dsphere' }, ); // Create objects const GeoObject = Parse.Object.extend('GeoObject'); diff --git a/spec/ParseServerRESTController.spec.js b/spec/ParseServerRESTController.spec.js index b3b8ea36f3..0e9e04e796 100644 --- a/spec/ParseServerRESTController.spec.js +++ b/spec/ParseServerRESTController.spec.js @@ -3,6 +3,7 @@ const ParseServerRESTController = require('../lib/ParseServerRESTController') const ParseServer = require('../lib/ParseServer').default; const Parse = require('parse/node').Parse; const TestUtils = require('../lib/TestUtils'); +const semver = require('semver'); let RESTController; @@ -101,7 +102,7 @@ describe('ParseServerRESTController', () => { }); if ( - (process.env.MONGODB_VERSION === '4.0.4' && + (semver.satisfies(process.env.MONGODB_VERSION, '>=4.0.4') && process.env.MONGODB_TOPOLOGY === 'replicaset' && process.env.MONGODB_STORAGE_ENGINE === 'wiredTiger') || process.env.PARSE_SERVER_TEST_DB === 'postgres' @@ -109,7 +110,7 @@ describe('ParseServerRESTController', () => { describe('transactions', () => { beforeAll(async () => { if ( - process.env.MONGODB_VERSION === '4.0.4' && + semver.satisfies(process.env.MONGODB_VERSION, '>=4.0.4') && process.env.MONGODB_TOPOLOGY === 'replicaset' && process.env.MONGODB_STORAGE_ENGINE === 'wiredTiger' ) { @@ -161,9 +162,9 @@ describe('ParseServerRESTController', () => { expect(databaseAdapter.createObject.calls.argsFor(0)[3]).toBe( databaseAdapter.createObject.calls.argsFor(1)[3] ); - expect(results.map(result => result.get('key')).sort()).toEqual( - ['value1', 'value2'] - ); + expect( + results.map(result => result.get('key')).sort() + ).toEqual(['value1', 'value2']); done(); }); }); @@ -517,6 +518,22 @@ describe('ParseServerRESTController', () => { }); }); + it('should handle a POST request with context', async () => { + Parse.Cloud.beforeSave('MyObject', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.afterSave('MyObject', req => { + expect(req.context.a).toEqual('a'); + }); + + await RESTController.request( + 'POST', + '/classes/MyObject', + { key: 'value' }, + { context: { a: 'a' } } + ); + }); + it('ensures sessionTokens are properly handled', done => { let userId; Parse.User.signUp('user', 'pass') diff --git a/spec/batch.spec.js b/spec/batch.spec.js index c225be320e..9bb0cf15e1 100644 --- a/spec/batch.spec.js +++ b/spec/batch.spec.js @@ -1,6 +1,7 @@ const batch = require('../lib/batch'); const request = require('../lib/request'); const TestUtils = require('../lib/TestUtils'); +const semver = require('semver'); const originalURL = '/parse/batch'; const serverURL = 'http://localhost:1234/parse'; @@ -153,7 +154,7 @@ describe('batch', () => { }); if ( - (process.env.MONGODB_VERSION === '4.0.4' && + (semver.satisfies(process.env.MONGODB_VERSION, '>=4.0.4') && process.env.MONGODB_TOPOLOGY === 'replicaset' && process.env.MONGODB_STORAGE_ENGINE === 'wiredTiger') || process.env.PARSE_SERVER_TEST_DB === 'postgres' @@ -161,7 +162,7 @@ describe('batch', () => { describe('transactions', () => { beforeAll(async () => { if ( - process.env.MONGODB_VERSION === '4.0.4' && + semver.satisfies(process.env.MONGODB_VERSION, '>=4.0.4') && process.env.MONGODB_TOPOLOGY === 'replicaset' && process.env.MONGODB_STORAGE_ENGINE === 'wiredTiger' ) { diff --git a/spec/helper.js b/spec/helper.js index 16d25ba1b8..84e704f7e3 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -230,6 +230,7 @@ afterEach(function(done) { '_Session', '_Product', '_Audience', + '_Idempotency' ].indexOf(className) >= 0 ); } diff --git a/src/Adapters/Auth/google.js b/src/Adapters/Auth/google.js index 9dacabdd62..e156eb1afb 100644 --- a/src/Adapters/Auth/google.js +++ b/src/Adapters/Auth/google.js @@ -1,47 +1,91 @@ +"use strict"; + // Helper functions for accessing the google API. var Parse = require('parse/node').Parse; -const httpsRequest = require('./httpsRequest'); - -function validateIdToken(id, token) { - return googleRequest('tokeninfo?id_token=' + token).then(response => { - if (response && (response.sub == id || response.user_id == id)) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Google auth is invalid for this user.' - ); + +const https = require('https'); +const jwt = require('jsonwebtoken'); + +const TOKEN_ISSUER = 'accounts.google.com'; +const HTTPS_TOKEN_ISSUER = 'https://accounts.google.com'; + +let cache = {}; + + +// Retrieve Google Signin Keys (with cache control) +function getGoogleKeyByKeyId(keyId) { + if (cache[keyId] && cache.expiresAt > new Date()) { + return cache[keyId]; + } + + return new Promise((resolve, reject) => { + https.get(`https://www.googleapis.com/oauth2/v3/certs`, res => { + let data = ''; + res.on('data', chunk => { + data += chunk.toString('utf8'); + }); + res.on('end', () => { + const {keys} = JSON.parse(data); + const pems = keys.reduce((pems, {n: modulus, e: exposant, kid}) => Object.assign(pems, {[kid]: rsaPublicKeyToPEM(modulus, exposant)}), {}); + + if (res.headers['cache-control']) { + var expire = res.headers['cache-control'].match(/max-age=([0-9]+)/); + + if (expire) { + cache = Object.assign({}, pems, {expiresAt: new Date((new Date()).getTime() + Number(expire[1]) * 1000)}); + } + } + + resolve(pems[keyId]); + }); + }).on('error', reject); }); } -function validateAuthToken(id, token) { - return googleRequest('tokeninfo?access_token=' + token).then(response => { - if (response && (response.sub == id || response.user_id == id)) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Google auth is invalid for this user.' - ); - }); +function getHeaderFromToken(token) { + const decodedToken = jwt.decode(token, {complete: true}); + + if (!decodedToken) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `provided token does not decode as JWT`); + } + + return decodedToken.header; } -// Returns a promise that fulfills if this user id is valid. -function validateAuthData(authData) { - if (authData.id_token) { - return validateIdToken(authData.id, authData.id_token); - } else { - return validateAuthToken(authData.id, authData.access_token).then( - () => { - // Validation with auth token worked - return; - }, - () => { - // Try with the id_token param - return validateIdToken(authData.id, authData.access_token); - } - ); +async function verifyIdToken({id_token: token, id}, {clientId}) { + if (!token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`); + } + + const { kid: keyId, alg: algorithm } = getHeaderFromToken(token); + let jwtClaims; + const googleKey = await getGoogleKeyByKeyId(keyId); + + try { + jwtClaims = jwt.verify(token, googleKey, { algorithms: algorithm, audience: clientId }); + } catch (exception) { + const message = exception.message; + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${message}`); + } + + if (jwtClaims.iss !== TOKEN_ISSUER && jwtClaims.iss !== HTTPS_TOKEN_ISSUER) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token not issued by correct provider - expected: ${TOKEN_ISSUER} or ${HTTPS_TOKEN_ISSUER} | from: ${jwtClaims.iss}`); + } + + if (jwtClaims.sub !== id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `auth data is invalid for this user.`); } + + if (clientId && jwtClaims.aud !== clientId) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token not authorized for this clientId.`); + } + + return jwtClaims; +} + +// Returns a promise that fulfills if this user id is valid. +function validateAuthData(authData, options) { + return verifyIdToken(authData, options); } // Returns a promise that fulfills if this app id is valid. @@ -49,12 +93,58 @@ function validateAppId() { return Promise.resolve(); } -// A promisey wrapper for api requests -function googleRequest(path) { - return httpsRequest.get('https://www.googleapis.com/oauth2/v3/' + path); -} - module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData, + validateAuthData: validateAuthData }; + + +// Helpers functions to convert the RSA certs to PEM (from jwks-rsa) +function rsaPublicKeyToPEM(modulusB64, exponentB64) { + const modulus = new Buffer(modulusB64, 'base64'); + const exponent = new Buffer(exponentB64, 'base64'); + const modulusHex = prepadSigned(modulus.toString('hex')); + const exponentHex = prepadSigned(exponent.toString('hex')); + const modlen = modulusHex.length / 2; + const explen = exponentHex.length / 2; + + const encodedModlen = encodeLengthHex(modlen); + const encodedExplen = encodeLengthHex(explen); + const encodedPubkey = '30' + + encodeLengthHex(modlen + explen + encodedModlen.length / 2 + encodedExplen.length / 2 + 2) + + '02' + encodedModlen + modulusHex + + '02' + encodedExplen + exponentHex; + + const der = new Buffer(encodedPubkey, 'hex') + .toString('base64'); + + let pem = '-----BEGIN RSA PUBLIC KEY-----\n'; + pem += `${der.match(/.{1,64}/g).join('\n')}`; + pem += '\n-----END RSA PUBLIC KEY-----\n'; + return pem; +} + +function prepadSigned(hexStr) { + const msb = hexStr[0]; + if (msb < '0' || msb > '7') { + return `00${hexStr}`; + } + return hexStr; +} + +function toHex(number) { + const nstr = number.toString(16); + if (nstr.length % 2) { + return `0${nstr}`; + } + return nstr; +} + +function encodeLengthHex(n) { + if (n <= 127) { + return toHex(n); + } + const nHex = toHex(n); + const lengthOfLengthByte = 128 + nHex.length / 2; + return toHex(lengthOfLengthByte) + nHex; +} diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 7b8d2a3ef3..9e75f5bb8a 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -433,6 +433,11 @@ export class MongoStorageAdapter implements StorageAdapter { collectionUpdate['$unset'][name] = null; }); + const collectionFilter = { $or: [] }; + mongoFormatNames.forEach(name => { + collectionFilter['$or'].push({ [name]: { $exists: true } }); + }); + const schemaUpdate = { $unset: {} }; fieldNames.forEach((name) => { schemaUpdate['$unset'][name] = null; @@ -440,7 +445,7 @@ export class MongoStorageAdapter implements StorageAdapter { }); return this._adaptiveCollection(className) - .then((collection) => collection.updateMany({}, collectionUpdate)) + .then((collection) => collection.updateMany(collectionFilter, collectionUpdate)) .then(() => this._schemaCollection()) .then((schemaCollection) => schemaCollection.updateSchema(className, schemaUpdate) @@ -692,7 +697,7 @@ export class MongoStorageAdapter implements StorageAdapter { fieldNames: string[], indexName: ?string, caseInsensitive: boolean = false, - indexType: any = 1 + options?: Object = {}, ): Promise { schema = convertParseSchemaToMongoSchema(schema); const indexCreationRequest = {}; @@ -700,11 +705,12 @@ export class MongoStorageAdapter implements StorageAdapter { transformKey(className, fieldName, schema) ); mongoFieldNames.forEach((fieldName) => { - indexCreationRequest[fieldName] = indexType; + indexCreationRequest[fieldName] = options.indexType !== undefined ? options.indexType : 1; }); const defaultOptions: Object = { background: true, sparse: true }; const indexNameOptions: Object = indexName ? { name: indexName } : {}; + const ttlOptions: Object = options.ttl !== undefined ? { expireAfterSeconds: options.ttl } : {}; const caseInsensitiveOptions: Object = caseInsensitive ? { collation: MongoCollection.caseInsensitiveCollation() } : {}; @@ -712,6 +718,7 @@ export class MongoStorageAdapter implements StorageAdapter { ...defaultOptions, ...caseInsensitiveOptions, ...indexNameOptions, + ...ttlOptions, }; return this._adaptiveCollection(className) diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 6b58120778..ce7b746f4f 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1209,6 +1209,7 @@ export class PostgresStorageAdapter implements StorageAdapter { '_GlobalConfig', '_GraphQLConfig', '_Audience', + '_Idempotency', ...results.map((result) => result.className), ...joins, ]; @@ -2576,9 +2577,9 @@ export class PostgresStorageAdapter implements StorageAdapter { fieldNames: string[], indexName: ?string, caseInsensitive: boolean = false, - conn: ?any = null + options?: Object = {}, ): Promise { - conn = conn != null ? conn : this._client; + const conn = options.conn !== undefined ? options.conn : this._client; const defaultIndexName = `parse_default_${fieldNames.sort().join('_')}`; const indexNameOptions: Object = indexName != null ? { name: indexName } : { name: defaultIndexName }; diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js index 0256841b23..7031c21d7f 100644 --- a/src/Adapters/Storage/StorageAdapter.js +++ b/src/Adapters/Storage/StorageAdapter.js @@ -93,7 +93,7 @@ export interface StorageAdapter { fieldNames: string[], indexName?: string, caseSensitive?: boolean, - indexType?: any + options?: Object, ): Promise; ensureUniqueness( className: string, diff --git a/src/Config.js b/src/Config.js index 2077626ff8..214e22ca57 100644 --- a/src/Config.js +++ b/src/Config.js @@ -6,6 +6,7 @@ import AppCache from './cache'; import SchemaCache from './Controllers/SchemaCache'; import DatabaseController from './Controllers/DatabaseController'; import net from 'net'; +import { IdempotencyOptions } from './Options/Definitions'; function removeTrailingSlash(str) { if (!str) { @@ -73,6 +74,7 @@ export class Config { masterKey, readOnlyMasterKey, allowHeaders, + idempotencyOptions, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -104,14 +106,27 @@ export class Config { throw 'publicServerURL should be a valid HTTPS URL starting with https://'; } } - this.validateSessionConfiguration(sessionLength, expireInactiveSessions); - this.validateMasterKeyIps(masterKeyIps); - this.validateMaxLimit(maxLimit); - this.validateAllowHeaders(allowHeaders); + this.validateIdempotencyOptions(idempotencyOptions); + } + + static validateIdempotencyOptions(idempotencyOptions) { + if (!idempotencyOptions) { return; } + if (idempotencyOptions.ttl === undefined) { + idempotencyOptions.ttl = IdempotencyOptions.ttl.default; + } else if (!isNaN(idempotencyOptions.ttl) && idempotencyOptions.ttl <= 0) { + throw 'idempotency TTL value must be greater than 0 seconds'; + } else if (isNaN(idempotencyOptions.ttl)) { + throw 'idempotency TTL value must be a number'; + } + if (!idempotencyOptions.paths) { + idempotencyOptions.paths = IdempotencyOptions.paths.default; + } else if (!(idempotencyOptions.paths instanceof Array)) { + throw 'idempotency paths must be of an array of strings'; + } } static validateAccountLockoutPolicy(accountLockout) { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index f71350eef3..334494b1b5 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -244,6 +244,7 @@ const filterSensitiveData = ( }; import type { LoadSchemaOptions } from './types'; +import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter'; // Runs an update on the database. // Returns a promise for an object with the new values for field @@ -1566,23 +1567,42 @@ class DatabaseController { objectId: userId, }; - const ors = permFields.flatMap(key => { - // constraint for single pointer setup - const q = { - [key]: userPointer, - }; - // constraint for users-array setup - const qa = { - [key]: { $all: [userPointer] }, - }; + const queries = permFields.map(key => { + const fieldDescriptor = schema.getExpectedType(className, key); + const fieldType = + fieldDescriptor && + typeof fieldDescriptor === 'object' && + Object.prototype.hasOwnProperty.call(fieldDescriptor, 'type') + ? fieldDescriptor.type + : null; + + let queryClause; + + if (fieldType === 'Pointer') { + // constraint for single pointer setup + queryClause = { [key]: userPointer }; + } else if (fieldType === 'Array') { + // constraint for users-array setup + queryClause = { [key]: { $all: [userPointer] } }; + } else if (fieldType === 'Object') { + // constraint for object setup + queryClause = { [key]: userPointer }; + } else { + // This means that there is a CLP field of an unexpected type. This condition should not happen, which is + // why is being treated as an error. + throw Error( + `An unexpected condition occurred when resolving pointer permissions: ${className} ${key}` + ); + } // if we already have a constraint on the key, use the $and if (Object.prototype.hasOwnProperty.call(query, key)) { - return [{ $and: [q, query] }, { $and: [qa, query] }]; + return { $and: [queryClause, query] }; } // otherwise just add the constaint - return [Object.assign({}, query, q), Object.assign({}, query, qa)]; + return Object.assign({}, query, queryClause); }); - return { $or: ors }; + + return queries.length === 1 ? queries[0] : { $or: queries }; } else { return query; } @@ -1736,6 +1756,12 @@ class DatabaseController { ...SchemaController.defaultColumns._Role, }, }; + const requiredIdempotencyFields = { + fields: { + ...SchemaController.defaultColumns._Default, + ...SchemaController.defaultColumns._Idempotency, + }, + }; const userClassPromise = this.loadSchema().then(schema => schema.enforceClassExists('_User') @@ -1743,6 +1769,12 @@ class DatabaseController { const roleClassPromise = this.loadSchema().then(schema => schema.enforceClassExists('_Role') ); + const idempotencyClassPromise = + this.adapter instanceof MongoStorageAdapter + ? this.loadSchema().then(schema => + schema.enforceClassExists('_Idempotency') + ) + : Promise.resolve(); const usernameUniqueness = userClassPromise .then(() => @@ -1807,6 +1839,47 @@ class DatabaseController { throw error; }); + const idempotencyRequestIdIndex = + this.adapter instanceof MongoStorageAdapter + ? idempotencyClassPromise + .then(() => + this.adapter.ensureUniqueness( + '_Idempotency', + requiredIdempotencyFields, + ['reqId'] + ) + ) + .catch(error => { + logger.warn( + 'Unable to ensure uniqueness for idempotency request ID: ', + error + ); + throw error; + }) + : Promise.resolve(); + + const idempotencyExpireIndex = + this.adapter instanceof MongoStorageAdapter + ? idempotencyClassPromise + .then(() => + this.adapter.ensureIndex( + '_Idempotency', + requiredIdempotencyFields, + ['expire'], + 'ttl', + false, + { ttl: 0 } + ) + ) + .catch(error => { + logger.warn( + 'Unable to create TTL index for idempotency expire date: ', + error + ); + throw error; + }) + : Promise.resolve(); + const indexPromise = this.adapter.updateSchemaWithIndexes(); // Create tables for volatile classes @@ -1819,6 +1892,8 @@ class DatabaseController { emailUniqueness, emailCaseInsensitiveIndex, roleUniqueness, + idempotencyRequestIdIndex, + idempotencyExpireIndex, adapterInit, indexPromise, ]); diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index c5b04dcfc6..be8a3102b4 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -144,6 +144,10 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ lastUsed: { type: 'Date' }, timesUsed: { type: 'Number' }, }, + _Idempotency: { + reqId: { type: 'String' }, + expire: { type: 'Date' }, + } }); const requiredColumns = Object.freeze({ @@ -161,6 +165,7 @@ const systemClasses = Object.freeze([ '_JobStatus', '_JobSchedule', '_Audience', + '_Idempotency' ]); const volatileClasses = Object.freeze([ @@ -171,6 +176,7 @@ const volatileClasses = Object.freeze([ '_GraphQLConfig', '_JobSchedule', '_Audience', + '_Idempotency' ]); // Anything that start with role @@ -660,6 +666,13 @@ const _AudienceSchema = convertSchemaToAdapterSchema( classLevelPermissions: {}, }) ); +const _IdempotencySchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_Idempotency', + fields: defaultColumns._Idempotency, + classLevelPermissions: {}, + }) +); const VolatileClassesSchemas = [ _HooksSchema, _JobStatusSchema, @@ -668,6 +681,7 @@ const VolatileClassesSchemas = [ _GlobalConfigSchema, _GraphQLConfigSchema, _AudienceSchema, + _IdempotencySchema ]; const dbTypeMatchesObjectType = ( diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index a041721887..5426cf5fee 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -109,6 +109,7 @@ class ParseGraphQLServer { res.write( renderPlaygroundPage({ endpoint: this.config.graphQLPath, + version: '1.7.25', subscriptionEndpoint: this.config.subscriptionsPath, headers: { 'X-Parse-Application-Id': this.parseServer.config.appId, diff --git a/src/GraphQL/loaders/usersMutations.js b/src/GraphQL/loaders/usersMutations.js index 701929677e..09d66bd90e 100644 --- a/src/GraphQL/loaders/usersMutations.js +++ b/src/GraphQL/loaders/usersMutations.js @@ -41,7 +41,7 @@ const load = parseGraphQLSchema => { const { fields } = args; const { config, auth, info } = context; - const { sessionToken } = await objectsMutations.createObject( + const { sessionToken, objectId } = await objectsMutations.createObject( '_User', fields, config, @@ -49,15 +49,14 @@ const load = parseGraphQLSchema => { info ); - info.sessionToken = sessionToken; + context.info.sessionToken = sessionToken; return { viewer: await getUserFromSessionToken( - config, - info, + context, mutationInfo, 'viewer.user.', - true + objectId ), }; } catch (e) { @@ -120,7 +119,7 @@ const load = parseGraphQLSchema => { const { fields, authData } = args; const { config, auth, info } = context; - const { sessionToken } = await objectsMutations.createObject( + const { sessionToken, objectId } = await objectsMutations.createObject( '_User', { ...fields, authData }, config, @@ -128,15 +127,14 @@ const load = parseGraphQLSchema => { info ); - info.sessionToken = sessionToken; + context.info.sessionToken = sessionToken; return { viewer: await getUserFromSessionToken( - config, - info, + context, mutationInfo, 'viewer.user.', - true + objectId ), }; } catch (e) { @@ -183,7 +181,7 @@ const load = parseGraphQLSchema => { const { username, password } = args; const { config, auth, info } = context; - const { sessionToken } = ( + const { sessionToken, objectId } = ( await usersRouter.handleLogIn({ body: { username, @@ -196,15 +194,14 @@ const load = parseGraphQLSchema => { }) ).response; - info.sessionToken = sessionToken; + context.info.sessionToken = sessionToken; return { viewer: await getUserFromSessionToken( - config, - info, + context, mutationInfo, 'viewer.user.', - true + objectId ), }; } catch (e) { @@ -236,11 +233,10 @@ const load = parseGraphQLSchema => { const { config, auth, info } = context; const viewer = await getUserFromSessionToken( - config, - info, + context, mutationInfo, 'viewer.user.', - true + auth.user.id ); await usersRouter.handleLogOut({ diff --git a/src/GraphQL/loaders/usersQueries.js b/src/GraphQL/loaders/usersQueries.js index 6a1d3ea945..976d0b3b02 100644 --- a/src/GraphQL/loaders/usersQueries.js +++ b/src/GraphQL/loaders/usersQueries.js @@ -2,16 +2,16 @@ import { GraphQLNonNull } from 'graphql'; import getFieldNames from 'graphql-list-fields'; import Parse from 'parse/node'; import rest from '../../rest'; -import Auth from '../../Auth'; import { extractKeysAndInclude } from './parseClassTypes'; +import { Auth } from '../../Auth'; const getUserFromSessionToken = async ( - config, - info, + context, queryInfo, keysPrefix, - validatedToken + userId ) => { + const { info, config } = context; if (!info || !info.sessionToken) { throw new Parse.Error( Parse.Error.INVALID_SESSION_TOKEN, @@ -27,7 +27,7 @@ const getUserFromSessionToken = async ( const { keys } = keysAndInclude; let { include } = keysAndInclude; - if (validatedToken && !keys && !include) { + if (userId && !keys && !include) { return { sessionToken, }; @@ -35,40 +35,47 @@ const getUserFromSessionToken = async ( include = 'user'; } + if (userId) { + // We need to re create the auth context + // to avoid security breach if userId is provided + context.auth = new Auth({ + config, + isMaster: context.auth.isMaster, + user: { id: userId }, + }); + } + const options = {}; if (keys) { options.keys = keys .split(',') - .map(key => `user.${key}`) + .map(key => `${key}`) .join(','); } if (include) { options.include = include .split(',') - .map(included => `user.${included}`) + .map(included => `${included}`) .join(','); } const response = await rest.find( config, - Auth.master(config), - '_Session', - { sessionToken }, + context.auth, + '_User', + // Get the user it self from auth object + { objectId: context.auth.user.id }, options, info.clientVersion, - info.context, + info.context ); - if ( - !response.results || - response.results.length == 0 || - !response.results[0].user - ) { + if (!response.results || response.results.length == 0) { throw new Parse.Error( Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token' ); } else { - const user = response.results[0].user; + const user = response.results[0]; return { sessionToken, user, @@ -89,10 +96,8 @@ const load = parseGraphQLSchema => { type: new GraphQLNonNull(parseGraphQLSchema.viewerType), async resolve(_source, _args, context, queryInfo) { try { - const { config, info } = context; return await getUserFromSessionToken( - config, - info, + context, queryInfo, 'user.', false diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js index 5d28367961..12af52a5a0 100644 --- a/src/LiveQuery/ParseLiveQueryServer.js +++ b/src/LiveQuery/ParseLiveQueryServer.js @@ -14,6 +14,7 @@ import { runLiveQueryEventHandlers, maybeRunConnectTrigger, maybeRunSubscribeTrigger, + maybeRunAfterEventTrigger, } from '../triggers'; import { getAuthForSessionToken, Auth } from '../Auth'; import { getCacheController } from '../Controllers'; @@ -124,7 +125,7 @@ class ParseLiveQueryServer { _onAfterDelete(message: any): void { logger.verbose(Parse.applicationId + 'afterDelete is triggered'); - const deletedParseObject = message.currentParseObject.toJSON(); + let deletedParseObject = message.currentParseObject.toJSON(); const classLevelPermissions = message.classLevelPermissions; const className = deletedParseObject.className; logger.verbose( @@ -158,6 +159,7 @@ class ParseLiveQueryServer { const acl = message.currentParseObject.getACL(); // Check CLP const op = this._getCLPOperation(subscription.query); + let res; this._matchesCLP( classLevelPermissions, message.currentParseObject, @@ -173,6 +175,23 @@ class ParseLiveQueryServer { if (!isMatched) { return null; } + res = { + event: 'Delete', + sessionToken: client.sessionToken, + object: deletedParseObject, + }; + return maybeRunAfterEventTrigger('afterEvent', className, res); + }) + .then(newObj => { + if (res.object && typeof res.object.toJSON === 'function') { + deletedParseObject = res.object.toJSON(); + deletedParseObject.className = className; + } + + if (newObj && typeof newObj.toJSON === 'function') { + deletedParseObject = newObj.toJSON(); + deletedParseObject.className = newObj.className; + } client.pushDelete(requestId, deletedParseObject); }) .catch(error => { @@ -193,7 +212,7 @@ class ParseLiveQueryServer { originalParseObject = message.originalParseObject.toJSON(); } const classLevelPermissions = message.classLevelPermissions; - const currentParseObject = message.currentParseObject.toJSON(); + let currentParseObject = message.currentParseObject.toJSON(); const className = currentParseObject.className; logger.verbose( 'ClassName: %s | ObjectId: %s', @@ -243,6 +262,7 @@ class ParseLiveQueryServer { // Set current ParseObject ACL checking promise, if the object does not match // subscription, we do not need to check ACL let currentACLCheckingPromise; + let res; if (!isCurrentSubscriptionMatched) { currentACLCheckingPromise = Promise.resolve(false); } else { @@ -267,40 +287,67 @@ class ParseLiveQueryServer { currentACLCheckingPromise, ]); }) - .then( - ([isOriginalMatched, isCurrentMatched]) => { - logger.verbose( - 'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s', - originalParseObject, - currentParseObject, - isOriginalSubscriptionMatched, - isCurrentSubscriptionMatched, - isOriginalMatched, - isCurrentMatched, - subscription.hash - ); - - // Decide event type - let type; - if (isOriginalMatched && isCurrentMatched) { - type = 'Update'; - } else if (isOriginalMatched && !isCurrentMatched) { - type = 'Leave'; - } else if (!isOriginalMatched && isCurrentMatched) { - if (originalParseObject) { - type = 'Enter'; - } else { - type = 'Create'; - } + .then(([isOriginalMatched, isCurrentMatched]) => { + logger.verbose( + 'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s', + originalParseObject, + currentParseObject, + isOriginalSubscriptionMatched, + isCurrentSubscriptionMatched, + isOriginalMatched, + isCurrentMatched, + subscription.hash + ); + + // Decide event type + let type; + if (isOriginalMatched && isCurrentMatched) { + type = 'Update'; + } else if (isOriginalMatched && !isCurrentMatched) { + type = 'Leave'; + } else if (!isOriginalMatched && isCurrentMatched) { + if (originalParseObject) { + type = 'Enter'; } else { - return null; + type = 'Create'; + } + } else { + return null; + } + message.event = type; + res = { + event: type, + sessionToken: client.sessionToken, + object: currentParseObject, + original: originalParseObject, + }; + return maybeRunAfterEventTrigger('afterEvent', className, res); + }) + .then( + newObj => { + if (res.object && typeof res.object.toJSON === 'function') { + currentParseObject = res.object.toJSON(); + currentParseObject.className = className; + } + + if (res.original && typeof res.original.toJSON === 'function') { + originalParseObject = res.original.toJSON(); + originalParseObject.className = className; + } + + if (newObj && typeof newObj.toJSON === 'function') { + currentParseObject = newObj.toJSON(); + currentParseObject.className = newObj.className; + } + + const functionName = 'push' + message.event; + if (client[functionName]) { + client[functionName]( + requestId, + currentParseObject, + originalParseObject + ); } - const functionName = 'push' + type; - client[functionName]( - requestId, - currentParseObject, - originalParseObject - ); }, error => { logger.error('Matching ACL error : ', error); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 682784b1f4..3879198d68 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -3,527 +3,522 @@ This code has been generated by resources/buildConfigDefinitions.js Do not edit manually, but update Options/index.js */ -var parsers = require('./parsers'); +var parsers = require("./parsers"); module.exports.ParseServerOptions = { - accountLockout: { - env: 'PARSE_SERVER_ACCOUNT_LOCKOUT', - help: 'account lockout policy for failed login attempts', - action: parsers.objectParser, - }, - allowClientClassCreation: { - env: 'PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION', - help: 'Enable (or disable) client class creation, defaults to true', - action: parsers.booleanParser, - default: true, - }, - allowCustomObjectId: { - env: 'PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID', - help: 'Enable (or disable) custom objectId', - action: parsers.booleanParser, - default: false, - }, - allowHeaders: { - env: 'PARSE_SERVER_ALLOW_HEADERS', - help: 'Add headers to Access-Control-Allow-Headers', - action: parsers.arrayParser, - }, - allowOrigin: { - env: 'PARSE_SERVER_ALLOW_ORIGIN', - help: 'Sets the origin to Access-Control-Allow-Origin', - }, - analyticsAdapter: { - env: 'PARSE_SERVER_ANALYTICS_ADAPTER', - help: 'Adapter module for the analytics', - action: parsers.moduleOrObjectParser, - }, - appId: { - env: 'PARSE_SERVER_APPLICATION_ID', - help: 'Your Parse Application ID', - required: true, - }, - appName: { - env: 'PARSE_SERVER_APP_NAME', - help: 'Sets the app name', - }, - auth: { - env: 'PARSE_SERVER_AUTH_PROVIDERS', - help: - 'Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication', - action: parsers.objectParser, - }, - cacheAdapter: { - env: 'PARSE_SERVER_CACHE_ADAPTER', - help: 'Adapter module for the cache', - action: parsers.moduleOrObjectParser, - }, - cacheMaxSize: { - env: 'PARSE_SERVER_CACHE_MAX_SIZE', - help: 'Sets the maximum size for the in memory cache, defaults to 10000', - action: parsers.numberParser('cacheMaxSize'), - default: 10000, - }, - cacheTTL: { - env: 'PARSE_SERVER_CACHE_TTL', - help: - 'Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds)', - action: parsers.numberParser('cacheTTL'), - default: 5000, - }, - clientKey: { - env: 'PARSE_SERVER_CLIENT_KEY', - help: 'Key for iOS, MacOS, tvOS clients', - }, - cloud: { - env: 'PARSE_SERVER_CLOUD', - help: 'Full path to your cloud code main.js', - }, - cluster: { - env: 'PARSE_SERVER_CLUSTER', - help: - 'Run with cluster, optionally set the number of processes default to os.cpus().length', - action: parsers.numberOrBooleanParser, - }, - collectionPrefix: { - env: 'PARSE_SERVER_COLLECTION_PREFIX', - help: 'A collection prefix for the classes', - default: '', - }, - customPages: { - env: 'PARSE_SERVER_CUSTOM_PAGES', - help: 'custom pages for password validation and reset', - action: parsers.objectParser, - default: {}, - }, - databaseAdapter: { - env: 'PARSE_SERVER_DATABASE_ADAPTER', - help: 'Adapter module for the database', - action: parsers.moduleOrObjectParser, - }, - databaseOptions: { - env: 'PARSE_SERVER_DATABASE_OPTIONS', - help: 'Options to pass to the mongodb client', - action: parsers.objectParser, - }, - databaseURI: { - env: 'PARSE_SERVER_DATABASE_URI', - help: - 'The full URI to your database. Supported databases are mongodb or postgres.', - required: true, - default: 'mongodb://localhost:27017/parse', - }, - directAccess: { - env: 'PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS', - help: - 'Replace HTTP Interface when using JS SDK in current node runtime, defaults to false. Caution, this is an experimental feature that may not be appropriate for production.', - action: parsers.booleanParser, - default: false, - }, - dotNetKey: { - env: 'PARSE_SERVER_DOT_NET_KEY', - help: 'Key for Unity and .Net SDK', - }, - emailAdapter: { - env: 'PARSE_SERVER_EMAIL_ADAPTER', - help: 'Adapter module for email sending', - action: parsers.moduleOrObjectParser, - }, - emailVerifyTokenValidityDuration: { - env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_VALIDITY_DURATION', - help: 'Email verification token validity duration, in seconds', - action: parsers.numberParser('emailVerifyTokenValidityDuration'), - }, - enableAnonymousUsers: { - env: 'PARSE_SERVER_ENABLE_ANON_USERS', - help: 'Enable (or disable) anonymous users, defaults to true', - action: parsers.booleanParser, - default: true, - }, - enableExpressErrorHandler: { - env: 'PARSE_SERVER_ENABLE_EXPRESS_ERROR_HANDLER', - help: 'Enables the default express error handler for all errors', - action: parsers.booleanParser, - default: false, - }, - enableSingleSchemaCache: { - env: 'PARSE_SERVER_ENABLE_SINGLE_SCHEMA_CACHE', - help: - 'Use a single schema cache shared across requests. Reduces number of queries made to _SCHEMA, defaults to false, i.e. unique schema cache per request.', - action: parsers.booleanParser, - default: false, - }, - expireInactiveSessions: { - env: 'PARSE_SERVER_EXPIRE_INACTIVE_SESSIONS', - help: - 'Sets wether we should expire the inactive sessions, defaults to true', - action: parsers.booleanParser, - default: true, - }, - fileKey: { - env: 'PARSE_SERVER_FILE_KEY', - help: 'Key for your files', - }, - filesAdapter: { - env: 'PARSE_SERVER_FILES_ADAPTER', - help: 'Adapter module for the files sub-system', - action: parsers.moduleOrObjectParser, - }, - graphQLPath: { - env: 'PARSE_SERVER_GRAPHQL_PATH', - help: 'Mount path for the GraphQL endpoint, defaults to /graphql', - default: '/graphql', - }, - graphQLSchema: { - env: 'PARSE_SERVER_GRAPH_QLSCHEMA', - help: 'Full path to your GraphQL custom schema.graphql file', - }, - host: { - env: 'PARSE_SERVER_HOST', - help: 'The host to serve ParseServer on, defaults to 0.0.0.0', - default: '0.0.0.0', - }, - javascriptKey: { - env: 'PARSE_SERVER_JAVASCRIPT_KEY', - help: 'Key for the Javascript SDK', - }, - jsonLogs: { - env: 'JSON_LOGS', - help: 'Log as structured JSON objects', - action: parsers.booleanParser, - }, - liveQuery: { - env: 'PARSE_SERVER_LIVE_QUERY', - help: "parse-server's LiveQuery configuration object", - action: parsers.objectParser, - }, - liveQueryServerOptions: { - env: 'PARSE_SERVER_LIVE_QUERY_SERVER_OPTIONS', - help: - 'Live query server configuration options (will start the liveQuery server)', - action: parsers.objectParser, - }, - loggerAdapter: { - env: 'PARSE_SERVER_LOGGER_ADAPTER', - help: 'Adapter module for the logging sub-system', - action: parsers.moduleOrObjectParser, - }, - logLevel: { - env: 'PARSE_SERVER_LOG_LEVEL', - help: 'Sets the level for logs', - }, - logsFolder: { - env: 'PARSE_SERVER_LOGS_FOLDER', - help: - "Folder for the logs (defaults to './logs'); set to null to disable file based logging", - default: './logs', - }, - masterKey: { - env: 'PARSE_SERVER_MASTER_KEY', - help: 'Your Parse Master Key', - required: true, - }, - masterKeyIps: { - env: 'PARSE_SERVER_MASTER_KEY_IPS', - help: - 'Restrict masterKey to be used by only these ips, defaults to [] (allow all ips)', - action: parsers.arrayParser, - default: [], - }, - maxLimit: { - env: 'PARSE_SERVER_MAX_LIMIT', - help: 'Max value for limit option on queries, defaults to unlimited', - action: parsers.numberParser('maxLimit'), - }, - maxLogFiles: { - env: 'PARSE_SERVER_MAX_LOG_FILES', - help: - "Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null)", - action: parsers.objectParser, - }, - maxUploadSize: { - env: 'PARSE_SERVER_MAX_UPLOAD_SIZE', - help: 'Max file size for uploads, defaults to 20mb', - default: '20mb', - }, - middleware: { - env: 'PARSE_SERVER_MIDDLEWARE', - help: 'middleware for express server, can be string or function', - }, - mountGraphQL: { - env: 'PARSE_SERVER_MOUNT_GRAPHQL', - help: 'Mounts the GraphQL endpoint', - action: parsers.booleanParser, - default: false, - }, - mountPath: { - env: 'PARSE_SERVER_MOUNT_PATH', - help: 'Mount path for the server, defaults to /parse', - default: '/parse', - }, - mountPlayground: { - env: 'PARSE_SERVER_MOUNT_PLAYGROUND', - help: 'Mounts the GraphQL Playground - never use this option in production', - action: parsers.booleanParser, - default: false, - }, - objectIdSize: { - env: 'PARSE_SERVER_OBJECT_ID_SIZE', - help: "Sets the number of characters in generated object id's, default 10", - action: parsers.numberParser('objectIdSize'), - default: 10, - }, - passwordPolicy: { - env: 'PARSE_SERVER_PASSWORD_POLICY', - help: 'Password policy for enforcing password related rules', - action: parsers.objectParser, - }, - playgroundPath: { - env: 'PARSE_SERVER_PLAYGROUND_PATH', - help: 'Mount path for the GraphQL Playground, defaults to /playground', - default: '/playground', - }, - port: { - env: 'PORT', - help: 'The port to run the ParseServer, defaults to 1337.', - action: parsers.numberParser('port'), - default: 1337, - }, - preserveFileName: { - env: 'PARSE_SERVER_PRESERVE_FILE_NAME', - help: 'Enable (or disable) the addition of a unique hash to the file names', - action: parsers.booleanParser, - default: false, - }, - preventLoginWithUnverifiedEmail: { - env: 'PARSE_SERVER_PREVENT_LOGIN_WITH_UNVERIFIED_EMAIL', - help: - 'Prevent user from login if email is not verified and PARSE_SERVER_VERIFY_USER_EMAILS is true, defaults to false', - action: parsers.booleanParser, - default: false, - }, - protectedFields: { - env: 'PARSE_SERVER_PROTECTED_FIELDS', - help: - 'Protected fields that should be treated with extra security when fetching details.', - action: parsers.objectParser, - default: { - _User: { - '*': ['email'], - }, - }, - }, - publicServerURL: { - env: 'PARSE_PUBLIC_SERVER_URL', - help: 'Public URL to your parse server with http:// or https://.', - }, - push: { - env: 'PARSE_SERVER_PUSH', - help: - 'Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications', - action: parsers.objectParser, - }, - readOnlyMasterKey: { - env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', - help: - 'Read-only key, which has the same capabilities as MasterKey without writes', - }, - restAPIKey: { - env: 'PARSE_SERVER_REST_API_KEY', - help: 'Key for REST calls', - }, - revokeSessionOnPasswordReset: { - env: 'PARSE_SERVER_REVOKE_SESSION_ON_PASSWORD_RESET', - help: - "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.", - action: parsers.booleanParser, - default: true, - }, - scheduledPush: { - env: 'PARSE_SERVER_SCHEDULED_PUSH', - help: 'Configuration for push scheduling, defaults to false.', - action: parsers.booleanParser, - default: false, - }, - schemaCacheTTL: { - env: 'PARSE_SERVER_SCHEMA_CACHE_TTL', - help: - 'The TTL for caching the schema for optimizing read/write operations. You should put a long TTL when your DB is in production. default to 5000; set 0 to disable.', - action: parsers.numberParser('schemaCacheTTL'), - default: 5000, - }, - serverCloseComplete: { - env: 'PARSE_SERVER_SERVER_CLOSE_COMPLETE', - help: 'Callback when server has closed', - }, - serverStartComplete: { - env: 'PARSE_SERVER_SERVER_START_COMPLETE', - help: 'Callback when server has started', - }, - serverURL: { - env: 'PARSE_SERVER_URL', - help: 'URL to your parse server with http:// or https://.', - required: true, - }, - sessionLength: { - env: 'PARSE_SERVER_SESSION_LENGTH', - help: 'Session duration, in seconds, defaults to 1 year', - action: parsers.numberParser('sessionLength'), - default: 31536000, - }, - silent: { - env: 'SILENT', - help: 'Disables console output', - action: parsers.booleanParser, - }, - startLiveQueryServer: { - env: 'PARSE_SERVER_START_LIVE_QUERY_SERVER', - help: 'Starts the liveQuery server', - action: parsers.booleanParser, - }, - userSensitiveFields: { - env: 'PARSE_SERVER_USER_SENSITIVE_FIELDS', - help: - 'Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields', - action: parsers.arrayParser, - }, - verbose: { - env: 'VERBOSE', - help: 'Set the logging to verbose', - action: parsers.booleanParser, - }, - verifyUserEmails: { - env: 'PARSE_SERVER_VERIFY_USER_EMAILS', - help: 'Enable (or disable) user email validation, defaults to false', - action: parsers.booleanParser, - default: false, - }, - webhookKey: { - env: 'PARSE_SERVER_WEBHOOK_KEY', - help: 'Key sent with outgoing webhook calls', - }, + "accountLockout": { + "env": "PARSE_SERVER_ACCOUNT_LOCKOUT", + "help": "account lockout policy for failed login attempts", + "action": parsers.objectParser + }, + "allowClientClassCreation": { + "env": "PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION", + "help": "Enable (or disable) client class creation, defaults to true", + "action": parsers.booleanParser, + "default": true + }, + "allowCustomObjectId": { + "env": "PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID", + "help": "Enable (or disable) custom objectId", + "action": parsers.booleanParser, + "default": false + }, + "allowHeaders": { + "env": "PARSE_SERVER_ALLOW_HEADERS", + "help": "Add headers to Access-Control-Allow-Headers", + "action": parsers.arrayParser + }, + "allowOrigin": { + "env": "PARSE_SERVER_ALLOW_ORIGIN", + "help": "Sets the origin to Access-Control-Allow-Origin" + }, + "analyticsAdapter": { + "env": "PARSE_SERVER_ANALYTICS_ADAPTER", + "help": "Adapter module for the analytics", + "action": parsers.moduleOrObjectParser + }, + "appId": { + "env": "PARSE_SERVER_APPLICATION_ID", + "help": "Your Parse Application ID", + "required": true + }, + "appName": { + "env": "PARSE_SERVER_APP_NAME", + "help": "Sets the app name" + }, + "auth": { + "env": "PARSE_SERVER_AUTH_PROVIDERS", + "help": "Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication", + "action": parsers.objectParser + }, + "cacheAdapter": { + "env": "PARSE_SERVER_CACHE_ADAPTER", + "help": "Adapter module for the cache", + "action": parsers.moduleOrObjectParser + }, + "cacheMaxSize": { + "env": "PARSE_SERVER_CACHE_MAX_SIZE", + "help": "Sets the maximum size for the in memory cache, defaults to 10000", + "action": parsers.numberParser("cacheMaxSize"), + "default": 10000 + }, + "cacheTTL": { + "env": "PARSE_SERVER_CACHE_TTL", + "help": "Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds)", + "action": parsers.numberParser("cacheTTL"), + "default": 5000 + }, + "clientKey": { + "env": "PARSE_SERVER_CLIENT_KEY", + "help": "Key for iOS, MacOS, tvOS clients" + }, + "cloud": { + "env": "PARSE_SERVER_CLOUD", + "help": "Full path to your cloud code main.js" + }, + "cluster": { + "env": "PARSE_SERVER_CLUSTER", + "help": "Run with cluster, optionally set the number of processes default to os.cpus().length", + "action": parsers.numberOrBooleanParser + }, + "collectionPrefix": { + "env": "PARSE_SERVER_COLLECTION_PREFIX", + "help": "A collection prefix for the classes", + "default": "" + }, + "customPages": { + "env": "PARSE_SERVER_CUSTOM_PAGES", + "help": "custom pages for password validation and reset", + "action": parsers.objectParser, + "default": {} + }, + "databaseAdapter": { + "env": "PARSE_SERVER_DATABASE_ADAPTER", + "help": "Adapter module for the database", + "action": parsers.moduleOrObjectParser + }, + "databaseOptions": { + "env": "PARSE_SERVER_DATABASE_OPTIONS", + "help": "Options to pass to the mongodb client", + "action": parsers.objectParser + }, + "databaseURI": { + "env": "PARSE_SERVER_DATABASE_URI", + "help": "The full URI to your database. Supported databases are mongodb or postgres.", + "required": true, + "default": "mongodb://localhost:27017/parse" + }, + "directAccess": { + "env": "PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS", + "help": "Replace HTTP Interface when using JS SDK in current node runtime, defaults to false. Caution, this is an experimental feature that may not be appropriate for production.", + "action": parsers.booleanParser, + "default": false + }, + "dotNetKey": { + "env": "PARSE_SERVER_DOT_NET_KEY", + "help": "Key for Unity and .Net SDK" + }, + "emailAdapter": { + "env": "PARSE_SERVER_EMAIL_ADAPTER", + "help": "Adapter module for email sending", + "action": parsers.moduleOrObjectParser + }, + "emailVerifyTokenValidityDuration": { + "env": "PARSE_SERVER_EMAIL_VERIFY_TOKEN_VALIDITY_DURATION", + "help": "Email verification token validity duration, in seconds", + "action": parsers.numberParser("emailVerifyTokenValidityDuration") + }, + "enableAnonymousUsers": { + "env": "PARSE_SERVER_ENABLE_ANON_USERS", + "help": "Enable (or disable) anonymous users, defaults to true", + "action": parsers.booleanParser, + "default": true + }, + "enableExpressErrorHandler": { + "env": "PARSE_SERVER_ENABLE_EXPRESS_ERROR_HANDLER", + "help": "Enables the default express error handler for all errors", + "action": parsers.booleanParser, + "default": false + }, + "enableSingleSchemaCache": { + "env": "PARSE_SERVER_ENABLE_SINGLE_SCHEMA_CACHE", + "help": "Use a single schema cache shared across requests. Reduces number of queries made to _SCHEMA, defaults to false, i.e. unique schema cache per request.", + "action": parsers.booleanParser, + "default": false + }, + "expireInactiveSessions": { + "env": "PARSE_SERVER_EXPIRE_INACTIVE_SESSIONS", + "help": "Sets wether we should expire the inactive sessions, defaults to true", + "action": parsers.booleanParser, + "default": true + }, + "fileKey": { + "env": "PARSE_SERVER_FILE_KEY", + "help": "Key for your files" + }, + "filesAdapter": { + "env": "PARSE_SERVER_FILES_ADAPTER", + "help": "Adapter module for the files sub-system", + "action": parsers.moduleOrObjectParser + }, + "graphQLPath": { + "env": "PARSE_SERVER_GRAPHQL_PATH", + "help": "Mount path for the GraphQL endpoint, defaults to /graphql", + "default": "/graphql" + }, + "graphQLSchema": { + "env": "PARSE_SERVER_GRAPH_QLSCHEMA", + "help": "Full path to your GraphQL custom schema.graphql file" + }, + "host": { + "env": "PARSE_SERVER_HOST", + "help": "The host to serve ParseServer on, defaults to 0.0.0.0", + "default": "0.0.0.0" + }, + "idempotencyOptions": { + "env": "PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS", + "help": "Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production.", + "action": parsers.objectParser, + "default": {} + }, + "javascriptKey": { + "env": "PARSE_SERVER_JAVASCRIPT_KEY", + "help": "Key for the Javascript SDK" + }, + "jsonLogs": { + "env": "JSON_LOGS", + "help": "Log as structured JSON objects", + "action": parsers.booleanParser + }, + "liveQuery": { + "env": "PARSE_SERVER_LIVE_QUERY", + "help": "parse-server's LiveQuery configuration object", + "action": parsers.objectParser + }, + "liveQueryServerOptions": { + "env": "PARSE_SERVER_LIVE_QUERY_SERVER_OPTIONS", + "help": "Live query server configuration options (will start the liveQuery server)", + "action": parsers.objectParser + }, + "loggerAdapter": { + "env": "PARSE_SERVER_LOGGER_ADAPTER", + "help": "Adapter module for the logging sub-system", + "action": parsers.moduleOrObjectParser + }, + "logLevel": { + "env": "PARSE_SERVER_LOG_LEVEL", + "help": "Sets the level for logs" + }, + "logsFolder": { + "env": "PARSE_SERVER_LOGS_FOLDER", + "help": "Folder for the logs (defaults to './logs'); set to null to disable file based logging", + "default": "./logs" + }, + "masterKey": { + "env": "PARSE_SERVER_MASTER_KEY", + "help": "Your Parse Master Key", + "required": true + }, + "masterKeyIps": { + "env": "PARSE_SERVER_MASTER_KEY_IPS", + "help": "Restrict masterKey to be used by only these ips, defaults to [] (allow all ips)", + "action": parsers.arrayParser, + "default": [] + }, + "maxLimit": { + "env": "PARSE_SERVER_MAX_LIMIT", + "help": "Max value for limit option on queries, defaults to unlimited", + "action": parsers.numberParser("maxLimit") + }, + "maxLogFiles": { + "env": "PARSE_SERVER_MAX_LOG_FILES", + "help": "Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null)", + "action": parsers.objectParser + }, + "maxUploadSize": { + "env": "PARSE_SERVER_MAX_UPLOAD_SIZE", + "help": "Max file size for uploads, defaults to 20mb", + "default": "20mb" + }, + "middleware": { + "env": "PARSE_SERVER_MIDDLEWARE", + "help": "middleware for express server, can be string or function" + }, + "mountGraphQL": { + "env": "PARSE_SERVER_MOUNT_GRAPHQL", + "help": "Mounts the GraphQL endpoint", + "action": parsers.booleanParser, + "default": false + }, + "mountPath": { + "env": "PARSE_SERVER_MOUNT_PATH", + "help": "Mount path for the server, defaults to /parse", + "default": "/parse" + }, + "mountPlayground": { + "env": "PARSE_SERVER_MOUNT_PLAYGROUND", + "help": "Mounts the GraphQL Playground - never use this option in production", + "action": parsers.booleanParser, + "default": false + }, + "objectIdSize": { + "env": "PARSE_SERVER_OBJECT_ID_SIZE", + "help": "Sets the number of characters in generated object id's, default 10", + "action": parsers.numberParser("objectIdSize"), + "default": 10 + }, + "passwordPolicy": { + "env": "PARSE_SERVER_PASSWORD_POLICY", + "help": "Password policy for enforcing password related rules", + "action": parsers.objectParser + }, + "playgroundPath": { + "env": "PARSE_SERVER_PLAYGROUND_PATH", + "help": "Mount path for the GraphQL Playground, defaults to /playground", + "default": "/playground" + }, + "port": { + "env": "PORT", + "help": "The port to run the ParseServer, defaults to 1337.", + "action": parsers.numberParser("port"), + "default": 1337 + }, + "preserveFileName": { + "env": "PARSE_SERVER_PRESERVE_FILE_NAME", + "help": "Enable (or disable) the addition of a unique hash to the file names", + "action": parsers.booleanParser, + "default": false + }, + "preventLoginWithUnverifiedEmail": { + "env": "PARSE_SERVER_PREVENT_LOGIN_WITH_UNVERIFIED_EMAIL", + "help": "Prevent user from login if email is not verified and PARSE_SERVER_VERIFY_USER_EMAILS is true, defaults to false", + "action": parsers.booleanParser, + "default": false + }, + "protectedFields": { + "env": "PARSE_SERVER_PROTECTED_FIELDS", + "help": "Protected fields that should be treated with extra security when fetching details.", + "action": parsers.objectParser, + "default": { + "_User": { + "*": ["email"] + } + } + }, + "publicServerURL": { + "env": "PARSE_PUBLIC_SERVER_URL", + "help": "Public URL to your parse server with http:// or https://." + }, + "push": { + "env": "PARSE_SERVER_PUSH", + "help": "Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications", + "action": parsers.objectParser + }, + "readOnlyMasterKey": { + "env": "PARSE_SERVER_READ_ONLY_MASTER_KEY", + "help": "Read-only key, which has the same capabilities as MasterKey without writes" + }, + "restAPIKey": { + "env": "PARSE_SERVER_REST_API_KEY", + "help": "Key for REST calls" + }, + "revokeSessionOnPasswordReset": { + "env": "PARSE_SERVER_REVOKE_SESSION_ON_PASSWORD_RESET", + "help": "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.", + "action": parsers.booleanParser, + "default": true + }, + "scheduledPush": { + "env": "PARSE_SERVER_SCHEDULED_PUSH", + "help": "Configuration for push scheduling, defaults to false.", + "action": parsers.booleanParser, + "default": false + }, + "schemaCacheTTL": { + "env": "PARSE_SERVER_SCHEMA_CACHE_TTL", + "help": "The TTL for caching the schema for optimizing read/write operations. You should put a long TTL when your DB is in production. default to 5000; set 0 to disable.", + "action": parsers.numberParser("schemaCacheTTL"), + "default": 5000 + }, + "serverCloseComplete": { + "env": "PARSE_SERVER_SERVER_CLOSE_COMPLETE", + "help": "Callback when server has closed" + }, + "serverStartComplete": { + "env": "PARSE_SERVER_SERVER_START_COMPLETE", + "help": "Callback when server has started" + }, + "serverURL": { + "env": "PARSE_SERVER_URL", + "help": "URL to your parse server with http:// or https://.", + "required": true + }, + "sessionLength": { + "env": "PARSE_SERVER_SESSION_LENGTH", + "help": "Session duration, in seconds, defaults to 1 year", + "action": parsers.numberParser("sessionLength"), + "default": 31536000 + }, + "silent": { + "env": "SILENT", + "help": "Disables console output", + "action": parsers.booleanParser + }, + "startLiveQueryServer": { + "env": "PARSE_SERVER_START_LIVE_QUERY_SERVER", + "help": "Starts the liveQuery server", + "action": parsers.booleanParser + }, + "userSensitiveFields": { + "env": "PARSE_SERVER_USER_SENSITIVE_FIELDS", + "help": "Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields", + "action": parsers.arrayParser + }, + "verbose": { + "env": "VERBOSE", + "help": "Set the logging to verbose", + "action": parsers.booleanParser + }, + "verifyUserEmails": { + "env": "PARSE_SERVER_VERIFY_USER_EMAILS", + "help": "Enable (or disable) user email validation, defaults to false", + "action": parsers.booleanParser, + "default": false + }, + "webhookKey": { + "env": "PARSE_SERVER_WEBHOOK_KEY", + "help": "Key sent with outgoing webhook calls" + } }; module.exports.CustomPagesOptions = { - choosePassword: { - env: 'PARSE_SERVER_CUSTOM_PAGES_CHOOSE_PASSWORD', - help: 'choose password page path', - }, - invalidLink: { - env: 'PARSE_SERVER_CUSTOM_PAGES_INVALID_LINK', - help: 'invalid link page path', - }, - invalidVerificationLink: { - env: 'PARSE_SERVER_CUSTOM_PAGES_INVALID_VERIFICATION_LINK', - help: 'invalid verification link page path', - }, - linkSendFail: { - env: 'PARSE_SERVER_CUSTOM_PAGES_LINK_SEND_FAIL', - help: 'verification link send fail page path', - }, - linkSendSuccess: { - env: 'PARSE_SERVER_CUSTOM_PAGES_LINK_SEND_SUCCESS', - help: 'verification link send success page path', - }, - parseFrameURL: { - env: 'PARSE_SERVER_CUSTOM_PAGES_PARSE_FRAME_URL', - help: 'for masking user-facing pages', - }, - passwordResetSuccess: { - env: 'PARSE_SERVER_CUSTOM_PAGES_PASSWORD_RESET_SUCCESS', - help: 'password reset success page path', - }, - verifyEmailSuccess: { - env: 'PARSE_SERVER_CUSTOM_PAGES_VERIFY_EMAIL_SUCCESS', - help: 'verify email success page path', - }, + "choosePassword": { + "env": "PARSE_SERVER_CUSTOM_PAGES_CHOOSE_PASSWORD", + "help": "choose password page path" + }, + "invalidLink": { + "env": "PARSE_SERVER_CUSTOM_PAGES_INVALID_LINK", + "help": "invalid link page path" + }, + "invalidVerificationLink": { + "env": "PARSE_SERVER_CUSTOM_PAGES_INVALID_VERIFICATION_LINK", + "help": "invalid verification link page path" + }, + "linkSendFail": { + "env": "PARSE_SERVER_CUSTOM_PAGES_LINK_SEND_FAIL", + "help": "verification link send fail page path" + }, + "linkSendSuccess": { + "env": "PARSE_SERVER_CUSTOM_PAGES_LINK_SEND_SUCCESS", + "help": "verification link send success page path" + }, + "parseFrameURL": { + "env": "PARSE_SERVER_CUSTOM_PAGES_PARSE_FRAME_URL", + "help": "for masking user-facing pages" + }, + "passwordResetSuccess": { + "env": "PARSE_SERVER_CUSTOM_PAGES_PASSWORD_RESET_SUCCESS", + "help": "password reset success page path" + }, + "verifyEmailSuccess": { + "env": "PARSE_SERVER_CUSTOM_PAGES_VERIFY_EMAIL_SUCCESS", + "help": "verify email success page path" + } }; module.exports.LiveQueryOptions = { - classNames: { - env: 'PARSE_SERVER_LIVEQUERY_CLASSNAMES', - help: "parse-server's LiveQuery classNames", - action: parsers.arrayParser, - }, - pubSubAdapter: { - env: 'PARSE_SERVER_LIVEQUERY_PUB_SUB_ADAPTER', - help: 'LiveQuery pubsub adapter', - action: parsers.moduleOrObjectParser, - }, - redisOptions: { - env: 'PARSE_SERVER_LIVEQUERY_REDIS_OPTIONS', - help: "parse-server's LiveQuery redisOptions", - action: parsers.objectParser, - }, - redisURL: { - env: 'PARSE_SERVER_LIVEQUERY_REDIS_URL', - help: "parse-server's LiveQuery redisURL", - }, - wssAdapter: { - env: 'PARSE_SERVER_LIVEQUERY_WSS_ADAPTER', - help: 'Adapter module for the WebSocketServer', - action: parsers.moduleOrObjectParser, - }, + "classNames": { + "env": "PARSE_SERVER_LIVEQUERY_CLASSNAMES", + "help": "parse-server's LiveQuery classNames", + "action": parsers.arrayParser + }, + "pubSubAdapter": { + "env": "PARSE_SERVER_LIVEQUERY_PUB_SUB_ADAPTER", + "help": "LiveQuery pubsub adapter", + "action": parsers.moduleOrObjectParser + }, + "redisOptions": { + "env": "PARSE_SERVER_LIVEQUERY_REDIS_OPTIONS", + "help": "parse-server's LiveQuery redisOptions", + "action": parsers.objectParser + }, + "redisURL": { + "env": "PARSE_SERVER_LIVEQUERY_REDIS_URL", + "help": "parse-server's LiveQuery redisURL" + }, + "wssAdapter": { + "env": "PARSE_SERVER_LIVEQUERY_WSS_ADAPTER", + "help": "Adapter module for the WebSocketServer", + "action": parsers.moduleOrObjectParser + } }; module.exports.LiveQueryServerOptions = { - appId: { - env: 'PARSE_LIVE_QUERY_SERVER_APP_ID', - help: - 'This string should match the appId in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same appId.', - }, - cacheTimeout: { - env: 'PARSE_LIVE_QUERY_SERVER_CACHE_TIMEOUT', - help: - "Number in milliseconds. When clients provide the sessionToken to the LiveQuery server, the LiveQuery server will try to fetch its ParseUser's objectId from parse server and store it in the cache. The value defines the duration of the cache. Check the following Security section and our protocol specification for details, defaults to 30 * 24 * 60 * 60 * 1000 ms (~30 days).", - action: parsers.numberParser('cacheTimeout'), - }, - keyPairs: { - env: 'PARSE_LIVE_QUERY_SERVER_KEY_PAIRS', - help: - 'A JSON object that serves as a whitelist of keys. It is used for validating clients when they try to connect to the LiveQuery server. Check the following Security section and our protocol specification for details.', - action: parsers.objectParser, - }, - logLevel: { - env: 'PARSE_LIVE_QUERY_SERVER_LOG_LEVEL', - help: - 'This string defines the log level of the LiveQuery server. We support VERBOSE, INFO, ERROR, NONE, defaults to INFO.', - }, - masterKey: { - env: 'PARSE_LIVE_QUERY_SERVER_MASTER_KEY', - help: - 'This string should match the masterKey in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same masterKey.', - }, - port: { - env: 'PARSE_LIVE_QUERY_SERVER_PORT', - help: 'The port to run the LiveQuery server, defaults to 1337.', - action: parsers.numberParser('port'), - default: 1337, - }, - pubSubAdapter: { - env: 'PARSE_LIVE_QUERY_SERVER_PUB_SUB_ADAPTER', - help: 'LiveQuery pubsub adapter', - action: parsers.moduleOrObjectParser, - }, - redisOptions: { - env: 'PARSE_LIVE_QUERY_SERVER_REDIS_OPTIONS', - help: "parse-server's LiveQuery redisOptions", - action: parsers.objectParser, - }, - redisURL: { - env: 'PARSE_LIVE_QUERY_SERVER_REDIS_URL', - help: "parse-server's LiveQuery redisURL", - }, - serverURL: { - env: 'PARSE_LIVE_QUERY_SERVER_SERVER_URL', - help: - 'This string should match the serverURL in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same serverURL.', - }, - websocketTimeout: { - env: 'PARSE_LIVE_QUERY_SERVER_WEBSOCKET_TIMEOUT', - help: - 'Number of milliseconds between ping/pong frames. The WebSocket server sends ping/pong frames to the clients to keep the WebSocket alive. This value defines the interval of the ping/pong frame from the server to clients, defaults to 10 * 1000 ms (10 s).', - action: parsers.numberParser('websocketTimeout'), - }, - wssAdapter: { - env: 'PARSE_LIVE_QUERY_SERVER_WSS_ADAPTER', - help: 'Adapter module for the WebSocketServer', - action: parsers.moduleOrObjectParser, - }, + "appId": { + "env": "PARSE_LIVE_QUERY_SERVER_APP_ID", + "help": "This string should match the appId in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same appId." + }, + "cacheTimeout": { + "env": "PARSE_LIVE_QUERY_SERVER_CACHE_TIMEOUT", + "help": "Number in milliseconds. When clients provide the sessionToken to the LiveQuery server, the LiveQuery server will try to fetch its ParseUser's objectId from parse server and store it in the cache. The value defines the duration of the cache. Check the following Security section and our protocol specification for details, defaults to 30 * 24 * 60 * 60 * 1000 ms (~30 days).", + "action": parsers.numberParser("cacheTimeout") + }, + "keyPairs": { + "env": "PARSE_LIVE_QUERY_SERVER_KEY_PAIRS", + "help": "A JSON object that serves as a whitelist of keys. It is used for validating clients when they try to connect to the LiveQuery server. Check the following Security section and our protocol specification for details.", + "action": parsers.objectParser + }, + "logLevel": { + "env": "PARSE_LIVE_QUERY_SERVER_LOG_LEVEL", + "help": "This string defines the log level of the LiveQuery server. We support VERBOSE, INFO, ERROR, NONE, defaults to INFO." + }, + "masterKey": { + "env": "PARSE_LIVE_QUERY_SERVER_MASTER_KEY", + "help": "This string should match the masterKey in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same masterKey." + }, + "port": { + "env": "PARSE_LIVE_QUERY_SERVER_PORT", + "help": "The port to run the LiveQuery server, defaults to 1337.", + "action": parsers.numberParser("port"), + "default": 1337 + }, + "pubSubAdapter": { + "env": "PARSE_LIVE_QUERY_SERVER_PUB_SUB_ADAPTER", + "help": "LiveQuery pubsub adapter", + "action": parsers.moduleOrObjectParser + }, + "redisOptions": { + "env": "PARSE_LIVE_QUERY_SERVER_REDIS_OPTIONS", + "help": "parse-server's LiveQuery redisOptions", + "action": parsers.objectParser + }, + "redisURL": { + "env": "PARSE_LIVE_QUERY_SERVER_REDIS_URL", + "help": "parse-server's LiveQuery redisURL" + }, + "serverURL": { + "env": "PARSE_LIVE_QUERY_SERVER_SERVER_URL", + "help": "This string should match the serverURL in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same serverURL." + }, + "websocketTimeout": { + "env": "PARSE_LIVE_QUERY_SERVER_WEBSOCKET_TIMEOUT", + "help": "Number of milliseconds between ping/pong frames. The WebSocket server sends ping/pong frames to the clients to keep the WebSocket alive. This value defines the interval of the ping/pong frame from the server to clients, defaults to 10 * 1000 ms (10 s).", + "action": parsers.numberParser("websocketTimeout") + }, + "wssAdapter": { + "env": "PARSE_LIVE_QUERY_SERVER_WSS_ADAPTER", + "help": "Adapter module for the WebSocketServer", + "action": parsers.moduleOrObjectParser + } +}; +module.exports.IdempotencyOptions = { + "paths": { + "env": "PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_PATHS", + "help": "An array of paths for which the feature should be enabled. The mount path must not be included, for example instead of `/parse/functions/myFunction` specifiy `functions/myFunction`. The entries are interpreted as regular expression, for example `functions/.*` matches all functions, `jobs/.*` matches all jobs, `classes/.*` matches all classes, `.*` matches all paths.", + "action": parsers.arrayParser, + "default": [] + }, + "ttl": { + "env": "PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_TTL", + "help": "The duration in seconds after which a request record is discarded from the database, defaults to 300s.", + "action": parsers.numberParser("ttl"), + "default": 300 + } }; diff --git a/src/Options/docs.js b/src/Options/docs.js index 0e24594bcb..78821a0543 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -33,6 +33,7 @@ * @property {String} graphQLPath Mount path for the GraphQL endpoint, defaults to /graphql * @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file * @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0 + * @property {IdempotencyOptions} idempotencyOptions Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production. * @property {String} javascriptKey Key for the Javascript SDK * @property {Boolean} jsonLogs Log as structured JSON objects * @property {LiveQueryOptions} liveQuery parse-server's LiveQuery configuration object @@ -111,3 +112,10 @@ * @property {Number} websocketTimeout Number of milliseconds between ping/pong frames. The WebSocket server sends ping/pong frames to the clients to keep the WebSocket alive. This value defines the interval of the ping/pong frame from the server to clients, defaults to 10 * 1000 ms (10 s). * @property {Adapter} wssAdapter Adapter module for the WebSocketServer */ + +/** + * @interface IdempotencyOptions + * @property {String[]} paths An array of paths for which the feature should be enabled. The mount path must not be included, for example instead of `/parse/functions/myFunction` specifiy `functions/myFunction`. The entries are interpreted as regular expression, for example `functions/.*` matches all functions, `jobs/.*` matches all jobs, `classes/.*` matches all classes, `.*` matches all paths. + * @property {Number} ttl The duration in seconds after which a request record is discarded from the database, defaults to 300s. + */ + diff --git a/src/Options/index.js b/src/Options/index.js index d4c09dd790..0e97d84b5a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -188,6 +188,10 @@ export interface ParseServerOptions { startLiveQueryServer: ?boolean; /* Live query server configuration options (will start the liveQuery server) */ liveQueryServerOptions: ?LiveQueryServerOptions; + /* Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production. + :ENV: PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS + :DEFAULT: false */ + idempotencyOptions: ?IdempotencyOptions; /* Full path to your GraphQL custom schema.graphql file */ graphQLSchema: ?string; /* Mounts the GraphQL endpoint @@ -272,3 +276,12 @@ export interface LiveQueryServerOptions { /* Adapter module for the WebSocketServer */ wssAdapter: ?Adapter; } + +export interface IdempotencyOptions { + /* An array of paths for which the feature should be enabled. The mount path must not be included, for example instead of `/parse/functions/myFunction` specifiy `functions/myFunction`. The entries are interpreted as regular expression, for example `functions/.*` matches all functions, `jobs/.*` matches all jobs, `classes/.*` matches all classes, `.*` matches all paths. + :DEFAULT: [] */ + paths: ?(string[]); + /* The duration in seconds after which a request record is discarded from the database, defaults to 300s. + :DEFAULT: 300 */ + ttl: ?number; +} diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index b5bba161fa..23e3016b39 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -2,6 +2,7 @@ import PromiseRouter from '../PromiseRouter'; import rest from '../rest'; import _ from 'lodash'; import Parse from 'parse/node'; +import { promiseEnsureIdempotency } from '../middlewares'; const ALLOWED_GET_QUERY_KEYS = [ 'keys', @@ -247,10 +248,10 @@ export class ClassesRouter extends PromiseRouter { this.route('GET', '/classes/:className/:objectId', req => { return this.handleGet(req); }); - this.route('POST', '/classes/:className', req => { + this.route('POST', '/classes/:className', promiseEnsureIdempotency, req => { return this.handleCreate(req); }); - this.route('PUT', '/classes/:className/:objectId', req => { + this.route('PUT', '/classes/:className/:objectId', promiseEnsureIdempotency, req => { return this.handleUpdate(req); }); this.route('DELETE', '/classes/:className/:objectId', req => { diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index c31eeb031f..a90e5c7491 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -4,7 +4,7 @@ var Parse = require('parse/node').Parse, triggers = require('../triggers'); import PromiseRouter from '../PromiseRouter'; -import { promiseEnforceMasterKeyAccess } from '../middlewares'; +import { promiseEnforceMasterKeyAccess, promiseEnsureIdempotency } from '../middlewares'; import { jobStatusHandler } from '../StatusHandler'; import _ from 'lodash'; import { logger } from '../logger'; @@ -34,11 +34,13 @@ export class FunctionsRouter extends PromiseRouter { this.route( 'POST', '/functions/:functionName', + promiseEnsureIdempotency, FunctionsRouter.handleCloudFunction ); this.route( 'POST', '/jobs/:jobName', + promiseEnsureIdempotency, promiseEnforceMasterKeyAccess, function (req) { return FunctionsRouter.handleCloudJob(req); diff --git a/src/Routers/InstallationsRouter.js b/src/Routers/InstallationsRouter.js index 28876b7f31..f87f54c605 100644 --- a/src/Routers/InstallationsRouter.js +++ b/src/Routers/InstallationsRouter.js @@ -2,6 +2,7 @@ import ClassesRouter from './ClassesRouter'; import rest from '../rest'; +import { promiseEnsureIdempotency } from '../middlewares'; export class InstallationsRouter extends ClassesRouter { className() { @@ -36,10 +37,10 @@ export class InstallationsRouter extends ClassesRouter { this.route('GET', '/installations/:objectId', req => { return this.handleGet(req); }); - this.route('POST', '/installations', req => { + this.route('POST', '/installations', promiseEnsureIdempotency, req => { return this.handleCreate(req); }); - this.route('PUT', '/installations/:objectId', req => { + this.route('PUT', '/installations/:objectId', promiseEnsureIdempotency, req => { return this.handleUpdate(req); }); this.route('DELETE', '/installations/:objectId', req => { diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 05d0081792..9d015f9659 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -8,6 +8,7 @@ import rest from '../rest'; import Auth from '../Auth'; import passwordCrypto from '../password'; import { maybeRunTrigger, Types as TriggerTypes } from '../triggers'; +import { promiseEnsureIdempotency } from '../middlewares'; export class UsersRouter extends ClassesRouter { className() { @@ -445,7 +446,7 @@ export class UsersRouter extends ClassesRouter { this.route('GET', '/users', req => { return this.handleFind(req); }); - this.route('POST', '/users', req => { + this.route('POST', '/users', promiseEnsureIdempotency, req => { return this.handleCreate(req); }); this.route('GET', '/users/me', req => { @@ -454,7 +455,7 @@ export class UsersRouter extends ClassesRouter { this.route('GET', '/users/:objectId', req => { return this.handleGet(req); }); - this.route('PUT', '/users/:objectId', req => { + this.route('PUT', '/users/:objectId', promiseEnsureIdempotency, req => { return this.handleUpdate(req); }); this.route('DELETE', '/users/:objectId', req => { diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 088c4dc3c1..2ea210bccc 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -511,6 +511,16 @@ ParseCloud.onLiveQueryEvent = function (handler) { triggers.addLiveQueryEventHandler(handler, Parse.applicationId); }; +ParseCloud.afterLiveQueryEvent = function (parseClass, handler) { + const className = getClassName(parseClass); + triggers.addTrigger( + triggers.Types.afterEvent, + className, + handler, + Parse.applicationId + ); +}; + ParseCloud._removeAllHooks = () => { triggers._unregisterAll(); }; diff --git a/src/middlewares.js b/src/middlewares.js index a73f4c8105..526372ba39 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -4,9 +4,11 @@ import auth from './Auth'; import Config from './Config'; import ClientSDK from './ClientSDK'; import defaultLogger from './logger'; +import rest from './rest'; +import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter'; export const DEFAULT_ALLOWED_HEADERS = - 'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, Content-Type, Pragma, Cache-Control'; + 'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, X-Parse-Request-Id, Content-Type, Pragma, Cache-Control'; const getMountForRequest = function (req) { const mountPathLength = req.originalUrl.length - req.url.length; @@ -406,6 +408,52 @@ export function promiseEnforceMasterKeyAccess(request) { return Promise.resolve(); } +/** + * Deduplicates a request to ensure idempotency. Duplicates are determined by the request ID + * in the request header. If a request has no request ID, it is executed anyway. + * @param {*} req The request to evaluate. + * @returns Promise<{}> + */ +export function promiseEnsureIdempotency(req) { + // Enable feature only for MongoDB + if (!(req.config.database.adapter instanceof MongoStorageAdapter)) { return Promise.resolve(); } + // Get parameters + const config = req.config; + const requestId = ((req || {}).headers || {})["x-parse-request-id"]; + const { paths, ttl } = config.idempotencyOptions; + if (!requestId || !config.idempotencyOptions) { return Promise.resolve(); } + // Request path may contain trailing slashes, depending on the original request, so remove + // leading and trailing slashes to make it easier to specify paths in the configuration + const reqPath = req.path.replace(/^\/|\/$/, ''); + // Determine whether idempotency is enabled for current request path + let match = false; + for (const path of paths) { + // Assume one wants a path to always match from the beginning to prevent any mistakes + const regex = new RegExp(path.charAt(0) === '^' ? path : '^' + path); + if (reqPath.match(regex)) { + match = true; + break; + } + } + if (!match) { return Promise.resolve(); } + // Try to store request + const expiryDate = new Date(new Date().setSeconds(new Date().getSeconds() + ttl)); + return rest.create( + config, + auth.master(config), + '_Idempotency', + { reqId: requestId, expire: Parse._encode(expiryDate) } + ).catch (e => { + if (e.code == Parse.Error.DUPLICATE_VALUE) { + throw new Parse.Error( + Parse.Error.DUPLICATE_REQUEST, + 'Duplicate request' + ); + } + throw e; + }); +} + function invalidRequest(req, res) { res.status(403); res.end('{"error":"unauthorized"}'); diff --git a/src/rest.js b/src/rest.js index 812a0da7f7..e3ffe2a38e 100644 --- a/src/rest.js +++ b/src/rest.js @@ -284,6 +284,7 @@ const classesWithMasterOnlyAccess = [ '_Hooks', '_GlobalConfig', '_JobSchedule', + '_Idempotency', ]; // Disallowing access to the _Role collection except by master key function enforceRoleSecurity(method, className, auth) { diff --git a/src/triggers.js b/src/triggers.js index 96dcb65e47..75c26ff039 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -18,6 +18,7 @@ export const Types = { afterDeleteFile: 'afterDeleteFile', beforeConnect: 'beforeConnect', beforeSubscribe: 'beforeSubscribe', + afterEvent: 'afterEvent', }; const FileClassName = '@File'; @@ -797,6 +798,25 @@ export async function maybeRunSubscribeTrigger( return trigger(request); } +export async function maybeRunAfterEventTrigger( + triggerType, + className, + request +) { + const trigger = getTrigger(className, triggerType, Parse.applicationId); + if (!trigger) { + return; + } + if (request.object) { + request.object = Parse.Object.fromJSON(request.object); + } + if (request.original) { + request.original = Parse.Object.fromJSON(request.original); + } + request.user = await userForSessionToken(request.sessionToken); + return trigger(request); +} + async function userForSessionToken(sessionToken) { if (!sessionToken) { return;