diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 5f66df8..a55b2d0 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -30,6 +30,7 @@ interface RoktKitSettings { loggingUrl?: string; errorUrl?: string; isLoggingEnabled?: string | boolean; + workspaceIdSyncApiKey?: string; } interface EventAttributeCondition { @@ -86,6 +87,29 @@ 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?: { + context?: string | null; + mpid?: string; + matched_identities?: Record; + is_ephemeral?: boolean; + is_logged_in?: boolean; + }; +} + +// TODO: Replace with `IdentitySearchCallback`-compatible reference from +// `@mparticle/web-sdk` once published (mirrors `SDKIdentityApi.search`). +type WorkspaceIdSyncSearcher = ( + apiKey: string, + knownIdentities: { email: string }, + callback: (result: WorkspaceIdSyncResult) => void, +) => void; + interface KitFilters { userAttributeFilters?: string[]; filterUserAttributes?: (attributes: Record, filters?: string[]) => Record; @@ -134,6 +158,7 @@ interface MParticleExtended { loggedEvents?: Array>; _registerErrorReportingService?(service: ErrorReportingService): void; _registerLoggingService?(service: LoggingService): void; + Identity?: { search?: WorkspaceIdSyncSearcher }; } interface TestHelpers { @@ -217,6 +242,13 @@ 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_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 WORKSPACE_SEARCH_SELECT_TIMEOUT_MS = 500; type RoktIdentityEventType = (typeof ROKT_IDENTITY_EVENT_TYPE)[keyof typeof ROKT_IDENTITY_EVENT_TYPE]; @@ -670,6 +702,9 @@ class RoktKit implements KitInterface { public launcher: RoktLauncher | null = null; public filters: KitFilters = {}; public userAttributes: Record = {}; + // 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 = {}; @@ -686,6 +721,17 @@ class RoktKit implements KitInterface { private _onboardingExpProvider?: string; 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 _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 + // search). Cleared on logout so a re-login re-evaluates fresh. + private _workspaceLastSearchedEmail?: string; // ---- Private helpers ---- @@ -1044,6 +1090,10 @@ class RoktKit implements KitInterface { this._mappedEmailSha256Key = kitSettings.hashedEmailUserIdentityType.toLowerCase(); } + this._workspaceIdSyncApiKey = isString(kitSettings.workspaceIdSyncApiKey) + ? kitSettings.workspaceIdSyncApiKey + : undefined; + const domain = mp().Rokt?.domain; const { roktExtensionsQueryParams, legacyRoktExtensions, loadThankYouElement } = extractRoktExtensionConfig( kitSettings.roktExtensions, @@ -1195,15 +1245,75 @@ 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._workspaceSearchInFlightPromise = this.search(filteredUser); return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.IDENTIFY, 'onUserIdentified'); } + private search(filteredUser: FilteredUser): Promise { + const apiKey = this._workspaceIdSyncApiKey; + if (!apiKey) { + this.userIdentifiedInWorkspace = false; + this._workspaceLastSearchedEmail = undefined; + return Promise.resolve(); + } + const search = mp().Identity?.search; + if (typeof search !== '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.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._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.userIdentifiedInWorkspace = false; + this._workspaceLastSearchedEmail = email; + + return new Promise((resolve) => { + try { + search(apiKey, { email }, (result: WorkspaceIdSyncResult) => { + if (result?.httpCode === 200) { + this.userIdentifiedInWorkspace = true; + } + resolve(); + }); + } catch (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._workspaceLastSearchedEmail = undefined; + resolve(); + } + }); + } + public onLoginComplete(user: IMParticleUser, _filteredIdentityRequest: unknown): string { return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.LOGIN, 'onLoginComplete'); } 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.userIdentifiedInWorkspace = false; + this._workspaceSearchInFlightPromise = null; + this._workspaceLastSearchedEmail = undefined; return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.LOGOUT, 'onLogoutComplete'); } @@ -1213,8 +1323,39 @@ class RoktKit implements KitInterface { /** * Selects placements for Rokt Web SDK with merged attributes, filters, and experimentation options. + * + * 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 + * `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. + * + * 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._workspaceSearchInFlightPromise) { + const inFlight = this._workspaceSearchInFlightPromise; + return Promise.race([ + inFlight, + new Promise((resolve) => setTimeout(resolve, WORKSPACE_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 }; @@ -1247,6 +1388,7 @@ class RoktKit implements KitInterface { ...filteredAttributes, ...optimizelyAttributes, ...localSessionAttributes, + ...(this.userIdentifiedInWorkspace ? { [USER_IDENTIFIED_IN_WORKSPACE_KEY]: true } : {}), mpid, }; diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index cc8dc8f..7bf8524 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -2933,6 +2933,380 @@ describe('Rokt Forwarder', () => { }); }); + describe('#workspaceIdSync', () => { + const WORKSPACE_API_KEY = 'workspace-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 = {}; + // 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._workspaceSearchInFlightPromise = null; + (window as any).mParticle.forwarder._workspaceLastSearchedEmail = undefined; + }); + + 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 = { + search: (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', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + + expect(receivedApiKey).toBe(WORKSPACE_API_KEY); + expect(receivedKnownIdentities).toEqual({ email: 'test@example.com' }); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(true); + }); + + it('should not set userIdentifiedInWorkspace when search returns 404', async () => { + (window as any).mParticle.Identity = { + search: (_apiKey: any, _knownIdentities: any, cb: any) => { + cb({ httpCode: 404 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); + }); + + it('should not call search when workspaceIdSyncApiKey is missing', async () => { + let searchCalled = false; + (window as any).mParticle.Identity = { + search: () => { + 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.userIdentifiedInWorkspace).toBe(false); + }); + + it('should not call search when workspaceIdSyncApiKey is an empty string', async () => { + let searchCalled = false; + (window as any).mParticle.Identity = { + search: () => { + searchCalled = true; + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', workspaceIdSyncApiKey: '' }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + + expect(searchCalled).toBe(false); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); + }); + + it('should not call search when the user has no plain email identity', async () => { + let searchCalled = false; + (window as any).mParticle.Identity = { + search: () => { + searchCalled = true; + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_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.userIdentifiedInWorkspace).toBe(false); + }); + + it('should not throw when Identity.search is unavailable', async () => { + (window as any).mParticle.Identity = {}; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + expect(() => { + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + }).not.toThrow(); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); + }); + + it('should swallow errors thrown by search', async () => { + (window as any).mParticle.Identity = { + search: () => { + throw new Error('boom'); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + expect(() => { + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + }).not.toThrow(); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); + }); + + 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 + // 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 = { + search: (_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', workspaceIdSyncApiKey: WORKSPACE_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.userIdentifiedInWorkspace).toBe(true); + }); + + it('should reset userIdentifiedInWorkspace on onLogoutComplete', async () => { + (window as any).mParticle.Identity = { + search: (_apiKey: any, _knownIdentities: any, cb: any) => { + cb({ httpCode: 200 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + 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 — search 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.userIdentifiedInWorkspace).toBe(false); + }); + + 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 search short-circuits without dispatching. The + // flag must reset to false rather than leak from the previous user. + (window as any).mParticle.Identity = { + search: (_apiKey: any, _knownIdentities: any, cb: any) => { + cb({ httpCode: 200 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_API_KEY }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.onUserIdentified(makeUser()); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(true); + + (window as any).mParticle.forwarder.onUserIdentified( + makeUser({ getUserIdentities: () => ({ userIdentities: {} }) }), + ); + + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); + }); + + it('should not re-call Identity.search when the same email re-identifies', async () => { + let searchCallCount = 0; + (window as any).mParticle.Identity = { + search: (_apiKey: any, _knownIdentities: any, cb: any) => { + searchCallCount += 1; + cb({ httpCode: 200 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_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.userIdentifiedInWorkspace).toBe(true); + }); + + it('should re-call Identity.search when the email changes', async () => { + let searchCallCount = 0; + const observedEmails: string[] = []; + (window as any).mParticle.Identity = { + search: (_apiKey: any, knownIdentities: any, cb: any) => { + searchCallCount += 1; + observedEmails.push(knownIdentities.email); + cb({ httpCode: 200 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_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.search after logout even with the same email', async () => { + let searchCallCount = 0; + (window as any).mParticle.Identity = { + search: (_apiKey: any, _knownIdentities: any, cb: any) => { + searchCallCount += 1; + cb({ httpCode: 200 }); + }, + }; + + await (window as any).mParticle.forwarder.init( + { accountId: '123456', workspaceIdSyncApiKey: WORKSPACE_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', () => { it('should update userAttributes from the filtered user', () => { (window as any).mParticle.forwarder.onLoginComplete({