From 7c386e6c2c233dff93dcda5dd587cccb8ab2ac23 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Mon, 4 May 2026 13:35:29 -0400 Subject: [PATCH 1/2] fix: _Installation update fails when clearing deviceToken handleInstallation runs before Parse __op operators are processed, so when a client cleared deviceToken via { __op: 'Delete' } or null, the operator object was being pushed into the Mongo $or lookup query and rejected by transformQueryKeyValue with "You cannot use [object Object] as a query parameter" (Parse error 107). Detect the clearing intent and route the identification/lookup paths through deviceTokenForLookup (undefined when clearing) while leaving this.data.deviceToken untouched so the field is still cleared on write. Adds regression specs covering both clearing shapes and the case where deviceToken is cleared alongside another field update. --- spec/ParseInstallation.spec.js | 104 +++++++++++++++++++++++++++++++++ src/RestWrite.js | 35 +++++++---- 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 261733c3af..cbf8e31f10 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -544,6 +544,110 @@ describe('Installations', () => { }); }); + it('clears deviceToken via Delete op without crashing the lookup', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const input = { + installationId: installId, + deviceType: 'ios', + deviceToken: t, + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + { deviceToken: { __op: 'Delete' } } + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].deviceToken).toBeUndefined(); + expect(results[0].installationId).toEqual(installId); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('clears deviceToken via null without crashing the lookup', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const input = { + installationId: installId, + deviceType: 'ios', + deviceToken: t, + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + { deviceToken: null } + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].deviceToken == null).toBeTrue(); + expect(results[0].installationId).toEqual(installId); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('clears deviceToken alongside another field update', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const input = { + installationId: installId, + deviceType: 'ios', + deviceToken: t, + appVersion: '1', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + { deviceToken: { __op: 'Delete' }, deviceType: 'ios', appVersion: '2' } + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].deviceToken).toBeUndefined(); + expect(results[0].appVersion).toEqual('2'); + expect(results[0].installationId).toEqual(installId); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + it('update fails to change deviceType', done => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; let input = { diff --git a/src/RestWrite.js b/src/RestWrite.js index 6d3c0d35a9..f1a0b73cb7 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1295,9 +1295,19 @@ RestWrite.prototype.handleInstallation = function () { return; } + // A client clearing the deviceToken sends either null or { __op: 'Delete' }. + // Treat that as "no deviceToken to identify/match by" so we do not feed the + // operator object into the lookup query (which would fail Mongo transform). + const clearingDeviceToken = + this.data.deviceToken === null || + (typeof this.data.deviceToken === 'object' && + this.data.deviceToken !== null && + this.data.deviceToken.__op === 'Delete'); + let deviceTokenForLookup = clearingDeviceToken ? undefined : this.data.deviceToken; + if ( !this.query && - !this.data.deviceToken && + !deviceTokenForLookup && !this.data.installationId && !this.auth.installationId ) { @@ -1309,8 +1319,9 @@ RestWrite.prototype.handleInstallation = function () { // If the device token is 64 characters long, we assume it is for iOS // and lowercase it. - if (this.data.deviceToken && this.data.deviceToken.length == 64) { - this.data.deviceToken = this.data.deviceToken.toLowerCase(); + if (deviceTokenForLookup && deviceTokenForLookup.length == 64) { + this.data.deviceToken = deviceTokenForLookup.toLowerCase(); + deviceTokenForLookup = this.data.deviceToken; } // We lowercase the installationId if present @@ -1330,7 +1341,7 @@ RestWrite.prototype.handleInstallation = function () { } // Updating _Installation but not updating anything critical - if (this.query && !this.data.deviceToken && !installationId && !this.data.deviceType) { + if (this.query && !deviceTokenForLookup && !installationId && !this.data.deviceType) { return; } @@ -1353,8 +1364,8 @@ RestWrite.prototype.handleInstallation = function () { installationId: installationId, }); } - if (this.data.deviceToken) { - orQueries.push({ deviceToken: this.data.deviceToken }); + if (deviceTokenForLookup) { + orQueries.push({ deviceToken: deviceTokenForLookup }); } if (orQueries.length == 0) { @@ -1379,7 +1390,7 @@ RestWrite.prototype.handleInstallation = function () { if (result.installationId == installationId) { installationIdMatch = result; } - if (result.deviceToken == this.data.deviceToken) { + if (deviceTokenForLookup && result.deviceToken == deviceTokenForLookup) { deviceTokenMatches.push(result); } }); @@ -1397,9 +1408,9 @@ RestWrite.prototype.handleInstallation = function () { throw new Parse.Error(136, 'installationId may not be changed in this ' + 'operation'); } if ( - this.data.deviceToken && + deviceTokenForLookup && objectIdMatch.deviceToken && - this.data.deviceToken !== objectIdMatch.deviceToken && + deviceTokenForLookup !== objectIdMatch.deviceToken && !this.data.installationId && !objectIdMatch.installationId ) { @@ -1451,7 +1462,7 @@ RestWrite.prototype.handleInstallation = function () { // deviceToken, and return nil to signal that a new object should be // created. const delQuery = { - deviceToken: this.data.deviceToken, + deviceToken: deviceTokenForLookup, installationId: { $ne: installationId, }, @@ -1486,12 +1497,12 @@ RestWrite.prototype.handleInstallation = function () { validSchemaController: this.validSchemaController, }); } else { - if (this.data.deviceToken && idMatch.deviceToken != this.data.deviceToken) { + if (deviceTokenForLookup && idMatch.deviceToken != deviceTokenForLookup) { // We're setting the device token on an existing installation, so // we should try cleaning out old installations that match this // device token. const delQuery = { - deviceToken: this.data.deviceToken, + deviceToken: deviceTokenForLookup, }; // We have a unique install Id, use that to preserve // the interesting installation From 9042b7efecd5b3cb5b7668a846a531981fc55905 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:38:32 -0400 Subject: [PATCH 2/2] fix: Unset deviceToken when cleared with null so the row is no longer push-addressable --- spec/ParseInstallation.spec.js | 38 +++++++++++++++++++++++++++++++++- src/RestWrite.js | 8 +++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index cbf8e31f10..a637602b7a 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -7,6 +7,7 @@ const Config = require('../lib/Config'); const Parse = require('parse/node').Parse; const rest = require('../lib/rest'); const request = require('../lib/request'); +const { applyDeviceTokenExists } = require('../lib/Push/utils'); let config; let database; @@ -602,7 +603,7 @@ describe('Installations', () => { .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) .then(results => { expect(results.length).toEqual(1); - expect(results[0].deviceToken == null).toBeTrue(); + expect(results[0].deviceToken).toBeUndefined(); expect(results[0].installationId).toEqual(installId); done(); }) @@ -612,6 +613,41 @@ describe('Installations', () => { }); }); + it('removes an installation cleared via null from push recipients', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const input = { + installationId: installId, + deviceType: 'ios', + deviceToken: t, + }; + // The push pipeline selects recipients with `deviceToken: { $exists: true }`, + // which a stored null would still satisfy. + const pushRecipientQuery = applyDeviceTokenExists({}); + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, pushRecipientQuery, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { installationId: installId }, + { deviceToken: null } + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, pushRecipientQuery, {})) + .then(results => { + expect(results.length).toEqual(0); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + it('clears deviceToken alongside another field update', done => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; diff --git a/src/RestWrite.js b/src/RestWrite.js index f1a0b73cb7..352079afc8 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1305,6 +1305,14 @@ RestWrite.prototype.handleInstallation = function () { this.data.deviceToken.__op === 'Delete'); let deviceTokenForLookup = clearingDeviceToken ? undefined : this.data.deviceToken; + // Collapse the null form onto the delete form so the field is removed rather + // than stored as null. A stored null still satisfies the `$exists: true` + // filter that selects push recipients, which would keep the installation + // addressable and hand an invalid token to the push adapter. + if (clearingDeviceToken) { + this.data.deviceToken = { __op: 'Delete' }; + } + if ( !this.query && !deviceTokenForLookup &&