From fb9f90fe36906ccc1a6211971ca35e9f86c631ba Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:27:40 +0100 Subject: [PATCH 01/11] feat: add installations.duplicateDeviceToken* options --- resources/buildConfigDefinitions.js | 2 + spec/ParseInstallation.spec.js | 86 +++++++++++++++++++++++++++++ src/Config.js | 42 ++++++++++++++ src/Deprecator/Deprecations.js | 5 ++ src/Options/Definitions.js | 25 +++++++++ src/Options/docs.js | 8 +++ src/Options/index.js | 16 ++++++ 7 files changed, 184 insertions(+) diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index d9267e58d2..b14766e6a3 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -18,6 +18,7 @@ const nestedOptionTypes = [ 'FileDownloadOptions', 'FileUploadOptions', 'IdempotencyOptions', + 'InstallationsOptions', 'Object', 'PagesCustomUrlsOptions', 'PagesOptions', @@ -39,6 +40,7 @@ const nestedOptionEnvPrefix = { FileDownloadOptions: 'PARSE_SERVER_FILE_DOWNLOAD_', FileUploadOptions: 'PARSE_SERVER_FILE_UPLOAD_', IdempotencyOptions: 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_', + InstallationsOptions: 'PARSE_SERVER_INSTALLATIONS_', LiveQueryOptions: 'PARSE_SERVER_LIVEQUERY_', LiveQueryServerOptions: 'PARSE_LIVE_QUERY_SERVER_', LogClientEvent: 'PARSE_SERVER_DATABASE_LOG_CLIENT_EVENTS_', diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 408e8fa7bf..58156f0f56 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1297,4 +1297,90 @@ describe('Installations', () => { // TODO: Look at additional tests from installation_collection_test.go:882 // TODO: Do we need to support _tombstone disabling of installations? // TODO: Test deletion, badge increments + + describe('options validation', () => { + it('should accept default empty config', async () => { + await expectAsync(reconfigureServer({})).toBeResolved(); + }); + + it('should accept fully specified valid config', async () => { + await expectAsync( + reconfigureServer({ + installations: { + duplicateDeviceTokenActionEnforceAuth: true, + duplicateDeviceTokenAction: 'update', + duplicateDeviceTokenMergePriority: 'installationId', + }, + }) + ).toBeResolved(); + }); + + it('should reject non-object values', async () => { + await expectAsync( + reconfigureServer({ installations: 'invalid' }) + ).toBeRejectedWith('installations must be an object.'); + }); + + it('should reject array values', async () => { + await expectAsync( + reconfigureServer({ installations: [] }) + ).toBeRejectedWith('installations must be an object.'); + }); + + it('should reject unknown nested keys', async () => { + await expectAsync( + reconfigureServer({ + installations: { unknownKey: 'foo' }, + }) + ).toBeRejectedWith("installations contains unknown property 'unknownKey'."); + }); + + it('should reject non-boolean duplicateDeviceTokenActionEnforceAuth', async () => { + await expectAsync( + reconfigureServer({ + installations: { duplicateDeviceTokenActionEnforceAuth: 'true' }, + }) + ).toBeRejectedWith('installations.duplicateDeviceTokenActionEnforceAuth must be a boolean.'); + }); + + it('should reject invalid duplicateDeviceTokenAction value', async () => { + await expectAsync( + reconfigureServer({ + installations: { duplicateDeviceTokenAction: 'merge' }, + }) + ).toBeRejectedWith( + "installations.duplicateDeviceTokenAction must be one of: 'delete', 'update'." + ); + }); + + it('should reject invalid duplicateDeviceTokenMergePriority value', async () => { + await expectAsync( + reconfigureServer({ + installations: { duplicateDeviceTokenMergePriority: 'objectId' }, + }) + ).toBeRejectedWith( + "installations.duplicateDeviceTokenMergePriority must be one of: 'deviceToken', 'installationId'." + ); + }); + + it('should apply defaults for missing nested keys', async () => { + await reconfigureServer({ + installations: { duplicateDeviceTokenActionEnforceAuth: true }, + }); + const config = Config.get('test'); + expect(config.installations.duplicateDeviceTokenActionEnforceAuth).toBe(true); + expect(config.installations.duplicateDeviceTokenAction).toBe('delete'); + expect(config.installations.duplicateDeviceTokenMergePriority).toBe('deviceToken'); + }); + + it('should apply full defaults when block omitted', async () => { + await reconfigureServer({}); + const config = Config.get('test'); + expect(config.installations).toEqual({ + duplicateDeviceTokenActionEnforceAuth: false, + duplicateDeviceTokenAction: 'delete', + duplicateDeviceTokenMergePriority: 'deviceToken', + }); + }); + }); }); diff --git a/src/Config.js b/src/Config.js index dad033dd1d..1c4be0df80 100644 --- a/src/Config.js +++ b/src/Config.js @@ -15,6 +15,7 @@ import { FileDownloadOptions, FileUploadOptions, IdempotencyOptions, + InstallationsOptions, LiveQueryOptions, LogLevels, PagesOptions, @@ -147,6 +148,7 @@ export class Config { requestComplexity, liveQuery, routeAllowList, + installations, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -197,6 +199,7 @@ export class Config { this.validateRequestComplexity(requestComplexity); this.validateLiveQueryOptions(liveQuery); this.validateRouteAllowList(routeAllowList); + this.validateInstallations(installations); } static validateCustomPages(customPages) { @@ -711,6 +714,45 @@ export class Config { } } + static validateInstallations(installations) { + if (installations === undefined) { + return; + } + if (typeof installations !== 'object' || Array.isArray(installations) || installations === null) { + throw 'installations must be an object.'; + } + const validKeys = [ + 'duplicateDeviceTokenActionEnforceAuth', + 'duplicateDeviceTokenAction', + 'duplicateDeviceTokenMergePriority', + ]; + for (const key of Object.keys(installations)) { + if (!validKeys.includes(key)) { + throw `installations contains unknown property '${key}'.`; + } + } + if (installations.duplicateDeviceTokenActionEnforceAuth === undefined) { + installations.duplicateDeviceTokenActionEnforceAuth = + InstallationsOptions.duplicateDeviceTokenActionEnforceAuth.default; + } else if (typeof installations.duplicateDeviceTokenActionEnforceAuth !== 'boolean') { + throw 'installations.duplicateDeviceTokenActionEnforceAuth must be a boolean.'; + } + const validActions = ['delete', 'update']; + if (installations.duplicateDeviceTokenAction === undefined) { + installations.duplicateDeviceTokenAction = + InstallationsOptions.duplicateDeviceTokenAction.default; + } else if (!validActions.includes(installations.duplicateDeviceTokenAction)) { + throw "installations.duplicateDeviceTokenAction must be one of: 'delete', 'update'."; + } + const validPriorities = ['deviceToken', 'installationId']; + if (installations.duplicateDeviceTokenMergePriority === undefined) { + installations.duplicateDeviceTokenMergePriority = + InstallationsOptions.duplicateDeviceTokenMergePriority.default; + } else if (!validPriorities.includes(installations.duplicateDeviceTokenMergePriority)) { + throw "installations.duplicateDeviceTokenMergePriority must be one of: 'deviceToken', 'installationId'."; + } + } + static validateAllowHeaders(allowHeaders) { if (![null, undefined].includes(allowHeaders)) { if (Array.isArray(allowHeaders)) { diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index 8e9d47885f..4584bedbaf 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -108,4 +108,9 @@ module.exports = [ changeNewDefault: 'false', solution: "Set 'protectedFieldsSaveResponseExempt' to 'false' to strip protected fields from write operation responses (create, update), consistent with how they are stripped from query results. Set to 'true' to keep the current behavior where protected fields are included in write responses.", }, + { + optionKey: 'installations.duplicateDeviceTokenActionEnforceAuth', + changeNewDefault: 'true', + solution: "Set 'installations.duplicateDeviceTokenActionEnforceAuth' to 'true' to enforce the caller's auth context (and the resulting ACL and CLP) when Parse Server deduplicates _Installation records sharing the same deviceToken. Set to 'false' to keep the current behavior of bypassing permissions on the dedup operation.", + }, ]; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 77e5011354..61659a69ab 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -322,6 +322,13 @@ module.exports.ParseServerOptions = { type: 'IdempotencyOptions', default: {}, }, + installations: { + env: 'PARSE_SERVER_INSTALLATIONS', + help: 'Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`.', + action: parsers.objectParser, + type: 'InstallationsOptions', + default: {}, + }, javascriptKey: { env: 'PARSE_SERVER_JAVASCRIPT_KEY', help: 'Key for the Javascript SDK', @@ -766,6 +773,24 @@ module.exports.RequestComplexityOptions = { default: -1, }, }; +module.exports.InstallationsOptions = { + duplicateDeviceTokenAction: { + env: 'PARSE_SERVER_INSTALLATIONS_DUPLICATE_DEVICE_TOKEN_ACTION', + help: "What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`.", + default: 'delete', + }, + duplicateDeviceTokenActionEnforceAuth: { + env: 'PARSE_SERVER_INSTALLATIONS_DUPLICATE_DEVICE_TOKEN_ACTION_ENFORCE_AUTH', + help: "Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`.", + action: parsers.booleanParser, + default: false, + }, + duplicateDeviceTokenMergePriority: { + env: 'PARSE_SERVER_INSTALLATIONS_DUPLICATE_DEVICE_TOKEN_MERGE_PRIORITY', + help: "At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` \u2014 the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` \u2014 the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`.", + default: 'deviceToken', + }, +}; module.exports.SecurityOptions = { checkGroups: { env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS', diff --git a/src/Options/docs.js b/src/Options/docs.js index 09aff594d2..ccabfada4e 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -61,6 +61,7 @@ * @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file * @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0 * @property {IdempotencyOptions} idempotencyOptions Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production. + * @property {InstallationsOptions} installations Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`. * @property {String} javascriptKey Key for the Javascript SDK * @property {Boolean} jsonLogs Log as structured JSON objects * @property {LiveQueryOptions} liveQuery parse-server's LiveQuery configuration object @@ -148,6 +149,13 @@ * @property {Number} subqueryLimit Maximum number of results returned by a `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subquery. Set to `-1` to disable. Default is `-1`. */ +/** + * @interface InstallationsOptions + * @property {String} duplicateDeviceTokenAction What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`. + * @property {Boolean} duplicateDeviceTokenActionEnforceAuth Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`. + * @property {String} duplicateDeviceTokenMergePriority At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` — the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` — the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`. + */ + /** * @interface SecurityOptions * @property {CheckGroup[]} checkGroups The security check groups to run. This allows to add custom security checks or override existing ones. Default are the groups defined in `CheckGroups.js`. diff --git a/src/Options/index.js b/src/Options/index.js index 68d1d61a94..dd7cf75353 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -391,6 +391,10 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_REQUEST_COMPLEXITY :DEFAULT: {} */ requestComplexity: ?RequestComplexityOptions; + /* Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`. + :ENV: PARSE_SERVER_INSTALLATIONS + :DEFAULT: {} */ + installations: ?InstallationsOptions; /* Query-related server defaults. :ENV: PARSE_SERVER_QUERY :DEFAULT: {} */ @@ -483,6 +487,18 @@ export interface RequestComplexityOptions { batchRequestLimit: ?number; } +export interface InstallationsOptions { + /* Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`. + :DEFAULT: false */ + duplicateDeviceTokenActionEnforceAuth: ?boolean; + /* What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`. + :DEFAULT: delete */ + duplicateDeviceTokenAction: ?string; + /* At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` — the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` — the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`. + :DEFAULT: deviceToken */ + duplicateDeviceTokenMergePriority: ?string; +} + export interface SecurityOptions { /* Is true if Parse Server should check for weak security settings. :DEFAULT: false */ From cc113b583490445c4b30ef9c6409ac6eee7f3f16 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:37:04 +0100 Subject: [PATCH 02/11] refactor: rename installation option to singular form --- resources/buildConfigDefinitions.js | 4 +-- spec/ParseInstallation.spec.js | 36 +++++++++++----------- src/Config.js | 48 ++++++++++++++--------------- src/Deprecator/Deprecations.js | 4 +-- src/Options/Definitions.js | 14 ++++----- src/Options/docs.js | 4 +-- src/Options/index.js | 6 ++-- 7 files changed, 58 insertions(+), 58 deletions(-) diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index b14766e6a3..1a3cbb4b55 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -18,7 +18,7 @@ const nestedOptionTypes = [ 'FileDownloadOptions', 'FileUploadOptions', 'IdempotencyOptions', - 'InstallationsOptions', + 'InstallationOptions', 'Object', 'PagesCustomUrlsOptions', 'PagesOptions', @@ -40,7 +40,7 @@ const nestedOptionEnvPrefix = { FileDownloadOptions: 'PARSE_SERVER_FILE_DOWNLOAD_', FileUploadOptions: 'PARSE_SERVER_FILE_UPLOAD_', IdempotencyOptions: 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_', - InstallationsOptions: 'PARSE_SERVER_INSTALLATIONS_', + InstallationOptions: 'PARSE_SERVER_INSTALLATION_', LiveQueryOptions: 'PARSE_SERVER_LIVEQUERY_', LiveQueryServerOptions: 'PARSE_LIVE_QUERY_SERVER_', LogClientEvent: 'PARSE_SERVER_DATABASE_LOG_CLIENT_EVENTS_', diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 58156f0f56..bb667a90c1 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1306,7 +1306,7 @@ describe('Installations', () => { it('should accept fully specified valid config', async () => { await expectAsync( reconfigureServer({ - installations: { + installation: { duplicateDeviceTokenActionEnforceAuth: true, duplicateDeviceTokenAction: 'update', duplicateDeviceTokenMergePriority: 'installationId', @@ -1317,66 +1317,66 @@ describe('Installations', () => { it('should reject non-object values', async () => { await expectAsync( - reconfigureServer({ installations: 'invalid' }) - ).toBeRejectedWith('installations must be an object.'); + reconfigureServer({ installation: 'invalid' }) + ).toBeRejectedWith('installation must be an object.'); }); it('should reject array values', async () => { await expectAsync( - reconfigureServer({ installations: [] }) - ).toBeRejectedWith('installations must be an object.'); + reconfigureServer({ installation: [] }) + ).toBeRejectedWith('installation must be an object.'); }); it('should reject unknown nested keys', async () => { await expectAsync( reconfigureServer({ - installations: { unknownKey: 'foo' }, + installation: { unknownKey: 'foo' }, }) - ).toBeRejectedWith("installations contains unknown property 'unknownKey'."); + ).toBeRejectedWith("installation contains unknown property 'unknownKey'."); }); it('should reject non-boolean duplicateDeviceTokenActionEnforceAuth', async () => { await expectAsync( reconfigureServer({ - installations: { duplicateDeviceTokenActionEnforceAuth: 'true' }, + installation: { duplicateDeviceTokenActionEnforceAuth: 'true' }, }) - ).toBeRejectedWith('installations.duplicateDeviceTokenActionEnforceAuth must be a boolean.'); + ).toBeRejectedWith('installation.duplicateDeviceTokenActionEnforceAuth must be a boolean.'); }); it('should reject invalid duplicateDeviceTokenAction value', async () => { await expectAsync( reconfigureServer({ - installations: { duplicateDeviceTokenAction: 'merge' }, + installation: { duplicateDeviceTokenAction: 'merge' }, }) ).toBeRejectedWith( - "installations.duplicateDeviceTokenAction must be one of: 'delete', 'update'." + "installation.duplicateDeviceTokenAction must be one of: 'delete', 'update'." ); }); it('should reject invalid duplicateDeviceTokenMergePriority value', async () => { await expectAsync( reconfigureServer({ - installations: { duplicateDeviceTokenMergePriority: 'objectId' }, + installation: { duplicateDeviceTokenMergePriority: 'objectId' }, }) ).toBeRejectedWith( - "installations.duplicateDeviceTokenMergePriority must be one of: 'deviceToken', 'installationId'." + "installation.duplicateDeviceTokenMergePriority must be one of: 'deviceToken', 'installationId'." ); }); it('should apply defaults for missing nested keys', async () => { await reconfigureServer({ - installations: { duplicateDeviceTokenActionEnforceAuth: true }, + installation: { duplicateDeviceTokenActionEnforceAuth: true }, }); const config = Config.get('test'); - expect(config.installations.duplicateDeviceTokenActionEnforceAuth).toBe(true); - expect(config.installations.duplicateDeviceTokenAction).toBe('delete'); - expect(config.installations.duplicateDeviceTokenMergePriority).toBe('deviceToken'); + expect(config.installation.duplicateDeviceTokenActionEnforceAuth).toBe(true); + expect(config.installation.duplicateDeviceTokenAction).toBe('delete'); + expect(config.installation.duplicateDeviceTokenMergePriority).toBe('deviceToken'); }); it('should apply full defaults when block omitted', async () => { await reconfigureServer({}); const config = Config.get('test'); - expect(config.installations).toEqual({ + expect(config.installation).toEqual({ duplicateDeviceTokenActionEnforceAuth: false, duplicateDeviceTokenAction: 'delete', duplicateDeviceTokenMergePriority: 'deviceToken', diff --git a/src/Config.js b/src/Config.js index 1c4be0df80..95543c6c6b 100644 --- a/src/Config.js +++ b/src/Config.js @@ -15,7 +15,7 @@ import { FileDownloadOptions, FileUploadOptions, IdempotencyOptions, - InstallationsOptions, + InstallationOptions, LiveQueryOptions, LogLevels, PagesOptions, @@ -148,7 +148,7 @@ export class Config { requestComplexity, liveQuery, routeAllowList, - installations, + installation, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -199,7 +199,7 @@ export class Config { this.validateRequestComplexity(requestComplexity); this.validateLiveQueryOptions(liveQuery); this.validateRouteAllowList(routeAllowList); - this.validateInstallations(installations); + this.validateInstallation(installation); } static validateCustomPages(customPages) { @@ -714,42 +714,42 @@ export class Config { } } - static validateInstallations(installations) { - if (installations === undefined) { + static validateInstallation(installation) { + if (installation === undefined) { return; } - if (typeof installations !== 'object' || Array.isArray(installations) || installations === null) { - throw 'installations must be an object.'; + if (typeof installation !== 'object' || Array.isArray(installation) || installation === null) { + throw 'installation must be an object.'; } const validKeys = [ 'duplicateDeviceTokenActionEnforceAuth', 'duplicateDeviceTokenAction', 'duplicateDeviceTokenMergePriority', ]; - for (const key of Object.keys(installations)) { + for (const key of Object.keys(installation)) { if (!validKeys.includes(key)) { - throw `installations contains unknown property '${key}'.`; + throw `installation contains unknown property '${key}'.`; } } - if (installations.duplicateDeviceTokenActionEnforceAuth === undefined) { - installations.duplicateDeviceTokenActionEnforceAuth = - InstallationsOptions.duplicateDeviceTokenActionEnforceAuth.default; - } else if (typeof installations.duplicateDeviceTokenActionEnforceAuth !== 'boolean') { - throw 'installations.duplicateDeviceTokenActionEnforceAuth must be a boolean.'; + if (installation.duplicateDeviceTokenActionEnforceAuth === undefined) { + installation.duplicateDeviceTokenActionEnforceAuth = + InstallationOptions.duplicateDeviceTokenActionEnforceAuth.default; + } else if (typeof installation.duplicateDeviceTokenActionEnforceAuth !== 'boolean') { + throw 'installation.duplicateDeviceTokenActionEnforceAuth must be a boolean.'; } const validActions = ['delete', 'update']; - if (installations.duplicateDeviceTokenAction === undefined) { - installations.duplicateDeviceTokenAction = - InstallationsOptions.duplicateDeviceTokenAction.default; - } else if (!validActions.includes(installations.duplicateDeviceTokenAction)) { - throw "installations.duplicateDeviceTokenAction must be one of: 'delete', 'update'."; + if (installation.duplicateDeviceTokenAction === undefined) { + installation.duplicateDeviceTokenAction = + InstallationOptions.duplicateDeviceTokenAction.default; + } else if (!validActions.includes(installation.duplicateDeviceTokenAction)) { + throw "installation.duplicateDeviceTokenAction must be one of: 'delete', 'update'."; } const validPriorities = ['deviceToken', 'installationId']; - if (installations.duplicateDeviceTokenMergePriority === undefined) { - installations.duplicateDeviceTokenMergePriority = - InstallationsOptions.duplicateDeviceTokenMergePriority.default; - } else if (!validPriorities.includes(installations.duplicateDeviceTokenMergePriority)) { - throw "installations.duplicateDeviceTokenMergePriority must be one of: 'deviceToken', 'installationId'."; + if (installation.duplicateDeviceTokenMergePriority === undefined) { + installation.duplicateDeviceTokenMergePriority = + InstallationOptions.duplicateDeviceTokenMergePriority.default; + } else if (!validPriorities.includes(installation.duplicateDeviceTokenMergePriority)) { + throw "installation.duplicateDeviceTokenMergePriority must be one of: 'deviceToken', 'installationId'."; } } diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index 4584bedbaf..5eb78c88c0 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -109,8 +109,8 @@ module.exports = [ solution: "Set 'protectedFieldsSaveResponseExempt' to 'false' to strip protected fields from write operation responses (create, update), consistent with how they are stripped from query results. Set to 'true' to keep the current behavior where protected fields are included in write responses.", }, { - optionKey: 'installations.duplicateDeviceTokenActionEnforceAuth', + optionKey: 'installation.duplicateDeviceTokenActionEnforceAuth', changeNewDefault: 'true', - solution: "Set 'installations.duplicateDeviceTokenActionEnforceAuth' to 'true' to enforce the caller's auth context (and the resulting ACL and CLP) when Parse Server deduplicates _Installation records sharing the same deviceToken. Set to 'false' to keep the current behavior of bypassing permissions on the dedup operation.", + solution: "Set 'installation.duplicateDeviceTokenActionEnforceAuth' to 'true' to enforce the caller's auth context (and the resulting ACL and CLP) when Parse Server deduplicates _Installation records sharing the same deviceToken. Set to 'false' to keep the current behavior of bypassing permissions on the dedup operation.", }, ]; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 61659a69ab..277ba9a477 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -322,11 +322,11 @@ module.exports.ParseServerOptions = { type: 'IdempotencyOptions', default: {}, }, - installations: { - env: 'PARSE_SERVER_INSTALLATIONS', + installation: { + env: 'PARSE_SERVER_INSTALLATION', help: 'Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`.', action: parsers.objectParser, - type: 'InstallationsOptions', + type: 'InstallationOptions', default: {}, }, javascriptKey: { @@ -773,20 +773,20 @@ module.exports.RequestComplexityOptions = { default: -1, }, }; -module.exports.InstallationsOptions = { +module.exports.InstallationOptions = { duplicateDeviceTokenAction: { - env: 'PARSE_SERVER_INSTALLATIONS_DUPLICATE_DEVICE_TOKEN_ACTION', + env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION', help: "What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`.", default: 'delete', }, duplicateDeviceTokenActionEnforceAuth: { - env: 'PARSE_SERVER_INSTALLATIONS_DUPLICATE_DEVICE_TOKEN_ACTION_ENFORCE_AUTH', + env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION_ENFORCE_AUTH', help: "Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`.", action: parsers.booleanParser, default: false, }, duplicateDeviceTokenMergePriority: { - env: 'PARSE_SERVER_INSTALLATIONS_DUPLICATE_DEVICE_TOKEN_MERGE_PRIORITY', + env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_MERGE_PRIORITY', help: "At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` \u2014 the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` \u2014 the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`.", default: 'deviceToken', }, diff --git a/src/Options/docs.js b/src/Options/docs.js index ccabfada4e..f3c454e763 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -61,7 +61,7 @@ * @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file * @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0 * @property {IdempotencyOptions} idempotencyOptions Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production. - * @property {InstallationsOptions} installations Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`. + * @property {InstallationOptions} installation Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`. * @property {String} javascriptKey Key for the Javascript SDK * @property {Boolean} jsonLogs Log as structured JSON objects * @property {LiveQueryOptions} liveQuery parse-server's LiveQuery configuration object @@ -150,7 +150,7 @@ */ /** - * @interface InstallationsOptions + * @interface InstallationOptions * @property {String} duplicateDeviceTokenAction What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`. * @property {Boolean} duplicateDeviceTokenActionEnforceAuth Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`. * @property {String} duplicateDeviceTokenMergePriority At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` — the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` — the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`. diff --git a/src/Options/index.js b/src/Options/index.js index dd7cf75353..e1266d239a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -392,9 +392,9 @@ export interface ParseServerOptions { :DEFAULT: {} */ requestComplexity: ?RequestComplexityOptions; /* Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`. - :ENV: PARSE_SERVER_INSTALLATIONS + :ENV: PARSE_SERVER_INSTALLATION :DEFAULT: {} */ - installations: ?InstallationsOptions; + installation: ?InstallationOptions; /* Query-related server defaults. :ENV: PARSE_SERVER_QUERY :DEFAULT: {} */ @@ -487,7 +487,7 @@ export interface RequestComplexityOptions { batchRequestLimit: ?number; } -export interface InstallationsOptions { +export interface InstallationOptions { /* Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`. :DEFAULT: false */ duplicateDeviceTokenActionEnforceAuth: ?boolean; From 417b3904b68f1cfde647ae0be5ce4d325710bce0 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:54:38 +0100 Subject: [PATCH 03/11] feat: add InstallationDedup helpers for deviceToken collision handling --- spec/InstallationDedup.spec.js | 295 +++++++++++++++++++++++++++++++++ src/InstallationDedup.js | 169 +++++++++++++++++++ 2 files changed, 464 insertions(+) create mode 100644 spec/InstallationDedup.spec.js create mode 100644 src/InstallationDedup.js diff --git a/spec/InstallationDedup.spec.js b/spec/InstallationDedup.spec.js new file mode 100644 index 0000000000..776bcafb73 --- /dev/null +++ b/spec/InstallationDedup.spec.js @@ -0,0 +1,295 @@ +'use strict'; + +const Parse = require('parse/node').Parse; + +describe('InstallationDedup', () => { + let InstallationDedup; + let logger; + let logSpy; + + beforeEach(() => { + InstallationDedup = require('../lib/InstallationDedup'); + logger = require('../lib/logger').logger; + logSpy = { + verbose: spyOn(logger, 'verbose').and.callFake(() => {}), + warn: spyOn(logger, 'warn').and.callFake(() => {}), + error: spyOn(logger, 'error').and.callFake(() => {}), + }; + }); + + describe('removeConflictingDeviceToken', () => { + it('action="delete" with no match resolves silently and logs verbose', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue(Promise.reject({ code: Parse.Error.OBJECT_NOT_FOUND })), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalled(); + expect(logSpy.verbose).toHaveBeenCalled(); + expect(logSpy.warn).not.toHaveBeenCalled(); + expect(logSpy.error).not.toHaveBeenCalled(); + }); + + it('action="delete" with matches calls destroy with empty options when enforceAuth=false', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: false, + runOptions: { acl: ['*'] }, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { deviceToken: 'X' }, + {}, + undefined + ); + expect(logSpy.verbose).toHaveBeenCalled(); + }); + + it('action="delete" with enforceAuth=true passes runOptions to destroy', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const runOptions = { acl: ['*', 'userABC'] }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: true, + runOptions, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { deviceToken: 'X' }, + runOptions, + undefined + ); + }); + + it('action="update" calls update with deviceToken cleared and many=true', async () => { + const database = { + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'update', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(database.update).toHaveBeenCalledWith( + '_Installation', + { deviceToken: 'X' }, + { deviceToken: { __op: 'Delete' } }, + {}, + true, + undefined + ); + expect(logSpy.verbose).toHaveBeenCalled(); + }); + + it('OPERATION_FORBIDDEN error is swallowed and logged as warn', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue( + Promise.reject({ code: Parse.Error.OPERATION_FORBIDDEN, message: 'denied' }) + ), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: true, + runOptions: { acl: ['*'] }, + validSchemaController: undefined, + }); + expect(logSpy.warn).toHaveBeenCalled(); + expect(logSpy.error).not.toHaveBeenCalled(); + }); + + it('unexpected error is logged as error and rethrown', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue(Promise.reject(new Error('database connection lost'))), + }; + let caught; + try { + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + } catch (e) { + caught = e; + } + expect(caught).toBeDefined(); + expect(caught.message).toBe('database connection lost'); + expect(logSpy.error).toHaveBeenCalled(); + }); + }); + + describe('applyDuplicateDeviceTokenMerge', () => { + const idMatch = { objectId: 'A', installationId: 'I' }; + const deviceTokenMatch = { objectId: 'B', deviceToken: 'X' }; + + it('mergePriority="deviceToken" + action="delete" destroys idMatch and returns deviceTokenMatch.objectId', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('B'); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { objectId: 'A' }, + {}, + undefined + ); + expect(logSpy.verbose).toHaveBeenCalled(); + }); + + it('mergePriority="deviceToken" + action="update" clears installationId on idMatch and returns deviceTokenMatch.objectId', async () => { + const database = { + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'update', + mergePriority: 'deviceToken', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('B'); + expect(database.update).toHaveBeenCalledWith( + '_Installation', + { objectId: 'A' }, + { installationId: { __op: 'Delete' } }, + {}, + false, + undefined + ); + }); + + it('mergePriority="installationId" + action="delete" destroys deviceTokenMatch and returns idMatch.objectId', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'installationId', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('A'); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { objectId: 'B' }, + {}, + undefined + ); + }); + + it('mergePriority="installationId" + action="update" clears deviceToken on deviceTokenMatch and returns idMatch.objectId', async () => { + const database = { + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'update', + mergePriority: 'installationId', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('A'); + expect(database.update).toHaveBeenCalledWith( + '_Installation', + { objectId: 'B' }, + { deviceToken: { __op: 'Delete' } }, + {}, + false, + undefined + ); + }); + + it('OPERATION_FORBIDDEN on the merge action still returns survivor objectId (silent skip)', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue(Promise.reject({ code: Parse.Error.OPERATION_FORBIDDEN })), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: true, + runOptions: { acl: ['*'] }, + validSchemaController: undefined, + }); + expect(result).toBe('B'); + expect(logSpy.warn).toHaveBeenCalled(); + }); + + it('enforceAuth=true passes runOptions to destroy', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const runOptions = { acl: ['*', 'userABC'] }; + await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: true, + runOptions, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { objectId: 'A' }, + runOptions, + undefined + ); + }); + }); +}); diff --git a/src/InstallationDedup.js b/src/InstallationDedup.js new file mode 100644 index 0000000000..eb108a4e5d --- /dev/null +++ b/src/InstallationDedup.js @@ -0,0 +1,169 @@ +import Parse from 'parse/node'; +import logger from './logger'; + +const CLASS_NAME = '_Installation'; + +function logResult(action, count, err) { + if (err && err.code === Parse.Error.OBJECT_NOT_FOUND) { + logger.verbose(`Installation dedup ${action} matched no rows; nothing to do.`); + return; + } + if (err && err.code === Parse.Error.OPERATION_FORBIDDEN) { + logger.warn( + `Installation dedup ${action} skipped: caller has no permission to ${action} the conflicting row(s). The conflicting row remains.` + ); + return; + } + if (err) { + logger.error(`Installation dedup ${action} failed: ${err.message || err}`); + return; + } + logger.verbose( + `Installation dedup ${action} applied to ${count == null ? 'matching' : count} conflicting row(s).` + ); +} + +async function performAction({ + database, + query, + action, + fieldToClear, + runOptions, + many, + validSchemaController, +}) { + if (action === 'delete') { + return database.destroy(CLASS_NAME, query, runOptions, validSchemaController); + } + if (action === 'update') { + return database.update( + CLASS_NAME, + query, + { [fieldToClear]: { __op: 'Delete' } }, + runOptions, + many, + validSchemaController + ); + } + throw new Error(`Unknown installation dedup action: ${action}`); +} + +/** + * Removes or updates `_Installation` rows that hold a `deviceToken` matching the query, + * allowing the caller to claim that `deviceToken` exclusively. Used when a new or updated + * install collides with one or more existing rows on `deviceToken`. + * + * @param {Object} options + * @param {DatabaseController} options.database + * @param {Object} options.query e.g. { deviceToken: 'X', installationId: { $ne: 'I' } } + * @param {'delete'|'update'} options.action + * @param {boolean} options.enforceAuth + * @param {Object} options.runOptions RestWrite.runOptions + * @param {SchemaController} options.validSchemaController + */ +export async function removeConflictingDeviceToken({ + database, + query, + action, + enforceAuth, + runOptions, + validSchemaController, +}) { + const opts = enforceAuth ? runOptions : {}; + try { + await performAction({ + database, + query, + action, + fieldToClear: 'deviceToken', + runOptions: opts, + many: true, + validSchemaController, + }); + logResult(action, null, null); + } catch (err) { + if (err && err.code === Parse.Error.OBJECT_NOT_FOUND) { + logResult(action, 0, err); + return; + } + if (err && err.code === Parse.Error.OPERATION_FORBIDDEN) { + logResult(action, null, err); + return; + } + logResult(action, null, err); + throw err; + } +} + +/** + * Resolves a merge conflict between two `_Installation` rows that together represent the + * same install: one matched by `installationId`/`objectId` (`idMatch`), and another holding + * the same `deviceToken` but no `installationId` (`deviceTokenMatch`). The `mergePriority` + * determines which row survives; the loser receives the configured `action`. Returns the + * survivor's `objectId` so the save flow can target it. + * + * @param {Object} options + * @param {DatabaseController} options.database + * @param {{ objectId: string, installationId?: string, deviceToken?: string }} options.idMatch + * @param {{ objectId: string, deviceToken?: string }} options.deviceTokenMatch + * @param {'delete'|'update'} options.action + * @param {'deviceToken'|'installationId'} options.mergePriority + * @param {boolean} options.enforceAuth + * @param {Object} options.runOptions + * @param {SchemaController} options.validSchemaController + * @returns {Promise} survivor's objectId + */ +export async function applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action, + mergePriority, + enforceAuth, + runOptions, + validSchemaController, +}) { + const opts = enforceAuth ? runOptions : {}; + let loser; + let survivorId; + let fieldToClear; + if (mergePriority === 'deviceToken') { + loser = idMatch; + survivorId = deviceTokenMatch.objectId; + fieldToClear = 'installationId'; + } else if (mergePriority === 'installationId') { + loser = deviceTokenMatch; + survivorId = idMatch.objectId; + fieldToClear = 'deviceToken'; + } else { + throw new Error(`Unknown installation dedup mergePriority: ${mergePriority}`); + } + + try { + await performAction({ + database, + query: { objectId: loser.objectId }, + action, + fieldToClear, + runOptions: opts, + many: false, + validSchemaController, + }); + logResult(action, 1, null); + } catch (err) { + if (err && err.code === Parse.Error.OBJECT_NOT_FOUND) { + logResult(action, 0, err); + } else if (err && err.code === Parse.Error.OPERATION_FORBIDDEN) { + logResult(action, null, err); + } else { + logResult(action, null, err); + throw err; + } + } + return survivorId; +} + +export default { + removeConflictingDeviceToken, + applyDuplicateDeviceTokenMerge, +}; From b6eebb82923fc165c5631280c29f95c6a4c62bac Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:59:00 +0100 Subject: [PATCH 04/11] feat: honor installation dedup options on new install collisions --- spec/ParseInstallation.spec.js | 124 +++++++++++++++++++++++++++++++++ src/RestWrite.js | 25 +++---- 2 files changed, 137 insertions(+), 12 deletions(-) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index bb667a90c1..413ffeb6ed 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1298,6 +1298,130 @@ describe('Installations', () => { // TODO: Do we need to support _tombstone disabling of installations? // TODO: Test deletion, badge increments + describe('deviceToken deduplication on new install (no installationId match)', () => { + const installationSchema = { + fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), + }; + + async function reconfigureWithInstallationOptions(installationOpts) { + await reconfigureServer({ installation: installationOpts }); + config = Config.get('test'); + database = config.database; + } + + it('default options destroy conflicting rows', async () => { + const t = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-b', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-c', + }); + + const results = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(results.length).toBe(1); + expect(results[0].installationId).toBe('iid-c'); + }); + + it('action="update" preserves channels on conflicting rows but clears deviceToken', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-a', + channels: ['old-news'], + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-b', + channels: ['old-sports'], + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-c', + channels: ['fresh'], + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(3); + const survivor = all.find(r => r.installationId === 'iid-c'); + expect(survivor.deviceToken).toBe(t); + const cleared = all.filter(r => r.installationId !== 'iid-c'); + cleared.forEach(r => { + expect(r.deviceToken).toBeUndefined(); + expect(r.channels).toBeDefined(); + }); + }); + + it('enforceAuth=true preserves ACL-protected rows from unauthenticated dedup', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); + const t = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + const user = await Parse.User.signUp('alice-' + Date.now(), 'pass'); + const aliceId = user.id; + + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-protected', + ACL: { [aliceId]: { read: true, write: true } }, + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-other', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-attacker', + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + const protectedRow = all.find(r => r.installationId === 'iid-protected'); + expect(protectedRow).toBeDefined(); + expect(protectedRow.deviceToken).toBe(t); + }); + + it('enforceAuth=true with master-key caller still bypasses ACL and dedups', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); + const t = 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'; + const user = await Parse.User.signUp('bob-' + Date.now(), 'pass'); + const bobId = user.id; + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-1', + ACL: { [bobId]: { read: true, write: true } }, + }); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-2', + }); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-3', + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].installationId).toBe('iid-3'); + }); + }); + describe('options validation', () => { it('should accept default empty config', async () => { await expectAsync(reconfigureServer({})).toBeResolved(); diff --git a/src/RestWrite.js b/src/RestWrite.js index 6fd6bff94b..3673bb2873 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -18,6 +18,7 @@ import logger from './logger'; import { requiredColumns } from './Controllers/SchemaController'; import { createSanitizedError } from './Error'; import { applyAuthDataOptimisticLock } from './AuthDataLock'; +import * as InstallationDedup from './InstallationDedup'; // query and data are both provided in REST API format. So data // types are encoded by plain old objects. @@ -1446,10 +1447,10 @@ RestWrite.prototype.handleInstallation = function () { } else { // Multiple device token matches and we specified an installation ID, // or a single match where both the passed and matching objects have - // an installation ID. Try cleaning out old installations that match - // the deviceToken, and return nil to signal that a new object should - // be created. - var delQuery = { + // an installation ID. Clean out other installations that match the + // deviceToken, and return nil to signal that a new object should be + // created. + const delQuery = { deviceToken: this.data.deviceToken, installationId: { $ne: installationId, @@ -1458,15 +1459,15 @@ RestWrite.prototype.handleInstallation = function () { if (this.data.appIdentifier) { delQuery['appIdentifier'] = this.data.appIdentifier; } - this.config.database.destroy('_Installation', delQuery).catch(err => { - if (err.code == Parse.Error.OBJECT_NOT_FOUND) { - // no deletions were made. Can be ignored. - return; - } - // rethrow the error - throw err; + const installationOpts = this.config.installation || {}; + return InstallationDedup.removeConflictingDeviceToken({ + database: this.config.database, + query: delQuery, + action: installationOpts.duplicateDeviceTokenAction || 'delete', + enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, + runOptions: this.runOptions, + validSchemaController: this.validSchemaController, }); - return; } } else { if (deviceTokenMatches.length == 1 && !deviceTokenMatches[0]['installationId']) { From e8b044b6447906fb8eb8f4d743b39378339988b9 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:42:30 +0100 Subject: [PATCH 05/11] feat: honor installation dedup options on existing install deviceToken changes --- spec/ParseInstallation.spec.js | 115 +++++++++++++++++++++++++++++++-- src/RestWrite.js | 17 ++--- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 413ffeb6ed..be869dfb82 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1299,6 +1299,7 @@ describe('Installations', () => { // TODO: Test deletion, badge increments describe('deviceToken deduplication on new install (no installationId match)', () => { + const { randomUUID } = require('crypto'); const installationSchema = { fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), }; @@ -1310,7 +1311,7 @@ describe('Installations', () => { } it('default options destroy conflicting rows', async () => { - const t = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const t = randomUUID(); await rest.create(config, auth.nobody(config), '_Installation', { deviceToken: t, deviceType: 'ios', @@ -1334,7 +1335,7 @@ describe('Installations', () => { it('action="update" preserves channels on conflicting rows but clears deviceToken', async () => { await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); - const t = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const t = randomUUID(); await rest.create(config, auth.nobody(config), '_Installation', { deviceToken: t, deviceType: 'ios', @@ -1367,7 +1368,7 @@ describe('Installations', () => { it('enforceAuth=true preserves ACL-protected rows from unauthenticated dedup', async () => { await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); - const t = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + const t = randomUUID(); const user = await Parse.User.signUp('alice-' + Date.now(), 'pass'); const aliceId = user.id; @@ -1396,7 +1397,7 @@ describe('Installations', () => { it('enforceAuth=true with master-key caller still bypasses ACL and dedups', async () => { await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); - const t = 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'; + const t = randomUUID(); const user = await Parse.User.signUp('bob-' + Date.now(), 'pass'); const bobId = user.id; await rest.create(config, auth.master(config), '_Installation', { @@ -1422,6 +1423,112 @@ describe('Installations', () => { }); }); + describe('deviceToken deduplication on existing install update (deviceToken changes)', () => { + const { randomUUID } = require('crypto'); + const installationSchema = { + fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), + }; + + async function reconfigureWithInstallationOptions(installationOpts) { + await reconfigureServer({ installation: installationOpts }); + config = Config.get('test'); + database = config.database; + } + + it('default options destroy conflicting row when PUT sets a new deviceToken', async () => { + const t1 = randomUUID(); + const t2 = randomUUID(); + const a = await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t1, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t2, + deviceType: 'ios', + installationId: 'iid-b', + }); + await rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: a.response.objectId }, + { deviceToken: t2, installationId: 'iid-a' } + ); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].deviceToken).toBe(t2); + expect(all[0].installationId).toBe('iid-a'); + }); + + it('action="update" preserves the conflicting row and only clears its deviceToken', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t1 = randomUUID(); + const t2 = randomUUID(); + const a = await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t1, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t2, + deviceType: 'ios', + installationId: 'iid-b', + channels: ['preserve-me'], + }); + await rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: a.response.objectId }, + { deviceToken: t2, installationId: 'iid-a' } + ); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(2); + const aRow = all.find(r => r.installationId === 'iid-a'); + const bRow = all.find(r => r.installationId === 'iid-b'); + expect(aRow.deviceToken).toBe(t2); + expect(bRow.deviceToken).toBeUndefined(); + expect(bRow.channels).toEqual(['preserve-me']); + }); + + it('enforceAuth=true preserves ACL-protected conflicting rows', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); + const t1 = randomUUID(); + const t2 = randomUUID(); + const user = await Parse.User.signUp('carol-' + Date.now(), 'pass'); + const carolId = user.id; + + const a = await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t1, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t2, + deviceType: 'ios', + installationId: 'iid-b', + ACL: { [carolId]: { read: true, write: true } }, + }); + await rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: a.response.objectId }, + { deviceToken: t2, installationId: 'iid-a' } + ); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + const bRow = all.find(r => r.installationId === 'iid-b'); + expect(bRow).toBeDefined(); + expect(bRow.deviceToken).toBe(t2); + const aRow = all.find(r => r.installationId === 'iid-a'); + expect(aRow.deviceToken).toBe(t2); + }); + }); + describe('options validation', () => { it('should accept default empty config', async () => { await expectAsync(reconfigureServer({})).toBeResolved(); diff --git a/src/RestWrite.js b/src/RestWrite.js index 3673bb2873..06dabfb929 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1518,14 +1518,15 @@ RestWrite.prototype.handleInstallation = function () { if (this.data.appIdentifier) { delQuery['appIdentifier'] = this.data.appIdentifier; } - this.config.database.destroy('_Installation', delQuery).catch(err => { - if (err.code == Parse.Error.OBJECT_NOT_FOUND) { - // no deletions were made. Can be ignored. - return; - } - // rethrow the error - throw err; - }); + const installationOpts = this.config.installation || {}; + return InstallationDedup.removeConflictingDeviceToken({ + database: this.config.database, + query: delQuery, + action: installationOpts.duplicateDeviceTokenAction || 'delete', + enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, + runOptions: this.runOptions, + validSchemaController: this.validSchemaController, + }).then(() => idMatch.objectId); } // In non-merge scenarios, just return the installation match id return idMatch.objectId; From a3a5f416869d09ff41092d325a307c365f0de95a Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:46:34 +0100 Subject: [PATCH 06/11] feat: honor installation dedup options on deviceToken merge case --- spec/ParseInstallation.spec.js | 118 +++++++++++++++++++++++++++++++++ src/RestWrite.js | 29 ++++---- 2 files changed, 131 insertions(+), 16 deletions(-) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index be869dfb82..1fcb68c02b 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1529,6 +1529,124 @@ describe('Installations', () => { }); }); + describe('deviceToken deduplication merge case (idMatch + deviceToken-only orphan)', () => { + const { randomUUID } = require('crypto'); + const installationSchema = { + fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), + }; + + async function reconfigureWithInstallationOptions(installationOpts) { + await reconfigureServer({ installation: installationOpts }); + config = Config.get('test'); + database = config.database; + } + + /** + * Sets up the merge fixture: + * Row A — { installationId: iid, deviceType: 'ios' } (no deviceToken) + * Row B — { deviceToken: t, deviceType: 'ios', channels } (no installationId) + * Then triggers the merge by POSTing { installationId: iid, deviceToken: t }. + */ + async function setupMergeFixture(t, iid, bChannels = ['orphan-history']) { + // Row A: matched by installationId, no deviceToken yet. + await rest.create(config, auth.master(config), '_Installation', { + deviceType: 'ios', + installationId: iid, + }); + // Row B: deviceToken-only orphan. Insert via the storage adapter to bypass + // the require-at-least-one-ID check (the orphan has only deviceToken). + const objectId = 'orph' + Math.random().toString(36).substring(2, 12); + await database.adapter.createObject( + '_Installation', + installationSchema, + { + objectId, + deviceType: 'ios', + deviceToken: t, + channels: bChannels, + _created_at: new Date(), + _updated_at: new Date(), + }, + null + ); + return objectId; + } + + it('default options merge: deviceToken-holder wins, idMatch destroyed', async () => { + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + // POST that triggers the merge. + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].objectId).toBe(orphanObjectId); + expect(all[0].installationId).toBe('merge-iid-a'); + expect(all[0].deviceToken).toBe(t); + expect(all[0].channels).toEqual(['orphan-history']); + }); + + it('mergePriority=deviceToken, action=update clears installationId on idMatch (loser)', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(2); + const survivor = all.find(r => r.objectId === orphanObjectId); + expect(survivor.installationId).toBe('merge-iid-a'); + expect(survivor.deviceToken).toBe(t); + const loser = all.find(r => r.objectId !== orphanObjectId); + expect(loser.installationId).toBeUndefined(); + }); + + it('mergePriority=installationId, action=delete destroys orphan, idMatch wins', async () => { + await reconfigureWithInstallationOptions({ + duplicateDeviceTokenMergePriority: 'installationId', + }); + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].installationId).toBe('merge-iid-a'); + expect(all[0].deviceToken).toBe(t); + expect(all[0].objectId).not.toBe(orphanObjectId); + }); + + it('mergePriority=installationId, action=update clears deviceToken on orphan', async () => { + await reconfigureWithInstallationOptions({ + duplicateDeviceTokenMergePriority: 'installationId', + duplicateDeviceTokenAction: 'update', + }); + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(2); + const survivor = all.find(r => r.installationId === 'merge-iid-a'); + expect(survivor.deviceToken).toBe(t); + const loser = all.find(r => r.objectId === orphanObjectId); + expect(loser.deviceToken).toBeUndefined(); + expect(loser.channels).toEqual(['orphan-history']); + }); + }); + describe('options validation', () => { it('should accept default empty config', async () => { await expectAsync(reconfigureServer({})).toBeResolved(); diff --git a/src/RestWrite.js b/src/RestWrite.js index 06dabfb929..6d3c0d35a9 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1472,22 +1472,19 @@ RestWrite.prototype.handleInstallation = function () { } else { if (deviceTokenMatches.length == 1 && !deviceTokenMatches[0]['installationId']) { // Exactly one device token match and it doesn't have an installation - // ID. This is the one case where we want to merge with the existing - // object. - const delQuery = { objectId: idMatch.objectId }; - return this.config.database - .destroy('_Installation', delQuery) - .then(() => { - return deviceTokenMatches[0]['objectId']; - }) - .catch(err => { - if (err.code == Parse.Error.OBJECT_NOT_FOUND) { - // no deletions were made. Can be ignored - return; - } - // rethrow the error - throw err; - }); + // ID. The two rows represent the same install; resolve the merge per + // the configured options. + const installationOpts = this.config.installation || {}; + return InstallationDedup.applyDuplicateDeviceTokenMerge({ + database: this.config.database, + idMatch, + deviceTokenMatch: deviceTokenMatches[0], + action: installationOpts.duplicateDeviceTokenAction || 'delete', + mergePriority: installationOpts.duplicateDeviceTokenMergePriority || 'deviceToken', + enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, + runOptions: this.runOptions, + validSchemaController: this.validSchemaController, + }); } else { if (this.data.deviceToken && idMatch.deviceToken != this.data.deviceToken) { // We're setting the device token on an existing installation, so From 50b381f812a175988ea3e9e83ced3a70628f8df0 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:10:00 +0100 Subject: [PATCH 07/11] test: verify installation.duplicateDeviceTokenActionEnforceAuth deprecation --- spec/Deprecator.spec.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/spec/Deprecator.spec.js b/spec/Deprecator.spec.js index 1412795620..993d18682f 100644 --- a/spec/Deprecator.spec.js +++ b/spec/Deprecator.spec.js @@ -234,4 +234,39 @@ describe('Deprecator', () => { ); } }); + + it('registers a deprecation entry for installation.duplicateDeviceTokenActionEnforceAuth', () => { + const Deprecations = require('../lib/Deprecator/Deprecations'); + const entry = Deprecations.find( + d => d.optionKey === 'installation.duplicateDeviceTokenActionEnforceAuth' + ); + expect(entry).toBeDefined(); + expect(entry.changeNewDefault).toBe('true'); + expect(entry.solution).toContain('duplicateDeviceTokenActionEnforceAuth'); + }); + + it('logs deprecation for installation.duplicateDeviceTokenActionEnforceAuth when not set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer(); + expect(logSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'installation.duplicateDeviceTokenActionEnforceAuth', + changeNewDefault: 'true', + }) + ); + }); + + it('does not log deprecation for installation.duplicateDeviceTokenActionEnforceAuth when explicitly set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ + installation: { duplicateDeviceTokenActionEnforceAuth: false }, + }); + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'installation.duplicateDeviceTokenActionEnforceAuth', + }) + ); + }); }); From 001ffa6cb74deb0f493ed31e320e743ac849cc83 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:24:48 +0100 Subject: [PATCH 08/11] docs: document installation deviceToken dedup options --- DEPRECATIONS.md | 1 + README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index 18e8d4275d..27ccee409e 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -27,6 +27,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h | DEPPS21 | Config option `protectedFieldsOwnerExempt` defaults to `false` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS22 | Config option `protectedFieldsTriggerExempt` defaults to `true` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS23 | Config option `protectedFieldsSaveResponseExempt` defaults to `false` | | 9.7.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS24 | Config option `installation.duplicateDeviceTokenActionEnforceAuth` defaults to `true` | [#10451](https://github.com/parse-community/parse-server/pull/10451) | 9.9.0 (2026) | 10.0.0 (2027) | deprecated | - | [i_deprecation]: ## "The version and date of the deprecation." [i_change]: ## "The version and date of the planned change." diff --git a/README.md b/README.md index 6fa6cf4ab4..6e9862f898 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who - [Configuring File Adapters](#configuring-file-adapters) - [Restricting File URL Domains](#restricting-file-url-domains) - [Idempotency Enforcement](#idempotency-enforcement) + - [Installations](#installations) - [Localization](#localization) - [Pages](#pages) - [Localization with Directory Structure](#localization-with-directory-structure) @@ -658,6 +659,49 @@ Assuming the script above is named, `parse_idempotency_delete_expired_records.sh 2 * * * * /root/parse_idempotency_delete_expired_records.sh >/dev/null 2>&1 ``` +## Installations + +Parse Server deduplicates `_Installation` records when a new install collides with an existing row's `deviceToken`. The `installation` option block configures the dedup behavior. + +### Options + +| Parameter | Optional | Type | Default | Environment Variable | +|---|---|---|---|---| +| `installation.duplicateDeviceTokenActionEnforceAuth` | yes | `Boolean` | `false` | `PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION_ENFORCE_AUTH` | +| `installation.duplicateDeviceTokenAction` | yes | `String` | `'delete'` | `PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION` | +| `installation.duplicateDeviceTokenMergePriority` | yes | `String` | `'deviceToken'` | `PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_MERGE_PRIORITY` | + +#### `duplicateDeviceTokenActionEnforceAuth` + +When `true`, the dedup operation runs with the caller's auth context so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. + +#### `duplicateDeviceTokenAction` + +What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. + +- `'delete'`: destroys the conflicting row. +- `'update'`: clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. + +#### `duplicateDeviceTokenMergePriority` + +When an existing row holds the new `deviceToken` but has no `installationId` of its own, Parse Server merges the two rows. This option controls which side wins. + +- `'deviceToken'`: the deviceToken-only row survives; the request's installationId-matched row is the loser. +- `'installationId'`: the request's installationId-matched row survives; the deviceToken-only orphan is the loser. + +### Configuration example + +```javascript +const parseServer = new ParseServer({ + ...otherOptions, + installation: { + duplicateDeviceTokenActionEnforceAuth: true, + duplicateDeviceTokenAction: 'update', + duplicateDeviceTokenMergePriority: 'installationId', + }, +}); +``` + ## Localization ### Pages From 7ce2ff39b7d8a05438186ab472e7b333aba040d3 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:46:33 +0100 Subject: [PATCH 09/11] fix: skip self-merge in applyDuplicateDeviceTokenMerge --- spec/InstallationDedup.spec.js | 21 +++++++++++++++++++++ src/InstallationDedup.js | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/spec/InstallationDedup.spec.js b/spec/InstallationDedup.spec.js index 776bcafb73..61749cb40e 100644 --- a/spec/InstallationDedup.spec.js +++ b/spec/InstallationDedup.spec.js @@ -269,6 +269,27 @@ describe('InstallationDedup', () => { expect(logSpy.warn).toHaveBeenCalled(); }); + it('returns the shared objectId without calling destroy/update when idMatch and deviceTokenMatch are the same row', async () => { + const sameRow = { objectId: 'SAME', installationId: 'I', deviceToken: 'X' }; + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch: sameRow, + deviceTokenMatch: sameRow, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('SAME'); + expect(database.destroy).not.toHaveBeenCalled(); + expect(database.update).not.toHaveBeenCalled(); + }); + it('enforceAuth=true passes runOptions to destroy', async () => { const database = { destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), diff --git a/src/InstallationDedup.js b/src/InstallationDedup.js index eb108a4e5d..ae5d04ff8c 100644 --- a/src/InstallationDedup.js +++ b/src/InstallationDedup.js @@ -123,6 +123,12 @@ export async function applyDuplicateDeviceTokenMerge({ runOptions, validSchemaController, }) { + // Self-merge guard: when both matches resolve to the same row, there's + // nothing to clean up. Skip the action so we don't destroy/update the row + // we're about to return as the survivor. + if (idMatch.objectId === deviceTokenMatch.objectId) { + return idMatch.objectId; + } const opts = enforceAuth ? runOptions : {}; let loser; let survivorId; From 0c05d6bc88dee02765305f920e7d72bf3b43cbc8 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:45:53 +0100 Subject: [PATCH 10/11] fix: pass many flag inside update options for installation dedup --- spec/InstallationDedup.spec.js | 13 +++++++---- spec/ParseInstallation.spec.js | 42 ++++++++++++++++++++++++++++++++++ src/InstallationDedup.js | 5 ++-- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/spec/InstallationDedup.spec.js b/spec/InstallationDedup.spec.js index 61749cb40e..c73c574cf6 100644 --- a/spec/InstallationDedup.spec.js +++ b/spec/InstallationDedup.spec.js @@ -80,7 +80,7 @@ describe('InstallationDedup', () => { ); }); - it('action="update" calls update with deviceToken cleared and many=true', async () => { + it('action="update" calls update with deviceToken cleared and many=true in options', async () => { const database = { update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), }; @@ -96,8 +96,9 @@ describe('InstallationDedup', () => { '_Installation', { deviceToken: 'X' }, { deviceToken: { __op: 'Delete' } }, - {}, - true, + jasmine.objectContaining({ many: true }), + false, + false, undefined ); expect(logSpy.verbose).toHaveBeenCalled(); @@ -195,7 +196,8 @@ describe('InstallationDedup', () => { '_Installation', { objectId: 'A' }, { installationId: { __op: 'Delete' } }, - {}, + jasmine.objectContaining({ many: false }), + false, false, undefined ); @@ -243,7 +245,8 @@ describe('InstallationDedup', () => { '_Installation', { objectId: 'B' }, { deviceToken: { __op: 'Delete' } }, - {}, + jasmine.objectContaining({ many: false }), + false, false, undefined ); diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 1fcb68c02b..948dd9e98b 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1421,6 +1421,48 @@ describe('Installations', () => { expect(all.length).toBe(1); expect(all[0].installationId).toBe('iid-3'); }); + + it('action="update" clears deviceToken on ALL matching rows (multi-row update)', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t = randomUUID(); + // Insert three rows directly via the storage adapter so they all hold the + // same deviceToken simultaneously, bypassing the sequential REST dedup + // that would otherwise prevent this state. + const adapter = config.database.adapter; + for (const iid of ['multi-iid-a', 'multi-iid-b', 'multi-iid-c']) { + await adapter.createObject( + '_Installation', + installationSchema, + { + objectId: 'oid-' + iid, + deviceType: 'ios', + deviceToken: t, + installationId: iid, + channels: ['c-' + iid], + _created_at: new Date(), + _updated_at: new Date(), + }, + null + ); + } + // Trigger site 1: new install with same deviceToken, different installationId. + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'multi-iid-d', + channels: ['fresh'], + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + const survivor = all.find(r => r.installationId === 'multi-iid-d'); + expect(survivor).toBeDefined(); + expect(survivor.deviceToken).toBe(t); + const cleared = all.filter(r => r.installationId !== 'multi-iid-d'); + expect(cleared.length).toBe(3); + cleared.forEach(r => { + expect(r.deviceToken).toBeUndefined(); + }); + }); }); describe('deviceToken deduplication on existing install update (deviceToken changes)', () => { diff --git a/src/InstallationDedup.js b/src/InstallationDedup.js index ae5d04ff8c..391b29def4 100644 --- a/src/InstallationDedup.js +++ b/src/InstallationDedup.js @@ -40,8 +40,9 @@ async function performAction({ CLASS_NAME, query, { [fieldToClear]: { __op: 'Delete' } }, - runOptions, - many, + { ...runOptions, many }, + false, + false, validSchemaController ); } From ea95995b91984d616cec378933d937f66d3abb65 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:56:29 +0100 Subject: [PATCH 11/11] test: fix installation dedup fixture to work on Postgres --- spec/ParseInstallation.spec.js | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 948dd9e98b..261733c3af 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1425,11 +1425,19 @@ describe('Installations', () => { it('action="update" clears deviceToken on ALL matching rows (multi-row update)', async () => { await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); const t = randomUUID(); - // Insert three rows directly via the storage adapter so they all hold the - // same deviceToken simultaneously, bypassing the sequential REST dedup - // that would otherwise prevent this state. + // First REST create ensures the storage class/table exists before direct + // adapter inserts (relevant for Postgres, which creates tables lazily). + await rest.create(config, auth.master(config), '_Installation', { + deviceType: 'ios', + deviceToken: t, + installationId: 'multi-iid-a', + channels: ['c-multi-iid-a'], + }); + // Insert two more rows directly via the storage adapter so all three hold + // the same deviceToken simultaneously — bypassing the sequential REST + // dedup that would otherwise prevent this state. const adapter = config.database.adapter; - for (const iid of ['multi-iid-a', 'multi-iid-b', 'multi-iid-c']) { + for (const iid of ['multi-iid-b', 'multi-iid-c']) { await adapter.createObject( '_Installation', installationSchema, @@ -1439,8 +1447,6 @@ describe('Installations', () => { deviceToken: t, installationId: iid, channels: ['c-' + iid], - _created_at: new Date(), - _updated_at: new Date(), }, null ); @@ -1606,8 +1612,6 @@ describe('Installations', () => { deviceType: 'ios', deviceToken: t, channels: bChannels, - _created_at: new Date(), - _updated_at: new Date(), }, null );