From 89fad468c3a43772879c06c4d939a83b72517a8e Mon Sep 17 00:00:00 2001 From: Rahul Lanjewar <63550998+RahulLanjewar93@users.noreply.github.com> Date: Fri, 3 Oct 2025 18:05:34 +0530 Subject: [PATCH 01/30] feat: Add option `keepUnknownIndexes` to retain indexes which are not specified in schema (#9857) --- spec/DefinedSchemas.spec.js | 49 +++++++++++++++++++++++++- src/Options/Definitions.js | 7 ++++ src/Options/docs.js | 1 + src/Options/index.js | 3 ++ src/SchemaMigrations/DefinedSchemas.js | 5 ++- src/SchemaMigrations/Migrations.js | 1 + 6 files changed, 64 insertions(+), 2 deletions(-) diff --git a/spec/DefinedSchemas.spec.js b/spec/DefinedSchemas.spec.js index e3d6fd51fe..b2cae864c1 100644 --- a/spec/DefinedSchemas.spec.js +++ b/spec/DefinedSchemas.spec.js @@ -371,7 +371,7 @@ describe('DefinedSchemas', () => { expect(schema.indexes).toEqual(indexes); }); - it('should delete removed indexes', async () => { + it('should delete unknown indexes when keepUnknownIndexes is not set', async () => { const server = await reconfigureServer(); let indexes = { complex: { createdAt: 1, updatedAt: 1 } }; @@ -393,6 +393,53 @@ describe('DefinedSchemas', () => { cleanUpIndexes(schema); expect(schema.indexes).toBeUndefined(); }); + + it('should delete unknown indexes when keepUnknownIndexes is set to false', async () => { + const server = await reconfigureServer(); + + let indexes = { complex: { createdAt: 1, updatedAt: 1 } }; + + let schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: false }; + await new DefinedSchemas(schemas, server.config).execute(); + + indexes = {}; + schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: false }; + // Change indexes + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toBeUndefined(); + + // Update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toBeUndefined(); + }); + + it('should not delete unknown indexes when keepUnknownIndexes is set to true', async () => { + const server = await reconfigureServer(); + + const indexes = { complex: { createdAt: 1, updatedAt: 1 } }; + + let schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: true }; + await new DefinedSchemas(schemas, server.config).execute(); + + schemas = { definitions: [{ className: 'Test', indexes: {} }], keepUnknownIndexes: true }; + + // Change indexes + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual({ complex: { createdAt: 1, updatedAt: 1 } }); + + // Update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual(indexes); + }); + xit('should keep protected indexes', async () => { const server = await reconfigureServer(); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index a23a0de3e5..ab25b1017d 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -28,6 +28,13 @@ module.exports.SchemaOptions = { action: parsers.booleanParser, default: false, }, + keepUnknownIndexes: { + env: 'PARSE_SERVER_SCHEMA_KEEP_UNKNOWN_INDEXES', + help: + "(Optional) Keep indexes that are present in the database but not defined in the schema. Set this to `true` if you are adding indexes manually, so that they won't be removed when running schema migration. Default is `false`.", + action: parsers.booleanParser, + default: false, + }, lockSchemas: { env: 'PARSE_SERVER_SCHEMA_LOCK_SCHEMAS', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index bfba129bb2..0a8df6d137 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -4,6 +4,7 @@ * @property {Function} beforeMigration Execute a callback before running schema migrations. * @property {Any} definitions Rest representation on Parse.Schema https://docs.parseplatform.org/rest/guide/#adding-a-schema * @property {Boolean} deleteExtraFields Is true if Parse Server should delete any fields not defined in a schema definition. This should only be used during development. + * @property {Boolean} keepUnknownIndexes (Optional) Keep indexes that are present in the database but not defined in the schema. Set this to `true` if you are adding indexes manually, so that they won't be removed when running schema migration. Default is `false`. * @property {Boolean} lockSchemas Is true if Parse Server will reject any attempts to modify the schema while the server is running. * @property {Boolean} recreateModifiedFields Is true if Parse Server should recreate any fields that are different between the current database schema and theschema definition. This should only be used during development. * @property {Boolean} strict Is true if Parse Server should exit if schema update fail. diff --git a/src/Options/index.js b/src/Options/index.js index b1827d808a..9f70700345 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -25,6 +25,9 @@ export interface SchemaOptions { /* Is true if Parse Server will reject any attempts to modify the schema while the server is running. :DEFAULT: false */ lockSchemas: ?boolean; + /* (Optional) Keep indexes that are present in the database but not defined in the schema. Set this to `true` if you are adding indexes manually, so that they won't be removed when running schema migration. Default is `false`. + :DEFAULT: false */ + keepUnknownIndexes: ?boolean; /* Execute a callback before running schema migrations. */ beforeMigration: ?() => void | Promise; /* Execute a callback after running schema migrations. */ diff --git a/src/SchemaMigrations/DefinedSchemas.js b/src/SchemaMigrations/DefinedSchemas.js index cf2b1761f4..0a94a5e4ad 100644 --- a/src/SchemaMigrations/DefinedSchemas.js +++ b/src/SchemaMigrations/DefinedSchemas.js @@ -349,7 +349,10 @@ export class DefinedSchemas { Object.keys(cloudSchema.indexes).forEach(indexName => { if (!this.isProtectedIndex(localSchema.className, indexName)) { if (!localSchema.indexes || !localSchema.indexes[indexName]) { - newLocalSchema.deleteIndex(indexName); + // If keepUnknownIndex is falsy, then delete all unknown indexes from the db. + if(!this.schemaOptions.keepUnknownIndexes){ + newLocalSchema.deleteIndex(indexName); + } } else if ( !this.paramsAreEquals(localSchema.indexes[indexName], cloudSchema.indexes[indexName]) ) { diff --git a/src/SchemaMigrations/Migrations.js b/src/SchemaMigrations/Migrations.js index 8768911189..23499bdba7 100644 --- a/src/SchemaMigrations/Migrations.js +++ b/src/SchemaMigrations/Migrations.js @@ -6,6 +6,7 @@ export interface SchemaOptions { deleteExtraFields: ?boolean; recreateModifiedFields: ?boolean; lockSchemas: ?boolean; + keepUnknownIndexes: ?boolean; beforeMigration: ?() => void | Promise; afterMigration: ?() => void | Promise; } From f755a5df21ff91e6854a6ce81fe9904b5c78f0a2 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 3 Oct 2025 12:36:25 +0000 Subject: [PATCH 02/30] chore(release): 8.3.0-alpha.1 [skip ci] # [8.3.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.5...8.3.0-alpha.1) (2025-10-03) ### Features * Add option `keepUnknownIndexes` to retain indexes which are not specified in schema ([#9857](https://github.com/parse-community/parse-server/issues/9857)) ([89fad46](https://github.com/parse-community/parse-server/commit/89fad468c3a43772879c06c4d939a83b72517a8e)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 0467ddaac2..f4cfa070eb 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.5...8.3.0-alpha.1) (2025-10-03) + + +### Features + +* Add option `keepUnknownIndexes` to retain indexes which are not specified in schema ([#9857](https://github.com/parse-community/parse-server/issues/9857)) ([89fad46](https://github.com/parse-community/parse-server/commit/89fad468c3a43772879c06c4d939a83b72517a8e)) + ## [8.2.5-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.4...8.2.5-alpha.1) (2025-09-21) diff --git a/package-lock.json b/package-lock.json index 0c4f4c4680..8029d405dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.2.5", + "version": "8.3.0-alpha.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.2.5", + "version": "8.3.0-alpha.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 1de6321911..25dde9be54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.2.5", + "version": "8.3.0-alpha.1", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 7cb962a02845f3dded61baffd84515f94b66ee50 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:38:41 +0200 Subject: [PATCH 03/30] feat: Add regex option `u` for unicode support in `Parse.Query.matches` for MongoDB (#9867) --- spec/ParseQuery.spec.js | 10 ++++++++++ src/Controllers/DatabaseController.js | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index 0e4039979a..98ef70564f 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -2113,6 +2113,16 @@ describe('Parse.Query testing', () => { .then(done); }); + it_id('351f57a8-e00a-4da2-887d-6e25c9e359fc')(it)('regex with unicode option', async function () { + const thing = new TestObject(); + thing.set('myString', 'hello 世界'); + await Parse.Object.saveAll([thing]); + const query = new Parse.Query(TestObject); + query.matches('myString', '世界', 'u'); + const results = await query.find(); + equal(results.length, 1); + }); + it_id('823852f6-1de5-45ba-a2b9-ed952fcc6012')(it)('Use a regex that requires all modifiers', function (done) { const thing = new TestObject(); thing.set('myString', 'PArSe\nCom'); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 0050216e2c..095c2e83c1 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -111,7 +111,7 @@ const validateQuery = ( Object.keys(query).forEach(key => { if (query && query[key] && query[key].$regex) { if (typeof query[key].$options === 'string') { - if (!query[key].$options.match(/^[imxs]+$/)) { + if (!query[key].$options.match(/^[imxsu]+$/)) { throw new Parse.Error( Parse.Error.INVALID_QUERY, `Bad $options value for query: ${query[key].$options}` From f62da3f3c506feed941819aa5ed68e6c9b3d4ff5 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 3 Oct 2025 14:39:31 +0000 Subject: [PATCH 04/30] chore(release): 8.3.0-alpha.2 [skip ci] # [8.3.0-alpha.2](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.1...8.3.0-alpha.2) (2025-10-03) ### Features * Add regex option `u` for unicode support in `Parse.Query.matches` for MongoDB ([#9867](https://github.com/parse-community/parse-server/issues/9867)) ([7cb962a](https://github.com/parse-community/parse-server/commit/7cb962a02845f3dded61baffd84515f94b66ee50)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index f4cfa070eb..276612563d 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.2](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.1...8.3.0-alpha.2) (2025-10-03) + + +### Features + +* Add regex option `u` for unicode support in `Parse.Query.matches` for MongoDB ([#9867](https://github.com/parse-community/parse-server/issues/9867)) ([7cb962a](https://github.com/parse-community/parse-server/commit/7cb962a02845f3dded61baffd84515f94b66ee50)) + # [8.3.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.5...8.3.0-alpha.1) (2025-10-03) diff --git a/package-lock.json b/package-lock.json index 8029d405dd..8aa6f6c402 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.1", + "version": "8.3.0-alpha.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.1", + "version": "8.3.0-alpha.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 25dde9be54..e9eda2a2a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.1", + "version": "8.3.0-alpha.2", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From d275c1806e0a5a037cc06cde7eefff3e12c91d7d Mon Sep 17 00:00:00 2001 From: Corey Date: Tue, 7 Oct 2025 03:02:58 -0700 Subject: [PATCH 05/30] feat: Add support for Postgres 18 (#9870) --- .github/workflows/ci.yml | 3 +++ README.md | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bebc7b570..5aaa5ff9cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -238,6 +238,9 @@ jobs: - name: PostgreSQL 17, PostGIS 3.5 POSTGRES_IMAGE: postgis/postgis:17-3.5 NODE_VERSION: 22.12.0 + - name: PostgreSQL 18, PostGIS 3.6 + POSTGRES_IMAGE: postgis/postgis:18-3.6 + NODE_VERSION: 22.12.0 fail-fast: false name: ${{ matrix.name }} timeout-minutes: 20 diff --git a/README.md b/README.md index 9cd61ec59c..9103e0ecfb 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Node Version](https://img.shields.io/badge/nodejs-18,_20,_22-green.svg?logo=node.js&style=flat)](https://nodejs.org) [![MongoDB Version](https://img.shields.io/badge/mongodb-6,_7,_8-green.svg?logo=mongodb&style=flat)](https://www.mongodb.com) -[![Postgres Version](https://img.shields.io/badge/postgresql-13,_14,_15,_16,_17-green.svg?logo=postgresql&style=flat)](https://www.postgresql.org) +[![Postgres Version](https://img.shields.io/badge/postgresql-13,_14,_15,_16,_17,_18-green.svg?logo=postgresql&style=flat)](https://www.postgresql.org) [![npm latest version](https://img.shields.io/npm/v/parse-server/latest.svg)](https://www.npmjs.com/package/parse-server) [![npm alpha version](https://img.shields.io/npm/v/parse-server/alpha.svg)](https://www.npmjs.com/package/parse-server) @@ -152,6 +152,7 @@ Parse Server is continuously tested with the most recent releases of PostgreSQL | Postgres 15 | 3.3, 3.4, 3.5 | November 2027 | <= 8.x (2025) | | Postgres 16 | 3.5 | November 2028 | <= 9.x (2026) | | Postgres 17 | 3.5 | November 2029 | <= 10.x (2027) | +| Postgres 18 | 3.6 | November 2030 | <= 11.x (2028) | ### Locally From be362fe59d4fdff78b4356ad979a123ddbfcfaca Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 7 Oct 2025 10:03:57 +0000 Subject: [PATCH 06/30] chore(release): 8.3.0-alpha.3 [skip ci] # [8.3.0-alpha.3](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.2...8.3.0-alpha.3) (2025-10-07) ### Features * Add support for Postgres 18 ([#9870](https://github.com/parse-community/parse-server/issues/9870)) ([d275c18](https://github.com/parse-community/parse-server/commit/d275c1806e0a5a037cc06cde7eefff3e12c91d7d)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 276612563d..3095cc76db 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.3](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.2...8.3.0-alpha.3) (2025-10-07) + + +### Features + +* Add support for Postgres 18 ([#9870](https://github.com/parse-community/parse-server/issues/9870)) ([d275c18](https://github.com/parse-community/parse-server/commit/d275c1806e0a5a037cc06cde7eefff3e12c91d7d)) + # [8.3.0-alpha.2](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.1...8.3.0-alpha.2) (2025-10-03) diff --git a/package-lock.json b/package-lock.json index 8aa6f6c402..30c37e58d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.2", + "version": "8.3.0-alpha.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.2", + "version": "8.3.0-alpha.3", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index e9eda2a2a6..ae828051b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.2", + "version": "8.3.0-alpha.3", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From f024d532f03fce3d4072a6284be2a7d0b4c19db8 Mon Sep 17 00:00:00 2001 From: Corey Date: Wed, 8 Oct 2025 15:19:46 -0700 Subject: [PATCH 07/30] refactor: Upgrade pg-promise to 12.2.0 (#9874) --- package-lock.json | 172 +++++++++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 87 insertions(+), 87 deletions(-) diff --git a/package-lock.json b/package-lock.json index 30c37e58d5..466f37922a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "parse": "6.1.1", "path-to-regexp": "6.3.0", "pg-monitor": "3.0.0", - "pg-promise": "11.14.0", + "pg-promise": "12.2.0", "pluralize": "8.0.0", "punycode": "2.3.1", "rate-limit-redis": "4.2.0", @@ -18784,22 +18784,22 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "node_modules/pg": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", - "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.7.0", - "pg-pool": "^3.8.0", - "pg-protocol": "^1.8.0", - "pg-types": "^2.1.0", - "pgpass": "1.x" + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" }, "engines": { - "node": ">= 8.0.0" + "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.1.1" + "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -18811,22 +18811,22 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", - "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", - "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", "license": "MIT" }, "node_modules/pg-cursor": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.13.1.tgz", - "integrity": "sha512-t7niROd7/BVlRn2juI0S0MP/Ps87lNMpmnxMRQMOH0fboL0n7gH/MxpymSdR4rZRcPfoR3Sx47JG1u5JOJf6Gg==", + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.15.3.tgz", + "integrity": "sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA==", "license": "MIT", "peer": true, "peerDependencies": { @@ -18843,12 +18843,12 @@ } }, "node_modules/pg-minify": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.7.0.tgz", - "integrity": "sha512-kFPxAWAhPMvOqnY7klP3scdU5R7bxpAYOm8vGExuIkcSIwuFkZYl4C4XIPQ8DtXY2NzVmAX1aFHpvFSXQ/qQmA==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.8.0.tgz", + "integrity": "sha512-jO/oJOununpx8DzKgvSsWm61P8JjwXlaxSlbbfTBo1nvSWoo/+I6qZYaSN96jm/KDwa5d+JMQwPGgcP6HXDRow==", "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0" } }, "node_modules/pg-monitor": { @@ -18864,46 +18864,46 @@ } }, "node_modules/pg-pool": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", - "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-promise": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-11.14.0.tgz", - "integrity": "sha512-x/HZ6hK0MxYllyfUbmN/XZc7JBYoow7KElyNW9hnlhgRHMiRZmRUtfNM/wcuElpjSoASPxkoIKi4IA5QlwOONA==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-12.2.0.tgz", + "integrity": "sha512-th+GB7ftaRv5gAjSVslURSaYr5gf8d+T9/h5dZTJ/uyMqnQV8lJ8cDo3p5Crv3rprLC8ZCav9yLFcMKnobib+g==", "license": "MIT", "dependencies": { "assert-options": "0.8.3", - "pg": "8.14.1", - "pg-minify": "1.7.0", - "spex": "3.4.1" + "pg": "8.16.3", + "pg-minify": "1.8.0", + "spex": "4.0.2" }, "engines": { - "node": ">=14.0" + "node": ">=16.0" }, "peerDependencies": { - "pg-query-stream": "4.8.1" + "pg-query-stream": "4.10.3" } }, "node_modules/pg-protocol": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", - "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", "license": "MIT" }, "node_modules/pg-query-stream": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.8.1.tgz", - "integrity": "sha512-kZo6C6HSzYFF6mlwl+etDk5QZD9CMdlHUXpof6PkK9+CHHaBLvOd2lZMwErOOpC/ldg4thrAojS8sG1B8PZ9Yw==", + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.10.3.tgz", + "integrity": "sha512-h2utrzpOIzeT9JfaqfvBbVuvCfBjH86jNfVrGGTbyepKAIOyTfDew0lAt8bbJjs9n/I5bGDl7S2sx6h5hPyJxw==", "license": "MIT", "peer": true, "dependencies": { - "pg-cursor": "^2.13.1" + "pg-cursor": "^2.15.3" }, "peerDependencies": { "pg": "^8" @@ -21071,12 +21071,12 @@ "dev": true }, "node_modules/spex": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/spex/-/spex-3.4.1.tgz", - "integrity": "sha512-Br0Mu3S+c70kr4keXF+6K4B8ohR+aJjI9s7SbdsI3hliE1Riz4z+FQk7FQL+r7X1t90KPkpuKwQyITpCIQN9mg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spex/-/spex-4.0.2.tgz", + "integrity": "sha512-/8VnouFOkRlkfj/sDN6GYnRKCutBHjUndkg6oAgv374VvjYQRzzR2gTEXRsmFmgd1SrbI8W948iTDnkEkUieCw==", "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0" } }, "node_modules/split2": { @@ -35985,33 +35985,33 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "pg": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", - "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "requires": { - "pg-cloudflare": "^1.1.1", - "pg-connection-string": "^2.7.0", - "pg-pool": "^3.8.0", - "pg-protocol": "^1.8.0", - "pg-types": "^2.1.0", - "pgpass": "1.x" + "pg-cloudflare": "^1.2.7", + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" } }, "pg-cloudflare": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", - "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", "optional": true }, "pg-connection-string": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", - "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==" }, "pg-cursor": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.13.1.tgz", - "integrity": "sha512-t7niROd7/BVlRn2juI0S0MP/Ps87lNMpmnxMRQMOH0fboL0n7gH/MxpymSdR4rZRcPfoR3Sx47JG1u5JOJf6Gg==", + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.15.3.tgz", + "integrity": "sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA==", "peer": true, "requires": {} }, @@ -36021,9 +36021,9 @@ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" }, "pg-minify": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.7.0.tgz", - "integrity": "sha512-kFPxAWAhPMvOqnY7klP3scdU5R7bxpAYOm8vGExuIkcSIwuFkZYl4C4XIPQ8DtXY2NzVmAX1aFHpvFSXQ/qQmA==" + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.8.0.tgz", + "integrity": "sha512-jO/oJOununpx8DzKgvSsWm61P8JjwXlaxSlbbfTBo1nvSWoo/+I6qZYaSN96jm/KDwa5d+JMQwPGgcP6HXDRow==" }, "pg-monitor": { "version": "3.0.0", @@ -36034,34 +36034,34 @@ } }, "pg-pool": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", - "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", "requires": {} }, "pg-promise": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-11.14.0.tgz", - "integrity": "sha512-x/HZ6hK0MxYllyfUbmN/XZc7JBYoow7KElyNW9hnlhgRHMiRZmRUtfNM/wcuElpjSoASPxkoIKi4IA5QlwOONA==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-12.2.0.tgz", + "integrity": "sha512-th+GB7ftaRv5gAjSVslURSaYr5gf8d+T9/h5dZTJ/uyMqnQV8lJ8cDo3p5Crv3rprLC8ZCav9yLFcMKnobib+g==", "requires": { "assert-options": "0.8.3", - "pg": "8.14.1", - "pg-minify": "1.7.0", - "spex": "3.4.1" + "pg": "8.16.3", + "pg-minify": "1.8.0", + "spex": "4.0.2" } }, "pg-protocol": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", - "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==" + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==" }, "pg-query-stream": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.8.1.tgz", - "integrity": "sha512-kZo6C6HSzYFF6mlwl+etDk5QZD9CMdlHUXpof6PkK9+CHHaBLvOd2lZMwErOOpC/ldg4thrAojS8sG1B8PZ9Yw==", + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.10.3.tgz", + "integrity": "sha512-h2utrzpOIzeT9JfaqfvBbVuvCfBjH86jNfVrGGTbyepKAIOyTfDew0lAt8bbJjs9n/I5bGDl7S2sx6h5hPyJxw==", "peer": true, "requires": { - "pg-cursor": "^2.13.1" + "pg-cursor": "^2.15.3" } }, "pg-types": { @@ -37590,9 +37590,9 @@ "dev": true }, "spex": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/spex/-/spex-3.4.1.tgz", - "integrity": "sha512-Br0Mu3S+c70kr4keXF+6K4B8ohR+aJjI9s7SbdsI3hliE1Riz4z+FQk7FQL+r7X1t90KPkpuKwQyITpCIQN9mg==" + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spex/-/spex-4.0.2.tgz", + "integrity": "sha512-/8VnouFOkRlkfj/sDN6GYnRKCutBHjUndkg6oAgv374VvjYQRzzR2gTEXRsmFmgd1SrbI8W948iTDnkEkUieCw==" }, "split2": { "version": "4.2.0", diff --git a/package.json b/package.json index ae828051b8..242719d047 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "parse": "6.1.1", "path-to-regexp": "6.3.0", "pg-monitor": "3.0.0", - "pg-promise": "11.14.0", + "pg-promise": "12.2.0", "pluralize": "8.0.0", "punycode": "2.3.1", "rate-limit-redis": "4.2.0", From 1b2347524ca84ade0f6badf175a815fc8a7bef49 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Fri, 10 Oct 2025 00:03:52 +0200 Subject: [PATCH 08/30] feat: Disable index-field validation to create index for fields that don't yet exist (#8137) --- spec/GridFSBucketStorageAdapter.spec.js | 8 +++ spec/schemas.spec.js | 24 +++++++++ src/Adapters/Files/GridFSBucketAdapter.js | 2 +- .../Storage/Mongo/MongoStorageAdapter.js | 5 +- .../Postgres/PostgresStorageAdapter.js | 51 ++++++++++++------- 5 files changed, 69 insertions(+), 21 deletions(-) diff --git a/spec/GridFSBucketStorageAdapter.spec.js b/spec/GridFSBucketStorageAdapter.spec.js index 7e9c84a59e..d30415edf3 100644 --- a/spec/GridFSBucketStorageAdapter.spec.js +++ b/spec/GridFSBucketStorageAdapter.spec.js @@ -421,6 +421,14 @@ describe_only_db('mongo')('GridFSBucket', () => { expect(gfsResult.toString('utf8')).toBe(twoMegabytesFile); }); + it('properly upload a file when disableIndexFieldValidation exist in databaseOptions', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI, { disableIndexFieldValidation: true }); + const twoMegabytesFile = randomString(2048 * 1024); + await gfsAdapter.createFile('myFileName', twoMegabytesFile); + const gfsResult = await gfsAdapter.getFileData('myFileName'); + expect(gfsResult.toString('utf8')).toBe(twoMegabytesFile); + }); + it('properly deletes a file from GridFS', async () => { const gfsAdapter = new GridFSBucketAdapter(databaseURI); await gfsAdapter.createFile('myFileName', 'a simple file'); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 167f3ff19a..7891fa847e 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -3008,6 +3008,7 @@ describe('schemas', () => { beforeEach(async () => { await TestUtils.destroyAllDataPermanently(false); await config.database.adapter.performInitialization({ VolatileClassesSchemas: [] }); + databaseAdapter.disableIndexFieldValidation = false; }); it('cannot create index if field does not exist', done => { @@ -3036,6 +3037,29 @@ describe('schemas', () => { }); }); + it('can create index if field does not exist with disableIndexFieldValidation true ', async () => { + databaseAdapter.disableIndexFieldValidation = true; + await request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }); + const response = await request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { aString: 1 }, + }, + }, + }); + expect(response.data.indexes.name1).toEqual({ aString: 1 }); + }); + it('can create index on default field', done => { request({ url: 'http://localhost:8378/1/schemas/NewClass', diff --git a/src/Adapters/Files/GridFSBucketAdapter.js b/src/Adapters/Files/GridFSBucketAdapter.js index 242fc08a0d..45a585ecc2 100644 --- a/src/Adapters/Files/GridFSBucketAdapter.js +++ b/src/Adapters/Files/GridFSBucketAdapter.js @@ -37,7 +37,7 @@ export class GridFSBucketAdapter extends FilesAdapter { const defaultMongoOptions = { }; const _mongoOptions = Object.assign(defaultMongoOptions, mongoOptions); - for (const key of ['enableSchemaHooks', 'schemaCacheTtl', 'maxTimeMS']) { + for (const key of ['enableSchemaHooks', 'schemaCacheTtl', 'maxTimeMS', 'disableIndexFieldValidation']) { delete _mongoOptions[key]; } this._mongoOptions = _mongoOptions; diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 2c82a2019c..481d5257d9 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -140,6 +140,7 @@ export class MongoStorageAdapter implements StorageAdapter { canSortOnJoinTables: boolean; enableSchemaHooks: boolean; schemaCacheTtl: ?number; + disableIndexFieldValidation: boolean; constructor({ uri = defaults.DefaultMongoURI, collectionPrefix = '', mongoOptions = {} }: any) { this._uri = uri; @@ -152,7 +153,8 @@ export class MongoStorageAdapter implements StorageAdapter { this.canSortOnJoinTables = true; this.enableSchemaHooks = !!mongoOptions.enableSchemaHooks; this.schemaCacheTtl = mongoOptions.schemaCacheTtl; - for (const key of ['enableSchemaHooks', 'schemaCacheTtl', 'maxTimeMS']) { + this.disableIndexFieldValidation = !!mongoOptions.disableIndexFieldValidation; + for (const key of ['enableSchemaHooks', 'schemaCacheTtl', 'maxTimeMS', 'disableIndexFieldValidation']) { delete mongoOptions[key]; delete this._mongoOptions[key]; } @@ -289,6 +291,7 @@ export class MongoStorageAdapter implements StorageAdapter { } else { Object.keys(field).forEach(key => { if ( + !this.disableIndexFieldValidation && !Object.prototype.hasOwnProperty.call( fields, key.indexOf('_p_') === 0 ? key.replace('_p_', '') : key diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index b13179553f..7eaafcbde2 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -627,13 +627,11 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus const distance = fieldValue.$maxDistance; const distanceInKM = distance * 6371 * 1000; patterns.push( - `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${ - index + 2 + `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${index + 2 })::geometry) <= $${index + 3}` ); sorts.push( - `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${ - index + 2 + `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${index + 2 })::geometry) ASC` ); values.push(fieldName, point.longitude, point.latitude, distanceInKM); @@ -681,8 +679,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus } const distanceInKM = distance * 6371 * 1000; patterns.push( - `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${ - index + 2 + `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${index + 2 })::geometry) <= $${index + 3}` ); values.push(fieldName, point.longitude, point.latitude, distanceInKM); @@ -862,19 +859,22 @@ export class PostgresStorageAdapter implements StorageAdapter { _stream: any; _uuid: any; schemaCacheTtl: ?number; + disableIndexFieldValidation: boolean; constructor({ uri, collectionPrefix = '', databaseOptions = {} }: any) { const options = { ...databaseOptions }; this._collectionPrefix = collectionPrefix; this.enableSchemaHooks = !!databaseOptions.enableSchemaHooks; + this.disableIndexFieldValidation = !!databaseOptions.disableIndexFieldValidation; + this.schemaCacheTtl = databaseOptions.schemaCacheTtl; - for (const key of ['enableSchemaHooks', 'schemaCacheTtl']) { + for (const key of ['enableSchemaHooks', 'schemaCacheTtl', 'disableIndexFieldValidation']) { delete options[key]; } const { client, pgp } = createClient(uri, options); this._client = client; - this._onchange = () => {}; + this._onchange = () => { }; this._pgp = pgp; this._uuid = uuidv4(); this.canSortOnJoinTables = false; @@ -991,7 +991,10 @@ export class PostgresStorageAdapter implements StorageAdapter { delete existingIndexes[name]; } else { Object.keys(field).forEach(key => { - if (!Object.prototype.hasOwnProperty.call(fields, key)) { + if ( + !this.disableIndexFieldValidation && + !Object.prototype.hasOwnProperty.call(fields, key) + ) { throw new Parse.Error( Parse.Error.INVALID_QUERY, `Field ${key} does not exist, cannot add index.` @@ -1006,8 +1009,22 @@ export class PostgresStorageAdapter implements StorageAdapter { } }); await conn.tx('set-indexes-with-schema-format', async t => { - if (insertedIndexes.length > 0) { - await self.createIndexes(className, insertedIndexes, t); + try { + if (insertedIndexes.length > 0) { + await self.createIndexes(className, insertedIndexes, t); + } + } catch (e) { + // pg-promise use Batch error see https://github.com/vitaly-t/spex/blob/e572030f261be1a8e9341fc6f637e36ad07f5231/src/errors/batch.js#L59 + const columnDoesNotExistError = e.getErrors && e.getErrors()[0] && e.getErrors()[0].code === '42703'; + // Specific case when the column does not exist + if (columnDoesNotExistError) { + // If the disableIndexFieldValidation is true, we should ignore the error + if (!this.disableIndexFieldValidation) { + throw e; + } + } else { + throw e; + } } if (deletedIndexes.length > 0) { await self.dropIndexes(className, deletedIndexes, t); @@ -1625,16 +1642,14 @@ export class PostgresStorageAdapter implements StorageAdapter { index += 2; } else if (fieldValue.__op === 'Remove') { updatePatterns.push( - `$${index}:name = array_remove(COALESCE($${index}:name, '[]'::jsonb), $${ - index + 1 + `$${index}:name = array_remove(COALESCE($${index}:name, '[]'::jsonb), $${index + 1 }::jsonb)` ); values.push(fieldName, JSON.stringify(fieldValue.objects)); index += 2; } else if (fieldValue.__op === 'AddUnique') { updatePatterns.push( - `$${index}:name = array_add_unique(COALESCE($${index}:name, '[]'::jsonb), $${ - index + 1 + `$${index}:name = array_add_unique(COALESCE($${index}:name, '[]'::jsonb), $${index + 1 }::jsonb)` ); values.push(fieldName, JSON.stringify(fieldValue.objects)); @@ -1745,8 +1760,7 @@ export class PostgresStorageAdapter implements StorageAdapter { updateObject = `COALESCE($${index}:name, '{}'::jsonb)`; } updatePatterns.push( - `$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${ - index + 1 + keysToDelete.length + `$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + 1 + keysToDelete.length }::jsonb )` ); values.push(fieldName, ...keysToDelete, JSON.stringify(fieldValue)); @@ -2185,8 +2199,7 @@ export class PostgresStorageAdapter implements StorageAdapter { groupByFields.push(`"${source}"`); } columns.push( - `EXTRACT(${ - mongoAggregateToPostgres[operation] + `EXTRACT(${mongoAggregateToPostgres[operation] } FROM $${index}:name AT TIME ZONE 'UTC')::integer AS $${index + 1}:name` ); values.push(source, alias); From 0b606ae9c60f405ac0b297178f59185f8eec8ac7 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 9 Oct 2025 22:05:09 +0000 Subject: [PATCH 09/30] chore(release): 8.3.0-alpha.4 [skip ci] # [8.3.0-alpha.4](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.3...8.3.0-alpha.4) (2025-10-09) ### Features * Disable index-field validation to create index for fields that don't yet exist ([#8137](https://github.com/parse-community/parse-server/issues/8137)) ([1b23475](https://github.com/parse-community/parse-server/commit/1b2347524ca84ade0f6badf175a815fc8a7bef49)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 3095cc76db..f139f68716 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.4](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.3...8.3.0-alpha.4) (2025-10-09) + + +### Features + +* Disable index-field validation to create index for fields that don't yet exist ([#8137](https://github.com/parse-community/parse-server/issues/8137)) ([1b23475](https://github.com/parse-community/parse-server/commit/1b2347524ca84ade0f6badf175a815fc8a7bef49)) + # [8.3.0-alpha.3](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.2...8.3.0-alpha.3) (2025-10-07) diff --git a/package-lock.json b/package-lock.json index 466f37922a..9be2015c10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.3", + "version": "8.3.0-alpha.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.3", + "version": "8.3.0-alpha.4", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 242719d047..eb987cdffa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.3", + "version": "8.3.0-alpha.4", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 0b4740714c29ba99672bc535619ee3516abd356f Mon Sep 17 00:00:00 2001 From: EmpiDev <95050065+EmpiDev@users.noreply.github.com> Date: Tue, 14 Oct 2025 18:13:28 +0200 Subject: [PATCH 10/30] feat: Allow returning objects in `Parse.Cloud.beforeFind` without invoking database query (#9770) --- spec/CloudCode.spec.js | 340 +++++++++++++++++++++++++++++++++++++++++ src/RestQuery.js | 13 +- src/rest.js | 114 ++++++++++++-- src/triggers.js | 98 ++++++++---- 4 files changed, 524 insertions(+), 41 deletions(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 0b881bef47..a5a005d6cd 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -202,6 +202,346 @@ describe('Cloud Code', () => { } }); + describe('beforeFind without DB operations', () => { + let findSpy; + + beforeEach(() => { + const config = Config.get('test'); + const databaseAdapter = config.database.adapter; + findSpy = spyOn(databaseAdapter, 'find').and.callThrough(); + }); + + it('beforeFind can return object without DB operation', async () => { + Parse.Cloud.beforeFind('TestObject', () => { + return new Parse.Object('TestObject', { foo: 'bar' }); + }); + Parse.Cloud.afterFind('TestObject', req => { + expect(req.objects).toBeDefined(); + expect(req.objects[0].get('foo')).toBe('bar'); + }); + + const newObj = await new Parse.Query('TestObject').first(); + expect(newObj.className).toBe('TestObject'); + expect(newObj.toJSON()).toEqual({ foo: 'bar' }); + expect(findSpy).not.toHaveBeenCalled(); + await newObj.save(); + }); + + it('beforeFind can return array of objects without DB operation', async () => { + Parse.Cloud.beforeFind('TestObject', () => { + return [new Parse.Object('TestObject', { foo: 'bar' })]; + }); + Parse.Cloud.afterFind('TestObject', req => { + expect(req.objects).toBeDefined(); + expect(req.objects[0].get('foo')).toBe('bar'); + }); + + const newObj = await new Parse.Query('TestObject').first(); + expect(newObj.className).toBe('TestObject'); + expect(newObj.toJSON()).toEqual({ foo: 'bar' }); + expect(findSpy).not.toHaveBeenCalled(); + await newObj.save(); + }); + + it('beforeFind can return object for get query without DB operation', async () => { + Parse.Cloud.beforeFind('TestObject', () => { + return [new Parse.Object('TestObject', { foo: 'bar' })]; + }); + Parse.Cloud.afterFind('TestObject', req => { + expect(req.objects).toBeDefined(); + expect(req.objects[0].get('foo')).toBe('bar'); + }); + + const testObj = new Parse.Object('TestObject'); + await testObj.save(); + findSpy.calls.reset(); + + const newObj = await new Parse.Query('TestObject').get(testObj.id); + expect(newObj.className).toBe('TestObject'); + expect(newObj.toJSON()).toEqual({ foo: 'bar' }); + expect(findSpy).not.toHaveBeenCalled(); + await newObj.save(); + }); + + it('beforeFind can return empty array without DB operation', async () => { + Parse.Cloud.beforeFind('TestObject', () => { + return []; + }); + Parse.Cloud.afterFind('TestObject', req => { + expect(req.objects.length).toBe(0); + }); + + const obj = new Parse.Object('TestObject'); + await obj.save(); + findSpy.calls.reset(); + + const newObj = await new Parse.Query('TestObject').first(); + expect(newObj).toBeUndefined(); + expect(findSpy).not.toHaveBeenCalled(); + }); + }); + + describe('beforeFind security with returned objects', () => { + let userA; + let userB; + let secret; + + beforeEach(async () => { + userA = new Parse.User(); + userA.setUsername('userA_' + Date.now()); + userA.setPassword('passA'); + await userA.signUp(); + + userB = new Parse.User(); + userB.setUsername('userB_' + Date.now()); + userB.setPassword('passB'); + await userB.signUp(); + + // Create an object readable only by userB + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setPublicWriteAccess(false); + acl.setReadAccess(userB.id, true); + acl.setWriteAccess(userB.id, true); + + secret = new Parse.Object('SecretDoc'); + secret.set('title', 'top'); + secret.set('content', 'classified'); + secret.setACL(acl); + await secret.save(null, { sessionToken: userB.getSessionToken() }); + + Parse.Cloud.beforeFind('SecretDoc', () => { + return [secret]; + }); + }); + + it('should not expose objects not readable by current user', async () => { + const q = new Parse.Query('SecretDoc'); + const results = await q.find({ sessionToken: userA.getSessionToken() }); + expect(results.length).toBe(0); + }); + + it('should allow authorized user to see their objects', async () => { + const q = new Parse.Query('SecretDoc'); + const results = await q.find({ sessionToken: userB.getSessionToken() }); + expect(results.length).toBe(1); + expect(results[0].id).toBe(secret.id); + expect(results[0].get('title')).toBe('top'); + expect(results[0].get('content')).toBe('classified'); + }); + + it('should return OBJECT_NOT_FOUND on get() for unauthorized user', async () => { + const q = new Parse.Query('SecretDoc'); + await expectAsync( + q.get(secret.id, { sessionToken: userA.getSessionToken() }) + ).toBeRejectedWith(jasmine.objectContaining({ code: Parse.Error.OBJECT_NOT_FOUND })); + }); + + it('should allow master key to bypass ACL filtering when returning objects', async () => { + const q = new Parse.Query('SecretDoc'); + const results = await q.find({ useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].id).toBe(secret.id); + }); + + it('should apply protectedFields masking after re-filtering', async () => { + // Configure protectedFields for SecretMask: mask `secretField` for everyone + const protectedFields = { SecretMask: { '*': ['secretField'] } }; + await reconfigureServer({ protectedFields }); + + const user = new Parse.User(); + user.setUsername('pfUser'); + user.setPassword('pfPass'); + await user.signUp(); + + // Object is publicly readable but has a protected field + const doc = new Parse.Object('SecretMask'); + doc.set('name', 'visible'); + doc.set('secretField', 'hiddenValue'); + await doc.save(null, { useMasterKey: true }); + + Parse.Cloud.beforeFind('SecretMask', () => { + return [doc]; + }); + + // Query as normal user; after re-filtering, secretField should be removed + const res = await new Parse.Query('SecretMask').first({ sessionToken: user.getSessionToken() }); + expect(res).toBeDefined(); + expect(res.get('name')).toBe('visible'); + expect(res.get('secretField')).toBeUndefined(); + const json = res.toJSON(); + expect(Object.prototype.hasOwnProperty.call(json, 'secretField')).toBeFalse(); + }); + }); + const { maybeRunAfterFindTrigger } = require('../lib/triggers'); + + describe('maybeRunAfterFindTrigger - direct function tests', () => { + const testConfig = { + applicationId: 'test', + logLevels: { triggerBeforeSuccess: 'info', triggerAfter: 'info' }, + }; + + it('should convert Parse.Object instances to JSON when no trigger defined', async () => { + const className = 'TestParseObjectDirect_' + Date.now(); + + const parseObj1 = new Parse.Object(className); + parseObj1.set('name', 'test1'); + parseObj1.id = 'obj1'; + + const parseObj2 = new Parse.Object(className); + parseObj2.set('name', 'test2'); + parseObj2.id = 'obj2'; + + const result = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [parseObj1, parseObj2], + testConfig, + null, + {} + ); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + expect(result[0].name).toBe('test1'); + expect(result[1].name).toBe('test2'); + }); + + it('should handle null/undefined objectsInput when no trigger', async () => { + const className = 'TestNullDirect_' + Date.now(); + + const resultNull = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + null, + testConfig, + null, + {} + ); + expect(resultNull).toEqual([]); + + const resultUndefined = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + undefined, + testConfig, + null, + {} + ); + expect(resultUndefined).toEqual([]); + + const resultEmpty = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [], + testConfig, + null, + {} + ); + expect(resultEmpty).toEqual([]); + }); + + it('should handle plain object query with where clause', async () => { + const className = 'TestQueryWhereDirect_' + Date.now(); + let receivedQuery = null; + + Parse.Cloud.afterFind(className, req => { + receivedQuery = req.query; + return req.objects; + }); + + const mockObject = { id: 'test123', className: className, name: 'test' }; + + const result = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [mockObject], + testConfig, + { where: { name: 'test' }, limit: 10 }, + {} + ); + + expect(receivedQuery).toBeInstanceOf(Parse.Query); + expect(result).toBeDefined(); + }); + + it('should handle plain object query without where clause', async () => { + const className = 'TestQueryNoWhereDirect_' + Date.now(); + let receivedQuery = null; + + Parse.Cloud.afterFind(className, req => { + receivedQuery = req.query; + return req.objects; + }); + + const mockObject = { id: 'test456', className: className, name: 'test' }; + const pq = new Parse.Query(className).withJSON({ limit: 5, skip: 1 }); + + const result = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [mockObject], + testConfig, + pq, + {} + ); + + expect(receivedQuery).toBeInstanceOf(Parse.Query); + const qJSON = receivedQuery.toJSON(); + expect(qJSON.limit).toBe(5); + expect(qJSON.skip).toBe(1); + expect(qJSON.where).toEqual({}); + expect(result).toBeDefined(); + }); + + it('should create default query for invalid query parameter', async () => { + const className = 'TestInvalidQueryDirect_' + Date.now(); + let receivedQuery = null; + + Parse.Cloud.afterFind(className, req => { + receivedQuery = req.query; + return req.objects; + }); + + const mockObject = { id: 'test789', className: className, name: 'test' }; + + await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [mockObject], + testConfig, + 'invalid_query_string', + {} + ); + + expect(receivedQuery).toBeInstanceOf(Parse.Query); + expect(receivedQuery.className).toBe(className); + + receivedQuery = null; + + await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [mockObject], + testConfig, + null, + {} + ); + + expect(receivedQuery).toBeInstanceOf(Parse.Query); + expect(receivedQuery.className).toBe(className); + }); + }); + it('beforeSave rejection with custom error code', function (done) { Parse.Cloud.beforeSave('BeforeSaveFailWithErrorCode', function () { throw new Parse.Error(999, 'Nope'); diff --git a/src/RestQuery.js b/src/RestQuery.js index 621700984b..dd226f249c 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -50,6 +50,7 @@ async function RestQuery({ if (![RestQuery.Method.find, RestQuery.Method.get].includes(method)) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'bad query type'); } + const isGet = method === RestQuery.Method.get; enforceRoleSecurity(method, className, auth); const result = runBeforeFind ? await triggers.maybeRunQueryTrigger( @@ -60,7 +61,7 @@ async function RestQuery({ config, auth, context, - method === RestQuery.Method.get + isGet ) : Promise.resolve({ restWhere, restOptions }); @@ -72,7 +73,8 @@ async function RestQuery({ result.restOptions || restOptions, clientSDK, runAfterFind, - context + context, + isGet ); } @@ -101,7 +103,8 @@ function _UnsafeRestQuery( restOptions = {}, clientSDK, runAfterFind = true, - context + context, + isGet ) { this.config = config; this.auth = auth; @@ -113,6 +116,7 @@ function _UnsafeRestQuery( this.response = null; this.findOptions = {}; this.context = context || {}; + this.isGet = isGet; if (!this.auth.isMaster) { if (this.className == '_Session') { if (!this.auth.user) { @@ -914,7 +918,8 @@ _UnsafeRestQuery.prototype.runAfterFindTrigger = function () { this.response.results, this.config, parseQuery, - this.context + this.context, + this.isGet ) .then(results => { // Ensure we properly set the className back diff --git a/src/rest.js b/src/rest.js index 1f9dbacb73..8297121a68 100644 --- a/src/rest.js +++ b/src/rest.js @@ -23,11 +23,91 @@ function checkTriggers(className, config, types) { function checkLiveQuery(className, config) { return config.liveQueryController && config.liveQueryController.hasLiveQuery(className); } +async function runFindTriggers( + config, + auth, + className, + restWhere, + restOptions, + clientSDK, + context, + options = {} +) { + const { isGet } = options; -// Returns a promise for an object with optional keys 'results' and 'count'. -const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => { + // Run beforeFind trigger - may modify query or return objects directly + const result = await triggers.maybeRunQueryTrigger( + triggers.Types.beforeFind, + className, + restWhere, + restOptions, + config, + auth, + context, + isGet + ); + + restWhere = result.restWhere || restWhere; + restOptions = result.restOptions || restOptions; + + // Short-circuit path: beforeFind returned objects directly + // Security risk: These objects may have been fetched with master privileges + if (result?.objects) { + const objectsFromBeforeFind = result.objects; + + let objectsForAfterFind = objectsFromBeforeFind; + + // Security check: Re-filter objects if not master to ensure ACL/CLP compliance + if (!auth?.isMaster && !auth?.isMaintenance) { + const ids = (Array.isArray(objectsFromBeforeFind) ? objectsFromBeforeFind : [objectsFromBeforeFind]) + .map(o => (o && (o.id || o.objectId)) || null) + .filter(Boolean); + + // Objects without IDs are(normally) unsaved objects + // For unsaved objects, the ACL security does not apply, so no need to redo the query. + // For saved objects, we need to re-query to ensure proper ACL/CLP enforcement + if (ids.length > 0) { + const refilterWhere = isGet ? { objectId: ids[0] } : { objectId: { $in: ids } }; + + // Re-query with proper security: no triggers to avoid infinite loops + const refilterQuery = await RestQuery({ + method: isGet ? RestQuery.Method.get : RestQuery.Method.find, + config, + auth, + className, + restWhere: refilterWhere, + restOptions, + clientSDK, + context, + runBeforeFind: false, + runAfterFind: false, + }); + + const refiltered = await refilterQuery.execute(); + objectsForAfterFind = (refiltered && refiltered.results) || []; + } + } + + // Run afterFind trigger on security-filtered objects + const afterFindProcessedObjects = await triggers.maybeRunAfterFindTrigger( + triggers.Types.afterFind, + auth, + className, + objectsForAfterFind, + config, + new Parse.Query(className).withJSON({ where: restWhere, ...restOptions }), + context, + isGet + ); + + return { + results: afterFindProcessedObjects, + }; + } + + // Normal path: execute database query with modified conditions const query = await RestQuery({ - method: RestQuery.Method.find, + method: isGet ? RestQuery.Method.get : RestQuery.Method.find, config, auth, className, @@ -35,24 +115,40 @@ const find = async (config, auth, className, restWhere, restOptions, clientSDK, restOptions, clientSDK, context, + runBeforeFind: false, }); + return query.execute(); +} + +// Returns a promise for an object with optional keys 'results' and 'count'. +const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => { + enforceRoleSecurity('find', className, auth); + return runFindTriggers( + config, + auth, + className, + restWhere, + restOptions, + clientSDK, + context, + { isGet: false } + ); }; // get is just like find but only queries an objectId. const get = async (config, auth, className, objectId, restOptions, clientSDK, context) => { - var restWhere = { objectId }; - const query = await RestQuery({ - method: RestQuery.Method.get, + enforceRoleSecurity('get', className, auth); + return runFindTriggers( config, auth, className, - restWhere, + { objectId }, restOptions, clientSDK, context, - }); - return query.execute(); + { isGet: true } + ); }; // Returns a promise that doesn't resolve to any useful value. diff --git a/src/triggers.js b/src/triggers.js index 2dfbeff7ac..c4028ce478 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -182,8 +182,11 @@ export function toJSONwithObjects(object, className) { } toJSON[key] = val._toFullJSON(); } + // Preserve original object's className if no override className is provided if (className) { toJSON.className = className; + } else if (object.className && !toJSON.className) { + toJSON.className = object.className; } return toJSON; } @@ -257,7 +260,8 @@ export function getRequestObject( parseObject, originalParseObject, config, - context + context, + isGet ) { const request = { triggerName: triggerType, @@ -268,6 +272,10 @@ export function getRequestObject( ip: config.ip, }; + if (isGet !== undefined) { + request.isGet = !!isGet; + } + if (originalParseObject) { request.original = originalParseObject; } @@ -437,69 +445,93 @@ function logTriggerErrorBeforeHook(triggerType, className, input, auth, error, l export function maybeRunAfterFindTrigger( triggerType, auth, - className, - objects, + classNameQuery, + objectsInput, config, query, - context + context, + isGet ) { return new Promise((resolve, reject) => { - const trigger = getTrigger(className, triggerType, config.applicationId); + const trigger = getTrigger(classNameQuery, triggerType, config.applicationId); + if (!trigger) { - return resolve(); + if (objectsInput && objectsInput.length > 0 && objectsInput[0] instanceof Parse.Object) { + return resolve(objectsInput.map(obj => toJSONwithObjects(obj))); + } + return resolve(objectsInput || []); } - const request = getRequestObject(triggerType, auth, null, null, config, context); - if (query) { + + const request = getRequestObject(triggerType, auth, null, null, config, context, isGet); + // Convert query parameter to Parse.Query instance + if (query instanceof Parse.Query) { request.query = query; + } else if (typeof query === 'object' && query !== null) { + const parseQueryInstance = new Parse.Query(classNameQuery); + if (query.where) { + parseQueryInstance.withJSON(query); + } + request.query = parseQueryInstance; + } else { + request.query = new Parse.Query(classNameQuery); } + const { success, error } = getResponseObject( request, - object => { - resolve(object); + processedObjectsJSON => { + resolve(processedObjectsJSON); }, - error => { - reject(error); + errorData => { + reject(errorData); } ); logTriggerSuccessBeforeHook( triggerType, - className, - 'AfterFind', - JSON.stringify(objects), + classNameQuery, + 'AfterFind Input (Pre-Transform)', + JSON.stringify( + objectsInput.map(o => (o instanceof Parse.Object ? o.id + ':' + o.className : o)) + ), auth, config.logLevels.triggerBeforeSuccess ); - request.objects = objects.map(object => { - //setting the class name to transform into parse object - object.className = className; - return Parse.Object.fromJSON(object); + + // Convert plain objects to Parse.Object instances for trigger + request.objects = objectsInput.map(currentObject => { + if (currentObject instanceof Parse.Object) { + return currentObject; + } + // Preserve the original className if it exists, otherwise use the query className + const originalClassName = currentObject.className || classNameQuery; + const tempObjectWithClassName = { ...currentObject, className: originalClassName }; + return Parse.Object.fromJSON(tempObjectWithClassName); }); return Promise.resolve() .then(() => { - return maybeRunValidator(request, `${triggerType}.${className}`, auth); + return maybeRunValidator(request, `${triggerType}.${classNameQuery}`, auth); }) .then(() => { if (request.skipWithMasterKey) { return request.objects; } - const response = trigger(request); - if (response && typeof response.then === 'function') { - return response.then(results => { + const responseFromTrigger = trigger(request); + if (responseFromTrigger && typeof responseFromTrigger.then === 'function') { + return responseFromTrigger.then(results => { return results; }); } - return response; + return responseFromTrigger; }) .then(success, error); - }).then(results => { + }).then(resultsAsJSON => { logTriggerAfterHook( triggerType, - className, - JSON.stringify(results), + classNameQuery, + JSON.stringify(resultsAsJSON), auth, config.logLevels.triggerAfter ); - return results; + return resultsAsJSON; }); } @@ -607,9 +639,19 @@ export function maybeRunQueryTrigger( restOptions = restOptions || {}; restOptions.subqueryReadPreference = requestObject.subqueryReadPreference; } + let objects = undefined; + if (result instanceof Parse.Object) { + objects = [result]; + } else if ( + Array.isArray(result) && + (!result.length || result.every(obj => obj instanceof Parse.Object)) + ) { + objects = result; + } return { restWhere, restOptions, + objects, }; }, err => { From e704de83e6059b5257c47be572dfd808fcc8b33b Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 14 Oct 2025 16:14:19 +0000 Subject: [PATCH 11/30] chore(release): 8.3.0-alpha.5 [skip ci] # [8.3.0-alpha.5](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.4...8.3.0-alpha.5) (2025-10-14) ### Features * Allow returning objects in `Parse.Cloud.beforeFind` without invoking database query ([#9770](https://github.com/parse-community/parse-server/issues/9770)) ([0b47407](https://github.com/parse-community/parse-server/commit/0b4740714c29ba99672bc535619ee3516abd356f)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index f139f68716..2b9cb2c9ae 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.5](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.4...8.3.0-alpha.5) (2025-10-14) + + +### Features + +* Allow returning objects in `Parse.Cloud.beforeFind` without invoking database query ([#9770](https://github.com/parse-community/parse-server/issues/9770)) ([0b47407](https://github.com/parse-community/parse-server/commit/0b4740714c29ba99672bc535619ee3516abd356f)) + # [8.3.0-alpha.4](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.3...8.3.0-alpha.4) (2025-10-09) diff --git a/package-lock.json b/package-lock.json index 9be2015c10..2b110a411a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.4", + "version": "8.3.0-alpha.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.4", + "version": "8.3.0-alpha.5", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index eb987cdffa..8c7e921664 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.4", + "version": "8.3.0-alpha.5", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 64f104e5c5f8863098e801eee632c14fcbd9b6f9 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Tue, 14 Oct 2025 20:16:31 +0200 Subject: [PATCH 12/30] feat: Add request context middleware for config and dependency injection in hooks (#8480) --- spec/CloudCode.spec.js | 142 +++++++++++++++++++++++------ spec/ParseGraphQLServer.spec.js | 35 +++++++ spec/rest.spec.js | 22 +++++ src/Controllers/HooksController.js | 2 + src/GraphQL/ParseGraphQLServer.js | 14 +++ src/Options/Definitions.js | 5 + src/Options/docs.js | 1 + src/Options/index.js | 2 + src/ParseServer.ts | 14 ++- src/Routers/FunctionsRouter.js | 2 + src/cloud-code/Parse.Cloud.js | 6 ++ src/triggers.js | 3 + 12 files changed, 219 insertions(+), 29 deletions(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index a5a005d6cd..308c7731b8 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -11,8 +11,8 @@ const mockAdapter = { name: filename, location: `http://www.somewhere.com/${filename}`, }), - deleteFile: () => {}, - getFileData: () => {}, + deleteFile: () => { }, + getFileData: () => { }, getFileLocation: (config, filename) => `http://www.somewhere.com/${filename}`, validateFilename: () => { return null; @@ -49,7 +49,7 @@ describe('Cloud Code', () => { }); it('cloud code must be valid type', async () => { - spyOn(console, 'error').and.callFake(() => {}); + spyOn(console, 'error').and.callFake(() => { }); await expectAsync(reconfigureServer({ cloud: true })).toBeRejectedWith( "argument 'cloud' must either be a string or a function" ); @@ -114,7 +114,7 @@ describe('Cloud Code', () => { it('show warning on duplicate cloud functions', done => { const logger = require('../lib/logger').logger; - spyOn(logger, 'warn').and.callFake(() => {}); + spyOn(logger, 'warn').and.callFake(() => { }); Parse.Cloud.define('hello', () => { return 'Hello world!'; }); @@ -1672,7 +1672,7 @@ describe('Cloud Code', () => { }); it('trivial beforeSave should not affect fetched pointers (regression test for #1238)', done => { - Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => {}); + Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => { }); const TestObject = Parse.Object.extend('TestObject'); const NoBeforeSaveObject = Parse.Object.extend('NoBeforeSave'); @@ -1745,7 +1745,7 @@ describe('Cloud Code', () => { }); it('beforeSave should not affect fetched pointers', done => { - Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => {}); + Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => { }); Parse.Cloud.beforeSave('BeforeSaveChanged', function (req) { req.object.set('foo', 'baz'); @@ -2059,7 +2059,7 @@ describe('Cloud Code', () => { }); it('pointer should not be cleared by triggers', async () => { - Parse.Cloud.afterSave('MyObject', () => {}); + Parse.Cloud.afterSave('MyObject', () => { }); const foo = await new Parse.Object('Test', { foo: 'bar' }).save(); const obj = await new Parse.Object('MyObject', { foo }).save(); const foo2 = obj.get('foo'); @@ -2067,7 +2067,7 @@ describe('Cloud Code', () => { }); it('can set a pointer in triggers', async () => { - Parse.Cloud.beforeSave('MyObject', () => {}); + Parse.Cloud.beforeSave('MyObject', () => { }); Parse.Cloud.afterSave( 'MyObject', async ({ object }) => { @@ -2168,7 +2168,7 @@ describe('Cloud Code', () => { it('should not run without master key', done => { expect(() => { - Parse.Cloud.job('myJob', () => {}); + Parse.Cloud.job('myJob', () => { }); }).not.toThrow(); request({ @@ -2354,6 +2354,14 @@ describe('cloud functions', () => { Parse.Cloud.run('myFunction', {}).then(() => done()); }); + + it('should have request config', async () => { + Parse.Cloud.define('myConfigFunction', req => { + expect(req.config).toBeDefined(); + return 'success'; + }); + await Parse.Cloud.run('myConfigFunction', {}); + }); }); describe('beforeSave hooks', () => { @@ -2377,6 +2385,16 @@ describe('beforeSave hooks', () => { myObject.save().then(() => done()); }); + it('should have request config', async () => { + Parse.Cloud.beforeSave('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + }); + it('should respect custom object ids (#6733)', async () => { Parse.Cloud.beforeSave('TestObject', req => { expect(req.object.id).toEqual('test_6733'); @@ -2432,6 +2450,16 @@ describe('afterSave hooks', () => { myObject.save().then(() => done()); }); + it('should have request config', async () => { + Parse.Cloud.afterSave('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + }); + it('should unset in afterSave', async () => { Parse.Cloud.afterSave( 'MyObject', @@ -2489,6 +2517,17 @@ describe('beforeDelete hooks', () => { .then(myObj => myObj.destroy()) .then(() => done()); }); + + it('should have request config', async () => { + Parse.Cloud.beforeDelete('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + await myObject.destroy(); + }); }); describe('afterDelete hooks', () => { @@ -2517,6 +2556,17 @@ describe('afterDelete hooks', () => { .then(myObj => myObj.destroy()) .then(() => done()); }); + + it('should have request config', async () => { + Parse.Cloud.afterDelete('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + await myObject.destroy(); + }); }); describe('beforeFind hooks', () => { @@ -2824,6 +2874,18 @@ describe('beforeFind hooks', () => { .then(() => done()); }); + it('should have request config', async () => { + Parse.Cloud.beforeFind('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', myObject.id); + await Promise.all([query.get(myObject.id), query.first(), query.find()]); + }) it('should run beforeFind on pointers and array of pointers from an object', async () => { const obj1 = new Parse.Object('TestObject'); const obj2 = new Parse.Object('TestObject2'); @@ -3208,54 +3270,67 @@ describe('afterFind hooks', () => { .catch(done.fail); }); + it('should have request config', async () => { + Parse.Cloud.afterFind('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', myObject.id); + await Promise.all([query.get(myObject.id), query.first(), query.find()]); + }); + it('should validate triggers correctly', () => { expect(() => { - Parse.Cloud.beforeSave('_Session', () => {}); + Parse.Cloud.beforeSave('_Session', () => { }); }).toThrow('Only the afterLogout trigger is allowed for the _Session class.'); expect(() => { - Parse.Cloud.afterSave('_Session', () => {}); + Parse.Cloud.afterSave('_Session', () => { }); }).toThrow('Only the afterLogout trigger is allowed for the _Session class.'); expect(() => { - Parse.Cloud.beforeSave('_PushStatus', () => {}); + Parse.Cloud.beforeSave('_PushStatus', () => { }); }).toThrow('Only afterSave is allowed on _PushStatus'); expect(() => { - Parse.Cloud.afterSave('_PushStatus', () => {}); + Parse.Cloud.afterSave('_PushStatus', () => { }); }).not.toThrow(); expect(() => { - Parse.Cloud.beforeLogin(() => {}); + Parse.Cloud.beforeLogin(() => { }); }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); expect(() => { - Parse.Cloud.beforeLogin('_User', () => {}); + Parse.Cloud.beforeLogin('_User', () => { }); }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); expect(() => { - Parse.Cloud.beforeLogin(Parse.User, () => {}); + Parse.Cloud.beforeLogin(Parse.User, () => { }); }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); expect(() => { - Parse.Cloud.beforeLogin('SomeClass', () => {}); + Parse.Cloud.beforeLogin('SomeClass', () => { }); }).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); expect(() => { - Parse.Cloud.afterLogin(() => {}); + Parse.Cloud.afterLogin(() => { }); }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); expect(() => { - Parse.Cloud.afterLogin('_User', () => {}); + Parse.Cloud.afterLogin('_User', () => { }); }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); expect(() => { - Parse.Cloud.afterLogin(Parse.User, () => {}); + Parse.Cloud.afterLogin(Parse.User, () => { }); }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); expect(() => { - Parse.Cloud.afterLogin('SomeClass', () => {}); + Parse.Cloud.afterLogin('SomeClass', () => { }); }).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); expect(() => { - Parse.Cloud.afterLogout(() => {}); + Parse.Cloud.afterLogout(() => { }); }).not.toThrow(); expect(() => { - Parse.Cloud.afterLogout('_Session', () => {}); + Parse.Cloud.afterLogout('_Session', () => { }); }).not.toThrow(); expect(() => { - Parse.Cloud.afterLogout('_User', () => {}); + Parse.Cloud.afterLogout('_User', () => { }); }).toThrow('Only the _Session class is allowed for the afterLogout trigger.'); expect(() => { - Parse.Cloud.afterLogout('SomeClass', () => {}); + Parse.Cloud.afterLogout('SomeClass', () => { }); }).toThrow('Only the _Session class is allowed for the afterLogout trigger.'); }); @@ -3695,6 +3770,7 @@ describe('beforeLogin hook', () => { expect(req.ip).toBeDefined(); expect(req.installationId).toBeDefined(); expect(req.context).toBeDefined(); + expect(req.config).toBeDefined(); }); await Parse.User.signUp('tupac', 'shakur'); @@ -3812,6 +3888,7 @@ describe('afterLogin hook', () => { expect(req.ip).toBeDefined(); expect(req.installationId).toBeDefined(); expect(req.context).toBeDefined(); + expect(req.config).toBeDefined(); }); await Parse.User.signUp('testuser', 'p@ssword'); @@ -4014,6 +4091,15 @@ describe('saveFile hooks', () => { } }); + it('beforeSaveFile should have config', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + Parse.Cloud.beforeSave(Parse.File, req => { + expect(req.config).toBeDefined(); + }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + }); + it('beforeSave(Parse.File) should change values of uploaded file by editing fileObject directly', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); @@ -4316,7 +4402,7 @@ describe('Parse.File hooks', () => { beforeFind() { throw 'unauthorized'; }, - afterFind() {}, + afterFind() { }, }; for (const hook in hooks) { spyOn(hooks, hook).and.callThrough(); @@ -4344,7 +4430,7 @@ describe('Parse.File hooks', () => { await file.save({ useMasterKey: true }); const user = await Parse.User.signUp('username', 'password'); const hooks = { - beforeFind() {}, + beforeFind() { }, afterFind() { throw 'unauthorized'; }, @@ -4563,7 +4649,7 @@ describe('sendEmail', () => { it('cannot send email without adapter', async () => { const logger = require('../lib/logger').logger; - spyOn(logger, 'error').and.callFake(() => {}); + spyOn(logger, 'error').and.callFake(() => { }); await Parse.Cloud.sendEmail({}); expect(logger.error).toHaveBeenCalledWith( 'Failed to send email because no mail adapter is configured for Parse Server.' diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index cac3f448ce..aee3575079 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -607,6 +607,41 @@ describe('ParseGraphQLServer', () => { ]); }; + describe('Context', () => { + it('should support dependency injection on graphql api', async () => { + const requestContextMiddleware = (req, res, next) => { + req.config.aCustomController = 'aCustomController'; + next(); + }; + + let called; + const parseServer = await reconfigureServer({ requestContextMiddleware }); + await createGQLFromParseServer(parseServer); + Parse.Cloud.beforeSave('_User', request => { + expect(request.config.aCustomController).toEqual('aCustomController'); + called = true; + }); + + await apolloClient.query({ + query: gql` + mutation { + createUser(input: { fields: { username: "test", password: "test" } }) { + user { + objectId + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + } + }) + expect(called).toBe(true); + }) + }) + describe('Introspection', () => { it('should have public introspection disabled by default without master key', async () => { diff --git a/spec/rest.spec.js b/spec/rest.spec.js index fed64c988b..1fff4fad59 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -1139,3 +1139,25 @@ describe('read-only masterKey', () => { }); }); }); + +describe('rest context', () => { + it('should support dependency injection on rest api', async () => { + const requestContextMiddleware = (req, res, next) => { + req.config.aCustomController = 'aCustomController'; + next(); + }; + + let called + await reconfigureServer({ requestContextMiddleware }); + Parse.Cloud.beforeSave('_User', request => { + expect(request.config.aCustomController).toEqual('aCustomController'); + called = true; + }); + const user = new Parse.User(); + user.setUsername('test'); + user.setPassword('test'); + await user.signUp(); + + expect(called).toBe(true); + }); +}); diff --git a/src/Controllers/HooksController.js b/src/Controllers/HooksController.js index 277104ef32..fef01946f6 100644 --- a/src/Controllers/HooksController.js +++ b/src/Controllers/HooksController.js @@ -188,6 +188,8 @@ function wrapToHTTPRequest(hook, key) { return req => { const jsonBody = {}; for (var i in req) { + // Parse Server config is not serializable + if (i === 'config') { continue; } jsonBody[i] = req[i]; } if (req.object) { diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 5e17b62642..bf7e14f7e2 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -128,6 +128,19 @@ class ParseGraphQLServer { ); } + /** + * @static + * Allow developers to customize each request with inversion of control/dependency injection + */ + applyRequestContextMiddleware(api, options) { + if (options.requestContextMiddleware) { + if (typeof options.requestContextMiddleware !== 'function') { + throw new Error('requestContextMiddleware must be a function'); + } + api.use(this.config.graphQLPath, options.requestContextMiddleware); + } + } + applyGraphQL(app) { if (!app || !app.use) { requiredParameter('You must provide an Express.js app instance!'); @@ -135,6 +148,7 @@ class ParseGraphQLServer { app.use(this.config.graphQLPath, corsMiddleware()); app.use(this.config.graphQLPath, handleParseHeaders); app.use(this.config.graphQLPath, handleParseSession); + this.applyRequestContextMiddleware(app, this.parseServer.config); app.use(this.config.graphQLPath, handleParseErrors); app.use( this.config.graphQLPath, diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index ab25b1017d..6218e484a8 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -514,6 +514,11 @@ module.exports.ParseServerOptions = { env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', help: 'Read-only key, which has the same capabilities as MasterKey without writes', }, + requestContextMiddleware: { + env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE', + help: + 'Options to customize the request context using inversion of control/dependency injection.', + }, requestKeywordDenylist: { env: 'PARSE_SERVER_REQUEST_KEYWORD_DENYLIST', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index 0a8df6d137..e3ef19655a 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -91,6 +91,7 @@ * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case. * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes + * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. * @property {String} restAPIKey Key for REST calls * @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. diff --git a/src/Options/index.js b/src/Options/index.js index 9f70700345..29ac1628f7 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -342,6 +342,8 @@ export interface ParseServerOptions { /* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case. :DEFAULT: [] */ rateLimit: ?(RateLimitOptions[]); + /* Options to customize the request context using inversion of control/dependency injection.*/ + requestContextMiddleware: ?((req: any, res: any, next: any) => void); } export interface RateLimitOptions { diff --git a/src/ParseServer.ts b/src/ParseServer.ts index c0df4f3431..b928364c2e 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -279,6 +279,18 @@ class ParseServer { } } + /** + * @static + * Allow developers to customize each request with inversion of control/dependency injection + */ + static applyRequestContextMiddleware(api, options) { + if (options.requestContextMiddleware) { + if (typeof options.requestContextMiddleware !== 'function') { + throw new Error('requestContextMiddleware must be a function'); + } + api.use(options.requestContextMiddleware); + } + } /** * @static * Create an express app for the parse server @@ -326,7 +338,7 @@ class ParseServer { middlewares.addRateLimit(route, options); } api.use(middlewares.handleParseSession); - + this.applyRequestContextMiddleware(api, options); const appRouter = ParseServer.promiseRouter({ appId }); api.use(appRouter.expressRouter()); diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index 4c90ac2810..2e56a1b426 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -73,6 +73,7 @@ export class FunctionsRouter extends PromiseRouter { headers: req.config.headers, ip: req.config.ip, jobName, + config: req.config, message: jobHandler.setMessage.bind(jobHandler), }; @@ -129,6 +130,7 @@ export class FunctionsRouter extends PromiseRouter { params = parseParams(params, req.config); const request = { params: params, + config: req.config, master: req.auth && req.auth.isMaster, user: req.auth && req.auth.user, installationId: req.info.installationId, diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 3f33e5100d..fa982de8f3 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -670,6 +670,7 @@ module.exports = ParseCloud; * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...) * @property {Object} log The current logger inside Parse Server. * @property {Parse.Object} original If set, the object, as currently stored. + * @property {Object} config The Parse Server config. */ /** @@ -684,6 +685,7 @@ module.exports = ParseCloud; * @property {Object} headers The original HTTP headers for the request. * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`) * @property {Object} log The current logger inside Parse Server. + * @property {Object} config The Parse Server config. */ /** @@ -721,6 +723,7 @@ module.exports = ParseCloud; * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...) * @property {Object} log The current logger inside Parse Server. * @property {Boolean} isGet wether the query a `get` or a `find` + * @property {Object} config The Parse Server config. */ /** @@ -734,6 +737,7 @@ module.exports = ParseCloud; * @property {Object} headers The original HTTP headers for the request. * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...) * @property {Object} log The current logger inside Parse Server. + * @property {Object} config The Parse Server config. */ /** @@ -742,12 +746,14 @@ module.exports = ParseCloud; * @property {Boolean} master If true, means the master key was used. * @property {Parse.User} user If set, the user that made the request. * @property {Object} params The params passed to the cloud function. + * @property {Object} config The Parse Server config. */ /** * @interface Parse.Cloud.JobRequest * @property {Object} params The params passed to the background job. * @property {function} message If message is called with a string argument, will update the current message to be stored in the job status. + * @property {Object} config The Parse Server config. */ /** diff --git a/src/triggers.js b/src/triggers.js index c4028ce478..26b107f062 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -270,6 +270,7 @@ export function getRequestObject( log: config.loggerController, headers: config.headers, ip: config.ip, + config, }; if (isGet !== undefined) { @@ -320,6 +321,7 @@ export function getRequestQueryObject(triggerType, auth, query, count, config, c headers: config.headers, ip: config.ip, context: context || {}, + config, }; if (!auth) { @@ -1018,6 +1020,7 @@ export function getRequestFileObject(triggerType, auth, fileObject, config) { log: config.loggerController, headers: config.headers, ip: config.ip, + config, }; if (!auth) { From 84cebd439e864d7859d2a15af86ffec9f6b786bc Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 14 Oct 2025 18:17:28 +0000 Subject: [PATCH 13/30] chore(release): 8.3.0-alpha.6 [skip ci] # [8.3.0-alpha.6](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.5...8.3.0-alpha.6) (2025-10-14) ### Features * Add request context middleware for config and dependency injection in hooks ([#8480](https://github.com/parse-community/parse-server/issues/8480)) ([64f104e](https://github.com/parse-community/parse-server/commit/64f104e5c5f8863098e801eee632c14fcbd9b6f9)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 2b9cb2c9ae..9545c264be 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.6](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.5...8.3.0-alpha.6) (2025-10-14) + + +### Features + +* Add request context middleware for config and dependency injection in hooks ([#8480](https://github.com/parse-community/parse-server/issues/8480)) ([64f104e](https://github.com/parse-community/parse-server/commit/64f104e5c5f8863098e801eee632c14fcbd9b6f9)) + # [8.3.0-alpha.5](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.4...8.3.0-alpha.5) (2025-10-14) diff --git a/package-lock.json b/package-lock.json index 2b110a411a..069bf50912 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.5", + "version": "8.3.0-alpha.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.5", + "version": "8.3.0-alpha.6", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 8c7e921664..c4b6f6b0b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.5", + "version": "8.3.0-alpha.6", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From abfa94cd6de2c4e76337931c8ea8311c4ccf2a1a Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Wed, 15 Oct 2025 18:39:37 +0200 Subject: [PATCH 14/30] fix: Security upgrade to parse 7.0.1 (#9877) --- package-lock.json | 83 ++++++++++++++++------------- package.json | 2 +- spec/Adapters/Auth/linkedIn.spec.js | 57 +++++++++++++------- spec/Adapters/Auth/wechat.spec.js | 6 ++- spec/CloudCodeLogger.spec.js | 17 ++++-- spec/ParseObject.spec.js | 9 ++-- spec/ParseQuery.Comment.spec.js | 80 ++++++++++++++++++++++++--- spec/ParseRelation.spec.js | 2 +- spec/helper.js | 45 ++++++++++++++-- spec/vulnerabilities.spec.js | 20 +++---- 10 files changed, 230 insertions(+), 91 deletions(-) diff --git a/package-lock.json b/package-lock.json index 069bf50912..7c8bf0e6a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "mongodb": "6.17.0", "mustache": "4.2.0", "otpauth": "9.4.0", - "parse": "6.1.1", + "parse": "7.0.1", "path-to-regexp": "6.3.0", "pg-monitor": "3.0.0", "pg-promise": "12.2.0", @@ -2502,13 +2502,12 @@ } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.0.tgz", - "integrity": "sha512-UWjX6t+v+0ckwZ50Y5ShZLnlk95pP5MyW/pon9tiYzl3+18pkTHTFNTKr7rQbfRXPkowt2QAn30o1b6oswszew==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz", + "integrity": "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==", "license": "MIT", "dependencies": { - "core-js-pure": "^3.30.2", - "regenerator-runtime": "^0.14.0" + "core-js-pure": "^3.43.0" }, "engines": { "node": ">=6.9.0" @@ -9036,10 +9035,11 @@ } }, "node_modules/core-js-pure": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.41.0.tgz", - "integrity": "sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q==", + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.46.0.tgz", + "integrity": "sha512-NMCW30bHNofuhwLhYPt66OLOKTMbOhgTTatKVbaQC3KRHpTCiRIBYvtshr+NBYSnBxwAFhjW/RfJ0XbIjS16rw==", "hasInstallScript": true, + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -18589,17 +18589,16 @@ } }, "node_modules/parse": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/parse/-/parse-6.1.1.tgz", - "integrity": "sha512-zf70XcHKesDcqpO2RVKyIc1l7pngxBsYQVl0Yl/A38pftOSP8BQeampqqLEqMknzUetNZy8B+wrR3k5uTQDXOw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/parse/-/parse-7.0.1.tgz", + "integrity": "sha512-6hCnE8EWky/MqDtlpMnztzL0BEEsU3jVI7iKl2+AlJeSAeWkCgkPcb30eBNq57FcCnqWWC6uVJAaUMmX3+zrvg==", "license": "Apache-2.0", "dependencies": { - "@babel/runtime-corejs3": "7.27.0", - "idb-keyval": "6.2.1", + "@babel/runtime-corejs3": "7.28.4", + "idb-keyval": "6.2.2", "react-native-crypto-js": "1.0.0", "uuid": "10.0.0", - "ws": "8.18.1", - "xmlhttprequest": "1.8.0" + "ws": "8.18.3" }, "engines": { "node": "18 || 19 || 20 || 22" @@ -18635,6 +18634,12 @@ "node": ">=6" } }, + "node_modules/parse/node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, "node_modules/parse/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -18643,14 +18648,15 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } }, "node_modules/parse/node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -24615,12 +24621,11 @@ } }, "@babel/runtime-corejs3": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.0.tgz", - "integrity": "sha512-UWjX6t+v+0ckwZ50Y5ShZLnlk95pP5MyW/pon9tiYzl3+18pkTHTFNTKr7rQbfRXPkowt2QAn30o1b6oswszew==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz", + "integrity": "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==", "requires": { - "core-js-pure": "^3.30.2", - "regenerator-runtime": "^0.14.0" + "core-js-pure": "^3.43.0" } }, "@babel/template": { @@ -29217,9 +29222,9 @@ } }, "core-js-pure": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.41.0.tgz", - "integrity": "sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q==" + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.46.0.tgz", + "integrity": "sha512-NMCW30bHNofuhwLhYPt66OLOKTMbOhgTTatKVbaQC3KRHpTCiRIBYvtshr+NBYSnBxwAFhjW/RfJ0XbIjS16rw==" }, "core-util-is": { "version": "1.0.3", @@ -35848,28 +35853,32 @@ } }, "parse": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/parse/-/parse-6.1.1.tgz", - "integrity": "sha512-zf70XcHKesDcqpO2RVKyIc1l7pngxBsYQVl0Yl/A38pftOSP8BQeampqqLEqMknzUetNZy8B+wrR3k5uTQDXOw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/parse/-/parse-7.0.1.tgz", + "integrity": "sha512-6hCnE8EWky/MqDtlpMnztzL0BEEsU3jVI7iKl2+AlJeSAeWkCgkPcb30eBNq57FcCnqWWC6uVJAaUMmX3+zrvg==", "requires": { - "@babel/runtime-corejs3": "7.27.0", + "@babel/runtime-corejs3": "7.28.4", "crypto-js": "4.2.0", - "idb-keyval": "6.2.1", + "idb-keyval": "6.2.2", "react-native-crypto-js": "1.0.0", "uuid": "10.0.0", - "ws": "8.18.1", - "xmlhttprequest": "1.8.0" + "ws": "8.18.3" }, "dependencies": { + "idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==" + }, "uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==" }, "ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "requires": {} } } diff --git a/package.json b/package.json index c4b6f6b0b3..76fc250904 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "mongodb": "6.17.0", "mustache": "4.2.0", "otpauth": "9.4.0", - "parse": "6.1.1", + "parse": "7.0.1", "path-to-regexp": "6.3.0", "pg-monitor": "3.0.0", "pg-promise": "12.2.0", diff --git a/spec/Adapters/Auth/linkedIn.spec.js b/spec/Adapters/Auth/linkedIn.spec.js index f6c84a79af..9f5a4b37ae 100644 --- a/spec/Adapters/Auth/linkedIn.spec.js +++ b/spec/Adapters/Auth/linkedIn.spec.js @@ -89,12 +89,16 @@ describe('LinkedInAdapter', function () { describe('Test getUserFromAccessToken', function () { it('should fetch user successfully', async function () { - global.fetch = jasmine.createSpy().and.returnValue( - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ id: 'validUserId' }), - }) - ); + mockFetch([ + { + url: 'https://api.linkedin.com/v2/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ id: 'validUserId' }), + }, + }, + ]); const user = await adapter.getUserFromAccessToken('validToken', false); @@ -104,14 +108,21 @@ describe('LinkedInAdapter', function () { 'x-li-format': 'json', 'x-li-src': undefined, }, + method: 'GET', }); expect(user).toEqual({ id: 'validUserId' }); }); it('should throw error for invalid response', async function () { - global.fetch = jasmine.createSpy().and.returnValue( - Promise.resolve({ ok: false }) - ); + mockFetch([ + { + url: 'https://api.linkedin.com/v2/me', + method: 'GET', + response: { + ok: false, + }, + }, + ]); await expectAsync(adapter.getUserFromAccessToken('invalidToken', false)).toBeRejectedWith( new Error('LinkedIn API request failed.') @@ -121,12 +132,16 @@ describe('LinkedInAdapter', function () { describe('Test getAccessTokenFromCode', function () { it('should fetch token successfully', async function () { - global.fetch = jasmine.createSpy().and.returnValue( - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ access_token: 'validToken' }), - }) - ); + mockFetch([ + { + url: 'https://www.linkedin.com/oauth/v2/accessToken', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validToken' }), + }, + }, + ]); const tokenResponse = await adapter.getAccessTokenFromCode('validCode', 'http://example.com'); @@ -139,9 +154,15 @@ describe('LinkedInAdapter', function () { }); it('should throw error for invalid response', async function () { - global.fetch = jasmine.createSpy().and.returnValue( - Promise.resolve({ ok: false }) - ); + mockFetch([ + { + url: 'https://www.linkedin.com/oauth/v2/accessToken', + method: 'POST', + response: { + ok: false, + }, + }, + ]); await expectAsync( adapter.getAccessTokenFromCode('invalidCode', 'http://example.com') diff --git a/spec/Adapters/Auth/wechat.spec.js b/spec/Adapters/Auth/wechat.spec.js index b82e3e877a..43518ec0df 100644 --- a/spec/Adapters/Auth/wechat.spec.js +++ b/spec/Adapters/Auth/wechat.spec.js @@ -23,7 +23,8 @@ describe('WeChatAdapter', function () { const user = await adapter.getUserFromAccessToken('validToken', { id: 'validOpenId' }); expect(global.fetch).toHaveBeenCalledWith( - 'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId' + 'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId', + jasmine.any(Object) ); expect(user).toEqual({ errcode: 0, id: 'validUserId' }); }); @@ -64,7 +65,8 @@ describe('WeChatAdapter', function () { const token = await adapter.getAccessTokenFromCode(authData); expect(global.fetch).toHaveBeenCalledWith( - 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code' + 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code', + jasmine.any(Object) ); expect(token).toEqual('validToken'); }); diff --git a/spec/CloudCodeLogger.spec.js b/spec/CloudCodeLogger.spec.js index a16b52365a..a405b6fc48 100644 --- a/spec/CloudCodeLogger.spec.js +++ b/spec/CloudCodeLogger.spec.js @@ -189,7 +189,7 @@ describe('Cloud Code Logger', () => { }); }); - it_id('8088de8a-7cba-4035-8b05-4a903307e674')(it)('should log cloud function execution using the custom log level', async done => { + it_id('8088de8a-7cba-4035-8b05-4a903307e674')(it)('should log cloud function execution using the custom log level', async () => { Parse.Cloud.define('aFunction', () => { return 'it worked!'; }); @@ -203,6 +203,7 @@ describe('Cloud Code Logger', () => { expect(log).toEqual('info'); }); + Parse.Cloud._removeAllHooks(); await reconfigureServer({ silent: true, logLevels: { @@ -211,6 +212,10 @@ describe('Cloud Code Logger', () => { }, }); + Parse.Cloud.define('bFunction', () => { + throw new Error('Failed'); + }); + spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough(); try { @@ -221,15 +226,12 @@ describe('Cloud Code Logger', () => { .allArgs() .find(log => log[1].startsWith('Failed running cloud function bFunction for '))?.[0]; expect(log).toEqual('info'); - done(); } }); it('should log cloud function triggers using the custom log level', async () => { - Parse.Cloud.beforeSave('TestClass', () => {}); - Parse.Cloud.afterSave('TestClass', () => {}); - const execTest = async (logLevel, triggerBeforeSuccess, triggerAfter) => { + Parse.Cloud._removeAllHooks(); await reconfigureServer({ silent: true, logLevel, @@ -239,6 +241,9 @@ describe('Cloud Code Logger', () => { }, }); + Parse.Cloud.beforeSave('TestClass', () => { }); + Parse.Cloud.afterSave('TestClass', () => { }); + spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough(); const obj = new Parse.Object('TestClass'); await obj.save(); @@ -344,6 +349,7 @@ describe('Cloud Code Logger', () => { }); it('should log cloud function execution using the silent log level', async () => { + Parse.Cloud._removeAllHooks(); await reconfigureServer({ logLevels: { cloudFunctionSuccess: 'silent', @@ -367,6 +373,7 @@ describe('Cloud Code Logger', () => { }); it('should log cloud function triggers using the silent log level', async () => { + Parse.Cloud._removeAllHooks(); await reconfigureServer({ logLevels: { triggerAfter: 'silent', diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js index 10558b209d..cf65e2df47 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -1395,10 +1395,10 @@ describe('Parse.Object testing', () => { .save() .then(function () { const query = new Parse.Query(TestObject); - return query.find(object.id); + return query.get(object.id); }) - .then(function (results) { - updatedObject = results[0]; + .then(function (result) { + updatedObject = result; updatedObject.set('x', 11); return updatedObject.save(); }) @@ -1409,7 +1409,8 @@ describe('Parse.Object testing', () => { equal(object.createdAt.getTime(), updatedObject.createdAt.getTime()); equal(object.updatedAt.getTime(), updatedObject.updatedAt.getTime()); done(); - }); + }) + .catch(done.fail); }); xit('fetchAll backbone-style callbacks', function (done) { diff --git a/spec/ParseQuery.Comment.spec.js b/spec/ParseQuery.Comment.spec.js index 7b37f2a2c2..df5b4aeac6 100644 --- a/spec/ParseQuery.Comment.spec.js +++ b/spec/ParseQuery.Comment.spec.js @@ -46,7 +46,7 @@ describe_only_db('mongo')('Parse.Query with comment testing', () => { }); it('send comment with query through REST', async () => { - const comment = 'Hello Parse'; + const comment = `Hello Parse ${Date.now()}`; const object = new TestObject(); object.set('name', 'object'); await object.save(); @@ -58,23 +58,55 @@ describe_only_db('mongo')('Parse.Query with comment testing', () => { }, }); await request(options); - const result = await database.collection('system.profile').findOne({}, { sort: { ts: -1 } }); + + // Wait for profile entry to appear with retry logic + let result; + const maxRetries = 10; + const retryDelay = 100; + for (let i = 0; i < maxRetries; i++) { + result = await database.collection('system.profile').findOne( + { 'command.explain.comment': comment }, + { sort: { ts: -1 } } + ); + if (result) { + break; + } + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + + expect(result).toBeDefined(); expect(result.command.explain.comment).toBe(comment); }); it('send comment with query', async () => { - const comment = 'Hello Parse'; + const comment = `Hello Parse ${Date.now()}`; const object = new TestObject(); object.set('name', 'object'); await object.save(); const collection = await config.database.adapter._adaptiveCollection('TestObject'); await collection._rawFind({ name: 'object' }, { comment: comment }); - const result = await database.collection('system.profile').findOne({}, { sort: { ts: -1 } }); + + // Wait for profile entry to appear with retry logic + let result; + const maxRetries = 10; + const retryDelay = 100; + for (let i = 0; i < maxRetries; i++) { + result = await database.collection('system.profile').findOne( + { 'command.comment': comment }, + { sort: { ts: -1 } } + ); + if (result) { + break; + } + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + + expect(result).toBeDefined(); expect(result.command.comment).toBe(comment); }); it('send a comment with a count query', async () => { - const comment = 'Hello Parse'; + const comment = `Hello Parse ${Date.now()}`; const object = new TestObject(); object.set('name', 'object'); await object.save(); @@ -86,12 +118,28 @@ describe_only_db('mongo')('Parse.Query with comment testing', () => { const collection = await config.database.adapter._adaptiveCollection('TestObject'); const countResult = await collection.count({ name: 'object' }, { comment: comment }); expect(countResult).toEqual(2); - const result = await database.collection('system.profile').findOne({}, { sort: { ts: -1 } }); + + // Wait for profile entry to appear with retry logic + let result; + const maxRetries = 10; + const retryDelay = 100; + for (let i = 0; i < maxRetries; i++) { + result = await database.collection('system.profile').findOne( + { 'command.comment': comment }, + { sort: { ts: -1 } } + ); + if (result) { + break; + } + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + + expect(result).toBeDefined(); expect(result.command.comment).toBe(comment); }); it('attach a comment to an aggregation', async () => { - const comment = 'Hello Parse'; + const comment = `Hello Parse ${Date.now()}`; const object = new TestObject(); object.set('name', 'object'); await object.save(); @@ -100,7 +148,23 @@ describe_only_db('mongo')('Parse.Query with comment testing', () => { explain: true, comment: comment, }); - const result = await database.collection('system.profile').findOne({}, { sort: { ts: -1 } }); + + // Wait for profile entry to appear with retry logic + let result; + const maxRetries = 10; + const retryDelay = 100; + for (let i = 0; i < maxRetries; i++) { + result = await database.collection('system.profile').findOne( + { 'command.explain.comment': comment }, + { sort: { ts: -1 } } + ); + if (result) { + break; + } + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + + expect(result).toBeDefined(); expect(result.command.explain.comment).toBe(comment); }); }); diff --git a/spec/ParseRelation.spec.js b/spec/ParseRelation.spec.js index f0c746065d..98b4938433 100644 --- a/spec/ParseRelation.spec.js +++ b/spec/ParseRelation.spec.js @@ -517,7 +517,7 @@ describe('Parse.Relation testing', () => { // Parent object is un-fetched, so this will call /1/classes/Car instead // of /1/classes/Wheel and pass { "redirectClassNameForKey":"wheels" }. - return query.find(origWheel.id); + return query.find(); }) .then(function (results) { // Make sure this is Wheel and not Car. diff --git a/spec/helper.js b/spec/helper.js index 9c31053421..d2f26e584c 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -7,6 +7,15 @@ const { SpecReporter } = require('jasmine-spec-reporter'); const SchemaCache = require('../lib/Adapters/Cache/SchemaCache').default; const { sleep, Connections } = require('../lib/TestUtils'); +const originalFetch = global.fetch; +let fetchWasMocked = false; + +global.restoreFetch = () => { + global.fetch = originalFetch; + fetchWasMocked = false; +} + + // Ensure localhost resolves to ipv4 address first on node v17+ if (dns.setDefaultResultOrder) { dns.setDefaultResultOrder('ipv4first'); @@ -205,6 +214,7 @@ const reconfigureServer = async (changedConfiguration = {}) => { }; beforeAll(async () => { + global.restoreFetch(); await reconfigureServer(); Parse.initialize('test', 'test', 'test'); Parse.serverURL = serverURL; @@ -212,7 +222,18 @@ beforeAll(async () => { Parse.CoreManager.set('REQUEST_ATTEMPT_LIMIT', 1); }); +beforeEach(async () => { + if(fetchWasMocked) { + global.restoreFetch(); + } +}); + global.afterEachFn = async () => { + // Restore fetch to prevent mock pollution between tests (only if it was mocked) + if (fetchWasMocked) { + global.restoreFetch(); + } + Parse.Cloud._removeAllHooks(); Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(); defaults.protectedFields = { _User: { '*': ['email'] } }; @@ -251,6 +272,7 @@ global.afterEachFn = async () => { afterEach(global.afterEachFn); afterAll(() => { + global.restoreFetch(); global.displayTestStats(); }); @@ -388,9 +410,22 @@ function mockShortLivedAuth() { } function mockFetch(mockResponses) { - global.fetch = jasmine.createSpy('fetch').and.callFake((url, options = { }) => { + const spy = jasmine.createSpy('fetch'); + fetchWasMocked = true; // Track that fetch was mocked for cleanup + + global.fetch = (url, options = {}) => { + // Allow requests to the Parse Server to pass through WITHOUT recording in spy + // This prevents tests from failing when they check that fetch wasn't called + // but the Parse SDK makes internal requests to the Parse Server + if (typeof url === 'string' && url.includes(serverURL)) { + return originalFetch(url, options); + } + + // Record non-Parse-Server calls in the spy + spy(url, options); + options.method ||= 'GET'; - const mockResponse = mockResponses.find( + const mockResponse = mockResponses?.find( (mock) => mock.url === url && mock.method === options.method ); @@ -402,7 +437,11 @@ function mockFetch(mockResponses) { ok: false, statusText: 'Unknown URL or method', }); - }); + }; + + // Expose spy methods for test assertions + global.fetch.calls = spy.calls; + global.fetch.and = spy.and; } diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index f7a94cd221..0d66c0a135 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -175,12 +175,10 @@ describe('Vulnerabilities', () => { }, }); }); - await expectAsync(new Parse.Object('TestObject').save()).toBeRejectedWith( - new Parse.Error( - Parse.Error.INVALID_KEY_NAME, - 'Prohibited keyword in request data: {"key":"constructor"}.' - ) - ); + // The new Parse SDK handles prototype pollution prevention in .set() + // so no error is thrown, but the object prototype should not be polluted + await new Parse.Object('TestObject').save(); + expect(Object.prototype.dummy).toBeUndefined(); }); it('denies creating global config with polluted data', async () => { @@ -270,12 +268,10 @@ describe('Vulnerabilities', () => { res.json({ success: object }); }); await Parse.Hooks.createTrigger('TestObject', 'beforeSave', hookServerURL + '/BeforeSave'); - await expectAsync(new Parse.Object('TestObject').save()).toBeRejectedWith( - new Parse.Error( - Parse.Error.INVALID_KEY_NAME, - 'Prohibited keyword in request data: {"key":"constructor"}.' - ) - ); + // The new Parse SDK handles prototype pollution prevention in .set() + // so no error is thrown, but the object prototype should not be polluted + await new Parse.Object('TestObject').save(); + expect(Object.prototype.dummy).toBeUndefined(); await new Promise(resolve => server.close(resolve)); }); From 3f0ec42240dd14a65391c261353ea3926e1d4c53 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 15 Oct 2025 16:40:35 +0000 Subject: [PATCH 15/30] chore(release): 8.3.0-alpha.7 [skip ci] # [8.3.0-alpha.7](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.6...8.3.0-alpha.7) (2025-10-15) ### Bug Fixes * Security upgrade to parse 7.0.1 ([#9877](https://github.com/parse-community/parse-server/issues/9877)) ([abfa94c](https://github.com/parse-community/parse-server/commit/abfa94cd6de2c4e76337931c8ea8311c4ccf2a1a)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 9545c264be..6cd93ab6f9 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.7](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.6...8.3.0-alpha.7) (2025-10-15) + + +### Bug Fixes + +* Security upgrade to parse 7.0.1 ([#9877](https://github.com/parse-community/parse-server/issues/9877)) ([abfa94c](https://github.com/parse-community/parse-server/commit/abfa94cd6de2c4e76337931c8ea8311c4ccf2a1a)) + # [8.3.0-alpha.6](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.5...8.3.0-alpha.6) (2025-10-14) diff --git a/package-lock.json b/package-lock.json index 7c8bf0e6a0..78ad936e0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.6", + "version": "8.3.0-alpha.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.6", + "version": "8.3.0-alpha.7", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 76fc250904..5411d6c9e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.6", + "version": "8.3.0-alpha.7", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 1815b019b52565d2bc87be2596a49aea7600aeba Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Thu, 16 Oct 2025 09:29:02 +0200 Subject: [PATCH 16/30] fix: Warning logged when setting option `databaseOptions.disableIndexFieldValidation` (#9880) --- spec/ParseConfigKey.spec.js | 3 ++- src/Options/Definitions.js | 6 ++++++ src/Options/docs.js | 1 + src/Options/index.js | 4 +++- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/spec/ParseConfigKey.spec.js b/spec/ParseConfigKey.spec.js index a171032087..ae31ff954d 100644 --- a/spec/ParseConfigKey.spec.js +++ b/spec/ParseConfigKey.spec.js @@ -81,7 +81,8 @@ describe('Config Keys', () => { connectTimeoutMS: 5000, socketTimeoutMS: 5000, autoSelectFamily: true, - autoSelectFamilyAttemptTimeout: 3000 + autoSelectFamilyAttemptTimeout: 3000, + disableIndexFieldValidation: true }, })).toBeResolved(); expect(loggerErrorSpy.calls.all().reduce((s, call) => s += call.args[0], '')).not.toMatch(invalidKeyErrorMessage); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 6218e484a8..81c4f56cce 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -1092,6 +1092,12 @@ module.exports.DatabaseOptions = { 'The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.', action: parsers.numberParser('connectTimeoutMS'), }, + disableIndexFieldValidation: { + env: 'PARSE_SERVER_DATABASE_DISABLE_INDEX_FIELD_VALIDATION', + help: + 'Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later.', + action: parsers.booleanParser, + }, enableSchemaHooks: { env: 'PARSE_SERVER_DATABASE_ENABLE_SCHEMA_HOOKS', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index e3ef19655a..51167b7f9d 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -242,6 +242,7 @@ * @property {Boolean} autoSelectFamily The MongoDB driver option to set whether the socket attempts to connect to IPv6 and IPv4 addresses until a connection is established. If available, the driver will select the first IPv6 address. * @property {Number} autoSelectFamilyAttemptTimeout The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead. * @property {Number} connectTimeoutMS The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout. + * @property {Boolean} disableIndexFieldValidation Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later. * @property {Boolean} enableSchemaHooks Enables database real-time hooks to update single schema cache. Set to `true` if using multiple Parse Servers instances connected to the same database. Failing to do so will cause a schema change to not propagate to all instances and re-syncing will only happen when the instances restart. To use this feature with MongoDB, a replica set cluster with [change stream](https://docs.mongodb.com/manual/changeStreams/#availability) support is required. * @property {Number} maxPoolSize The MongoDB driver option to set the maximum number of opened, cached, ready-to-use database connections maintained by the driver. * @property {Number} maxStalenessSeconds The MongoDB driver option to set the maximum replication lag for reads from secondary nodes. diff --git a/src/Options/index.js b/src/Options/index.js index 29ac1628f7..355d0d2888 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -343,7 +343,7 @@ export interface ParseServerOptions { :DEFAULT: [] */ rateLimit: ?(RateLimitOptions[]); /* Options to customize the request context using inversion of control/dependency injection.*/ - requestContextMiddleware: ?((req: any, res: any, next: any) => void); + requestContextMiddleware: ?(req: any, res: any, next: any) => void; } export interface RateLimitOptions { @@ -629,6 +629,8 @@ export interface DatabaseOptions { autoSelectFamily: ?boolean; /* The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead. */ autoSelectFamilyAttemptTimeout: ?number; + /* Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later. */ + disableIndexFieldValidation: ?boolean; } export interface AuthAdapter { From 115e76e8adfb8a48dd76e45e1682c52620315f96 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 16 Oct 2025 07:30:34 +0000 Subject: [PATCH 17/30] chore(release): 8.3.0-alpha.8 [skip ci] # [8.3.0-alpha.8](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.7...8.3.0-alpha.8) (2025-10-16) ### Bug Fixes * Warning logged when setting option `databaseOptions.disableIndexFieldValidation` ([#9880](https://github.com/parse-community/parse-server/issues/9880)) ([1815b01](https://github.com/parse-community/parse-server/commit/1815b019b52565d2bc87be2596a49aea7600aeba)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 6cd93ab6f9..069c7c045e 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.8](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.7...8.3.0-alpha.8) (2025-10-16) + + +### Bug Fixes + +* Warning logged when setting option `databaseOptions.disableIndexFieldValidation` ([#9880](https://github.com/parse-community/parse-server/issues/9880)) ([1815b01](https://github.com/parse-community/parse-server/commit/1815b019b52565d2bc87be2596a49aea7600aeba)) + # [8.3.0-alpha.7](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.6...8.3.0-alpha.7) (2025-10-15) diff --git a/package-lock.json b/package-lock.json index 78ad936e0b..1876023626 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.7", + "version": "8.3.0-alpha.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.7", + "version": "8.3.0-alpha.8", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 5411d6c9e2..983ec97f83 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.7", + "version": "8.3.0-alpha.8", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 178bd5c5e258d9501c9ac4d35a3a105ab64be67e Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 19 Oct 2025 21:25:46 +0200 Subject: [PATCH 18/30] fix: Server URL verification before server is ready (#9882) --- .gitignore | 3 +++ src/ParseServer.ts | 7 +------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index ce3eff2a59..b5b69e3891 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ lib/ # Redis Dump dump.rdb + +# AI agents +.claude diff --git a/src/ParseServer.ts b/src/ParseServer.ts index b928364c2e..d0bb288327 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -365,12 +365,6 @@ class ParseServer { process.exit(1); } }); - // verify the server url after a 'mount' event is received - /* istanbul ignore next */ - api.on('mount', async function () { - await new Promise(resolve => setTimeout(resolve, 1000)); - ParseServer.verifyServerUrl(); - }); } if (process.env.PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS === '1' || directAccess) { Parse.CoreManager.setRESTController(ParseServerRESTController(appId, appRouter)); @@ -487,6 +481,7 @@ class ParseServer { /* istanbul ignore next */ if (!process.env.TESTING) { configureListeners(this); + await ParseServer.verifyServerUrl(); } this.expressApp = app; return this; From 1de329d900be8b4ff12dc42ffd50896cc9b32113 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 19 Oct 2025 19:26:36 +0000 Subject: [PATCH 19/30] chore(release): 8.3.0-alpha.9 [skip ci] # [8.3.0-alpha.9](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.8...8.3.0-alpha.9) (2025-10-19) ### Bug Fixes * Server URL verification before server is ready ([#9882](https://github.com/parse-community/parse-server/issues/9882)) ([178bd5c](https://github.com/parse-community/parse-server/commit/178bd5c5e258d9501c9ac4d35a3a105ab64be67e)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 069c7c045e..2890440166 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.9](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.8...8.3.0-alpha.9) (2025-10-19) + + +### Bug Fixes + +* Server URL verification before server is ready ([#9882](https://github.com/parse-community/parse-server/issues/9882)) ([178bd5c](https://github.com/parse-community/parse-server/commit/178bd5c5e258d9501c9ac4d35a3a105ab64be67e)) + # [8.3.0-alpha.8](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.7...8.3.0-alpha.8) (2025-10-16) diff --git a/package-lock.json b/package-lock.json index 1876023626..10852fa312 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.8", + "version": "8.3.0-alpha.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.8", + "version": "8.3.0-alpha.9", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 983ec97f83..7861340935 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.8", + "version": "8.3.0-alpha.9", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From eb052d8e6abe1ae32505fd068d5445eaf950a770 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Wed, 22 Oct 2025 14:12:51 +0200 Subject: [PATCH 20/30] fix: Error in `afterSave` trigger for `Parse.Role` due to `name` field (#9883) --- spec/CloudCodeLogger.spec.js | 10 ++--- spec/ParseRole.spec.js | 74 ++++++++++++++++++++++++++++++++++++ src/RestWrite.js | 8 ++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/spec/CloudCodeLogger.spec.js b/spec/CloudCodeLogger.spec.js index a405b6fc48..bcbc4f91cf 100644 --- a/spec/CloudCodeLogger.spec.js +++ b/spec/CloudCodeLogger.spec.js @@ -25,7 +25,7 @@ describe('Cloud Code Logger', () => { }) .then(() => { return Parse.User.signUp('tester', 'abc') - .catch(() => {}) + .catch(() => { }) .then(loggedInUser => (user = loggedInUser)) .then(() => Parse.User.logIn(user.get('username'), 'abc')); }) @@ -139,7 +139,7 @@ describe('Cloud Code Logger', () => { }); it_id('9857e15d-bb18-478d-8a67-fdaad3e89565')(it)('should log an afterSave', done => { - Parse.Cloud.afterSave('MyObject', () => {}); + Parse.Cloud.afterSave('MyObject', () => { }); new Parse.Object('MyObject') .save() .then(() => { @@ -271,7 +271,7 @@ describe('Cloud Code Logger', () => { }); Parse.Cloud.run('aFunction', { foo: 'bar' }) - .catch(() => {}) + .catch(() => { }) .then(() => { const logs = spy.calls.all().reverse(); expect(logs[0].args[1]).toBe('Parse error: '); @@ -384,8 +384,8 @@ describe('Cloud Code Logger', () => { Parse.Cloud.beforeSave('TestClassError', () => { throw new Error('Failed'); }); - Parse.Cloud.beforeSave('TestClass', () => {}); - Parse.Cloud.afterSave('TestClass', () => {}); + Parse.Cloud.beforeSave('TestClass', () => { }); + Parse.Cloud.afterSave('TestClass', () => { }); spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough(); diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 35a91c6c15..95e6189a6a 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -601,4 +601,78 @@ describe('Parse Role testing', () => { }); }); }); + + it('should trigger afterSave hook when using Parse.Role', async () => { + const afterSavePromise = new Promise(resolve => { + Parse.Cloud.afterSave(Parse.Role, req => { + expect(req.object).toBeDefined(); + expect(req.object.get('name')).toBe('AnotherTestRole'); + resolve(); + }); + }); + + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + const role = new Parse.Role('AnotherTestRole', acl); + + const savedRole = await role.save({}, { useMasterKey: true }); + expect(savedRole.id).toBeDefined(); + + await afterSavePromise; + }); + + it('should trigger beforeSave hook and allow modifying role in beforeSave', async () => { + Parse.Cloud.beforeSave(Parse.Role, req => { + // Add a custom field in beforeSave + req.object.set('customField', 'addedInBeforeSave'); + }); + + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + const role = new Parse.Role('ModifiedRole', acl); + + const savedRole = await role.save({}, { useMasterKey: true }); + expect(savedRole.id).toBeDefined(); + expect(savedRole.get('customField')).toBe('addedInBeforeSave'); + }); + + it('should trigger beforeSave hook using Parse.Role', async () => { + let beforeSaveCalled = false; + + Parse.Cloud.beforeSave(Parse.Role, req => { + beforeSaveCalled = true; + expect(req.object).toBeDefined(); + expect(req.object.get('name')).toBe('BeforeSaveWithClassRef'); + }); + + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + const role = new Parse.Role('BeforeSaveWithClassRef', acl); + + const savedRole = await role.save({}, { useMasterKey: true }); + expect(savedRole.id).toBeDefined(); + expect(beforeSaveCalled).toBe(true); + }); + + it('should allow modifying role name in beforeSave hook', async () => { + Parse.Cloud.beforeSave(Parse.Role, req => { + // Modify the role name in beforeSave + if (req.object.get('name') === 'OriginalName') { + req.object.set('name', 'ModifiedName'); + } + }); + + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + const role = new Parse.Role('OriginalName', acl); + + const savedRole = await role.save({}, { useMasterKey: true }); + expect(savedRole.id).toBeDefined(); + expect(savedRole.get('name')).toBe('ModifiedName'); + + // Verify the name was actually saved to the database + const query = new Parse.Query(Parse.Role); + const fetchedRole = await query.get(savedRole.id, { useMasterKey: true }); + expect(fetchedRole.get('name')).toBe('ModifiedName'); + }); }); diff --git a/src/RestWrite.js b/src/RestWrite.js index 78dd8c8878..41b6c23468 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1743,6 +1743,14 @@ RestWrite.prototype.buildParseObjects = function () { const readOnlyAttributes = className.constructor.readOnlyAttributes ? className.constructor.readOnlyAttributes() : []; + + // For _Role class, 'name' cannot be set after the role has an objectId. + // In afterSave context, _handleSaveResponse has already set the objectId, + // so we treat 'name' as read-only to avoid Parse SDK validation errors. + const isRoleAfterSave = this.className === '_Role' && this.response && !this.query; + if (isRoleAfterSave && this.data.name && !readOnlyAttributes.includes('name')) { + readOnlyAttributes.push('name'); + } if (!this.originalData) { for (const attribute of readOnlyAttributes) { extraData[attribute] = this.data[attribute]; From 8006a9e2c1efb3adc118c88741f66ed678426d54 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 22 Oct 2025 12:13:47 +0000 Subject: [PATCH 21/30] chore(release): 8.3.0-alpha.10 [skip ci] # [8.3.0-alpha.10](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.9...8.3.0-alpha.10) (2025-10-22) ### Bug Fixes * Error in `afterSave` trigger for `Parse.Role` due to `name` field ([#9883](https://github.com/parse-community/parse-server/issues/9883)) ([eb052d8](https://github.com/parse-community/parse-server/commit/eb052d8e6abe1ae32505fd068d5445eaf950a770)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 2890440166..fc680f7ec7 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.10](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.9...8.3.0-alpha.10) (2025-10-22) + + +### Bug Fixes + +* Error in `afterSave` trigger for `Parse.Role` due to `name` field ([#9883](https://github.com/parse-community/parse-server/issues/9883)) ([eb052d8](https://github.com/parse-community/parse-server/commit/eb052d8e6abe1ae32505fd068d5445eaf950a770)) + # [8.3.0-alpha.9](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.8...8.3.0-alpha.9) (2025-10-19) diff --git a/package-lock.json b/package-lock.json index 10852fa312..0b0295fe27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.9", + "version": "8.3.0-alpha.10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.9", + "version": "8.3.0-alpha.10", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 7861340935..7c98fb66ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.9", + "version": "8.3.0-alpha.10", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From f49efaf5bb1d6b19f6d6712f7cdf073855c95c6e Mon Sep 17 00:00:00 2001 From: "mavriel@gmail.com" Date: Sat, 25 Oct 2025 03:58:44 +0900 Subject: [PATCH 22/30] fix: Stale data read in validation query on `Parse.Object` update causes inconsistency between validation read and subsequent update write operation (#9859) --- spec/DatabaseController.spec.js | 70 +++++++++++++++++++++++++++ src/Controllers/DatabaseController.js | 2 +- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index e1b50a5a52..d8ce516131 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -615,6 +615,76 @@ describe('DatabaseController', function () { expect(result2.length).toEqual(1); }); }); + + describe('update with validateOnly', () => { + const mockStorageAdapter = { + findOneAndUpdate: () => Promise.resolve({}), + find: () => Promise.resolve([{ objectId: 'test123', testField: 'initialValue' }]), + watch: () => Promise.resolve(), + getAllClasses: () => + Promise.resolve([ + { + className: 'TestObject', + fields: { testField: 'String' }, + indexes: {}, + classLevelPermissions: { protectedFields: {} }, + }, + ]), + }; + + it('should use primary readPreference when validateOnly is true', async () => { + const databaseController = new DatabaseController(mockStorageAdapter, {}); + const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough(); + const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough(); + + try { + // Call update with validateOnly: true (same as RestWrite.runBeforeSaveTrigger) + await databaseController.update( + 'TestObject', + { objectId: 'test123' }, + { testField: 'newValue' }, + {}, + true, // skipSanitization: true (matches RestWrite behavior) + true // validateOnly: true + ); + } catch (error) { + // validateOnly may throw, but we're checking the find call options + } + + // Verify that find was called with primary readPreference + expect(findSpy).toHaveBeenCalled(); + const findCall = findSpy.calls.mostRecent(); + expect(findCall.args[3]).toEqual({ readPreference: 'primary' }); // options parameter + + // Verify that findOneAndUpdate was NOT called (only validation, no actual update) + expect(findOneAndUpdateSpy).not.toHaveBeenCalled(); + }); + + it('should not use primary readPreference when validateOnly is false', async () => { + const databaseController = new DatabaseController(mockStorageAdapter, {}); + const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough(); + const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough(); + + try { + // Call update with validateOnly: false + await databaseController.update( + 'TestObject', + { objectId: 'test123' }, + { testField: 'newValue' }, + {}, + false, // skipSanitization + false // validateOnly + ); + } catch (error) { + // May throw for other reasons, but we're checking the call pattern + } + + // When validateOnly is false, find should not be called for validation + // Instead, findOneAndUpdate should be called + expect(findSpy).not.toHaveBeenCalled(); + expect(findOneAndUpdateSpy).toHaveBeenCalled(); + }); + }); }); function buildCLP(pointerNames) { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 095c2e83c1..15207a7295 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -593,7 +593,7 @@ class DatabaseController { convertUsernameToLowercase(update, className, this.options); transformAuthData(className, update, schema); if (validateOnly) { - return this.adapter.find(className, schema, query, {}).then(result => { + return this.adapter.find(className, schema, query, { readPreference: 'primary' }).then(result => { if (!result || !result.length) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } From a3ac82fc549e6ee346ab67fcf95eda2116a7a388 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 24 Oct 2025 18:59:41 +0000 Subject: [PATCH 23/30] chore(release): 8.3.0-alpha.11 [skip ci] # [8.3.0-alpha.11](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.10...8.3.0-alpha.11) (2025-10-24) ### Bug Fixes * Stale data read in validation query on `Parse.Object` update causes inconsistency between validation read and subsequent update write operation ([#9859](https://github.com/parse-community/parse-server/issues/9859)) ([f49efaf](https://github.com/parse-community/parse-server/commit/f49efaf5bb1d6b19f6d6712f7cdf073855c95c6e)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index fc680f7ec7..877c8f652b 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.11](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.10...8.3.0-alpha.11) (2025-10-24) + + +### Bug Fixes + +* Stale data read in validation query on `Parse.Object` update causes inconsistency between validation read and subsequent update write operation ([#9859](https://github.com/parse-community/parse-server/issues/9859)) ([f49efaf](https://github.com/parse-community/parse-server/commit/f49efaf5bb1d6b19f6d6712f7cdf073855c95c6e)) + # [8.3.0-alpha.10](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.9...8.3.0-alpha.10) (2025-10-22) diff --git a/package-lock.json b/package-lock.json index 0b0295fe27..0ce514868c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.10", + "version": "8.3.0-alpha.11", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.10", + "version": "8.3.0-alpha.11", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 7c98fb66ce..b8b5f14e19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.10", + "version": "8.3.0-alpha.11", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From b298cccd9fb4f664b9d83894faad6d1ea7a3c964 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Sat, 25 Oct 2025 18:01:58 +0200 Subject: [PATCH 24/30] feat: Add Parse Server option `verifyServerUrl` to disable server URL verification on server launch (#9881) --- src/Options/Definitions.js | 13 +++++++++++-- src/Options/docs.js | 5 +++-- src/Options/index.js | 7 +++++-- src/ParseServer.ts | 12 ++++++++++-- types/Options/index.d.ts | 1 + 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 81c4f56cce..205c35fa77 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -296,7 +296,8 @@ module.exports.ParseServerOptions = { }, graphQLPath: { env: 'PARSE_SERVER_GRAPHQL_PATH', - help: 'Mount path for the GraphQL endpoint, defaults to /graphql', + help: + 'The mount path for the GraphQL endpoint

\u26A0\uFE0F File upload inside the GraphQL mutation system requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Defaults is `/graphql`.', default: '/graphql', }, graphQLPublicIntrospection: { @@ -579,7 +580,8 @@ module.exports.ParseServerOptions = { }, serverURL: { env: 'PARSE_SERVER_URL', - help: 'URL to your parse server with http:// or https://.', + help: + 'The URL to Parse Server.

\u26A0\uFE0F Certain server features or adapters may require Parse Server to be able to call itself by making requests to the URL set in `serverURL`. If a feature requires this, it is mentioned in the documentation. In that case ensure that the URL is accessible from the server itself.', required: true, }, sessionLength: { @@ -616,6 +618,13 @@ module.exports.ParseServerOptions = { help: 'Set the logging to verbose', action: parsers.booleanParser, }, + verifyServerUrl: { + env: 'PARSE_SERVER_VERIFY_SERVER_URL', + help: + 'Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.

\u26A0\uFE0F Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Default is `true`.', + action: parsers.booleanParser, + default: true, + }, verifyUserEmails: { env: 'PARSE_SERVER_VERIFY_USER_EMAILS', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index 51167b7f9d..dde5942500 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -53,7 +53,7 @@ * @property {String} fileKey Key for your files * @property {Adapter} filesAdapter Adapter module for the files sub-system * @property {FileUploadOptions} fileUpload Options for file uploads - * @property {String} graphQLPath Mount path for the GraphQL endpoint, defaults to /graphql + * @property {String} graphQLPath The mount path for the GraphQL endpoint

⚠️ File upload inside the GraphQL mutation system requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Defaults is `/graphql`. * @property {Boolean} graphQLPublicIntrospection Enable public introspection for the GraphQL endpoint, defaults to false * @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file * @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0 @@ -100,13 +100,14 @@ * @property {SecurityOptions} security The security options to identify and report weak security settings. * @property {Boolean} sendUserEmailVerification Set to `false` to prevent sending of verification email. Supports a function with a return value of `true` or `false` for conditional email sending.

Default is `true`.
* @property {Function} serverCloseComplete Callback when server has closed - * @property {String} serverURL URL to your parse server with http:// or https://. + * @property {String} serverURL The URL to Parse Server.

⚠️ Certain server features or adapters may require Parse Server to be able to call itself by making requests to the URL set in `serverURL`. If a feature requires this, it is mentioned in the documentation. In that case ensure that the URL is accessible from the server itself. * @property {Number} sessionLength Session duration, in seconds, defaults to 1 year * @property {Boolean} silent Disables console output * @property {Boolean} startLiveQueryServer Starts the liveQuery server * @property {Any} trustProxy The trust proxy settings. It is important to understand the exact setup of the reverse proxy, since this setting will trust values provided in the Parse Server API request. See the express trust proxy settings documentation. Defaults to `false`. * @property {String[]} userSensitiveFields Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields * @property {Boolean} verbose Set the logging to verbose + * @property {Boolean} verifyServerUrl Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.

⚠️ Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Default is `true`. * @property {Boolean} verifyUserEmails Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification.

Default is `false`. * @property {String} webhookKey Key sent with outgoing webhook calls */ diff --git a/src/Options/index.js b/src/Options/index.js index 355d0d2888..ff8287b86b 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -54,9 +54,12 @@ export interface ParseServerOptions { masterKeyTtl: ?number; /* (Optional) The maintenance key is used for modifying internal and read-only fields of Parse Server.

⚠️ This key is not intended to be used as part of a regular operation of Parse Server. This key is intended to conduct out-of-band changes such as one-time migrations or data correction tasks. Internal fields are not officially documented and may change at any time without publication in release changelogs. We strongly advice not to rely on internal fields as part of your regular operation and to investigate the implications of any planned changes *directly in the source code* of your current version of Parse Server. */ maintenanceKey: string; - /* URL to your parse server with http:// or https://. + /* The URL to Parse Server.

⚠️ Certain server features or adapters may require Parse Server to be able to call itself by making requests to the URL set in `serverURL`. If a feature requires this, it is mentioned in the documentation. In that case ensure that the URL is accessible from the server itself. :ENV: PARSE_SERVER_URL */ serverURL: string; + /* Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.

⚠️ Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Default is `true`. + :DEFAULT: true */ + verifyServerUrl: ?boolean; /* (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

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

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

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

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. :DEFAULT: ["127.0.0.1","::1"] */ masterKeyIps: ?(string[]); @@ -305,7 +308,7 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_MOUNT_GRAPHQL :DEFAULT: false */ mountGraphQL: ?boolean; - /* Mount path for the GraphQL endpoint, defaults to /graphql + /* The mount path for the GraphQL endpoint

⚠️ File upload inside the GraphQL mutation system requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Defaults is `/graphql`. :ENV: PARSE_SERVER_GRAPHQL_PATH :DEFAULT: /graphql */ graphQLPath: ?string; diff --git a/src/ParseServer.ts b/src/ParseServer.ts index d0bb288327..04543ac1c3 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -296,7 +296,13 @@ class ParseServer { * Create an express app for the parse server * @param {Object} options let you specify the maxUploadSize when creating the express app */ static app(options) { - const { maxUploadSize = '20mb', appId, directAccess, pages, rateLimit = [] } = options; + const { + maxUploadSize = '20mb', + appId, + directAccess, + pages, + rateLimit = [], + } = options; // This app serves the Parse API directly. // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. var api = express(); @@ -481,7 +487,9 @@ class ParseServer { /* istanbul ignore next */ if (!process.env.TESTING) { configureListeners(this); - await ParseServer.verifyServerUrl(); + if (options.verifyServerUrl !== false) { + await ParseServer.verifyServerUrl(); + } } this.expressApp = app; return this; diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index ac1c71e886..7a572a2f10 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -122,6 +122,7 @@ export interface ParseServerOptions { allowExpiredAuthDataToken?: boolean; requestKeywordDenylist?: (RequestKeywordDenylist[]); rateLimit?: (RateLimitOptions[]); + verifyServerUrl?: boolean; } export interface RateLimitOptions { requestPath: string; From 00f8d4cda96d2f77535cac6db8037daba219d2db Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 25 Oct 2025 16:02:58 +0000 Subject: [PATCH 25/30] chore(release): 8.3.0-alpha.12 [skip ci] # [8.3.0-alpha.12](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.11...8.3.0-alpha.12) (2025-10-25) ### Features * Add Parse Server option `verifyServerUrl` to disable server URL verification on server launch ([#9881](https://github.com/parse-community/parse-server/issues/9881)) ([b298ccc](https://github.com/parse-community/parse-server/commit/b298cccd9fb4f664b9d83894faad6d1ea7a3c964)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 877c8f652b..5e04ed3c75 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.12](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.11...8.3.0-alpha.12) (2025-10-25) + + +### Features + +* Add Parse Server option `verifyServerUrl` to disable server URL verification on server launch ([#9881](https://github.com/parse-community/parse-server/issues/9881)) ([b298ccc](https://github.com/parse-community/parse-server/commit/b298cccd9fb4f664b9d83894faad6d1ea7a3c964)) + # [8.3.0-alpha.11](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.10...8.3.0-alpha.11) (2025-10-24) diff --git a/package-lock.json b/package-lock.json index 0ce514868c..c8ae286dfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.11", + "version": "8.3.0-alpha.12", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.11", + "version": "8.3.0-alpha.12", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index b8b5f14e19..ffd012e240 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.11", + "version": "8.3.0-alpha.12", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 62dd3c565ab70765cb1c547996b616b72e9bb800 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 1 Nov 2025 13:52:23 +0100 Subject: [PATCH 26/30] fix: Indexes `_email_verify_token` for email verification and `_perishable_token` password reset are not created automatically (#9893) --- 8.0.0.md | 19 ++- spec/DatabaseController.spec.js | 146 ++++++++++++++++++ spec/eslint.config.js | 1 + spec/helper.js | 11 ++ .../Storage/Mongo/MongoStorageAdapter.js | 2 + src/Controllers/DatabaseController.js | 14 ++ 6 files changed, 192 insertions(+), 1 deletion(-) diff --git a/8.0.0.md b/8.0.0.md index 3d7dd9d6e2..ab41c0cf29 100644 --- a/8.0.0.md +++ b/8.0.0.md @@ -5,6 +5,7 @@ This document only highlights specific changes that require a longer explanation --- - [Email Verification](#email-verification) +- [Database Indexes](#database-indexes) --- @@ -22,6 +23,22 @@ The request to re-send a verification email changed to sending a `POST` request > [!IMPORTANT] > Parse Server does not keep a history of verification tokens but only stores the most recently generated verification token in the database. Every time Parse Server generates a new verification token, the currently stored token is replaced. If a user opens a link with an expired token, and that token has already been replaced in the database, Parse Server cannot associate the expired token with any user. In this case, another way has to be offered to the user to re-send a verification email. To mitigate this issue, set the Parse Server option `emailVerifyTokenReuseIfValid: true` and set `emailVerifyTokenValidityDuration` to a longer duration, which ensures that the currently stored verification token is not replaced too soon. -Related pull requests: +Related pull request: - https://github.com/parse-community/parse-server/pull/8488 + +## Database Indexes + +As part of the email verification and password reset improvements in Parse Server 8, the queries used for these operations have changed to use tokens instead of username/email fields. To ensure optimal query performance, Parse Server now automatically creates indexes on the following fields during server initialization: + +- `_User._email_verify_token`: used for email verification queries +- `_User._perishable_token`: used for password reset queries + +These indexes are created automatically when Parse Server starts, similar to how indexes for `username` and `email` fields are created. No manual intervention is required. + +> [!WARNING] +> If you have a large existing user base, the index creation may take some time during the first server startup after upgrading to Parse Server 8. The server logs will indicate when index creation is complete or if any errors occur. If you have any concerns regarding a potential database performance impact during index creation, you could create these indexes manually in a controlled procedure before upgrading Parse Server. + +Related pull request: + +- https://github.com/parse-community/parse-server/pull/9893 diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index d8ce516131..b1ccc0d586 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -413,6 +413,8 @@ describe('DatabaseController', function () { case_insensitive_username: { username: 1 }, case_insensitive_email: { email: 1 }, email_1: { email: 1 }, + _email_verify_token: { _email_verify_token: 1 }, + _perishable_token: { _perishable_token: 1 }, }); } ); @@ -437,9 +439,153 @@ describe('DatabaseController', function () { _id_: { _id: 1 }, username_1: { username: 1 }, email_1: { email: 1 }, + _email_verify_token: { _email_verify_token: 1 }, + _perishable_token: { _perishable_token: 1 }, }); } ); + + it_only_db('mongo')( + 'should use _email_verify_token index in email verification', + async () => { + const TestUtils = require('../lib/TestUtils'); + let emailVerificationLink; + const emailSentPromise = TestUtils.resolvingPromise(); + const emailAdapter = { + sendVerificationEmail: options => { + emailVerificationLink = options.link; + emailSentPromise.resolve(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + databaseURI: 'mongodb://localhost:27017/testEmailVerifyTokenIndexStats', + databaseAdapter: undefined, + appName: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + // Create a user to trigger email verification + const user = new Parse.User(); + user.setUsername('statsuser'); + user.setPassword('password'); + user.set('email', 'stats@example.com'); + await user.signUp(); + await emailSentPromise; + + // Get index stats before the query + const config = Config.get(Parse.applicationId); + const collection = await config.database.adapter._adaptiveCollection('_User'); + const statsBefore = await collection._mongoCollection.aggregate([ + { $indexStats: {} }, + ]).toArray(); + const emailVerifyIndexBefore = statsBefore.find( + stat => stat.name === '_email_verify_token' + ); + const accessesBefore = emailVerifyIndexBefore?.accesses?.ops || 0; + + // Perform email verification (this should use the index) + const request = require('../lib/request'); + await request({ + url: emailVerificationLink, + followRedirects: false, + }); + + // Get index stats after the query + const statsAfter = await collection._mongoCollection.aggregate([ + { $indexStats: {} }, + ]).toArray(); + const emailVerifyIndexAfter = statsAfter.find( + stat => stat.name === '_email_verify_token' + ); + const accessesAfter = emailVerifyIndexAfter?.accesses?.ops || 0; + + // Verify the index was actually used + expect(accessesAfter).toBeGreaterThan(accessesBefore); + expect(emailVerifyIndexAfter).toBeDefined(); + + // Verify email verification succeeded + await user.fetch(); + expect(user.get('emailVerified')).toBe(true); + } + ); + + it_only_db('mongo')( + 'should use _perishable_token index in password reset', + async () => { + const TestUtils = require('../lib/TestUtils'); + let passwordResetLink; + const emailSentPromise = TestUtils.resolvingPromise(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + passwordResetLink = options.link; + emailSentPromise.resolve(); + }, + sendMail: () => {}, + }; + await reconfigureServer({ + databaseURI: 'mongodb://localhost:27017/testPerishableTokenIndexStats', + databaseAdapter: undefined, + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + // Create a user + const user = new Parse.User(); + user.setUsername('statsuser2'); + user.setPassword('oldpassword'); + user.set('email', 'stats2@example.com'); + await user.signUp(); + + // Request password reset + await Parse.User.requestPasswordReset('stats2@example.com'); + await emailSentPromise; + + const url = new URL(passwordResetLink); + const token = url.searchParams.get('token'); + + // Get index stats before the query + const config = Config.get(Parse.applicationId); + const collection = await config.database.adapter._adaptiveCollection('_User'); + const statsBefore = await collection._mongoCollection.aggregate([ + { $indexStats: {} }, + ]).toArray(); + const perishableTokenIndexBefore = statsBefore.find( + stat => stat.name === '_perishable_token' + ); + const accessesBefore = perishableTokenIndexBefore?.accesses?.ops || 0; + + // Perform password reset (this should use the index) + const request = require('../lib/request'); + await request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: { new_password: 'newpassword', token, username: 'statsuser2' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + }); + + // Get index stats after the query + const statsAfter = await collection._mongoCollection.aggregate([ + { $indexStats: {} }, + ]).toArray(); + const perishableTokenIndexAfter = statsAfter.find( + stat => stat.name === '_perishable_token' + ); + const accessesAfter = perishableTokenIndexAfter?.accesses?.ops || 0; + + // Verify the index was actually used + expect(accessesAfter).toBeGreaterThan(accessesBefore); + expect(perishableTokenIndexAfter).toBeDefined(); + } + ); }); describe('convertEmailToLowercase', () => { diff --git a/spec/eslint.config.js b/spec/eslint.config.js index 6d280d08e3..4d23d3b649 100644 --- a/spec/eslint.config.js +++ b/spec/eslint.config.js @@ -25,6 +25,7 @@ module.exports = [ it_id: "readonly", fit_id: "readonly", it_only_db: "readonly", + fit_only_db: "readonly", it_only_mongodb_version: "readonly", it_only_postgres_version: "readonly", it_only_node_version: "readonly", diff --git a/spec/helper.js b/spec/helper.js index d2f26e584c..43b5ceeb81 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -515,6 +515,17 @@ global.it_only_db = db => { } }; +global.fit_only_db = db => { + if ( + process.env.PARSE_SERVER_TEST_DB === db || + (!process.env.PARSE_SERVER_TEST_DB && db == 'mongo') + ) { + return fit; + } else { + return xit; + } +}; + global.it_only_mongodb_version = version => { if (!semver.validRange(version)) { throw new Error('Invalid version range'); diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 481d5257d9..ad5a69ea70 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -687,6 +687,7 @@ export class MongoStorageAdapter implements StorageAdapter { const defaultOptions: Object = { background: true, sparse: true }; const indexNameOptions: Object = indexName ? { name: indexName } : {}; const ttlOptions: Object = options.ttl !== undefined ? { expireAfterSeconds: options.ttl } : {}; + const sparseOptions: Object = options.sparse !== undefined ? { sparse: options.sparse } : {}; const caseInsensitiveOptions: Object = caseInsensitive ? { collation: MongoCollection.caseInsensitiveCollation() } : {}; @@ -695,6 +696,7 @@ export class MongoStorageAdapter implements StorageAdapter { ...caseInsensitiveOptions, ...indexNameOptions, ...ttlOptions, + ...sparseOptions, }; return this._adaptiveCollection(className) diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 15207a7295..e3b5cc210a 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1764,6 +1764,20 @@ class DatabaseController { throw error; }); + await this.adapter + .ensureIndex('_User', requiredUserFields, ['_email_verify_token'], '_email_verify_token', false) + .catch(error => { + logger.warn('Unable to create index for email verification token: ', error); + throw error; + }); + + await this.adapter + .ensureIndex('_User', requiredUserFields, ['_perishable_token'], '_perishable_token', false) + .catch(error => { + logger.warn('Unable to create index for password reset token: ', error); + throw error; + }); + await this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name']).catch(error => { logger.warn('Unable to ensure uniqueness for role name: ', error); throw error; From 4f4580a0833f4f69b4c460185e9c74af5b4ee56b Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 1 Nov 2025 12:53:21 +0000 Subject: [PATCH 27/30] chore(release): 8.3.0-alpha.13 [skip ci] # [8.3.0-alpha.13](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.12...8.3.0-alpha.13) (2025-11-01) ### Bug Fixes * Indexes `_email_verify_token` for email verification and `_perishable_token` password reset are not created automatically ([#9893](https://github.com/parse-community/parse-server/issues/9893)) ([62dd3c5](https://github.com/parse-community/parse-server/commit/62dd3c565ab70765cb1c547996b616b72e9bb800)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 5e04ed3c75..372fd187dc 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.13](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.12...8.3.0-alpha.13) (2025-11-01) + + +### Bug Fixes + +* Indexes `_email_verify_token` for email verification and `_perishable_token` password reset are not created automatically ([#9893](https://github.com/parse-community/parse-server/issues/9893)) ([62dd3c5](https://github.com/parse-community/parse-server/commit/62dd3c565ab70765cb1c547996b616b72e9bb800)) + # [8.3.0-alpha.12](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.11...8.3.0-alpha.12) (2025-10-25) diff --git a/package-lock.json b/package-lock.json index c8ae286dfc..b1c2340a0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.12", + "version": "8.3.0-alpha.13", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.12", + "version": "8.3.0-alpha.13", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index ffd012e240..9d94c02cbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.12", + "version": "8.3.0-alpha.13", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From ea91aca1420c33e038516a321b2640709589f886 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 1 Nov 2025 18:22:52 +0100 Subject: [PATCH 28/30] feat: Add options to skip automatic creation of internal database indexes on server start (#9897) --- spec/MongoStorageAdapter.spec.js | 175 ++++++++++++++++++ .../Storage/Mongo/MongoStorageAdapter.js | 18 +- src/Controllers/DatabaseController.js | 74 +++++--- src/Options/Definitions.js | 49 +++++ src/Options/docs.js | 7 + src/Options/index.js | 21 +++ 6 files changed, 313 insertions(+), 31 deletions(-) diff --git a/spec/MongoStorageAdapter.spec.js b/spec/MongoStorageAdapter.spec.js index b026fc0961..7d0d220cff 100644 --- a/spec/MongoStorageAdapter.spec.js +++ b/spec/MongoStorageAdapter.spec.js @@ -649,4 +649,179 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { }); }); } + + describe('index creation options', () => { + beforeEach(async () => { + await new MongoStorageAdapter({ uri: databaseURI }).deleteAllClasses(); + }); + + async function getIndexes(collectionName) { + const adapter = Config.get(Parse.applicationId).database.adapter; + const collections = await adapter.database.listCollections({ name: collectionName }).toArray(); + if (collections.length === 0) { + return []; + } + return await adapter.database.collection(collectionName).indexes(); + } + + it('should skip username index when createIndexUserUsername is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserUsername: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'username_1')).toBeUndefined(); + }); + + it('should create username index when createIndexUserUsername is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserUsername: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'username_1')).toBeDefined(); + }); + + it('should skip case-insensitive username index when createIndexUserUsernameCaseInsensitive is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserUsernameCaseInsensitive: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'case_insensitive_username')).toBeUndefined(); + }); + + it('should create case-insensitive username index when createIndexUserUsernameCaseInsensitive is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserUsernameCaseInsensitive: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'case_insensitive_username')).toBeDefined(); + }); + + it('should skip email index when createIndexUserEmail is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmail: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'email_1')).toBeUndefined(); + }); + + it('should create email index when createIndexUserEmail is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmail: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'email_1')).toBeDefined(); + }); + + it('should skip case-insensitive email index when createIndexUserEmailCaseInsensitive is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmailCaseInsensitive: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'case_insensitive_email')).toBeUndefined(); + }); + + it('should create case-insensitive email index when createIndexUserEmailCaseInsensitive is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmailCaseInsensitive: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'case_insensitive_email')).toBeDefined(); + }); + + it('should skip email verify token index when createIndexUserEmailVerifyToken is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmailVerifyToken: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeUndefined(); + }); + + it('should create email verify token index when createIndexUserEmailVerifyToken is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmailVerifyToken: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeDefined(); + }); + + it('should skip password reset token index when createIndexUserPasswordResetToken is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserPasswordResetToken: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeUndefined(); + }); + + it('should create password reset token index when createIndexUserPasswordResetToken is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserPasswordResetToken: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeDefined(); + }); + + it('should skip role name index when createIndexRoleName is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexRoleName: false }, + }); + const indexes = await getIndexes('_Role'); + expect(indexes.find(idx => idx.name === 'name_1')).toBeUndefined(); + }); + + it('should create role name index when createIndexRoleName is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexRoleName: true }, + }); + const indexes = await getIndexes('_Role'); + expect(indexes.find(idx => idx.name === 'name_1')).toBeDefined(); + }); + + it('should create all indexes by default when options are undefined', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: {}, + }); + + const userIndexes = await getIndexes('_User'); + const roleIndexes = await getIndexes('_Role'); + + // Verify all indexes are created with default behavior (backward compatibility) + expect(userIndexes.find(idx => idx.name === 'username_1')).toBeDefined(); + expect(userIndexes.find(idx => idx.name === 'case_insensitive_username')).toBeDefined(); + expect(userIndexes.find(idx => idx.name === 'email_1')).toBeDefined(); + expect(userIndexes.find(idx => idx.name === 'case_insensitive_email')).toBeDefined(); + expect(userIndexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeDefined(); + expect(userIndexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeDefined(); + expect(roleIndexes.find(idx => idx.name === 'name_1')).toBeDefined(); + }); + }); }); diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index ad5a69ea70..39b335d52e 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -154,8 +154,22 @@ export class MongoStorageAdapter implements StorageAdapter { this.enableSchemaHooks = !!mongoOptions.enableSchemaHooks; this.schemaCacheTtl = mongoOptions.schemaCacheTtl; this.disableIndexFieldValidation = !!mongoOptions.disableIndexFieldValidation; - for (const key of ['enableSchemaHooks', 'schemaCacheTtl', 'maxTimeMS', 'disableIndexFieldValidation']) { - delete mongoOptions[key]; + // Remove Parse Server-specific options that should not be passed to MongoDB client + // Note: We only delete from this._mongoOptions, not from the original mongoOptions object, + // because other components (like DatabaseController) need access to these options + for (const key of [ + 'enableSchemaHooks', + 'schemaCacheTtl', + 'maxTimeMS', + 'disableIndexFieldValidation', + 'createIndexUserUsername', + 'createIndexUserUsernameCaseInsensitive', + 'createIndexUserEmail', + 'createIndexUserEmailCaseInsensitive', + 'createIndexUserEmailVerifyToken', + 'createIndexUserPasswordResetToken', + 'createIndexRoleName', + ]) { delete this._mongoOptions[key]; } } diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index e3b5cc210a..f08bface5a 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1738,50 +1738,66 @@ class DatabaseController { await this.loadSchema().then(schema => schema.enforceClassExists('_Role')); await this.loadSchema().then(schema => schema.enforceClassExists('_Idempotency')); - await this.adapter.ensureUniqueness('_User', requiredUserFields, ['username']).catch(error => { - logger.warn('Unable to ensure uniqueness for usernames: ', error); - throw error; - }); + const databaseOptions = this.options.databaseOptions || {}; + + if (databaseOptions.createIndexUserUsername !== false) { + await this.adapter.ensureUniqueness('_User', requiredUserFields, ['username']).catch(error => { + logger.warn('Unable to ensure uniqueness for usernames: ', error); + throw error; + }); + } if (!this.options.enableCollationCaseComparison) { + if (databaseOptions.createIndexUserUsernameCaseInsensitive !== false) { + await this.adapter + .ensureIndex('_User', requiredUserFields, ['username'], 'case_insensitive_username', true) + .catch(error => { + logger.warn('Unable to create case insensitive username index: ', error); + throw error; + }); + } + + if (databaseOptions.createIndexUserEmailCaseInsensitive !== false) { + await this.adapter + .ensureIndex('_User', requiredUserFields, ['email'], 'case_insensitive_email', true) + .catch(error => { + logger.warn('Unable to create case insensitive email index: ', error); + throw error; + }); + } + } + + if (databaseOptions.createIndexUserEmail !== false) { + await this.adapter.ensureUniqueness('_User', requiredUserFields, ['email']).catch(error => { + logger.warn('Unable to ensure uniqueness for user email addresses: ', error); + throw error; + }); + } + + if (databaseOptions.createIndexUserEmailVerifyToken !== false) { await this.adapter - .ensureIndex('_User', requiredUserFields, ['username'], 'case_insensitive_username', true) + .ensureIndex('_User', requiredUserFields, ['_email_verify_token'], '_email_verify_token', false) .catch(error => { - logger.warn('Unable to create case insensitive username index: ', error); + logger.warn('Unable to create index for email verification token: ', error); throw error; }); + } + if (databaseOptions.createIndexUserPasswordResetToken !== false) { await this.adapter - .ensureIndex('_User', requiredUserFields, ['email'], 'case_insensitive_email', true) + .ensureIndex('_User', requiredUserFields, ['_perishable_token'], '_perishable_token', false) .catch(error => { - logger.warn('Unable to create case insensitive email index: ', error); + logger.warn('Unable to create index for password reset token: ', error); throw error; }); } - await this.adapter.ensureUniqueness('_User', requiredUserFields, ['email']).catch(error => { - logger.warn('Unable to ensure uniqueness for user email addresses: ', error); - throw error; - }); - - await this.adapter - .ensureIndex('_User', requiredUserFields, ['_email_verify_token'], '_email_verify_token', false) - .catch(error => { - logger.warn('Unable to create index for email verification token: ', error); - throw error; - }); - - await this.adapter - .ensureIndex('_User', requiredUserFields, ['_perishable_token'], '_perishable_token', false) - .catch(error => { - logger.warn('Unable to create index for password reset token: ', error); + if (databaseOptions.createIndexRoleName !== false) { + await this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name']).catch(error => { + logger.warn('Unable to ensure uniqueness for role name: ', error); throw error; }); - - await this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name']).catch(error => { - logger.warn('Unable to ensure uniqueness for role name: ', error); - throw error; - }); + } await this.adapter .ensureUniqueness('_Idempotency', requiredIdempotencyFields, ['reqId']) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 205c35fa77..d2f1e6eef1 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -1101,6 +1101,55 @@ module.exports.DatabaseOptions = { 'The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.', action: parsers.numberParser('connectTimeoutMS'), }, + createIndexRoleName: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_ROLE_NAME', + help: + 'Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserEmail: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_EMAIL', + help: + 'Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserEmailCaseInsensitive: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_EMAIL_CASE_INSENSITIVE', + help: + 'Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserEmailVerifyToken: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_EMAIL_VERIFY_TOKEN', + help: + 'Set to `true` to automatically create an index on the _email_verify_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserPasswordResetToken: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_PASSWORD_RESET_TOKEN', + help: + 'Set to `true` to automatically create an index on the _perishable_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserUsername: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_USERNAME', + help: + 'Set to `true` to automatically create indexes on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserUsernameCaseInsensitive: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_USERNAME_CASE_INSENSITIVE', + help: + 'Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, disableIndexFieldValidation: { env: 'PARSE_SERVER_DATABASE_DISABLE_INDEX_FIELD_VALIDATION', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index dde5942500..9e650b1038 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -243,6 +243,13 @@ * @property {Boolean} autoSelectFamily The MongoDB driver option to set whether the socket attempts to connect to IPv6 and IPv4 addresses until a connection is established. If available, the driver will select the first IPv6 address. * @property {Number} autoSelectFamilyAttemptTimeout The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead. * @property {Number} connectTimeoutMS The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout. + * @property {Boolean} createIndexRoleName Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserEmail Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserEmailCaseInsensitive Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserEmailVerifyToken Set to `true` to automatically create an index on the _email_verify_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserPasswordResetToken Set to `true` to automatically create an index on the _perishable_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserUsername Set to `true` to automatically create indexes on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserUsernameCaseInsensitive Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. * @property {Boolean} disableIndexFieldValidation Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later. * @property {Boolean} enableSchemaHooks Enables database real-time hooks to update single schema cache. Set to `true` if using multiple Parse Servers instances connected to the same database. Failing to do so will cause a schema change to not propagate to all instances and re-syncing will only happen when the instances restart. To use this feature with MongoDB, a replica set cluster with [change stream](https://docs.mongodb.com/manual/changeStreams/#availability) support is required. * @property {Number} maxPoolSize The MongoDB driver option to set the maximum number of opened, cached, ready-to-use database connections maintained by the driver. diff --git a/src/Options/index.js b/src/Options/index.js index ff8287b86b..42da7b2237 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -632,6 +632,27 @@ export interface DatabaseOptions { autoSelectFamily: ?boolean; /* The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead. */ autoSelectFamilyAttemptTimeout: ?number; + /* Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserEmail: ?boolean; + /* Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserEmailCaseInsensitive: ?boolean; + /* Set to `true` to automatically create an index on the _email_verify_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserEmailVerifyToken: ?boolean; + /* Set to `true` to automatically create an index on the _perishable_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserPasswordResetToken: ?boolean; + /* Set to `true` to automatically create indexes on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserUsername: ?boolean; + /* Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserUsernameCaseInsensitive: ?boolean; + /* Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexRoleName: ?boolean; /* Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later. */ disableIndexFieldValidation: ?boolean; } From d1590bfaeef9dc369047e11a2cb547729f210075 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 1 Nov 2025 17:23:42 +0000 Subject: [PATCH 29/30] chore(release): 8.3.0-alpha.14 [skip ci] # [8.3.0-alpha.14](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.13...8.3.0-alpha.14) (2025-11-01) ### Features * Add options to skip automatic creation of internal database indexes on server start ([#9897](https://github.com/parse-community/parse-server/issues/9897)) ([ea91aca](https://github.com/parse-community/parse-server/commit/ea91aca1420c33e038516a321b2640709589f886)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 372fd187dc..f1209f668b 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [8.3.0-alpha.14](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.13...8.3.0-alpha.14) (2025-11-01) + + +### Features + +* Add options to skip automatic creation of internal database indexes on server start ([#9897](https://github.com/parse-community/parse-server/issues/9897)) ([ea91aca](https://github.com/parse-community/parse-server/commit/ea91aca1420c33e038516a321b2640709589f886)) + # [8.3.0-alpha.13](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.12...8.3.0-alpha.13) (2025-11-01) diff --git a/package-lock.json b/package-lock.json index b1c2340a0b..e83ab14141 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "8.3.0-alpha.13", + "version": "8.3.0-alpha.14", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "8.3.0-alpha.13", + "version": "8.3.0-alpha.14", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 9d94c02cbe..eef863cd0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "8.3.0-alpha.13", + "version": "8.3.0-alpha.14", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 0a25e2d62cb07ccc3de46dc60a8deaff5796d22b Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 1 Nov 2025 19:26:14 +0000 Subject: [PATCH 30/30] empty commit to trigger CI