From b92e6116397fb717462a566bb46ea5ce061405e5 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 5 May 2026 00:54:55 -0400 Subject: [PATCH 1/2] refactor: Adopt IUserIdentities for kit user-identity types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@mparticle/web-sdk` 2.66.0 ships its own public types entry, which TypeScript prefers over `@types/mparticle__web-sdk`, so the previous `UserIdentities` import (only present in DT) no longer resolves. Switch to the SDK's `IUserIdentities` and thread it through `replaceOtherIdentityWithEmailsha256`, `WorkspaceIdSyncSearcher`, and the workspace-search identifier collection. Also collapse `FilteredUser` to a type alias of `IMParticleUser`. The previous interface redeclared `getMPID` and `getUserIdentities`, both of which are already required on the SDK's `User` base — the redeclarations conflicted with the authoritative shape. Adds a regression test covering `hashedEmailUserIdentityType` mapped at a key whose value is null: emailsha256 must not be synthesized and the source key must not appear in the placements payload. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Rokt-Kit.ts | 103 ++++++++++++-------- test/src/tests.spec.ts | 208 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 265 insertions(+), 46 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index a55b2d0..ebe52d8 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -17,6 +17,7 @@ // ============================================================ import { Batch, KitInterface, IMParticleUser, SDKEvent } from '@mparticle/web-sdk/internal'; +import type { IUserIdentities } from '@mparticle/web-sdk'; // BaseEvent not re-exported from @mparticle/web-sdk/internal, so we import directly from @mparticle/event-models. import { BaseEvent } from '@mparticle/event-models'; @@ -80,12 +81,9 @@ interface RoktGlobal { setExtensionData(data: Record): void; } -// TODO: getMPID and getUserIdentities exist on the User base type but are not re-exported from -// @mparticle/web-sdk/internal, so we redeclare them here until the internal types expose them. -interface FilteredUser extends IMParticleUser { - getMPID(): string; - getUserIdentities?: () => { userIdentities: Record }; -} +// FilteredUser is the IMParticleUser shape we receive after kit filtering. +// `getMPID` and `getUserIdentities` are inherited from the SDK's `User` base type. +type FilteredUser = IMParticleUser; // TODO: Replace with `IIdentitySearchResult` from `@mparticle/web-sdk` once // a version that exports it is published (currently on a feature branch in @@ -106,7 +104,7 @@ interface WorkspaceIdSyncResult { // `@mparticle/web-sdk` once published (mirrors `SDKIdentityApi.search`). type WorkspaceIdSyncSearcher = ( apiKey: string, - knownIdentities: { email: string }, + knownIdentities: IUserIdentities, callback: (result: WorkspaceIdSyncResult) => void, ) => void; @@ -727,11 +725,14 @@ class RoktKit implements KitInterface { // 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; + // Stable serialization of the identifier set sent in the most recent + // successful search dispatch. If a subsequent identification arrives with + // an identical set, we skip the network call (the flag is still correct + // from the prior search). Keyed over the full IUserIdentities map — not + // just email — so partners passing hashed email through `other`/`other2-10` + // or any other identifier benefit from the same dedupe. Cleared on logout + // so a re-login re-evaluates fresh. + private _workspaceLastSearchedIdentitiesKey?: string; // ---- Private helpers ---- @@ -836,7 +837,7 @@ class RoktKit implements KitInterface { return {}; } - const userIdentities = filteredUser.getUserIdentities().userIdentities; + const userIdentities: IUserIdentities = filteredUser.getUserIdentities().userIdentities; return this.replaceOtherIdentityWithEmailsha256(userIdentities); } @@ -851,11 +852,11 @@ class RoktKit implements KitInterface { return mp().Rokt.getLocalSessionAttributes!(); } - private replaceOtherIdentityWithEmailsha256(userIdentities: Record): Record { + private replaceOtherIdentityWithEmailsha256(userIdentities: IUserIdentities): Record { const newUserIdentities: Record = { ...(userIdentities || {}) }; const key = this._mappedEmailSha256Key; - if (key && userIdentities[key]) { - newUserIdentities[RoktKit.EMAIL_SHA256_KEY] = userIdentities[key]; + if (key && userIdentities[key as keyof IUserIdentities]) { + newUserIdentities[RoktKit.EMAIL_SHA256_KEY] = userIdentities[key as keyof IUserIdentities] as string; } if (key) { delete newUserIdentities[key]; @@ -1255,37 +1256,65 @@ class RoktKit implements KitInterface { const apiKey = this._workspaceIdSyncApiKey; if (!apiKey) { this.userIdentifiedInWorkspace = false; - this._workspaceLastSearchedEmail = undefined; + this._workspaceLastSearchedIdentitiesKey = undefined; return Promise.resolve(); } const search = mp().Identity?.search; if (typeof search !== 'function') { this.userIdentifiedInWorkspace = false; - this._workspaceLastSearchedEmail = undefined; + this._workspaceLastSearchedIdentitiesKey = undefined; return Promise.resolve(); } - const userIdentities = filteredUser.getUserIdentities ? filteredUser.getUserIdentities().userIdentities : null; - const email = userIdentities?.email; - if (!email || !isString(email)) { + + const userIdentities: IUserIdentities | null = filteredUser.getUserIdentities + ? filteredUser.getUserIdentities().userIdentities + : null; + + // Forward every non-empty string identifier the user has — email, + // customerid, other/other2-10 (commonly used for hashed email), + // mobile_number, facebook, etc. The host SDK's Identity.search accepts + // the full IUserIdentities surface and the server validates it. + const knownIdentities: Record = {}; + if (userIdentities) { + for (const key of Object.keys(userIdentities) as Array) { + const value = userIdentities[key]; + if (isString(value) && value.length > 0) { + knownIdentities[key] = value; + } + } + } + + const identityKeys = Object.keys(knownIdentities); + if (identityKeys.length === 0) { this.userIdentifiedInWorkspace = false; - this._workspaceLastSearchedEmail = undefined; + this._workspaceLastSearchedIdentitiesKey = 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) { + // Stable cache key: sort keys so insertion-order differences don't + // cause false misses. The values are partner-supplied strings; no + // hashing needed — equality on this serialization is sufficient. + const identitiesKey = identityKeys + .sort() + .map((k) => `${k}=${knownIdentities[k]}`) + .join('&'); + + // Same identifier set as the last successful dispatch → skip the + // network call. The current flag value still reflects the correct + // match status. + if (identitiesKey === this._workspaceLastSearchedIdentitiesKey) { 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. + // New / different identifier set → reset and re-search. Cache the key + // up front so a second concurrent invocation with the same set also + // dedupes. this.userIdentifiedInWorkspace = false; - this._workspaceLastSearchedEmail = email; + this._workspaceLastSearchedIdentitiesKey = identitiesKey; return new Promise((resolve) => { try { - search(apiKey, { email }, (result: WorkspaceIdSyncResult) => { + search(apiKey, knownIdentities as IUserIdentities, (result: WorkspaceIdSyncResult) => { if (result?.httpCode === 200) { this.userIdentifiedInWorkspace = true; } @@ -1293,10 +1322,10 @@ class RoktKit implements KitInterface { }); } 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; + // Dispatch failed — clear the cache so the same identifier set + // can retry on the next identification rather than being stuck + // behind a poisoned entry that short-circuits future searches. + this._workspaceLastSearchedIdentitiesKey = undefined; resolve(); } }); @@ -1308,12 +1337,12 @@ 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. + // Clear the flag explicitly here. Also clear the identities cache so a + // re-login (possibly with the same identifiers) dispatches a fresh + // search rather than reusing a stale answer. this.userIdentifiedInWorkspace = false; this._workspaceSearchInFlightPromise = null; - this._workspaceLastSearchedEmail = undefined; + this._workspaceLastSearchedIdentitiesKey = undefined; return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.LOGOUT, 'onLogoutComplete'); } diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 7bf8524..4bd087d 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -1438,6 +1438,64 @@ describe('Rokt Forwarder', () => { }); }); + it('should not set emailsha256 when the mapped source identity is null', async () => { + // hashedEmailUserIdentityType points at `other`, but `other` is null — + // the kit must not forward `emailsha256: null` (or any synthesized + // null value) to the placements payload. + (window as any).mParticle.Rokt.filters = { + userAttributeFilters: [], + filterUserAttributes: function () { + return {}; + }, + filteredUser: { + getMPID: function () { + return '234'; + }, + getUserIdentities: function () { + return { + userIdentities: { + customerid: 'customer123', + other: null, + }, + }; + }, + }, + }; + + (window as any).Rokt.createLauncher = async function () { + return Promise.resolve({ + selectPlacements: function (options: any) { + (window as any).mParticle.Rokt.selectPlacementsOptions = options; + (window as any).mParticle.Rokt.selectPlacementsCalled = true; + }, + }); + }; + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + hashedEmailUserIdentityType: 'Other', + }, + reportService.cb, + true, + null, + {}, + ); + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + await (window as any).mParticle.forwarder.selectPlacements({ + identifier: 'test-placement', + attributes: {}, + }); + + const attrs = (window as any).Rokt.selectPlacementsOptions.attributes; + expect(attrs).toEqual({ + customerid: 'customer123', + mpid: '234', + }); + expect(attrs).not.toHaveProperty('emailsha256'); + expect(attrs).not.toHaveProperty('other'); + }); + it('should map other to emailsha256 when other is passed through selectPlacements', async () => { (window as any).mParticle.Rokt.filters = { userAttributeFilters: [], @@ -2958,11 +3016,11 @@ describe('Rokt Forwarder', () => { // 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. + // otherwise the identities-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; + (window as any).mParticle.forwarder._workspaceLastSearchedIdentitiesKey = undefined; }); it('should call Identity.search with the configured api key and set userIdentifiedInWorkspace when 200 returned', async () => { @@ -3049,7 +3107,7 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not call search when the user has no plain email identity', async () => { + it('should not call search when the user has no usable identifiers', async () => { let searchCalled = false; (window as any).mParticle.Identity = { search: () => { @@ -3073,6 +3131,73 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); + it('should call search and forward non-email identifiers (e.g. hashed email in `other`)', async () => { + let receivedKnownIdentities: any = null; + (window as any).mParticle.Identity = { + search: (_apiKey: any, knownIdentities: any, cb: any) => { + 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({ + getUserIdentities: () => ({ + userIdentities: { other: 'sha256:abc123', customerid: 'cust-1' }, + }), + }), + ); + + expect(receivedKnownIdentities).toEqual({ other: 'sha256:abc123', customerid: 'cust-1' }); + expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(true); + }); + + it('should forward all non-empty string identifiers and drop empty/null entries', async () => { + let receivedKnownIdentities: any = null; + (window as any).mParticle.Identity = { + search: (_apiKey: any, knownIdentities: any, cb: any) => { + receivedKnownIdentities = knownIdentities; + 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: 'user@example.com', + other: 'sha256:abc', + customerid: '', + mobile_number: null, + facebook: 'fb-id', + }, + }), + }), + ); + + expect(receivedKnownIdentities).toEqual({ + email: 'user@example.com', + other: 'sha256:abc', + facebook: 'fb-id', + }); + }); + it('should not throw when Identity.search is unavailable', async () => { (window as any).mParticle.Identity = {}; @@ -3221,7 +3346,7 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(false); }); - it('should not re-call Identity.search when the same email re-identifies', async () => { + it('should not re-call Identity.search when the same identifier set re-identifies', async () => { let searchCallCount = 0; (window as any).mParticle.Identity = { search: (_apiKey: any, _knownIdentities: any, cb: any) => { @@ -3238,8 +3363,8 @@ describe('Rokt Forwarder', () => { {}, ); - // Two identifications with the same email. Should dispatch only once; - // the cached email skips the second network call. + // Two identifications with the same identifier set. Should dispatch + // only once; the cached identities key skips the second network call. (window as any).mParticle.forwarder.onUserIdentified(makeUser()); (window as any).mParticle.forwarder.onUserIdentified(makeUser()); @@ -3248,6 +3373,71 @@ describe('Rokt Forwarder', () => { expect((window as any).mParticle.forwarder.userIdentifiedInWorkspace).toBe(true); }); + it('should not re-call Identity.search when the same identifier set arrives with different key insertion order', 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({ + getUserIdentities: () => ({ + userIdentities: { email: 'a@example.com', other: 'sha256:abc' }, + }), + }), + ); + (window as any).mParticle.forwarder.onUserIdentified( + makeUser({ + getUserIdentities: () => ({ + userIdentities: { other: 'sha256:abc', email: 'a@example.com' }, + }), + }), + ); + + expect(searchCallCount).toBe(1); + }); + + it('should re-call Identity.search when a non-email identifier changes', async () => { + let searchCallCount = 0; + const observedHashes: string[] = []; + (window as any).mParticle.Identity = { + search: (_apiKey: any, knownIdentities: any, cb: any) => { + searchCallCount += 1; + observedHashes.push(knownIdentities.other); + 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: { other: 'sha256:aaa' } }) }), + ); + (window as any).mParticle.forwarder.onUserIdentified( + makeUser({ getUserIdentities: () => ({ userIdentities: { other: 'sha256:bbb' } }) }), + ); + + expect(searchCallCount).toBe(2); + expect(observedHashes).toEqual(['sha256:aaa', 'sha256:bbb']); + }); + it('should re-call Identity.search when the email changes', async () => { let searchCallCount = 0; const observedEmails: string[] = []; @@ -3278,7 +3468,7 @@ describe('Rokt Forwarder', () => { expect(observedEmails).toEqual(['a@example.com', 'b@example.com']); }); - it('should re-call Identity.search after logout even with the same email', async () => { + it('should re-call Identity.search after logout even with the same identifiers', async () => { let searchCallCount = 0; (window as any).mParticle.Identity = { search: (_apiKey: any, _knownIdentities: any, cb: any) => { @@ -3296,7 +3486,7 @@ describe('Rokt Forwarder', () => { ); (window as any).mParticle.forwarder.onUserIdentified(makeUser()); - // Logout clears the email cache so a re-login re-evaluates. + // Logout clears the identities cache so a re-login re-evaluates. (window as any).mParticle.forwarder.onLogoutComplete({ getAllUserAttributes: () => ({}), getMPID: () => '999', From 835201ad003f2ac7522929f0a940557f19ca4198 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 5 May 2026 08:50:01 -0400 Subject: [PATCH 2/2] fix: Make filteredUserAttributes optional on RoktKit.init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `KitInterface.init` declares `userAttributes?: AllUserAttributes`, so callers may omit it. The override required the param, which violated substitutability once the kit consumed the SDK's authoritative `KitInterface` types instead of the looser DT shape. Adding `?` is enough — `Record` is already a supertype of `AllUserAttributes` on the value side. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Rokt-Kit.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index ebe52d8..37822cd 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -18,6 +18,7 @@ import { Batch, KitInterface, IMParticleUser, SDKEvent } from '@mparticle/web-sdk/internal'; import type { IUserIdentities } from '@mparticle/web-sdk'; + // BaseEvent not re-exported from @mparticle/web-sdk/internal, so we import directly from @mparticle/event-models. import { BaseEvent } from '@mparticle/event-models'; @@ -1071,7 +1072,7 @@ class RoktKit implements KitInterface { _service: unknown, testMode: boolean, _trackerId: unknown, - filteredUserAttributes: Record, + filteredUserAttributes?: Record, ): string { const kitSettings = settings as unknown as RoktKitSettings; const accountId = kitSettings.accountId;