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 diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index d9267e58d2..1a3cbb4b55 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -18,6 +18,7 @@ const nestedOptionTypes = [ 'FileDownloadOptions', 'FileUploadOptions', 'IdempotencyOptions', + 'InstallationOptions', 'Object', 'PagesCustomUrlsOptions', 'PagesOptions', @@ -39,6 +40,7 @@ const nestedOptionEnvPrefix = { FileDownloadOptions: 'PARSE_SERVER_FILE_DOWNLOAD_', FileUploadOptions: 'PARSE_SERVER_FILE_UPLOAD_', IdempotencyOptions: 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_', + InstallationOptions: 'PARSE_SERVER_INSTALLATION_', LiveQueryOptions: 'PARSE_SERVER_LIVEQUERY_', LiveQueryServerOptions: 'PARSE_LIVE_QUERY_SERVER_', LogClientEvent: 'PARSE_SERVER_DATABASE_LOG_CLIENT_EVENTS_', 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', + }) + ); + }); }); diff --git a/spec/InstallationDedup.spec.js b/spec/InstallationDedup.spec.js new file mode 100644 index 0000000000..c73c574cf6 --- /dev/null +++ b/spec/InstallationDedup.spec.js @@ -0,0 +1,319 @@ +'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 in options', 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' } }, + jasmine.objectContaining({ many: true }), + false, + false, + 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' } }, + jasmine.objectContaining({ many: false }), + false, + 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' } }, + jasmine.objectContaining({ many: false }), + false, + 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('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()), + }; + 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/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 408e8fa7bf..261733c3af 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1297,4 +1297,485 @@ 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('deviceToken deduplication on new install (no installationId match)', () => { + 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 rows', async () => { + const t = randomUUID(); + 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 = randomUUID(); + 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 = randomUUID(); + 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 = randomUUID(); + 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'); + }); + + it('action="update" clears deviceToken on ALL matching rows (multi-row update)', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t = randomUUID(); + // 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-b', 'multi-iid-c']) { + await adapter.createObject( + '_Installation', + installationSchema, + { + objectId: 'oid-' + iid, + deviceType: 'ios', + deviceToken: t, + installationId: iid, + channels: ['c-' + iid], + }, + 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)', () => { + 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('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, + }, + 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(); + }); + + it('should accept fully specified valid config', async () => { + await expectAsync( + reconfigureServer({ + installation: { + duplicateDeviceTokenActionEnforceAuth: true, + duplicateDeviceTokenAction: 'update', + duplicateDeviceTokenMergePriority: 'installationId', + }, + }) + ).toBeResolved(); + }); + + it('should reject non-object values', async () => { + await expectAsync( + reconfigureServer({ installation: 'invalid' }) + ).toBeRejectedWith('installation must be an object.'); + }); + + it('should reject array values', async () => { + await expectAsync( + reconfigureServer({ installation: [] }) + ).toBeRejectedWith('installation must be an object.'); + }); + + it('should reject unknown nested keys', async () => { + await expectAsync( + reconfigureServer({ + installation: { unknownKey: 'foo' }, + }) + ).toBeRejectedWith("installation contains unknown property 'unknownKey'."); + }); + + it('should reject non-boolean duplicateDeviceTokenActionEnforceAuth', async () => { + await expectAsync( + reconfigureServer({ + installation: { duplicateDeviceTokenActionEnforceAuth: 'true' }, + }) + ).toBeRejectedWith('installation.duplicateDeviceTokenActionEnforceAuth must be a boolean.'); + }); + + it('should reject invalid duplicateDeviceTokenAction value', async () => { + await expectAsync( + reconfigureServer({ + installation: { duplicateDeviceTokenAction: 'merge' }, + }) + ).toBeRejectedWith( + "installation.duplicateDeviceTokenAction must be one of: 'delete', 'update'." + ); + }); + + it('should reject invalid duplicateDeviceTokenMergePriority value', async () => { + await expectAsync( + reconfigureServer({ + installation: { duplicateDeviceTokenMergePriority: 'objectId' }, + }) + ).toBeRejectedWith( + "installation.duplicateDeviceTokenMergePriority must be one of: 'deviceToken', 'installationId'." + ); + }); + + it('should apply defaults for missing nested keys', async () => { + await reconfigureServer({ + installation: { duplicateDeviceTokenActionEnforceAuth: true }, + }); + const config = Config.get('test'); + 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.installation).toEqual({ + duplicateDeviceTokenActionEnforceAuth: false, + duplicateDeviceTokenAction: 'delete', + duplicateDeviceTokenMergePriority: 'deviceToken', + }); + }); + }); }); diff --git a/src/Config.js b/src/Config.js index dad033dd1d..95543c6c6b 100644 --- a/src/Config.js +++ b/src/Config.js @@ -15,6 +15,7 @@ import { FileDownloadOptions, FileUploadOptions, IdempotencyOptions, + InstallationOptions, LiveQueryOptions, LogLevels, PagesOptions, @@ -147,6 +148,7 @@ export class Config { requestComplexity, liveQuery, routeAllowList, + installation, }) { 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.validateInstallation(installation); } static validateCustomPages(customPages) { @@ -711,6 +714,45 @@ export class Config { } } + static validateInstallation(installation) { + if (installation === undefined) { + return; + } + 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(installation)) { + if (!validKeys.includes(key)) { + throw `installation contains unknown property '${key}'.`; + } + } + 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 (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 (installation.duplicateDeviceTokenMergePriority === undefined) { + installation.duplicateDeviceTokenMergePriority = + InstallationOptions.duplicateDeviceTokenMergePriority.default; + } else if (!validPriorities.includes(installation.duplicateDeviceTokenMergePriority)) { + throw "installation.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..5eb78c88c0 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: 'installation.duplicateDeviceTokenActionEnforceAuth', + changeNewDefault: 'true', + 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/InstallationDedup.js b/src/InstallationDedup.js new file mode 100644 index 0000000000..391b29def4 --- /dev/null +++ b/src/InstallationDedup.js @@ -0,0 +1,176 @@ +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 }, + false, + false, + 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, +}) { + // 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; + 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, +}; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 77e5011354..277ba9a477 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -322,6 +322,13 @@ module.exports.ParseServerOptions = { type: 'IdempotencyOptions', default: {}, }, + installation: { + env: 'PARSE_SERVER_INSTALLATION', + help: 'Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`.', + action: parsers.objectParser, + type: 'InstallationOptions', + default: {}, + }, javascriptKey: { env: 'PARSE_SERVER_JAVASCRIPT_KEY', help: 'Key for the Javascript SDK', @@ -766,6 +773,24 @@ module.exports.RequestComplexityOptions = { default: -1, }, }; +module.exports.InstallationOptions = { + duplicateDeviceTokenAction: { + 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_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_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', + }, +}; module.exports.SecurityOptions = { checkGroups: { env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS', diff --git a/src/Options/docs.js b/src/Options/docs.js index 09aff594d2..f3c454e763 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 {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 @@ -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 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'`. + */ + /** * @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..e1266d239a 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_INSTALLATION + :DEFAULT: {} */ + installation: ?InstallationOptions; /* Query-related server defaults. :ENV: PARSE_SERVER_QUERY :DEFAULT: {} */ @@ -483,6 +487,18 @@ export interface RequestComplexityOptions { batchRequestLimit: ?number; } +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; + /* 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 */ diff --git a/src/RestWrite.js b/src/RestWrite.js index 6fd6bff94b..6d3c0d35a9 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,35 +1459,32 @@ 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']) { // 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 @@ -1517,14 +1515,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;