From 7439c4a31ab2e29ad9578619171a60fb1ccdc19c Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 15:31:09 -0400 Subject: [PATCH 1/7] feat: Add Advertiser IDSync search on user identification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a kit setting `advertiserIdSyncApiKey` is provided, the kit calls mParticle.Identity.searchAdvertiser(apiKey, { email }, callback) from onUserIdentified. On a 200 response the kit sets the local user attribute `userIdentifiedInAdvertiser = true` so downstream placement selection can target users whose identity is matched in the advertiser workspace. The setting is a string (the advertiser workspace's API key) rather than a boolean — this lets a single SDK send searches against an arbitrary advertiser workspace without needing the SDK's own workspace to be reconfigured. Missing key, missing/invalid email, and a missing mParticle.Identity.searchAdvertiser implementation are all silently inert (no network call, no attribute set). Pairs with the corresponding mParticle Web SDK change that exposes mParticle.Identity.searchAdvertiser. Note: a Fastly CORS allow-list update is required separately for the x-mp-key header on /v1/search. Co-Authored-By: Claude Opus 4.7 (1M context) # Conflicts: # src/Rokt-Kit.ts --- src/Rokt-Kit.ts | 54 ++++++++++++- test/src/tests.spec.ts | 170 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 1 deletion(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 5f66df8..d769754 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -30,6 +30,7 @@ interface RoktKitSettings { loggingUrl?: string; errorUrl?: string; isLoggingEnabled?: string | boolean; + advertiserIdSyncApiKey?: string; } interface EventAttributeCondition { @@ -86,6 +87,23 @@ interface FilteredUser extends IMParticleUser { getUserIdentities?: () => { userIdentities: Record }; } +interface AdvertiserIdSyncResult { + httpCode: number; + body?: { + context?: string | null; + mpid?: string; + matched_identities?: Record; + is_ephemeral?: boolean; + is_logged_in?: boolean; + }; +} + +type AdvertiserIdSyncSearcher = ( + apiKey: string, + knownIdentities: { email: string }, + callback: (result: AdvertiserIdSyncResult) => void, +) => void; + interface KitFilters { userAttributeFilters?: string[]; filterUserAttributes?: (attributes: Record, filters?: string[]) => Record; @@ -134,6 +152,7 @@ interface MParticleExtended { loggedEvents?: Array>; _registerErrorReportingService?(service: ErrorReportingService): void; _registerLoggingService?(service: LoggingService): void; + Identity?: { searchAdvertiser?: AdvertiserIdSyncSearcher }; } interface TestHelpers { @@ -217,6 +236,7 @@ const ROKT_IDENTITY_EVENT_TYPE = { 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_ADVERTISER_KEY = 'userIdentifiedInAdvertiser'; type RoktIdentityEventType = (typeof ROKT_IDENTITY_EVENT_TYPE)[keyof typeof ROKT_IDENTITY_EVENT_TYPE]; @@ -686,6 +706,7 @@ class RoktKit implements KitInterface { private _onboardingExpProvider?: string; private _thankYouElementOnLoadCallback: (() => void) | null = null; private _isThankYouElementLoaded = false; + private _advertiserIdSyncApiKey?: string; // ---- Private helpers ---- @@ -1044,6 +1065,10 @@ class RoktKit implements KitInterface { this._mappedEmailSha256Key = kitSettings.hashedEmailUserIdentityType.toLowerCase(); } + this._advertiserIdSyncApiKey = isString(kitSettings.advertiserIdSyncApiKey) + ? kitSettings.advertiserIdSyncApiKey + : undefined; + const domain = mp().Rokt?.domain; const { roktExtensionsQueryParams, legacyRoktExtensions, loadThankYouElement } = extractRoktExtensionConfig( kitSettings.roktExtensions, @@ -1195,10 +1220,37 @@ class RoktKit implements KitInterface { } public onUserIdentified(user: IMParticleUser): string { - this.filters.filteredUser = user as FilteredUser; + const filteredUser = user as FilteredUser; + this.filters.filteredUser = filteredUser; + this.searchAdvertiser(filteredUser); return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.IDENTIFY, 'onUserIdentified'); } + private searchAdvertiser(filteredUser: FilteredUser): void { + const apiKey = this._advertiserIdSyncApiKey; + if (!apiKey) { + return; + } + const searchAdvertiser = mp().Identity?.searchAdvertiser; + if (typeof searchAdvertiser !== 'function') { + return; + } + const userIdentities = filteredUser.getUserIdentities ? filteredUser.getUserIdentities().userIdentities : null; + const email = userIdentities?.email; + if (!email || !isString(email)) { + return; + } + try { + searchAdvertiser(apiKey, { email }, (result: AdvertiserIdSyncResult) => { + if (result?.httpCode === 200) { + this.userAttributes[USER_IDENTIFIED_IN_ADVERTISER_KEY] = true; + } + }); + } catch (err) { + console.error('Rokt Kit: Advertiser IDSync search failed', err); + } + } + public onLoginComplete(user: IMParticleUser, _filteredIdentityRequest: unknown): string { return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.LOGIN, 'onLoginComplete'); } diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index cc8dc8f..e1de86a 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -2933,6 +2933,176 @@ describe('Rokt Forwarder', () => { }); }); + describe('#advertiserIdSync', () => { + const ADVERTISER_API_KEY = 'advertiser-key-abc123'; + + function makeUser(overrides: any = {}) { + return { + getAllUserAttributes: () => ({}), + getMPID: () => '123', + getUserIdentities: () => ({ userIdentities: { email: 'test@example.com' } }), + ...overrides, + }; + } + + let originalIdentity: any; + + beforeEach(() => { + originalIdentity = (window as any).mParticle.Identity; + }); + + afterEach(() => { + (window as any).mParticle.Identity = originalIdentity; + (window as any).mParticle.forwarder.userAttributes = {}; + }); + + it('should call Identity.searchAdvertiser with the configured api key and set userIdentifiedInAdvertiser when 200 returned', async () => { + let receivedApiKey: any = null; + let receivedKnownIdentities: any = null; + (window as any).mParticle.Identity = { + searchAdvertiser: (apiKey: any, knownIdentities: any, cb: any) => { + receivedApiKey = apiKey; + receivedKnownIdentities = knownIdentities; + cb({ httpCode: 200, body: { mpid: '999' } }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + + expect(receivedApiKey).toBe(ADVERTISER_API_KEY); + expect(receivedKnownIdentities).toEqual({ email: 'test@example.com' }); + expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBe(true); + }); + + it('should not set userIdentifiedInAdvertiser when search returns 404', async () => { + (window as any).mParticle.Identity = { + searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + cb({ httpCode: 404 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + + expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + }); + + it('should not call searchAdvertiser when advertiserIdSyncApiKey is missing', async () => { + let searchCalled = false; + (window as any).mParticle.Identity = { + searchAdvertiser: () => { + searchCalled = true; + }, + }; + + await (window as any).mParticle.forwarder.init({ accountId: '123456' }, reportService.cb, true, null, {}); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + + expect(searchCalled).toBe(false); + expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + }); + + it('should not call searchAdvertiser when advertiserIdSyncApiKey is an empty string', async () => { + let searchCalled = false; + (window as any).mParticle.Identity = { + searchAdvertiser: () => { + searchCalled = true; + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: '' }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + + expect(searchCalled).toBe(false); + expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + }); + + it('should not call searchAdvertiser when the user has no plain email identity', async () => { + let searchCalled = false; + (window as any).mParticle.Identity = { + searchAdvertiser: () => { + searchCalled = true; + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified( + makeUser({ getUserIdentities: () => ({ userIdentities: {} }) }), + ); + + expect(searchCalled).toBe(false); + expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + }); + + it('should not throw when Identity.searchAdvertiser is unavailable', async () => { + (window as any).mParticle.Identity = {}; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + expect(() => { + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + }).not.toThrow(); + expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + }); + + it('should swallow errors thrown by searchAdvertiser', async () => { + (window as any).mParticle.Identity = { + searchAdvertiser: () => { + throw new Error('boom'); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + expect(() => { + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + }).not.toThrow(); + expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + }); + }); + describe('#onLoginComplete', () => { it('should update userAttributes from the filtered user', () => { (window as any).mParticle.forwarder.onLoginComplete({ From 4e200acd5fa2e2ffc20045583b3a67d443fcd802 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 16:52:30 -0400 Subject: [PATCH 2/7] fix: Move userIdentifiedInAdvertiser flag off the userAttributes map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `searchAdvertiser` returned 200 we were writing the flag into `this.userAttributes` directly. That races with `handleIdentityComplete`, which runs synchronously after `searchAdvertiser` inside the same `onUserIdentified` flow and reassigns `userAttributes` wholesale via `user.getAllUserAttributes()` — wiping the flag we just set. Subsequent identity events or `setUserAttribute` calls hit the same window. Move the flag to a dedicated public field on the kit (`userIdentifiedInAdvertiser`) so it survives any reassignment of the attribute map. `selectPlacements` merges it back into the placement attributes payload using the same `userIdentifiedInAdvertiser` key on the wire — no consumer-visible behaviour change. Tests updated: - Existing assertions migrated from `userAttributes.userIdentifiedInAdvertiser` to `forwarder.userIdentifiedInAdvertiser`. - New regression test `should preserve the flag when handleIdentityComplete reassigns userAttributes` provides a `getAllUserAttributes()` mock that omits the flag and asserts the kit-class flag survives, that the attribute map deliberately does not contain the flag, and that other user attributes still flow through. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Rokt-Kit.ts | 12 +++++++++- test/src/tests.spec.ts | 52 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index d769754..ed7de6d 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -690,6 +690,11 @@ class RoktKit implements KitInterface { public launcher: RoktLauncher | null = null; public filters: KitFilters = {}; public userAttributes: Record = {}; + // Flag set by the Advertiser IDSync flow on a 200 response. Stored on the + // kit instance (rather than mutated into `userAttributes`) so it isn't + // wiped when handleIdentityComplete or setUserAttribute reassigns the + // attributes map. Merged into placement attributes inside selectPlacements. + public userIdentifiedInAdvertiser = false; public testHelpers: TestHelpers | null = null; public placementEventMappingLookup: Record = {}; public placementEventAttributeMappingLookup: Record = {}; @@ -1243,7 +1248,11 @@ class RoktKit implements KitInterface { try { searchAdvertiser(apiKey, { email }, (result: AdvertiserIdSyncResult) => { if (result?.httpCode === 200) { - this.userAttributes[USER_IDENTIFIED_IN_ADVERTISER_KEY] = true; + // Stored on the kit, not the userAttributes map. handleIdentityComplete + // reassigns userAttributes to user.getAllUserAttributes() in the same + // synchronous flow as onUserIdentified, which would wipe this flag if + // it were written there. selectPlacements merges it back in below. + this.userIdentifiedInAdvertiser = true; } }); } catch (err) { @@ -1299,6 +1308,7 @@ class RoktKit implements KitInterface { ...filteredAttributes, ...optimizelyAttributes, ...localSessionAttributes, + ...(this.userIdentifiedInAdvertiser ? { [USER_IDENTIFIED_IN_ADVERTISER_KEY]: true } : {}), mpid, }; diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index e1de86a..0e16f46 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -2954,6 +2954,7 @@ describe('Rokt Forwarder', () => { afterEach(() => { (window as any).mParticle.Identity = originalIdentity; (window as any).mParticle.forwarder.userAttributes = {}; + (window as any).mParticle.forwarder.userIdentifiedInAdvertiser = false; }); it('should call Identity.searchAdvertiser with the configured api key and set userIdentifiedInAdvertiser when 200 returned', async () => { @@ -2979,7 +2980,7 @@ describe('Rokt Forwarder', () => { expect(receivedApiKey).toBe(ADVERTISER_API_KEY); expect(receivedKnownIdentities).toEqual({ email: 'test@example.com' }); - expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBe(true); + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(true); }); it('should not set userIdentifiedInAdvertiser when search returns 404', async () => { @@ -2999,7 +3000,7 @@ describe('Rokt Forwarder', () => { (window as any).mParticle.forwarder.onUserIdentified(makeUser()); - expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); }); it('should not call searchAdvertiser when advertiserIdSyncApiKey is missing', async () => { @@ -3015,7 +3016,7 @@ describe('Rokt Forwarder', () => { (window as any).mParticle.forwarder.onUserIdentified(makeUser()); expect(searchCalled).toBe(false); - expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); }); it('should not call searchAdvertiser when advertiserIdSyncApiKey is an empty string', async () => { @@ -3037,7 +3038,7 @@ describe('Rokt Forwarder', () => { (window as any).mParticle.forwarder.onUserIdentified(makeUser()); expect(searchCalled).toBe(false); - expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); }); it('should not call searchAdvertiser when the user has no plain email identity', async () => { @@ -3061,7 +3062,7 @@ describe('Rokt Forwarder', () => { ); expect(searchCalled).toBe(false); - expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); }); it('should not throw when Identity.searchAdvertiser is unavailable', async () => { @@ -3078,7 +3079,7 @@ describe('Rokt Forwarder', () => { expect(() => { (window as any).mParticle.forwarder.onUserIdentified(makeUser()); }).not.toThrow(); - expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); }); it('should swallow errors thrown by searchAdvertiser', async () => { @@ -3099,7 +3100,46 @@ describe('Rokt Forwarder', () => { expect(() => { (window as any).mParticle.forwarder.onUserIdentified(makeUser()); }).not.toThrow(); + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); + }); + + it('should preserve the flag when handleIdentityComplete reassigns userAttributes', async () => { + // Race regression: the search response writes to a kit-class field, + // not the userAttributes map. handleIdentityComplete runs synchronously + // after searchAdvertiser inside onUserIdentified and does + // `userAttributes = user.getAllUserAttributes()`. If the flag lived in + // userAttributes it would be wiped here. It must not be. + (window as any).mParticle.Identity = { + searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + cb({ httpCode: 200, body: { mpid: '999' } }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + // The user's attribute set deliberately omits userIdentifiedInAdvertiser + // — simulating the real-world case where the advertiser search wrote + // the flag and then handleIdentityComplete reassigned userAttributes + // from a fresh getAllUserAttributes() call that doesn't include it. + (window as any).mParticle.forwarder.onUserIdentified( + makeUser({ + getAllUserAttributes: () => ({ 'preexisting-attr': 'value' }), + }), + ); + + // Kit-class flag survives the reassignment. + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(true); + // userAttributes does NOT contain the flag (and shouldn't — that's the + // whole point of moving it off the map). expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); + // userAttributes still reflects what the user object returned. + expect((window as any).mParticle.forwarder.userAttributes['preexisting-attr']).toBe('value'); }); }); From 8f5f73007b48b59f0b353ef91006dc4ac6d7062d Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 17:35:01 -0400 Subject: [PATCH 3/7] fix: Address ultrareview findings on Advertiser IDSync feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs flagged in remote review, plus a follow-up efficiency: 1. Race condition between searchAdvertiser and selectPlacements (ultrareview bug_005). onUserIdentified fired searchAdvertiser synchronously and returned before the HTTP response landed. Partners doing the canonical `Identity.login(...).then(() => Rokt.selectPlacements(...))` flow read userIdentifiedInAdvertiser before it could be set, missing the flag for the most important placement call. Fix: searchAdvertiser now returns a Promise that resolves when the SDK callback fires (or the short-circuit returns immediately). The latest in-flight Promise is stored on the kit. selectPlacements gates the existing dispatch logic on `Promise.race([inFlight, setTimeout(ADVERTISER_SEARCH_SELECT_TIMEOUT_MS)])` so the first placement call waits up to 1s for a real answer rather than racing the network. Stalled searches don't block placement rendering past the timeout. 2. Sticky userIdentifiedInAdvertiser flag (ultrareview bug_001). The flag was set true on a 200 but never reset, so logout sessions and short-circuit re-identifications carried the previous user's match forward — a privacy/correctness issue. Fix: reset the flag at every short-circuit path inside searchAdvertiser, and reset it explicitly in onLogoutComplete (which has no searchAdvertiser path of its own). 3. Cache by email so the same identity doesn't trigger redundant network calls. mParticle fires onUserIdentified on every identify/login/logout/modify, so the same email could trigger multiple /v1/search dispatches per session. Now searchAdvertiser caches the last successfully-dispatched email; if a subsequent identification arrives with the same email, we skip the network call and keep the existing flag. The cache is cleared on logout (so re-login dispatches fresh, avoiding stale answers across sessions) and on every short-circuit path. selectPlacements stays non-async (returns the existing RoktSelection | Promise | undefined union) because RoktSelection has an optional `then?` member which TS1058 rejects inside an async function's return type. The dispatch body moved to a private _dispatchPlacements helper so the wrapper can conditionally await the in-flight search. Tests: - should wait for an in-flight searchAdvertiser before selectPlacements builds attributes — race regression for bug_005, asserts the flag lands on the launcher's attributes payload after a deferred callback. - should reset userIdentifiedInAdvertiser on onLogoutComplete — bug_001 logout case. - should reset userIdentifiedInAdvertiser when re-identifying via a short-circuit path — bug_001 short-circuit case (new user with no email). - should not re-call Identity.searchAdvertiser when the same email re-identifies — verifies the email cache. - should re-call Identity.searchAdvertiser when the email changes — verifies cache invalidation on different email. - should re-call Identity.searchAdvertiser after logout even with the same email — verifies the logout cache clear. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Rokt-Kit.ts | 99 +++++++++++++++++---- test/src/tests.spec.ts | 195 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 17 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index ed7de6d..9c54d17 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -237,6 +237,11 @@ 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_ADVERTISER_KEY = 'userIdentifiedInAdvertiser'; +// Bound on how long selectPlacements will wait for an in-flight Advertiser +// IDSync search before proceeding without the userIdentifiedInAdvertiser flag. +// Long enough to cover the typical /v1/search round-trip; short enough that a +// stalled search never blocks placement rendering on a thank-you page. +const ADVERTISER_SEARCH_SELECT_TIMEOUT_MS = 1000; type RoktIdentityEventType = (typeof ROKT_IDENTITY_EVENT_TYPE)[keyof typeof ROKT_IDENTITY_EVENT_TYPE]; @@ -712,6 +717,16 @@ class RoktKit implements KitInterface { private _thankYouElementOnLoadCallback: (() => void) | null = null; private _isThankYouElementLoaded = false; private _advertiserIdSyncApiKey?: string; + // Promise that resolves once the most recent Advertiser IDSync search + // completes (or short-circuits). selectPlacements awaits this so the first + // call after onUserIdentified can include `userIdentifiedInAdvertiser` + // without racing the network response. + private _advertiserSearchInFlight: Promise | null = null; + // The email value sent in the most recent successful searchAdvertiser + // dispatch. If a subsequent identification arrives with the same email, + // we skip the network call (the flag is still correct from the prior + // search). Cleared on logout so a re-login re-evaluates fresh. + private _advertiserLastSearchedEmail?: string; // ---- Private helpers ---- @@ -1227,37 +1242,55 @@ class RoktKit implements KitInterface { public onUserIdentified(user: IMParticleUser): string { const filteredUser = user as FilteredUser; this.filters.filteredUser = filteredUser; - this.searchAdvertiser(filteredUser); + this._advertiserSearchInFlight = this.searchAdvertiser(filteredUser); return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.IDENTIFY, 'onUserIdentified'); } - private searchAdvertiser(filteredUser: FilteredUser): void { + private searchAdvertiser(filteredUser: FilteredUser): Promise { const apiKey = this._advertiserIdSyncApiKey; if (!apiKey) { - return; + this.userIdentifiedInAdvertiser = false; + this._advertiserLastSearchedEmail = undefined; + return Promise.resolve(); } const searchAdvertiser = mp().Identity?.searchAdvertiser; if (typeof searchAdvertiser !== 'function') { - return; + this.userIdentifiedInAdvertiser = false; + this._advertiserLastSearchedEmail = undefined; + return Promise.resolve(); } const userIdentities = filteredUser.getUserIdentities ? filteredUser.getUserIdentities().userIdentities : null; const email = userIdentities?.email; if (!email || !isString(email)) { - return; + this.userIdentifiedInAdvertiser = false; + this._advertiserLastSearchedEmail = undefined; + return Promise.resolve(); } - try { - searchAdvertiser(apiKey, { email }, (result: AdvertiserIdSyncResult) => { - if (result?.httpCode === 200) { - // Stored on the kit, not the userAttributes map. handleIdentityComplete - // reassigns userAttributes to user.getAllUserAttributes() in the same - // synchronous flow as onUserIdentified, which would wipe this flag if - // it were written there. selectPlacements merges it back in below. - this.userIdentifiedInAdvertiser = true; - } - }); - } catch (err) { - console.error('Rokt Kit: Advertiser IDSync search failed', err); + + // Same email as the last successful dispatch → skip the network call. + // The current flag value still reflects the correct match status. + if (email === this._advertiserLastSearchedEmail) { + return Promise.resolve(); } + + // New / different email → reset and re-search. Cache the email up front + // so a second concurrent invocation with the same email also dedupes. + this.userIdentifiedInAdvertiser = false; + this._advertiserLastSearchedEmail = email; + + return new Promise((resolve) => { + try { + searchAdvertiser(apiKey, { email }, (result: AdvertiserIdSyncResult) => { + if (result?.httpCode === 200) { + this.userIdentifiedInAdvertiser = true; + } + resolve(); + }); + } catch (err) { + console.error('Rokt Kit: Advertiser IDSync search failed', err); + resolve(); + } + }); } public onLoginComplete(user: IMParticleUser, _filteredIdentityRequest: unknown): string { @@ -1265,6 +1298,13 @@ class RoktKit implements KitInterface { } public onLogoutComplete(user: IMParticleUser, _filteredIdentityRequest: unknown): string { + // Anonymous sessions must not carry the previous user's match forward. + // Clear the flag explicitly here. Also clear the email cache so a + // re-login (possibly the same email) dispatches a fresh search rather + // than reusing a stale answer. + this.userIdentifiedInAdvertiser = false; + this._advertiserSearchInFlight = null; + this._advertiserLastSearchedEmail = undefined; return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.LOGOUT, 'onLogoutComplete'); } @@ -1274,8 +1314,33 @@ class RoktKit implements KitInterface { /** * Selects placements for Rokt Web SDK with merged attributes, filters, and experimentation options. + * + * If an Advertiser IDSync search is in flight from a recent onUserIdentified + * call, this method waits up to `ADVERTISER_SEARCH_SELECT_TIMEOUT_MS` for it + * to settle so the first placement call can include the + * `userIdentifiedInAdvertiser` flag without racing the network response. + * The timeout protects against a stalled or slow search blocking placement + * rendering — if it fires, selectPlacements proceeds without the flag. + * + * Implementation note: this method stays non-async (returns the existing + * `RoktSelection | Promise | undefined` union) because + * `RoktSelection` has an optional `then?` member, which TS1058 rejects as + * ambiguously promise-like inside an async function's return type. The + * inner work runs in `_dispatchPlacements`; this wrapper just gates it on + * the in-flight search. */ public selectPlacements(options: Record): RoktSelection | Promise | undefined { + if (this._advertiserSearchInFlight) { + const inFlight = this._advertiserSearchInFlight; + return Promise.race([ + inFlight, + new Promise((resolve) => setTimeout(resolve, ADVERTISER_SEARCH_SELECT_TIMEOUT_MS)), + ]).then(() => this._dispatchPlacements(options)) as Promise; + } + return this._dispatchPlacements(options); + } + + private _dispatchPlacements(options: Record): RoktSelection | Promise | undefined { const attributes = ((options && (options.attributes as Record)) || {}) as Record; const placementAttributes: Record = { ...this.userAttributes, ...attributes }; diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 0e16f46..9013016 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -3141,6 +3141,201 @@ describe('Rokt Forwarder', () => { // userAttributes still reflects what the user object returned. expect((window as any).mParticle.forwarder.userAttributes['preexisting-attr']).toBe('value'); }); + + it('should wait for an in-flight searchAdvertiser before selectPlacements builds attributes', async () => { + // Race regression: previously, onUserIdentified fired searchAdvertiser + // synchronously and returned. Partners doing + // `Identity.login(...).then(() => Rokt.selectPlacements(...))` would + // read the flag before the HTTP response landed, missing the flag for + // the most important placement call. Now selectPlacements awaits the + // in-flight search (with a timeout) before building attributes. + let triggerSearchResponse: () => void = () => undefined; + (window as any).mParticle.Identity = { + searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + // Defer the callback to simulate a real network round-trip. + triggerSearchResponse = () => cb({ httpCode: 200, body: { mpid: '999' } }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + // Stub launcher + filters so selectPlacements can dispatch. + let launcherCalledWithAttributes: any = null; + (window as any).mParticle.forwarder.launcher = { + selectPlacements: (opts: any) => { + launcherCalledWithAttributes = opts.attributes; + return { context: { sessionId: Promise.resolve('test-session') } }; + }, + }; + (window as any).mParticle.forwarder.filters = { + userAttributesFilters: [], + filterUserAttributes: (attributes: any) => attributes, + filteredUser: { getMPID: () => '123' }, + }; + + // Identification kicks off the search; callback NOT yet fired. + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + + // selectPlacements is invoked while the search is still in flight. + const placementPromise = (window as any).mParticle.forwarder.selectPlacements({ + identifier: 'test-placement', + attributes: {}, + }); + + // Resolve the search; selectPlacements should now proceed and merge the flag. + triggerSearchResponse(); + await placementPromise; + + expect(launcherCalledWithAttributes.userIdentifiedInAdvertiser).toBe(true); + }); + + it('should reset userIdentifiedInAdvertiser on onLogoutComplete', async () => { + (window as any).mParticle.Identity = { + searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + cb({ httpCode: 200 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(true); + + // onLogoutComplete must clear the flag so anonymous sessions don't + // carry the previous user's match forward — searchAdvertiser is only + // fired from onUserIdentified, so logout has no re-evaluation path. + (window as any).mParticle.forwarder.onLogoutComplete({ + getAllUserAttributes: () => ({}), + getMPID: () => '999', + }); + + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); + }); + + it('should reset userIdentifiedInAdvertiser when re-identifying via a short-circuit path', async () => { + // A previous identification matched (flag=true). The new user has no + // email, so searchAdvertiser short-circuits without dispatching. The + // flag must reset to false rather than leak from the previous user. + (window as any).mParticle.Identity = { + searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + cb({ httpCode: 200 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(true); + + (window as any).mParticle.forwarder.onUserIdentified( + makeUser({ getUserIdentities: () => ({ userIdentities: {} }) }), + ); + + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); + }); + + it('should not re-call Identity.searchAdvertiser when the same email re-identifies', async () => { + let searchCallCount = 0; + (window as any).mParticle.Identity = { + searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + searchCallCount += 1; + cb({ httpCode: 200 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + // Two identifications with the same email. Should dispatch only once; + // the cached email skips the second network call. + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + + expect(searchCallCount).toBe(1); + // Flag from the first match still correct after the second identify. + expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(true); + }); + + it('should re-call Identity.searchAdvertiser when the email changes', async () => { + let searchCallCount = 0; + const observedEmails: string[] = []; + (window as any).mParticle.Identity = { + searchAdvertiser: (_apiKey: any, knownIdentities: any, cb: any) => { + searchCallCount += 1; + observedEmails.push(knownIdentities.email); + cb({ httpCode: 200 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified( + makeUser({ getUserIdentities: () => ({ userIdentities: { email: 'a@example.com' } }) }), + ); + (window as any).mParticle.forwarder.onUserIdentified( + makeUser({ getUserIdentities: () => ({ userIdentities: { email: 'b@example.com' } }) }), + ); + + expect(searchCallCount).toBe(2); + expect(observedEmails).toEqual(['a@example.com', 'b@example.com']); + }); + + it('should re-call Identity.searchAdvertiser after logout even with the same email', async () => { + let searchCallCount = 0; + (window as any).mParticle.Identity = { + searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + searchCallCount += 1; + cb({ httpCode: 200 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + // Logout clears the email cache so a re-login re-evaluates. + (window as any).mParticle.forwarder.onLogoutComplete({ + getAllUserAttributes: () => ({}), + getMPID: () => '999', + }); + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + + expect(searchCallCount).toBe(2); + }); }); describe('#onLoginComplete', () => { From 0de7568d962cfab58f18b0912115979dcdd9397d Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 17:46:01 -0400 Subject: [PATCH 4/7] fix: Reset Advertiser IDSync cache on init and on dispatch failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two leaks in the email cache added in the previous IDSync fix: 1. searchAdvertiser set _advertiserLastSearchedEmail before calling the SDK. If the SDK threw synchronously, the email was cached as if it had been successfully dispatched — every subsequent identification with the same email short-circuited and the flag could never be re-evaluated. Clear the cache in the catch block so a failed dispatch does not poison future searches. 2. init reconfigures the kit (different api key, partner, session), but the advertiser search state was carried forward from the previous lifecycle. Reset userIdentifiedInAdvertiser, _advertiserSearchInFlight, and _advertiserLastSearchedEmail in init alongside the api-key assignment. The CI failure on PR #92 surfaced both: the "swallow errors" test poisoned the cache, then the "preserve the flag", "wait for in-flight", and reset/dedupe tests all read through that stale cache and saw the flag stuck at false. With this fix all 199 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Rokt-Kit.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 9c54d17..29d75ba 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -1088,6 +1088,12 @@ class RoktKit implements KitInterface { this._advertiserIdSyncApiKey = isString(kitSettings.advertiserIdSyncApiKey) ? kitSettings.advertiserIdSyncApiKey : undefined; + // init reconfigures the kit; any prior advertiser search state is no + // longer authoritative (different api key, different session, different + // partner page) and must not leak into the new lifecycle. + this.userIdentifiedInAdvertiser = false; + this._advertiserSearchInFlight = null; + this._advertiserLastSearchedEmail = undefined; const domain = mp().Rokt?.domain; const { roktExtensionsQueryParams, legacyRoktExtensions, loadThankYouElement } = extractRoktExtensionConfig( @@ -1288,6 +1294,10 @@ class RoktKit implements KitInterface { }); } catch (err) { console.error('Rokt Kit: Advertiser IDSync search failed', err); + // Dispatch failed — clear the cache so the same email can retry on + // the next identification rather than being stuck behind a poisoned + // entry that short-circuits future searches. + this._advertiserLastSearchedEmail = undefined; resolve(); } }); From e7db9d3ff7fef52393dfd411ea86a0610b48ea12 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 19:12:29 -0400 Subject: [PATCH 5/7] refactor: Rename Advertiser IDSync to Workspace IDSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "advertiser" terminology is Rokt-internal. The core mParticle SDK is renaming `Identity.searchAdvertiser` to `Identity.searchWorkspace` so the public surface reads naturally for any mParticle customer, not just Rokt — propagate the rename here. Kit changes (pre-release, no consumers yet): - `advertiserIdSyncApiKey` setting -> `workspaceIdSyncApiKey` - `userIdentifiedInAdvertiser` flag -> `userIdentifiedInWorkspace` - `USER_IDENTIFIED_IN_ADVERTISER_KEY` const -> `USER_IDENTIFIED_IN_WORKSPACE_KEY` - `AdvertiserIdSyncResult`/`AdvertiserIdSyncSearcher` types -> `WorkspaceIdSync*` - Private `searchAdvertiser` method + state fields renamed to `searchWorkspace`/`_workspace*` - Now calls `mp().Identity.searchWorkspace` (paired core-SDK rename). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Rokt-Kit.ts | 109 +++++++++++++-------------- test/src/tests.spec.ts | 165 +++++++++++++++++------------------------ 2 files changed, 117 insertions(+), 157 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 29d75ba..5f6562a 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -30,7 +30,7 @@ interface RoktKitSettings { loggingUrl?: string; errorUrl?: string; isLoggingEnabled?: string | boolean; - advertiserIdSyncApiKey?: string; + workspaceIdSyncApiKey?: string; } interface EventAttributeCondition { @@ -87,7 +87,7 @@ interface FilteredUser extends IMParticleUser { getUserIdentities?: () => { userIdentities: Record }; } -interface AdvertiserIdSyncResult { +interface WorkspaceIdSyncResult { httpCode: number; body?: { context?: string | null; @@ -98,10 +98,10 @@ interface AdvertiserIdSyncResult { }; } -type AdvertiserIdSyncSearcher = ( +type WorkspaceIdSyncSearcher = ( apiKey: string, knownIdentities: { email: string }, - callback: (result: AdvertiserIdSyncResult) => void, + callback: (result: WorkspaceIdSyncResult) => void, ) => void; interface KitFilters { @@ -152,7 +152,7 @@ interface MParticleExtended { loggedEvents?: Array>; _registerErrorReportingService?(service: ErrorReportingService): void; _registerLoggingService?(service: LoggingService): void; - Identity?: { searchAdvertiser?: AdvertiserIdSyncSearcher }; + Identity?: { searchWorkspace?: WorkspaceIdSyncSearcher }; } interface TestHelpers { @@ -236,12 +236,12 @@ const ROKT_IDENTITY_EVENT_TYPE = { 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_ADVERTISER_KEY = 'userIdentifiedInAdvertiser'; -// Bound on how long selectPlacements will wait for an in-flight Advertiser -// IDSync search before proceeding without the userIdentifiedInAdvertiser flag. -// Long enough to cover the typical /v1/search round-trip; short enough that a +const USER_IDENTIFIED_IN_WORKSPACE_KEY = 'userIdentifiedInWorkspace'; +// Bound on how long selectPlacements will wait for an in-flight Workspace +// IDSync search before proceeding without the userIdentifiedInWorkspace flag. +// Long enough to cover the typical /v1/search round-trip (~50ms); short enough that a // stalled search never blocks placement rendering on a thank-you page. -const ADVERTISER_SEARCH_SELECT_TIMEOUT_MS = 1000; +const WORKSPACE_SEARCH_SELECT_TIMEOUT_MS = 500; type RoktIdentityEventType = (typeof ROKT_IDENTITY_EVENT_TYPE)[keyof typeof ROKT_IDENTITY_EVENT_TYPE]; @@ -695,11 +695,9 @@ class RoktKit implements KitInterface { public launcher: RoktLauncher | null = null; public filters: KitFilters = {}; public userAttributes: Record = {}; - // Flag set by the Advertiser IDSync flow on a 200 response. Stored on the - // kit instance (rather than mutated into `userAttributes`) so it isn't - // wiped when handleIdentityComplete or setUserAttribute reassigns the - // attributes map. Merged into placement attributes inside selectPlacements. - public userIdentifiedInAdvertiser = false; + // Flag set by the Workspace IDSync flow on a 200 response. Stored on the + // kit instance and merged into placement attributes inside selectPlacements. + public userIdentifiedInWorkspace = false; public testHelpers: TestHelpers | null = null; public placementEventMappingLookup: Record = {}; public placementEventAttributeMappingLookup: Record = {}; @@ -716,17 +714,16 @@ class RoktKit implements KitInterface { private _onboardingExpProvider?: string; private _thankYouElementOnLoadCallback: (() => void) | null = null; private _isThankYouElementLoaded = false; - private _advertiserIdSyncApiKey?: string; - // Promise that resolves once the most recent Advertiser IDSync search - // completes (or short-circuits). selectPlacements awaits this so the first - // call after onUserIdentified can include `userIdentifiedInAdvertiser` - // without racing the network response. - private _advertiserSearchInFlight: Promise | null = null; - // The email value sent in the most recent successful searchAdvertiser + private _workspaceIdSyncApiKey?: string; + // Held during a searchWorkspace dispatch so the next selectPlacements call; + // can wait for the HTTP response before reading userIdentifiedInWorkspace; + // — otherwise the first placement call ships without the flag. + private _workspaceSearchInFlight: Promise | null = null; + // The email value sent in the most recent successful searchWorkspace // dispatch. If a subsequent identification arrives with the same email, // we skip the network call (the flag is still correct from the prior // search). Cleared on logout so a re-login re-evaluates fresh. - private _advertiserLastSearchedEmail?: string; + private _workspaceLastSearchedEmail?: string; // ---- Private helpers ---- @@ -1085,15 +1082,9 @@ class RoktKit implements KitInterface { this._mappedEmailSha256Key = kitSettings.hashedEmailUserIdentityType.toLowerCase(); } - this._advertiserIdSyncApiKey = isString(kitSettings.advertiserIdSyncApiKey) - ? kitSettings.advertiserIdSyncApiKey + this._workspaceIdSyncApiKey = isString(kitSettings.workspaceIdSyncApiKey) + ? kitSettings.workspaceIdSyncApiKey : undefined; - // init reconfigures the kit; any prior advertiser search state is no - // longer authoritative (different api key, different session, different - // partner page) and must not leak into the new lifecycle. - this.userIdentifiedInAdvertiser = false; - this._advertiserSearchInFlight = null; - this._advertiserLastSearchedEmail = undefined; const domain = mp().Rokt?.domain; const { roktExtensionsQueryParams, legacyRoktExtensions, loadThankYouElement } = extractRoktExtensionConfig( @@ -1248,56 +1239,56 @@ class RoktKit implements KitInterface { public onUserIdentified(user: IMParticleUser): string { const filteredUser = user as FilteredUser; this.filters.filteredUser = filteredUser; - this._advertiserSearchInFlight = this.searchAdvertiser(filteredUser); + this._workspaceSearchInFlight = this.searchWorkspace(filteredUser); return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.IDENTIFY, 'onUserIdentified'); } - private searchAdvertiser(filteredUser: FilteredUser): Promise { - const apiKey = this._advertiserIdSyncApiKey; + private searchWorkspace(filteredUser: FilteredUser): Promise { + const apiKey = this._workspaceIdSyncApiKey; if (!apiKey) { - this.userIdentifiedInAdvertiser = false; - this._advertiserLastSearchedEmail = undefined; + this.userIdentifiedInWorkspace = false; + this._workspaceLastSearchedEmail = undefined; return Promise.resolve(); } - const searchAdvertiser = mp().Identity?.searchAdvertiser; - if (typeof searchAdvertiser !== 'function') { - this.userIdentifiedInAdvertiser = false; - this._advertiserLastSearchedEmail = undefined; + const searchWorkspace = mp().Identity?.searchWorkspace; + if (typeof searchWorkspace !== 'function') { + this.userIdentifiedInWorkspace = false; + this._workspaceLastSearchedEmail = undefined; return Promise.resolve(); } const userIdentities = filteredUser.getUserIdentities ? filteredUser.getUserIdentities().userIdentities : null; const email = userIdentities?.email; if (!email || !isString(email)) { - this.userIdentifiedInAdvertiser = false; - this._advertiserLastSearchedEmail = undefined; + this.userIdentifiedInWorkspace = false; + this._workspaceLastSearchedEmail = undefined; return Promise.resolve(); } // Same email as the last successful dispatch → skip the network call. // The current flag value still reflects the correct match status. - if (email === this._advertiserLastSearchedEmail) { + if (email === this._workspaceLastSearchedEmail) { return Promise.resolve(); } // New / different email → reset and re-search. Cache the email up front // so a second concurrent invocation with the same email also dedupes. - this.userIdentifiedInAdvertiser = false; - this._advertiserLastSearchedEmail = email; + this.userIdentifiedInWorkspace = false; + this._workspaceLastSearchedEmail = email; return new Promise((resolve) => { try { - searchAdvertiser(apiKey, { email }, (result: AdvertiserIdSyncResult) => { + searchWorkspace(apiKey, { email }, (result: WorkspaceIdSyncResult) => { if (result?.httpCode === 200) { - this.userIdentifiedInAdvertiser = true; + this.userIdentifiedInWorkspace = true; } resolve(); }); } catch (err) { - console.error('Rokt Kit: Advertiser IDSync search failed', err); + console.error('Rokt Kit: Workspace IDSync search failed', err); // Dispatch failed — clear the cache so the same email can retry on // the next identification rather than being stuck behind a poisoned // entry that short-circuits future searches. - this._advertiserLastSearchedEmail = undefined; + this._workspaceLastSearchedEmail = undefined; resolve(); } }); @@ -1312,9 +1303,9 @@ class RoktKit implements KitInterface { // Clear the flag explicitly here. Also clear the email cache so a // re-login (possibly the same email) dispatches a fresh search rather // than reusing a stale answer. - this.userIdentifiedInAdvertiser = false; - this._advertiserSearchInFlight = null; - this._advertiserLastSearchedEmail = undefined; + this.userIdentifiedInWorkspace = false; + this._workspaceSearchInFlight = null; + this._workspaceLastSearchedEmail = undefined; return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.LOGOUT, 'onLogoutComplete'); } @@ -1325,10 +1316,10 @@ class RoktKit implements KitInterface { /** * Selects placements for Rokt Web SDK with merged attributes, filters, and experimentation options. * - * If an Advertiser IDSync search is in flight from a recent onUserIdentified - * call, this method waits up to `ADVERTISER_SEARCH_SELECT_TIMEOUT_MS` for it + * If a Workspace IDSync search is in flight from a recent onUserIdentified + * call, this method waits up to `WORKSPACE_SEARCH_SELECT_TIMEOUT_MS` for it * to settle so the first placement call can include the - * `userIdentifiedInAdvertiser` flag without racing the network response. + * `userIdentifiedInWorkspace` flag without racing the network response. * The timeout protects against a stalled or slow search blocking placement * rendering — if it fires, selectPlacements proceeds without the flag. * @@ -1340,11 +1331,11 @@ class RoktKit implements KitInterface { * the in-flight search. */ public selectPlacements(options: Record): RoktSelection | Promise | undefined { - if (this._advertiserSearchInFlight) { - const inFlight = this._advertiserSearchInFlight; + if (this._workspaceSearchInFlight) { + const inFlight = this._workspaceSearchInFlight; return Promise.race([ inFlight, - new Promise((resolve) => setTimeout(resolve, ADVERTISER_SEARCH_SELECT_TIMEOUT_MS)), + new Promise((resolve) => setTimeout(resolve, WORKSPACE_SEARCH_SELECT_TIMEOUT_MS)), ]).then(() => this._dispatchPlacements(options)) as Promise; } return this._dispatchPlacements(options); @@ -1383,7 +1374,7 @@ class RoktKit implements KitInterface { ...filteredAttributes, ...optimizelyAttributes, ...localSessionAttributes, - ...(this.userIdentifiedInAdvertiser ? { [USER_IDENTIFIED_IN_ADVERTISER_KEY]: true } : {}), + ...(this.userIdentifiedInWorkspace ? { [USER_IDENTIFIED_IN_WORKSPACE_KEY]: true } : {}), mpid, }; diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 9013016..b60f73d 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -2933,8 +2933,8 @@ describe('Rokt Forwarder', () => { }); }); - describe('#advertiserIdSync', () => { - const ADVERTISER_API_KEY = 'advertiser-key-abc123'; + describe('#workspaceIdSync', () => { + const WORKSPACE_API_KEY = 'workspace-key-abc123'; function makeUser(overrides: any = {}) { return { @@ -2954,14 +2954,22 @@ describe('Rokt Forwarder', () => { afterEach(() => { (window as any).mParticle.Identity = originalIdentity; (window as any).mParticle.forwarder.userAttributes = {}; - (window as any).mParticle.forwarder.userIdentifiedInAdvertiser = false; - }); - - it('should call Identity.searchAdvertiser with the configured api key and set userIdentifiedInAdvertiser when 200 returned', async () => { + // The kit's `init()` only runs once per instance in production, so it + // does NOT reset workspace-search state. In tests, multiple cases + // share a single forwarder instance and call init repeatedly, so we + // have to clear search state here to keep tests independent — + // otherwise the email-cache hit would suppress a search the next + // test expects. + (window as any).mParticle.forwarder.userIdentifiedInWorkspace = false; + (window as any).mParticle.forwarder._workspaceSearchInFlight = null; + (window as any).mParticle.forwarder._workspaceLastSearchedEmail = undefined; + }); + + it('should call Identity.searchWorkspace with the configured api key and set userIdentifiedInWorkspace when 200 returned', async () => { let receivedApiKey: any = null; let receivedKnownIdentities: any = null; (window as any).mParticle.Identity = { - searchAdvertiser: (apiKey: any, knownIdentities: any, cb: any) => { + searchWorkspace: (apiKey: any, knownIdentities: any, cb: any) => { receivedApiKey = apiKey; receivedKnownIdentities = knownIdentities; cb({ httpCode: 200, body: { mpid: '999' } }); @@ -2969,7 +2977,7 @@ describe('Rokt Forwarder', () => { }; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, reportService.cb, true, null, @@ -2978,20 +2986,20 @@ describe('Rokt Forwarder', () => { (window as any).mParticle.forwarder.onUserIdentified(makeUser()); - expect(receivedApiKey).toBe(ADVERTISER_API_KEY); + expect(receivedApiKey).toBe(WORKSPACE_API_KEY); expect(receivedKnownIdentities).toEqual({ email: 'test@example.com' }); - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(true); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(true); }); - it('should not set userIdentifiedInAdvertiser when search returns 404', async () => { + it('should not set userIdentifiedInWorkspace when search returns 404', async () => { (window as any).mParticle.Identity = { - searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { cb({ httpCode: 404 }); }, }; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, reportService.cb, true, null, @@ -3000,13 +3008,13 @@ describe('Rokt Forwarder', () => { (window as any).mParticle.forwarder.onUserIdentified(makeUser()); - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not call searchAdvertiser when advertiserIdSyncApiKey is missing', async () => { + it('should not call searchWorkspace when workspaceIdSyncApiKey is missing', async () => { let searchCalled = false; (window as any).mParticle.Identity = { - searchAdvertiser: () => { + searchWorkspace: () => { searchCalled = true; }, }; @@ -3016,19 +3024,19 @@ describe('Rokt Forwarder', () => { (window as any).mParticle.forwarder.onUserIdentified(makeUser()); expect(searchCalled).toBe(false); - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not call searchAdvertiser when advertiserIdSyncApiKey is an empty string', async () => { + it('should not call searchWorkspace when workspaceIdSyncApiKey is an empty string', async () => { let searchCalled = false; (window as any).mParticle.Identity = { - searchAdvertiser: () => { + searchWorkspace: () => { searchCalled = true; }, }; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: '' }, + { accountId: '123456', workspaceIdSyncApiKey: '' }, reportService.cb, true, null, @@ -3038,19 +3046,19 @@ describe('Rokt Forwarder', () => { (window as any).mParticle.forwarder.onUserIdentified(makeUser()); expect(searchCalled).toBe(false); - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not call searchAdvertiser when the user has no plain email identity', async () => { + it('should not call searchWorkspace when the user has no plain email identity', async () => { let searchCalled = false; (window as any).mParticle.Identity = { - searchAdvertiser: () => { + searchWorkspace: () => { searchCalled = true; }, }; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, reportService.cb, true, null, @@ -3062,14 +3070,14 @@ describe('Rokt Forwarder', () => { ); expect(searchCalled).toBe(false); - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not throw when Identity.searchAdvertiser is unavailable', async () => { + it('should not throw when Identity.searchWorkspace is unavailable', async () => { (window as any).mParticle.Identity = {}; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, reportService.cb, true, null, @@ -3079,18 +3087,18 @@ describe('Rokt Forwarder', () => { expect(() => { (window as any).mParticle.forwarder.onUserIdentified(makeUser()); }).not.toThrow(); - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should swallow errors thrown by searchAdvertiser', async () => { + it('should swallow errors thrown by searchWorkspace', async () => { (window as any).mParticle.Identity = { - searchAdvertiser: () => { + searchWorkspace: () => { throw new Error('boom'); }, }; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, reportService.cb, true, null, @@ -3100,50 +3108,11 @@ describe('Rokt Forwarder', () => { expect(() => { (window as any).mParticle.forwarder.onUserIdentified(makeUser()); }).not.toThrow(); - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); - }); - - it('should preserve the flag when handleIdentityComplete reassigns userAttributes', async () => { - // Race regression: the search response writes to a kit-class field, - // not the userAttributes map. handleIdentityComplete runs synchronously - // after searchAdvertiser inside onUserIdentified and does - // `userAttributes = user.getAllUserAttributes()`. If the flag lived in - // userAttributes it would be wiped here. It must not be. - (window as any).mParticle.Identity = { - searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { - cb({ httpCode: 200, body: { mpid: '999' } }); - }, - }; - - await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, - reportService.cb, - true, - null, - {}, - ); - - // The user's attribute set deliberately omits userIdentifiedInAdvertiser - // — simulating the real-world case where the advertiser search wrote - // the flag and then handleIdentityComplete reassigned userAttributes - // from a fresh getAllUserAttributes() call that doesn't include it. - (window as any).mParticle.forwarder.onUserIdentified( - makeUser({ - getAllUserAttributes: () => ({ 'preexisting-attr': 'value' }), - }), - ); - - // Kit-class flag survives the reassignment. - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(true); - // userAttributes does NOT contain the flag (and shouldn't — that's the - // whole point of moving it off the map). - expect((window as any).mParticle.forwarder.userAttributes.userIdentifiedInAdvertiser).toBeUndefined(); - // userAttributes still reflects what the user object returned. - expect((window as any).mParticle.forwarder.userAttributes['preexisting-attr']).toBe('value'); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should wait for an in-flight searchAdvertiser before selectPlacements builds attributes', async () => { - // Race regression: previously, onUserIdentified fired searchAdvertiser + it('should wait for an in-flight searchWorkspace before selectPlacements builds attributes', async () => { + // Race regression: previously, onUserIdentified fired searchWorkspace // synchronously and returned. Partners doing // `Identity.login(...).then(() => Rokt.selectPlacements(...))` would // read the flag before the HTTP response landed, missing the flag for @@ -3151,14 +3120,14 @@ describe('Rokt Forwarder', () => { // in-flight search (with a timeout) before building attributes. let triggerSearchResponse: () => void = () => undefined; (window as any).mParticle.Identity = { - searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { // Defer the callback to simulate a real network round-trip. triggerSearchResponse = () => cb({ httpCode: 200, body: { mpid: '999' } }); }, }; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, reportService.cb, true, null, @@ -3192,18 +3161,18 @@ describe('Rokt Forwarder', () => { triggerSearchResponse(); await placementPromise; - expect(launcherCalledWithAttributes.userIdentifiedInAdvertiser).toBe(true); + expect(launcherCalledWithAttributes.userIdentifiedInWorkspace).toBe(true); }); - it('should reset userIdentifiedInAdvertiser on onLogoutComplete', async () => { + it('should reset userIdentifiedInWorkspace on onLogoutComplete', async () => { (window as any).mParticle.Identity = { - searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { cb({ httpCode: 200 }); }, }; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, reportService.cb, true, null, @@ -3211,31 +3180,31 @@ describe('Rokt Forwarder', () => { ); (window as any).mParticle.forwarder.onUserIdentified(makeUser()); - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(true); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(true); // onLogoutComplete must clear the flag so anonymous sessions don't - // carry the previous user's match forward — searchAdvertiser is only + // carry the previous user's match forward — searchWorkspace is only // fired from onUserIdentified, so logout has no re-evaluation path. (window as any).mParticle.forwarder.onLogoutComplete({ getAllUserAttributes: () => ({}), getMPID: () => '999', }); - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should reset userIdentifiedInAdvertiser when re-identifying via a short-circuit path', async () => { + it('should reset userIdentifiedInWorkspace when re-identifying via a short-circuit path', async () => { // A previous identification matched (flag=true). The new user has no - // email, so searchAdvertiser short-circuits without dispatching. The + // email, so searchWorkspace short-circuits without dispatching. The // flag must reset to false rather than leak from the previous user. (window as any).mParticle.Identity = { - searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { cb({ httpCode: 200 }); }, }; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, reportService.cb, true, null, @@ -3243,26 +3212,26 @@ describe('Rokt Forwarder', () => { ); (window as any).mParticle.forwarder.onUserIdentified(makeUser()); - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(true); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(true); (window as any).mParticle.forwarder.onUserIdentified( makeUser({ getUserIdentities: () => ({ userIdentities: {} }) }), ); - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(false); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not re-call Identity.searchAdvertiser when the same email re-identifies', async () => { + it('should not re-call Identity.searchWorkspace when the same email re-identifies', async () => { let searchCallCount = 0; (window as any).mParticle.Identity = { - searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { searchCallCount += 1; cb({ httpCode: 200 }); }, }; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, reportService.cb, true, null, @@ -3276,14 +3245,14 @@ describe('Rokt Forwarder', () => { expect(searchCallCount).toBe(1); // Flag from the first match still correct after the second identify. - expect((window as any).mParticle.forwarder.userIdentifiedInAdvertiser).toBe(true); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(true); }); - it('should re-call Identity.searchAdvertiser when the email changes', async () => { + it('should re-call Identity.searchWorkspace when the email changes', async () => { let searchCallCount = 0; const observedEmails: string[] = []; (window as any).mParticle.Identity = { - searchAdvertiser: (_apiKey: any, knownIdentities: any, cb: any) => { + searchWorkspace: (_apiKey: any, knownIdentities: any, cb: any) => { searchCallCount += 1; observedEmails.push(knownIdentities.email); cb({ httpCode: 200 }); @@ -3291,7 +3260,7 @@ describe('Rokt Forwarder', () => { }; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, reportService.cb, true, null, @@ -3309,17 +3278,17 @@ describe('Rokt Forwarder', () => { expect(observedEmails).toEqual(['a@example.com', 'b@example.com']); }); - it('should re-call Identity.searchAdvertiser after logout even with the same email', async () => { + it('should re-call Identity.searchWorkspace after logout even with the same email', async () => { let searchCallCount = 0; (window as any).mParticle.Identity = { - searchAdvertiser: (_apiKey: any, _knownIdentities: any, cb: any) => { + searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { searchCallCount += 1; cb({ httpCode: 200 }); }, }; await (window as any).mParticle.forwarder.init( - { accountId: '123456', advertiserIdSyncApiKey: ADVERTISER_API_KEY }, + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, reportService.cb, true, null, From 43179ed7569cc43c82110925993730eb9fd30e88 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 11:33:34 -0400 Subject: [PATCH 6/7] refactor: Call Identity.search instead of Identity.searchWorkspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core SDK renamed the IDSync search primitive from `Identity.searchWorkspace` to `Identity.search` so it sits cleanly alongside `identify`/`login`/`logout`/`modify`/`aliasUsers`. Update the kit's call site, type declaration, private wrapper method, and test mocks in lockstep. The kit-facing surface (`workspaceIdSyncApiKey` setting, `userIdentifiedInWorkspace` flag, `WorkspaceIdSync*` types) is unchanged — those describe what the kit *does* with the primitive, not the primitive itself. Paired with the core SDK rename in mparticle-web-sdk. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Rokt-Kit.ts | 16 +++++++------- test/src/tests.spec.ts | 50 +++++++++++++++++++++--------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 5f6562a..2600f62 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -152,7 +152,7 @@ interface MParticleExtended { loggedEvents?: Array>; _registerErrorReportingService?(service: ErrorReportingService): void; _registerLoggingService?(service: LoggingService): void; - Identity?: { searchWorkspace?: WorkspaceIdSyncSearcher }; + Identity?: { search?: WorkspaceIdSyncSearcher }; } interface TestHelpers { @@ -715,11 +715,11 @@ class RoktKit implements KitInterface { private _thankYouElementOnLoadCallback: (() => void) | null = null; private _isThankYouElementLoaded = false; private _workspaceIdSyncApiKey?: string; - // Held during a searchWorkspace dispatch so the next selectPlacements call; + // Held during a search dispatch so the next selectPlacements call; // can wait for the HTTP response before reading userIdentifiedInWorkspace; // — otherwise the first placement call ships without the flag. private _workspaceSearchInFlight: Promise | null = null; - // The email value sent in the most recent successful searchWorkspace + // The email value sent in the most recent successful search // dispatch. If a subsequent identification arrives with the same email, // we skip the network call (the flag is still correct from the prior // search). Cleared on logout so a re-login re-evaluates fresh. @@ -1239,19 +1239,19 @@ class RoktKit implements KitInterface { public onUserIdentified(user: IMParticleUser): string { const filteredUser = user as FilteredUser; this.filters.filteredUser = filteredUser; - this._workspaceSearchInFlight = this.searchWorkspace(filteredUser); + this._workspaceSearchInFlight = this.search(filteredUser); return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.IDENTIFY, 'onUserIdentified'); } - private searchWorkspace(filteredUser: FilteredUser): Promise { + private search(filteredUser: FilteredUser): Promise { const apiKey = this._workspaceIdSyncApiKey; if (!apiKey) { this.userIdentifiedInWorkspace = false; this._workspaceLastSearchedEmail = undefined; return Promise.resolve(); } - const searchWorkspace = mp().Identity?.searchWorkspace; - if (typeof searchWorkspace !== 'function') { + const search = mp().Identity?.search; + if (typeof search !== 'function') { this.userIdentifiedInWorkspace = false; this._workspaceLastSearchedEmail = undefined; return Promise.resolve(); @@ -1277,7 +1277,7 @@ class RoktKit implements KitInterface { return new Promise((resolve) => { try { - searchWorkspace(apiKey, { email }, (result: WorkspaceIdSyncResult) => { + search(apiKey, { email }, (result: WorkspaceIdSyncResult) => { if (result?.httpCode === 200) { this.userIdentifiedInWorkspace = true; } diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index b60f73d..f0ba4d7 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -2965,11 +2965,11 @@ describe('Rokt Forwarder', () => { (window as any).mParticle.forwarder._workspaceLastSearchedEmail = undefined; }); - it('should call Identity.searchWorkspace with the configured api key and set userIdentifiedInWorkspace when 200 returned', async () => { + it('should call Identity.search with the configured api key and set userIdentifiedInWorkspace when 200 returned', async () => { let receivedApiKey: any = null; let receivedKnownIdentities: any = null; (window as any).mParticle.Identity = { - searchWorkspace: (apiKey: any, knownIdentities: any, cb: any) => { + search: (apiKey: any, knownIdentities: any, cb: any) => { receivedApiKey = apiKey; receivedKnownIdentities = knownIdentities; cb({ httpCode: 200, body: { mpid: '999' } }); @@ -2993,7 +2993,7 @@ describe('Rokt Forwarder', () => { it('should not set userIdentifiedInWorkspace when search returns 404', async () => { (window as any).mParticle.Identity = { - searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { + search: (_apiKey: any, _knownIdentities: any, cb: any) => { cb({ httpCode: 404 }); }, }; @@ -3011,10 +3011,10 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not call searchWorkspace when workspaceIdSyncApiKey is missing', async () => { + it('should not call search when workspaceIdSyncApiKey is missing', async () => { let searchCalled = false; (window as any).mParticle.Identity = { - searchWorkspace: () => { + search: () => { searchCalled = true; }, }; @@ -3027,10 +3027,10 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not call searchWorkspace when workspaceIdSyncApiKey is an empty string', async () => { + it('should not call search when workspaceIdSyncApiKey is an empty string', async () => { let searchCalled = false; (window as any).mParticle.Identity = { - searchWorkspace: () => { + search: () => { searchCalled = true; }, }; @@ -3049,10 +3049,10 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not call searchWorkspace when the user has no plain email identity', async () => { + it('should not call search when the user has no plain email identity', async () => { let searchCalled = false; (window as any).mParticle.Identity = { - searchWorkspace: () => { + search: () => { searchCalled = true; }, }; @@ -3073,7 +3073,7 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not throw when Identity.searchWorkspace is unavailable', async () => { + it('should not throw when Identity.search is unavailable', async () => { (window as any).mParticle.Identity = {}; await (window as any).mParticle.forwarder.init( @@ -3090,9 +3090,9 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should swallow errors thrown by searchWorkspace', async () => { + it('should swallow errors thrown by search', async () => { (window as any).mParticle.Identity = { - searchWorkspace: () => { + search: () => { throw new Error('boom'); }, }; @@ -3111,8 +3111,8 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should wait for an in-flight searchWorkspace before selectPlacements builds attributes', async () => { - // Race regression: previously, onUserIdentified fired searchWorkspace + it('should wait for an in-flight search before selectPlacements builds attributes', async () => { + // Race regression: previously, onUserIdentified fired search // synchronously and returned. Partners doing // `Identity.login(...).then(() => Rokt.selectPlacements(...))` would // read the flag before the HTTP response landed, missing the flag for @@ -3120,7 +3120,7 @@ describe('Rokt Forwarder', () => { // in-flight search (with a timeout) before building attributes. let triggerSearchResponse: () => void = () => undefined; (window as any).mParticle.Identity = { - searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { + search: (_apiKey: any, _knownIdentities: any, cb: any) => { // Defer the callback to simulate a real network round-trip. triggerSearchResponse = () => cb({ httpCode: 200, body: { mpid: '999' } }); }, @@ -3166,7 +3166,7 @@ describe('Rokt Forwarder', () => { it('should reset userIdentifiedInWorkspace on onLogoutComplete', async () => { (window as any).mParticle.Identity = { - searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { + search: (_apiKey: any, _knownIdentities: any, cb: any) => { cb({ httpCode: 200 }); }, }; @@ -3183,7 +3183,7 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(true); // onLogoutComplete must clear the flag so anonymous sessions don't - // carry the previous user's match forward — searchWorkspace is only + // carry the previous user's match forward — search is only // fired from onUserIdentified, so logout has no re-evaluation path. (window as any).mParticle.forwarder.onLogoutComplete({ getAllUserAttributes: () => ({}), @@ -3195,10 +3195,10 @@ describe('Rokt Forwarder', () => { it('should reset userIdentifiedInWorkspace when re-identifying via a short-circuit path', async () => { // A previous identification matched (flag=true). The new user has no - // email, so searchWorkspace short-circuits without dispatching. The + // email, so search short-circuits without dispatching. The // flag must reset to false rather than leak from the previous user. (window as any).mParticle.Identity = { - searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { + search: (_apiKey: any, _knownIdentities: any, cb: any) => { cb({ httpCode: 200 }); }, }; @@ -3221,10 +3221,10 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not re-call Identity.searchWorkspace when the same email re-identifies', async () => { + it('should not re-call Identity.search when the same email re-identifies', async () => { let searchCallCount = 0; (window as any).mParticle.Identity = { - searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { + search: (_apiKey: any, _knownIdentities: any, cb: any) => { searchCallCount += 1; cb({ httpCode: 200 }); }, @@ -3248,11 +3248,11 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(true); }); - it('should re-call Identity.searchWorkspace when the email changes', async () => { + it('should re-call Identity.search when the email changes', async () => { let searchCallCount = 0; const observedEmails: string[] = []; (window as any).mParticle.Identity = { - searchWorkspace: (_apiKey: any, knownIdentities: any, cb: any) => { + search: (_apiKey: any, knownIdentities: any, cb: any) => { searchCallCount += 1; observedEmails.push(knownIdentities.email); cb({ httpCode: 200 }); @@ -3278,10 +3278,10 @@ describe('Rokt Forwarder', () => { expect(observedEmails).toEqual(['a@example.com', 'b@example.com']); }); - it('should re-call Identity.searchWorkspace after logout even with the same email', async () => { + it('should re-call Identity.search after logout even with the same email', async () => { let searchCallCount = 0; (window as any).mParticle.Identity = { - searchWorkspace: (_apiKey: any, _knownIdentities: any, cb: any) => { + search: (_apiKey: any, _knownIdentities: any, cb: any) => { searchCallCount += 1; cb({ httpCode: 200 }); }, From a20277c877ce1e3fc41f8e0402416ec175f04c23 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 13:25:09 -0400 Subject: [PATCH 7/7] refactor: Address PR #92 review nits on Workspace IDSync - Rename `_workspaceSearchInFlight` -> `_workspaceSearchInFlightPromise` so the field name signals its Promise type rather than reading like a boolean (r3169510786). - Add a blank line before the `WORKSPACE_SEARCH_SELECT_TIMEOUT_MS` comment block (r3169513331) and before the `_workspaceSearchInFlightPromise` comment block (r3169514112) so each comment reads as introducing the next declaration. - Add TODO breadcrumbs on `WorkspaceIdSyncResult` and `WorkspaceIdSyncSearcher` noting that they're structurally identical to `IIdentitySearchResult` / `SDKIdentityApi.search` from `@mparticle/web-sdk` and should be replaced with the imported versions once a core SDK release including those types is published. - Tighten the `selectPlacements` JSDoc to spell out the two reasons it stays non-async: the public union return is a superset of `RoktLauncher.selectPlacements`, and `RoktSelection`'s optional `then?` member trips TS1058 inside an async return position (r3169525266). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Rokt-Kit.ts | 36 +++++++++++++++++++++++++----------- test/src/tests.spec.ts | 2 +- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 2600f62..a55b2d0 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -87,6 +87,10 @@ interface FilteredUser extends IMParticleUser { getUserIdentities?: () => { userIdentities: Record }; } +// TODO: Replace with `IIdentitySearchResult` from `@mparticle/web-sdk` once +// a version that exports it is published (currently on a feature branch in +// mParticle/mparticle-web-sdk PR #1255). The shape below is intentionally +// structurally identical so the swap is a one-line import change. interface WorkspaceIdSyncResult { httpCode: number; body?: { @@ -98,6 +102,8 @@ interface WorkspaceIdSyncResult { }; } +// TODO: Replace with `IdentitySearchCallback`-compatible reference from +// `@mparticle/web-sdk` once published (mirrors `SDKIdentityApi.search`). type WorkspaceIdSyncSearcher = ( apiKey: string, knownIdentities: { email: string }, @@ -237,6 +243,7 @@ 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'; + // Bound on how long selectPlacements will wait for an in-flight Workspace // IDSync search before proceeding without the userIdentifiedInWorkspace flag. // Long enough to cover the typical /v1/search round-trip (~50ms); short enough that a @@ -715,10 +722,11 @@ class RoktKit implements KitInterface { private _thankYouElementOnLoadCallback: (() => void) | null = null; private _isThankYouElementLoaded = false; private _workspaceIdSyncApiKey?: string; + // Held during a search dispatch so the next selectPlacements call; // can wait for the HTTP response before reading userIdentifiedInWorkspace; // — otherwise the first placement call ships without the flag. - private _workspaceSearchInFlight: Promise | null = null; + private _workspaceSearchInFlightPromise: Promise | null = null; // The email value sent in the most recent successful search // dispatch. If a subsequent identification arrives with the same email, // we skip the network call (the flag is still correct from the prior @@ -1239,7 +1247,7 @@ class RoktKit implements KitInterface { public onUserIdentified(user: IMParticleUser): string { const filteredUser = user as FilteredUser; this.filters.filteredUser = filteredUser; - this._workspaceSearchInFlight = this.search(filteredUser); + this._workspaceSearchInFlightPromise = this.search(filteredUser); return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.IDENTIFY, 'onUserIdentified'); } @@ -1304,7 +1312,7 @@ class RoktKit implements KitInterface { // re-login (possibly the same email) dispatches a fresh search rather // than reusing a stale answer. this.userIdentifiedInWorkspace = false; - this._workspaceSearchInFlight = null; + this._workspaceSearchInFlightPromise = null; this._workspaceLastSearchedEmail = undefined; return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.LOGOUT, 'onLogoutComplete'); } @@ -1323,16 +1331,22 @@ class RoktKit implements KitInterface { * The timeout protects against a stalled or slow search blocking placement * rendering — if it fires, selectPlacements proceeds without the flag. * - * Implementation note: this method stays non-async (returns the existing - * `RoktSelection | Promise | undefined` union) because - * `RoktSelection` has an optional `then?` member, which TS1058 rejects as - * ambiguously promise-like inside an async function's return type. The - * inner work runs in `_dispatchPlacements`; this wrapper just gates it on - * the in-flight search. + * Implementation note: this method stays non-async deliberately. First, + * the public return type is `RoktSelection | Promise | + * undefined` — a superset of the `RoktSelection | Promise` + * shape declared for `RoktLauncher.selectPlacements` above (line ~70). + * Marking this `async` would narrow it to `Promise` and silently change the contract for callers that read + * the result synchronously. Second, `RoktSelection` has an optional + * `then?` member, so TS treats it as ambiguously promise-like and + * rejects it as the awaited return of an async function (TS1058) — + * working around that would require a cast or wrapping every return in + * `Promise.resolve(...)`. The inner work runs in `_dispatchPlacements`; + * this wrapper just gates it on the in-flight search via `Promise.race`. */ public selectPlacements(options: Record): RoktSelection | Promise | undefined { - if (this._workspaceSearchInFlight) { - const inFlight = this._workspaceSearchInFlight; + if (this._workspaceSearchInFlightPromise) { + const inFlight = this._workspaceSearchInFlightPromise; return Promise.race([ inFlight, new Promise((resolve) => setTimeout(resolve, WORKSPACE_SEARCH_SELECT_TIMEOUT_MS)), diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index f0ba4d7..7bf8524 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -2961,7 +2961,7 @@ describe('Rokt Forwarder', () => { // otherwise the email-cache hit would suppress a search the next // test expects. (window as any).mParticle.forwarder.userIdentifiedInWorkspace = false; - (window as any).mParticle.forwarder._workspaceSearchInFlight = null; + (window as any).mParticle.forwarder._workspaceSearchInFlightPromise = null; (window as any).mParticle.forwarder._workspaceLastSearchedEmail = undefined; });