From 156a1658a3b44961c86c3fff64e9459cf244b73d Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 26 May 2026 15:51:18 -0400 Subject: [PATCH 1/5] fix: prevent cached commerce attributes in Rokt placements --- src/Rokt-Kit.ts | 60 +++++++++++++++++++-- test/src/tests.spec.ts | 117 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 4 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 2af1cab..c5026a3 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -240,6 +240,31 @@ const ROKT_THANK_YOU_JOURNEY_EXTENSION = 'ThankYouPageJourney'; const ROKT_INTEGRATION_SCRIPT_ID = 'rokt-launcher'; const ROKT_THANK_YOU_ELEMENT_SCRIPT_ID = 'rokt-thank-you-element'; const USER_IDENTIFIED_IN_WORKSPACE_KEY = 'userIdentifiedInWorkspace'; +const SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_LIST = [ + 'billingaddress1', + 'billingaddress2', + 'billingcity', + 'billingstate', + 'billingzipcode', + 'cartitems', + 'ccbin', + 'confirmationref', + 'country', + 'couponcode', + 'currency', + 'language', + 'paymentserviceprovider', + 'paymentserviceproviderattribute', + 'paymenttype', + 'shippingaddress1', + 'shippingcity', + 'shippingcountry', + 'shippingmethod', + 'shippingstate', + 'shippingzipcode', + 'totalprice', +]; +const SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_SET = new Set(SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_LIST); // Bound on how long selectPlacements will wait for an in-flight Workspace // IDSync search before proceeding without the userIdentifiedInWorkspace flag. @@ -436,6 +461,27 @@ function isString(value: unknown): value is string { return typeof value === 'string'; } +function isSelectPlacementsAttributePersistenceDenied(key: string): boolean { + return SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_SET.has(key.toLowerCase()); +} + +function removeSelectPlacementsAttributePersistenceDeniedAttributes( + attributes: Record | null | undefined, +): Record { + const filteredAttributes: Record = {}; + const sourceAttributes = attributes || {}; + const attributeKeys = Object.keys(sourceAttributes); + + for (let i = 0; i < attributeKeys.length; i++) { + const key = attributeKeys[i]; + if (!isSelectPlacementsAttributePersistenceDenied(key)) { + filteredAttributes[key] = sourceAttributes[key]; + } + } + + return filteredAttributes; +} + function generateIntegrationName(customIntegrationName?: string): string { const coreSdkVersion = mp().getVersion(); const kitVersion = process.env.PACKAGE_VERSION; @@ -1091,7 +1137,7 @@ class RoktKit implements KitInterface { ): string { const kitSettings = settings as unknown as RoktKitSettings; const accountId = kitSettings.accountId; - this.userAttributes = filteredUserAttributes || {}; + this.userAttributes = removeSelectPlacementsAttributePersistenceDeniedAttributes(filteredUserAttributes); this._onboardingExpProvider = kitSettings.onboardingExpProvider; const placementEventMapping = parseSettingsString(kitSettings.placementEventMapping); @@ -1245,6 +1291,11 @@ class RoktKit implements KitInterface { } public setUserAttribute(key: string, value: unknown): string { + if (isSelectPlacementsAttributePersistenceDenied(key)) { + this.userAttributes = removeSelectPlacementsAttributePersistenceDeniedAttributes(this.userAttributes); + return 'Successfully set user attribute for forwarder: ' + name; + } + this.userAttributes[key] = value; return 'Successfully set user attribute for forwarder: ' + name; } @@ -1256,7 +1307,7 @@ class RoktKit implements KitInterface { private handleIdentityComplete(user: IMParticleUser, eventType: RoktIdentityEventType, callbackName: string): string { const filteredUser = user as FilteredUser; - this.userAttributes = user.getAllUserAttributes(); + this.userAttributes = removeSelectPlacementsAttributePersistenceDeniedAttributes(user.getAllUserAttributes()); this.pendingIdentityEvents.push(this.buildIdentityEvent(eventType, filteredUser)); return 'Successfully called ' + callbackName + ' for forwarder: ' + name; } @@ -1402,7 +1453,8 @@ class RoktKit implements KitInterface { private _dispatchPlacements(options: Record): RoktSelection | Promise | undefined { const attributes = ((options && (options.attributes as Record)) || {}) as Record; - const placementAttributes: Record = { ...this.userAttributes, ...attributes }; + const cachedUserAttributes = removeSelectPlacementsAttributePersistenceDeniedAttributes(this.userAttributes); + const placementAttributes: Record = { ...cachedUserAttributes, ...attributes }; const filters = this.filters || {}; const userAttributeFilters = (filters.userAttributeFilters as string[]) || []; @@ -1420,7 +1472,7 @@ class RoktKit implements KitInterface { filteredAttributes = placementAttributes; } - this.userAttributes = filteredAttributes; + this.userAttributes = removeSelectPlacementsAttributePersistenceDeniedAttributes(filteredAttributes); const optimizelyAttributes = this._onboardingExpProvider === 'Optimizely' ? this.fetchOptimizely() : {}; diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index d542657..870b25e 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -1098,6 +1098,123 @@ describe('Rokt Forwarder', () => { }, }); }); + + it('should not send denylisted commerce attributes from the cached user attributes', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + { + confirmationRef: 'previous-order', + PaymentServiceProviderAttribute: 'cached-provider', + totalPrice: '10.00', + couponCode: 'SAVE10', + shippingMethod: 'ground', + loyaltyTier: 'gold', + }, + ); + + await (window as any).mParticle.forwarder.selectPlacements({ + identifier: 'test-placement', + attributes: { + page: 'checkout', + }, + }); + + expect((window as any).Rokt.selectPlacementsCalled).toBe(true); + expect((window as any).Rokt.selectPlacementsOptions).toEqual({ + identifier: 'test-placement', + attributes: { + loyaltyTier: 'gold', + page: 'checkout', + mpid: '123', + }, + }); + expect((window as any).mParticle.forwarder.userAttributes).toEqual({ + loyaltyTier: 'gold', + page: 'checkout', + }); + }); + + it('should allow explicit commerce attributes for the current call without caching them', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + { + loyaltyTier: 'gold', + }, + ); + + await (window as any).mParticle.forwarder.selectPlacements({ + identifier: 'test-placement', + attributes: { + confirmationRef: 'current-order', + paymentServiceProviderAttribute: 'current-provider', + totalPrice: '10.00', + couponCode: 'SAVE10', + shippingMethod: 'ground', + page: 'checkout', + }, + }); + + expect((window as any).Rokt.selectPlacementsCalled).toBe(true); + expect((window as any).Rokt.selectPlacementsOptions).toEqual({ + identifier: 'test-placement', + attributes: { + loyaltyTier: 'gold', + confirmationRef: 'current-order', + paymentServiceProviderAttribute: 'current-provider', + totalPrice: '10.00', + couponCode: 'SAVE10', + shippingMethod: 'ground', + page: 'checkout', + mpid: '123', + }, + }); + expect((window as any).mParticle.forwarder.userAttributes).toEqual({ + loyaltyTier: 'gold', + page: 'checkout', + }); + }); + + it('should not cache denylisted commerce attributes set through setUserAttribute', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.setUserAttribute('paymentServiceProviderAttribute', 'cached-provider'); + (window as any).mParticle.forwarder.setUserAttribute('favoriteStore', 'test-store'); + + await (window as any).mParticle.forwarder.selectPlacements({ + identifier: 'test-placement', + attributes: {}, + }); + + expect((window as any).Rokt.selectPlacementsCalled).toBe(true); + expect((window as any).Rokt.selectPlacementsOptions).toEqual({ + identifier: 'test-placement', + attributes: { + favoriteStore: 'test-store', + mpid: '123', + }, + }); + expect((window as any).mParticle.forwarder.userAttributes).toEqual({ + favoriteStore: 'test-store', + }); + }); }); describe('Identity handling', () => { From 2fc6e51e1df082139308392481e43782dd7f9235 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 26 May 2026 16:48:31 -0400 Subject: [PATCH 2/5] test: cover denylisted identity attributes --- src/Rokt-Kit.ts | 7 ++----- test/src/tests.spec.ts | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index c5026a3..5267c72 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -1291,12 +1291,9 @@ class RoktKit implements KitInterface { } public setUserAttribute(key: string, value: unknown): string { - if (isSelectPlacementsAttributePersistenceDenied(key)) { - this.userAttributes = removeSelectPlacementsAttributePersistenceDeniedAttributes(this.userAttributes); - return 'Successfully set user attribute for forwarder: ' + name; + if (!isSelectPlacementsAttributePersistenceDenied(key)) { + this.userAttributes[key] = value; } - - this.userAttributes[key] = value; return 'Successfully set user attribute for forwarder: ' + name; } diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 870b25e..a6dd32e 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -3106,6 +3106,29 @@ describe('Rokt Forwarder', () => { }); expect((window as any).mParticle.forwarder.filters.filteredUser.getMPID()).toBe('123'); }); + + it('should not cache denylisted commerce attributes from the filtered user', () => { + (window as any).mParticle.forwarder.onUserIdentified({ + getAllUserAttributes: function () { + return { + confirmationRef: 'previous-order', + currency: 'USD', + paymentServiceProvider: 'test-provider', + 'test-attribute': 'test-value', + }; + }, + getMPID: function () { + return '123'; + }, + getUserIdentities: function () { + return { userIdentities: {} }; + }, + }); + + expect((window as any).mParticle.forwarder.userAttributes).toEqual({ + 'test-attribute': 'test-value', + }); + }); }); describe('#workspaceIdSync', () => { From b3573d689b25b9673c5f540f44f653f8678e0818 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 26 May 2026 21:59:26 -0400 Subject: [PATCH 3/5] fix: add conversion type to Rokt placement denylist --- src/Rokt-Kit.ts | 1 + test/src/tests.spec.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 5267c72..bc65ee6 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -249,6 +249,7 @@ const SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_LIST = [ 'cartitems', 'ccbin', 'confirmationref', + 'conversiontype', 'country', 'couponcode', 'currency', diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index a6dd32e..316fd9e 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -1109,6 +1109,7 @@ describe('Rokt Forwarder', () => { null, { confirmationRef: 'previous-order', + conversionType: 'purchase', PaymentServiceProviderAttribute: 'cached-provider', totalPrice: '10.00', couponCode: 'SAVE10', @@ -1156,6 +1157,7 @@ describe('Rokt Forwarder', () => { identifier: 'test-placement', attributes: { confirmationRef: 'current-order', + conversionType: 'purchase', paymentServiceProviderAttribute: 'current-provider', totalPrice: '10.00', couponCode: 'SAVE10', @@ -1170,6 +1172,7 @@ describe('Rokt Forwarder', () => { attributes: { loyaltyTier: 'gold', confirmationRef: 'current-order', + conversionType: 'purchase', paymentServiceProviderAttribute: 'current-provider', totalPrice: '10.00', couponCode: 'SAVE10', @@ -3112,6 +3115,7 @@ describe('Rokt Forwarder', () => { getAllUserAttributes: function () { return { confirmationRef: 'previous-order', + conversionType: 'purchase', currency: 'USD', paymentServiceProvider: 'test-provider', 'test-attribute': 'test-value', From 44af4f29c956e74e76e2ca26c8c024e3bce8bde9 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 27 May 2026 10:33:34 -0400 Subject: [PATCH 4/5] export functions and add unit tests --- src/Rokt-Kit.ts | 4 ++-- test/src/tests.spec.ts | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index bc65ee6..0b5d5cb 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -462,11 +462,11 @@ function isString(value: unknown): value is string { return typeof value === 'string'; } -function isSelectPlacementsAttributePersistenceDenied(key: string): boolean { +export function isSelectPlacementsAttributePersistenceDenied(key: string): boolean { return SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_SET.has(key.toLowerCase()); } -function removeSelectPlacementsAttributePersistenceDeniedAttributes( +export function removeSelectPlacementsAttributePersistenceDeniedAttributes( attributes: Record | null | undefined, ): Record { const filteredAttributes: Record = {}; diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 316fd9e..c5a969f 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -1,6 +1,10 @@ import packageJson from '../../package.json'; const packageVersion = packageJson.version; import '../../src/Rokt-Kit'; +import { + isSelectPlacementsAttributePersistenceDenied, + removeSelectPlacementsAttributePersistenceDeniedAttributes, +} from '../../src/Rokt-Kit'; import { Batch } from '@mparticle/web-sdk/internal'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -6333,6 +6337,50 @@ describe('Rokt Forwarder', () => { }); }); + describe('#isSelectPlacementsAttributePersistenceDenied', () => { + it('should identify denylisted attributes case-insensitively', () => { + expect(isSelectPlacementsAttributePersistenceDenied('confirmationref')).toBe(true); + expect(isSelectPlacementsAttributePersistenceDenied('confirmationRef')).toBe(true); + expect(isSelectPlacementsAttributePersistenceDenied('CONFIRMATIONREF')).toBe(true); + expect(isSelectPlacementsAttributePersistenceDenied('paymentServiceProvider')).toBe(true); + expect(isSelectPlacementsAttributePersistenceDenied('cartItems')).toBe(true); + expect(isSelectPlacementsAttributePersistenceDenied('conversionType')).toBe(true); + }); + + it('should return false for attributes that are not denylisted', () => { + expect(isSelectPlacementsAttributePersistenceDenied('loyaltyTier')).toBe(false); + expect(isSelectPlacementsAttributePersistenceDenied('favoriteStore')).toBe(false); + }); + }); + + describe('#removeSelectPlacementsAttributePersistenceDeniedAttributes', () => { + it('should remove denylisted attributes case-insensitively', () => { + const attributes = { + confirmationRef: 'previous-order', + PaymentServiceProvider: 'test-provider', + cartItems: [{ sku: 'test-sku' }], + conversionType: 'purchase', + loyaltyTier: 'gold', + }; + + expect(removeSelectPlacementsAttributePersistenceDeniedAttributes(attributes)).toEqual({ + loyaltyTier: 'gold', + }); + expect(attributes).toEqual({ + confirmationRef: 'previous-order', + PaymentServiceProvider: 'test-provider', + cartItems: [{ sku: 'test-sku' }], + conversionType: 'purchase', + loyaltyTier: 'gold', + }); + }); + + it('should return an empty object for null or undefined attributes', () => { + expect(removeSelectPlacementsAttributePersistenceDeniedAttributes(null)).toEqual({}); + expect(removeSelectPlacementsAttributePersistenceDeniedAttributes(undefined)).toEqual({}); + }); + }); + describe('#hashEventMessage', () => { it('should hash event message using generateHash in the proper order', () => { const eventName = 'Test Event'; From 3658f4d0bc19733f20d80039a45989c0e1a53935 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 27 May 2026 10:40:40 -0400 Subject: [PATCH 5/5] test: cover placement denylist helpers --- src/Rokt-Kit.ts | 51 ++------------------- src/selectPlacementsAttributePersistence.ts | 47 +++++++++++++++++++ test/src/tests.spec.ts | 2 +- 3 files changed, 52 insertions(+), 48 deletions(-) create mode 100644 src/selectPlacementsAttributePersistence.ts diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 0b5d5cb..adc3e03 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -21,6 +21,10 @@ import type { IUserIdentities } from '@mparticle/web-sdk'; // BaseEvent not re-exported from @mparticle/web-sdk/internal, so we import directly from @mparticle/event-models. import { BaseEvent, CommerceEvent } from '@mparticle/event-models'; +import { + isSelectPlacementsAttributePersistenceDenied, + removeSelectPlacementsAttributePersistenceDeniedAttributes, +} from './selectPlacementsAttributePersistence'; interface RoktKitSettings { accountId: string; @@ -240,32 +244,6 @@ const ROKT_THANK_YOU_JOURNEY_EXTENSION = 'ThankYouPageJourney'; const ROKT_INTEGRATION_SCRIPT_ID = 'rokt-launcher'; const ROKT_THANK_YOU_ELEMENT_SCRIPT_ID = 'rokt-thank-you-element'; const USER_IDENTIFIED_IN_WORKSPACE_KEY = 'userIdentifiedInWorkspace'; -const SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_LIST = [ - 'billingaddress1', - 'billingaddress2', - 'billingcity', - 'billingstate', - 'billingzipcode', - 'cartitems', - 'ccbin', - 'confirmationref', - 'conversiontype', - 'country', - 'couponcode', - 'currency', - 'language', - 'paymentserviceprovider', - 'paymentserviceproviderattribute', - 'paymenttype', - 'shippingaddress1', - 'shippingcity', - 'shippingcountry', - 'shippingmethod', - 'shippingstate', - 'shippingzipcode', - 'totalprice', -]; -const SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_SET = new Set(SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_LIST); // Bound on how long selectPlacements will wait for an in-flight Workspace // IDSync search before proceeding without the userIdentifiedInWorkspace flag. @@ -462,27 +440,6 @@ function isString(value: unknown): value is string { return typeof value === 'string'; } -export function isSelectPlacementsAttributePersistenceDenied(key: string): boolean { - return SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_SET.has(key.toLowerCase()); -} - -export function removeSelectPlacementsAttributePersistenceDeniedAttributes( - attributes: Record | null | undefined, -): Record { - const filteredAttributes: Record = {}; - const sourceAttributes = attributes || {}; - const attributeKeys = Object.keys(sourceAttributes); - - for (let i = 0; i < attributeKeys.length; i++) { - const key = attributeKeys[i]; - if (!isSelectPlacementsAttributePersistenceDenied(key)) { - filteredAttributes[key] = sourceAttributes[key]; - } - } - - return filteredAttributes; -} - function generateIntegrationName(customIntegrationName?: string): string { const coreSdkVersion = mp().getVersion(); const kitVersion = process.env.PACKAGE_VERSION; diff --git a/src/selectPlacementsAttributePersistence.ts b/src/selectPlacementsAttributePersistence.ts new file mode 100644 index 0000000..3d3ccf6 --- /dev/null +++ b/src/selectPlacementsAttributePersistence.ts @@ -0,0 +1,47 @@ +const SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_LIST = [ + 'billingaddress1', + 'billingaddress2', + 'billingcity', + 'billingstate', + 'billingzipcode', + 'cartitems', + 'ccbin', + 'confirmationref', + 'conversiontype', + 'country', + 'couponcode', + 'currency', + 'language', + 'paymentserviceprovider', + 'paymentserviceproviderattribute', + 'paymenttype', + 'shippingaddress1', + 'shippingcity', + 'shippingcountry', + 'shippingmethod', + 'shippingstate', + 'shippingzipcode', + 'totalprice', +]; +const SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_SET = new Set(SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_LIST); + +export function isSelectPlacementsAttributePersistenceDenied(key: string): boolean { + return SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_SET.has(key.toLowerCase()); +} + +export function removeSelectPlacementsAttributePersistenceDeniedAttributes( + attributes: Record | null | undefined, +): Record { + const filteredAttributes: Record = {}; + const sourceAttributes = attributes || {}; + const attributeKeys = Object.keys(sourceAttributes); + + for (let i = 0; i < attributeKeys.length; i++) { + const key = attributeKeys[i]; + if (!isSelectPlacementsAttributePersistenceDenied(key)) { + filteredAttributes[key] = sourceAttributes[key]; + } + } + + return filteredAttributes; +} diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index c5a969f..4cb02ed 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -4,7 +4,7 @@ import '../../src/Rokt-Kit'; import { isSelectPlacementsAttributePersistenceDenied, removeSelectPlacementsAttributePersistenceDeniedAttributes, -} from '../../src/Rokt-Kit'; +} from '../../src/selectPlacementsAttributePersistence'; import { Batch } from '@mparticle/web-sdk/internal'; /* eslint-disable @typescript-eslint/no-explicit-any */