From ee5e8884992355b4e2214d0ee8a3a996b95ebd06 Mon Sep 17 00:00:00 2001 From: James Newman Date: Tue, 21 Apr 2026 17:48:39 -0400 Subject: [PATCH 01/10] feat: Add alias for sha256 identities --- src/identity-utils.ts | 22 ++++++++++++++ src/identity.js | 13 +++++++- src/types.ts | 4 +++ test/jest/identity-utils.spec.ts | 51 ++++++++++++++++++++++++++++++++ test/jest/types.spec.ts | 2 ++ 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 test/jest/identity-utils.spec.ts diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 929e5be47..89939f1e0 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -289,6 +289,28 @@ const getExpireTimestamp = (maxAge: number = ONE_DAY_IN_SECONDS): number => const parseIdentityResponse = (responseText: string): IdentityResultBody => responseText ? JSON.parse(responseText) : ({} as IdentityResultBody); +// Maps convenience identity key names to their canonical server-side names. +// Mirrors the emailSha256/mobileSha256 helpers added in the Apple SDK (PR #756). +const SHA256_IDENTITY_ALIASES: Readonly> = { + email_sha256: 'other', + mobile_sha256: 'other', +}; + +export const normalizeUserIdentityKeys = ( + userIdentities: UserIdentities +): UserIdentities => { + const normalized: Record = { + ...(userIdentities as Record), + }; + for (const alias of Object.keys(SHA256_IDENTITY_ALIASES)) { + if (alias in normalized) { + normalized[SHA256_IDENTITY_ALIASES[alias]] = normalized[alias]; + delete normalized[alias]; + } + } + return normalized as UserIdentities; +}; + export const hasIdentityRequestChanged = ( currentUser: IMParticleUser, newIdentityRequest: IdentityApiData diff --git a/src/identity.js b/src/identity.js index 99e767e19..01a5edfd4 100644 --- a/src/identity.js +++ b/src/identity.js @@ -4,6 +4,7 @@ import { cacheOrClearIdCache, createKnownIdentities, executeSearchRequest, + normalizeUserIdentityKeys, tryCacheIdentity, } from './identity-utils'; import AudienceManager from './audienceManager'; @@ -35,11 +36,21 @@ export default function Identity(mpInstance) { ); // First, remove any falsy identity values and warn about them - const cleanedIdentityApiData = mpInstance._Helpers.Validators.removeFalsyIdentityValues( + const removedFalsyIdentityData = mpInstance._Helpers.Validators.removeFalsyIdentityValues( identityApiData, mpInstance.Logger ); + // Normalize convenience aliases (email_sha256 → other, mobile_sha256 → other) + const cleanedIdentityApiData = removedFalsyIdentityData?.userIdentities + ? { + ...removedFalsyIdentityData, + userIdentities: normalizeUserIdentityKeys( + removedFalsyIdentityData.userIdentities + ), + } + : removedFalsyIdentityData; + var identityValidationResult = mpInstance._Helpers.Validators.validateIdentities( cleanedIdentityApiData, method diff --git a/src/types.ts b/src/types.ts index c8eac68e9..0895a2bfd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -205,6 +205,10 @@ export const IdentityType = { return IdentityType.PhoneNumber2; case 'phone_number_3': return IdentityType.PhoneNumber3; + case 'email_sha256': + return IdentityType.Other; + case 'mobile_sha256': + return IdentityType.Other; default: return false; } diff --git a/test/jest/identity-utils.spec.ts b/test/jest/identity-utils.spec.ts new file mode 100644 index 000000000..80f6719c8 --- /dev/null +++ b/test/jest/identity-utils.spec.ts @@ -0,0 +1,51 @@ +import { normalizeUserIdentityKeys } from '../../src/identity-utils'; + +describe('normalizeUserIdentityKeys', () => { + it('maps email_sha256 to other', () => { + const result = normalizeUserIdentityKeys({ email_sha256: 'abc123hash' } as any); + expect(result).toEqual({ other: 'abc123hash' }); + }); + + it('maps mobile_sha256 to other', () => { + const result = normalizeUserIdentityKeys({ mobile_sha256: 'mobilehash456' } as any); + expect(result).toEqual({ other: 'mobilehash456' }); + }); + + it('last sha256 alias wins when both are set (same slot)', () => { + const result = normalizeUserIdentityKeys({ + email_sha256: 'emailhash', + mobile_sha256: 'mobilehash', + } as any); + expect(result).toEqual({ other: 'mobilehash' }); + }); + + it('preserves other canonical identity keys alongside sha256 aliases', () => { + const result = normalizeUserIdentityKeys({ + email: 'user@example.com', + customerid: 'cust123', + email_sha256: 'sha256ofEmail', + } as any); + expect(result).toEqual({ + email: 'user@example.com', + customerid: 'cust123', + other: 'sha256ofEmail', + }); + }); + + it('does not modify identities without sha256 aliases', () => { + const input = { email: 'user@example.com', customerid: 'cust123' }; + const result = normalizeUserIdentityKeys(input); + expect(result).toEqual({ email: 'user@example.com', customerid: 'cust123' }); + }); + + it('handles null values for sha256 aliases', () => { + const result = normalizeUserIdentityKeys({ email_sha256: null } as any); + expect(result).toEqual({ other: null }); + }); + + it('does not mutate the original object', () => { + const input = { email_sha256: 'hash' } as any; + normalizeUserIdentityKeys(input); + expect(input).toEqual({ email_sha256: 'hash' }); + }); +}); diff --git a/test/jest/types.spec.ts b/test/jest/types.spec.ts index 4cefcd21d..b3c13f010 100644 --- a/test/jest/types.spec.ts +++ b/test/jest/types.spec.ts @@ -439,6 +439,8 @@ describe('IdentityType', () => { expect(getIdentityType('mobile_number')).toBe(MobileNumber); expect(getIdentityType('phone_number_2')).toBe(PhoneNumber2); expect(getIdentityType('phone_number_3')).toBe(PhoneNumber3); + expect(getIdentityType('email_sha256')).toBe(Other); + expect(getIdentityType('mobile_sha256')).toBe(Other); }); it('returns false if the identity name is not found', () => { From 81f7352daf966bf4df89b316f46e1a25991727a3 Mon Sep 17 00:00:00 2001 From: James Newman Date: Tue, 21 Apr 2026 17:56:23 -0400 Subject: [PATCH 02/10] Add types for aliases --- src/identity-utils.ts | 12 +++++++++++- test/jest/identity-utils.spec.ts | 16 ++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 89939f1e0..47b0511bd 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -7,6 +7,13 @@ import { UserIdentities, IdentityCallback, } from '@mparticle/web-sdk'; + +declare module '@mparticle/web-sdk' { + interface UserIdentities { + email_sha256?: string | undefined; + mobile_sha256?: string | undefined; + } +} import { IdentityAPIMethod, IIdentityRequest } from './identity.interfaces'; import { IdentityResultBody, @@ -304,8 +311,11 @@ export const normalizeUserIdentityKeys = ( }; for (const alias of Object.keys(SHA256_IDENTITY_ALIASES)) { if (alias in normalized) { - normalized[SHA256_IDENTITY_ALIASES[alias]] = normalized[alias]; + const value = normalized[alias]; delete normalized[alias]; + if (value !== null) { + normalized[SHA256_IDENTITY_ALIASES[alias]] = value; + } } } return normalized as UserIdentities; diff --git a/test/jest/identity-utils.spec.ts b/test/jest/identity-utils.spec.ts index 80f6719c8..af3eb9693 100644 --- a/test/jest/identity-utils.spec.ts +++ b/test/jest/identity-utils.spec.ts @@ -2,12 +2,12 @@ import { normalizeUserIdentityKeys } from '../../src/identity-utils'; describe('normalizeUserIdentityKeys', () => { it('maps email_sha256 to other', () => { - const result = normalizeUserIdentityKeys({ email_sha256: 'abc123hash' } as any); + const result = normalizeUserIdentityKeys({ email_sha256: 'abc123hash' }); expect(result).toEqual({ other: 'abc123hash' }); }); it('maps mobile_sha256 to other', () => { - const result = normalizeUserIdentityKeys({ mobile_sha256: 'mobilehash456' } as any); + const result = normalizeUserIdentityKeys({ mobile_sha256: 'mobilehash456' }); expect(result).toEqual({ other: 'mobilehash456' }); }); @@ -15,7 +15,7 @@ describe('normalizeUserIdentityKeys', () => { const result = normalizeUserIdentityKeys({ email_sha256: 'emailhash', mobile_sha256: 'mobilehash', - } as any); + }); expect(result).toEqual({ other: 'mobilehash' }); }); @@ -24,7 +24,7 @@ describe('normalizeUserIdentityKeys', () => { email: 'user@example.com', customerid: 'cust123', email_sha256: 'sha256ofEmail', - } as any); + }); expect(result).toEqual({ email: 'user@example.com', customerid: 'cust123', @@ -38,13 +38,13 @@ describe('normalizeUserIdentityKeys', () => { expect(result).toEqual({ email: 'user@example.com', customerid: 'cust123' }); }); - it('handles null values for sha256 aliases', () => { - const result = normalizeUserIdentityKeys({ email_sha256: null } as any); - expect(result).toEqual({ other: null }); + it('drops null values for sha256 aliases', () => { + const result = normalizeUserIdentityKeys({ email_sha256: null }); + expect(result).toEqual({}); }); it('does not mutate the original object', () => { - const input = { email_sha256: 'hash' } as any; + const input = { email_sha256: 'hash' }; normalizeUserIdentityKeys(input); expect(input).toEqual({ email_sha256: 'hash' }); }); From 6cdd6f22818c06577a68a519469faf153acf1b84 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Fri, 1 May 2026 11:05:06 -0400 Subject: [PATCH 03/10] fix: Correct sha256 alias mapping and null-clear semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mobile_sha256 now maps to other2 (was: other) so it no longer collides with email_sha256 in the same canonical slot. - Null on an alias passes through to the canonical slot instead of being dropped, so a Modify call with `: null` actually clears the slot — matching the null=clear contract honored by removeFalsyIdentityValues and the identity validator. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-utils.ts | 8 ++----- src/types.ts | 2 +- test/jest/identity-utils.spec.ts | 37 +++++++++++++++++++++++++------- test/jest/types.spec.ts | 2 +- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 47b0511bd..638a28d5a 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -296,11 +296,9 @@ const getExpireTimestamp = (maxAge: number = ONE_DAY_IN_SECONDS): number => const parseIdentityResponse = (responseText: string): IdentityResultBody => responseText ? JSON.parse(responseText) : ({} as IdentityResultBody); -// Maps convenience identity key names to their canonical server-side names. -// Mirrors the emailSha256/mobileSha256 helpers added in the Apple SDK (PR #756). const SHA256_IDENTITY_ALIASES: Readonly> = { email_sha256: 'other', - mobile_sha256: 'other', + mobile_sha256: 'other2', }; export const normalizeUserIdentityKeys = ( @@ -313,9 +311,7 @@ export const normalizeUserIdentityKeys = ( if (alias in normalized) { const value = normalized[alias]; delete normalized[alias]; - if (value !== null) { - normalized[SHA256_IDENTITY_ALIASES[alias]] = value; - } + normalized[SHA256_IDENTITY_ALIASES[alias]] = value; } } return normalized as UserIdentities; diff --git a/src/types.ts b/src/types.ts index 0895a2bfd..af237e812 100644 --- a/src/types.ts +++ b/src/types.ts @@ -208,7 +208,7 @@ export const IdentityType = { case 'email_sha256': return IdentityType.Other; case 'mobile_sha256': - return IdentityType.Other; + return IdentityType.Other2; default: return false; } diff --git a/test/jest/identity-utils.spec.ts b/test/jest/identity-utils.spec.ts index af3eb9693..9acb96702 100644 --- a/test/jest/identity-utils.spec.ts +++ b/test/jest/identity-utils.spec.ts @@ -6,17 +6,22 @@ describe('normalizeUserIdentityKeys', () => { expect(result).toEqual({ other: 'abc123hash' }); }); - it('maps mobile_sha256 to other', () => { - const result = normalizeUserIdentityKeys({ mobile_sha256: 'mobilehash456' }); - expect(result).toEqual({ other: 'mobilehash456' }); + it('maps mobile_sha256 to other2', () => { + const result = normalizeUserIdentityKeys({ + mobile_sha256: 'mobilehash456', + }); + expect(result).toEqual({ other2: 'mobilehash456' }); }); - it('last sha256 alias wins when both are set (same slot)', () => { + it('maps both aliases to distinct canonical slots', () => { const result = normalizeUserIdentityKeys({ email_sha256: 'emailhash', mobile_sha256: 'mobilehash', }); - expect(result).toEqual({ other: 'mobilehash' }); + expect(result).toEqual({ + other: 'emailhash', + other2: 'mobilehash', + }); }); it('preserves other canonical identity keys alongside sha256 aliases', () => { @@ -35,12 +40,28 @@ describe('normalizeUserIdentityKeys', () => { it('does not modify identities without sha256 aliases', () => { const input = { email: 'user@example.com', customerid: 'cust123' }; const result = normalizeUserIdentityKeys(input); - expect(result).toEqual({ email: 'user@example.com', customerid: 'cust123' }); + expect(result).toEqual({ + email: 'user@example.com', + customerid: 'cust123', + }); }); - it('drops null values for sha256 aliases', () => { + it('passes null on email_sha256 through to other (clears canonical slot)', () => { const result = normalizeUserIdentityKeys({ email_sha256: null }); - expect(result).toEqual({}); + expect(result).toEqual({ other: null }); + }); + + it('passes null on mobile_sha256 through to other2 (clears canonical slot)', () => { + const result = normalizeUserIdentityKeys({ mobile_sha256: null }); + expect(result).toEqual({ other2: null }); + }); + + it('alias overwrites a same-slot canonical value (silent last-write-wins)', () => { + const result = normalizeUserIdentityKeys({ + other: 'preexisting', + email_sha256: 'aliasvalue', + }); + expect(result).toEqual({ other: 'aliasvalue' }); }); it('does not mutate the original object', () => { diff --git a/test/jest/types.spec.ts b/test/jest/types.spec.ts index b3c13f010..723811245 100644 --- a/test/jest/types.spec.ts +++ b/test/jest/types.spec.ts @@ -440,7 +440,7 @@ describe('IdentityType', () => { expect(getIdentityType('phone_number_2')).toBe(PhoneNumber2); expect(getIdentityType('phone_number_3')).toBe(PhoneNumber3); expect(getIdentityType('email_sha256')).toBe(Other); - expect(getIdentityType('mobile_sha256')).toBe(Other); + expect(getIdentityType('mobile_sha256')).toBe(Other2); }); it('returns false if the identity name is not found', () => { From e80cc181d90ec3c9969e32072b3f7d94690165e2 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Mon, 4 May 2026 10:26:08 -0400 Subject: [PATCH 04/10] fix: Address Bugbot finding and Alex review on sha256 alias PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hasIdentityRequestChanged now normalizes the new request before stringify-compare; fixes spurious identify-on-restore when SDKConfig.identifyRequest uses sha256 aliases. - Move the declare module augmentation to public-types.ts (the file shipped via package.json types). Marked for removal once DefinitelyTyped/DefinitelyTyped#74955 publishes. - Type SHA256_IDENTITY_ALIASES as Readonly to drop three downstream casts in normalizeUserIdentityKeys. - Add hasIdentityRequestChanged regression test for the alias→canonical round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-utils.ts | 19 ++++++------------- src/public-types.ts | 9 +++++++++ test/src/tests-identity-utils.ts | 27 +++++++++++++++++++++++++-- 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 638a28d5a..a68d80e4f 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -7,13 +7,6 @@ import { UserIdentities, IdentityCallback, } from '@mparticle/web-sdk'; - -declare module '@mparticle/web-sdk' { - interface UserIdentities { - email_sha256?: string | undefined; - mobile_sha256?: string | undefined; - } -} import { IdentityAPIMethod, IIdentityRequest } from './identity.interfaces'; import { IdentityResultBody, @@ -296,7 +289,7 @@ const getExpireTimestamp = (maxAge: number = ONE_DAY_IN_SECONDS): number => const parseIdentityResponse = (responseText: string): IdentityResultBody => responseText ? JSON.parse(responseText) : ({} as IdentityResultBody); -const SHA256_IDENTITY_ALIASES: Readonly> = { +const SHA256_IDENTITY_ALIASES: Readonly = { email_sha256: 'other', mobile_sha256: 'other2', }; @@ -304,9 +297,7 @@ const SHA256_IDENTITY_ALIASES: Readonly> = { export const normalizeUserIdentityKeys = ( userIdentities: UserIdentities ): UserIdentities => { - const normalized: Record = { - ...(userIdentities as Record), - }; + const normalized: UserIdentities = { ...userIdentities }; for (const alias of Object.keys(SHA256_IDENTITY_ALIASES)) { if (alias in normalized) { const value = normalized[alias]; @@ -314,7 +305,7 @@ export const normalizeUserIdentityKeys = ( normalized[SHA256_IDENTITY_ALIASES[alias]] = value; } } - return normalized as UserIdentities; + return normalized; }; export const hasIdentityRequestChanged = ( @@ -328,7 +319,9 @@ export const hasIdentityRequestChanged = ( const currentUserIdentities = currentUser.getUserIdentities().userIdentities; - const newIdentities = newIdentityRequest.userIdentities; + const newIdentities = normalizeUserIdentityKeys( + newIdentityRequest.userIdentities + ); return ( JSON.stringify(currentUserIdentities) !== JSON.stringify(newIdentities) diff --git a/src/public-types.ts b/src/public-types.ts index d84f52ab4..750ff5d03 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -86,3 +86,12 @@ export type { // Utilities export type { Dictionary, valueof } from './utils'; + +// Remove once DefinitelyTyped/DefinitelyTyped#74955 publishes to +// @types/mparticle__web-sdk. +declare module '@mparticle/web-sdk' { + interface UserIdentities { + email_sha256?: string | null; + mobile_sha256?: string | null; + } +} diff --git a/test/src/tests-identity-utils.ts b/test/src/tests-identity-utils.ts index 0c957760a..b2776c567 100644 --- a/test/src/tests-identity-utils.ts +++ b/test/src/tests-identity-utils.ts @@ -583,9 +583,32 @@ describe('identity-utils', () => { expect(result).to.equal(false); }); + it('returns false when SDKConfig.identifyRequest uses sha256 aliases that resolve to the stored canonical slots', () => { + const userIdentities = { + customerid: 'cust-123', + other: 'emailhash', + other2: 'mobilehash', + }; + + const newIdentities = { + customerid: 'cust-123', + email_sha256: 'emailhash', + mobile_sha256: 'mobilehash', + }; + + const mockCurrentUser = { + getUserIdentities: () => ({ + userIdentities: userIdentities, + }), + } as any; + + const result = hasIdentityRequestChanged(mockCurrentUser, { userIdentities: newIdentities }); + expect(result).to.equal(false); + }); + it('returns false when getCurrentUser() is null', () => { - const result = hasIdentityRequestChanged(null, { - userIdentities: { customerid: 'some-customer-id' } + const result = hasIdentityRequestChanged(null, { + userIdentities: { customerid: 'some-customer-id' } }); expect(result).to.equal(false); }); From 0341a9049e012d0a2ee902802eda5f8747868423 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Mon, 4 May 2026 10:37:45 -0400 Subject: [PATCH 05/10] Apply suggestion from @rmi22186 --- src/identity.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/identity.js b/src/identity.js index 01a5edfd4..29c9dacb9 100644 --- a/src/identity.js +++ b/src/identity.js @@ -41,7 +41,7 @@ export default function Identity(mpInstance) { mpInstance.Logger ); - // Normalize convenience aliases (email_sha256 → other, mobile_sha256 → other) + // Normalize convenience aliases (email_sha256 → other, mobile_sha256 → other2) const cleanedIdentityApiData = removedFalsyIdentityData?.userIdentities ? { ...removedFalsyIdentityData, From 79c7537420c5d2f9ca5a124fc35f7925748c9e06 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Mon, 4 May 2026 14:46:19 -0400 Subject: [PATCH 06/10] chore: Bump @types/mparticle__web-sdk to 2.66.0; reconcile DT type drift @types/mparticle__web-sdk@2.66.0 (DefinitelyTyped/DefinitelyTyped#74955) includes email_sha256 and mobile_sha256 on UserIdentities, so the local augmentation in public-types.ts is no longer needed. The bump also tightens two interfaces in ways the SDK already overrides: - IMParticleUser now extends Omit. DT 2.66.0 makes Product.Name required, breaking the existing override of getCart().getCartProducts() that returns SDKProduct[] (Name optional). Cart APIs are deprecated; the override stays as the canonical SDK shape. - SDKInitConfig adds 'identityCallback' to its Omit escape list. DT 2.66.0's IdentityCallback uses change_results.identity_type: string; the SDK uses SDKIdentityTypeEnum and already redeclares identityCallback locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 61 +++++---------------------------- package.json | 2 +- src/identity-user-interfaces.ts | 2 +- src/public-types.ts | 9 ----- src/sdkRuntimeModels.ts | 2 +- 5 files changed, 11 insertions(+), 65 deletions(-) diff --git a/package-lock.json b/package-lock.json index 345c13d59..e6113cd27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "@types/jest": "^29.5.1", "@types/jest-expect-message": "^1.1.0", "@types/mocha": "^9.0.0", - "@types/mparticle__web-sdk": "^2.16.1", + "@types/mparticle__web-sdk": "^2.66.0", "@types/node": "^20.1.0", "babel-preset-minify": "^0.5.1", "browser-sync": "^2.26.3", @@ -75,6 +75,9 @@ "uglify-js": "^3.4.9", "webpack": "^5.36.2", "webpack-cli": "^5.0.2" + }, + "peerDependencies": { + "@mparticle/event-models": "^1.1.9" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -4059,11 +4062,13 @@ "license": "MIT" }, "node_modules/@types/mparticle__web-sdk": { - "version": "2.20.2", + "version": "2.66.0", + "resolved": "https://registry.npmjs.org/@types/mparticle__web-sdk/-/mparticle__web-sdk-2.66.0.tgz", + "integrity": "sha512-+PwrdQ4Zwt2G3g0NFVfUsU+21+XZT/iiMNIvGtMB7CTu8+hJhNpJ9LiGhEJLdcTA10yZuxp14V3qMlK6eyRusQ==", "dev": true, "license": "MIT", "dependencies": { - "@mparticle/event-models": "^1.1.3" + "@mparticle/event-models": "^1.1.9" } }, "node_modules/@types/node": { @@ -13627,56 +13632,6 @@ "node": ">=18" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-int64": { "version": "0.4.0", "dev": true, diff --git a/package.json b/package.json index 4281518b1..fe1d39cdb 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "@types/jest": "^29.5.1", "@types/jest-expect-message": "^1.1.0", "@types/mocha": "^9.0.0", - "@types/mparticle__web-sdk": "^2.16.1", + "@types/mparticle__web-sdk": "^2.66.0", "@types/node": "^20.1.0", "babel-preset-minify": "^0.5.1", "browser-sync": "^2.26.3", diff --git a/src/identity-user-interfaces.ts b/src/identity-user-interfaces.ts index 9a4b673b6..0959a2007 100644 --- a/src/identity-user-interfaces.ts +++ b/src/identity-user-interfaces.ts @@ -28,7 +28,7 @@ interface ICart { // https://go.mparticle.com/work/SQDSDKS-5033 // https://go.mparticle.com/work/SQDSDKS-6354 -export interface IMParticleUser extends User { +export interface IMParticleUser extends Omit { getAllUserAttributes(): any; setUserTag(tagName: string, value?: any): void; setUserAttribute(key: string, value: any): void; diff --git a/src/public-types.ts b/src/public-types.ts index 750ff5d03..d84f52ab4 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -86,12 +86,3 @@ export type { // Utilities export type { Dictionary, valueof } from './utils'; - -// Remove once DefinitelyTyped/DefinitelyTyped#74955 publishes to -// @types/mparticle__web-sdk. -declare module '@mparticle/web-sdk' { - interface UserIdentities { - email_sha256?: string | null; - mobile_sha256?: string | null; - } -} diff --git a/src/sdkRuntimeModels.ts b/src/sdkRuntimeModels.ts index f9b0d4c13..c6d8e3cad 100644 --- a/src/sdkRuntimeModels.ts +++ b/src/sdkRuntimeModels.ts @@ -306,7 +306,7 @@ export const LogLevelType = { // Currently, this extends MPConfiguration in @types/mparticle__web-sdk // and the two will be merged in once the Store module is refactored export interface SDKInitConfig - extends Omit { + extends Omit { dataPlan?: DataPlanConfig | KitBlockerDataPlan; // TODO: These should be eventually split into two different attributes logLevel?: LogLevelType; From eeb55786037ddd587f174c1bca1785cb3c849790 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Mon, 4 May 2026 16:21:23 -0400 Subject: [PATCH 07/10] chore: Align SDKProduct with DT Product shape; drop getCart Omit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @types/mparticle__web-sdk@2.66.0 made Product.{Name,Sku,Price} required. The SDK's createProduct factory already requires all three at runtime (rejects with an error and returns null otherwise), so promoting them to required on SDKProduct matches the actual API contract. Also drop `| null` from SDKProduct.Attributes — no code path in src/ or test/ assigned null to it, and DT's Product.Attributes is `?: Record<...>` without the null variant. With SDKProduct now structurally assignable to DT's Product, the Omit escape on IMParticleUser is no longer needed — revert to `extends User`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-user-interfaces.ts | 2 +- src/sdkRuntimeModels.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/identity-user-interfaces.ts b/src/identity-user-interfaces.ts index 0959a2007..9a4b673b6 100644 --- a/src/identity-user-interfaces.ts +++ b/src/identity-user-interfaces.ts @@ -28,7 +28,7 @@ interface ICart { // https://go.mparticle.com/work/SQDSDKS-5033 // https://go.mparticle.com/work/SQDSDKS-6354 -export interface IMParticleUser extends Omit { +export interface IMParticleUser extends User { getAllUserAttributes(): any; setUserTag(tagName: string, value?: any): void; setUserAttribute(key: string, value: any): void; diff --git a/src/sdkRuntimeModels.ts b/src/sdkRuntimeModels.ts index c6d8e3cad..e31084228 100644 --- a/src/sdkRuntimeModels.ts +++ b/src/sdkRuntimeModels.ts @@ -161,9 +161,9 @@ export interface SDKProductAction { } export interface SDKProduct { - Sku?: string; - Name?: string; - Price?: number; + Sku: string; + Name: string; + Price: number; Quantity?: number; Brand?: string; Variant?: string; @@ -173,7 +173,7 @@ export interface SDKProduct { TotalAmount?: number; // https://go.mparticle.com/work/SQDSDKS-4801 - Attributes?: Record | null; + Attributes?: Record; } // https://go.mparticle.com/work/SQDSDKS-6949 From 8d9bc4fa848d519104e257d904cbd63f2cde9696 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Mon, 4 May 2026 16:55:34 -0400 Subject: [PATCH 08/10] chore: Tighten SHA256_IDENTITY_ALIASES typing to keys-to-keys mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Cursor Bugbot finding (PR #1252): the previous `Readonly` typing constrained keys to identity names but accepted `string | null | undefined` for values — modeling them as identity *values* rather than canonical key names. That left the map vulnerable to a silent corruption: a future maintainer setting an alias to `null` would compile, then at runtime `normalized[null] = value` coerces the index to the literal string "null", silently dropping the user's hash on the wire. Switch to `Readonly>>` so both keys *and* values must be valid identity key names. `null`, undefined, or arbitrary strings now fail type-check at the declaration site. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index a68d80e4f..948d09341 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -289,7 +289,9 @@ const getExpireTimestamp = (maxAge: number = ONE_DAY_IN_SECONDS): number => const parseIdentityResponse = (responseText: string): IdentityResultBody => responseText ? JSON.parse(responseText) : ({} as IdentityResultBody); -const SHA256_IDENTITY_ALIASES: Readonly = { +const SHA256_IDENTITY_ALIASES: Readonly< + Partial> +> = { email_sha256: 'other', mobile_sha256: 'other2', }; From 308f87bcfe2101c3182fe3794501946850b9bec4 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 6 May 2026 09:06:30 -0400 Subject: [PATCH 09/10] fix: Type SHA256_IDENTITY_ALIASES keys as alias literals, not UserIdentities keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous tightening typed the alias map as keyof UserIdentities → keyof UserIdentities, which broke the build because email_sha256/mobile_sha256 are input alias names that don't exist on the UserIdentities interface — they map TO UserIdentities keys. Introduce a Sha256IdentityAlias literal union for the keys, and widen normalizeUserIdentityKeys' input to accept the aliases. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-utils.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 948d09341..277dd761c 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -289,24 +289,31 @@ const getExpireTimestamp = (maxAge: number = ONE_DAY_IN_SECONDS): number => const parseIdentityResponse = (responseText: string): IdentityResultBody => responseText ? JSON.parse(responseText) : ({} as IdentityResultBody); +type Sha256IdentityAlias = 'email_sha256' | 'mobile_sha256'; + const SHA256_IDENTITY_ALIASES: Readonly< - Partial> + Record > = { email_sha256: 'other', mobile_sha256: 'other2', }; +type UserIdentitiesWithAliases = UserIdentities & + Partial>; + export const normalizeUserIdentityKeys = ( - userIdentities: UserIdentities + userIdentities: UserIdentitiesWithAliases ): UserIdentities => { - const normalized: UserIdentities = { ...userIdentities }; - for (const alias of Object.keys(SHA256_IDENTITY_ALIASES)) { - if (alias in normalized) { - const value = normalized[alias]; - delete normalized[alias]; - normalized[SHA256_IDENTITY_ALIASES[alias]] = value; + const normalized: UserIdentitiesWithAliases = { ...userIdentities }; + (Object.keys(SHA256_IDENTITY_ALIASES) as Sha256IdentityAlias[]).forEach( + (alias) => { + if (alias in normalized) { + const value = normalized[alias]; + delete normalized[alias]; + normalized[SHA256_IDENTITY_ALIASES[alias]] = value; + } } - } + ); return normalized; }; From b97f5660c1d7c5af87bdbd810743e458845fe5ee Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 6 May 2026 09:57:34 -0400 Subject: [PATCH 10/10] fix: Make hasIdentityRequestChanged comparison order-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous JSON.stringify-based comparison was sensitive to object key insertion order. The current-user side iterates a numeric IdentityType map (yielding keys in IdentityType ascending order), while the new-request side reflects partner-supplied / alias-normalized order. Equivalent identity sets in different orders produced different stringified output, flagging spurious changes and triggering unnecessary identify API calls on session resume — the bugbot finding on PR #1252. Replace the stringify with a structural compare that checks key-set equality and per-key value equality, independent of order. Add hasIdentityRequestChanged spec covering null inputs, order-mismatched matches (with and without sha256 aliases), and genuine value/key changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-utils.ts | 18 +++++- test/jest/identity-utils.spec.ts | 99 +++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 277dd761c..59ef35274 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -317,6 +317,20 @@ export const normalizeUserIdentityKeys = ( return normalized; }; +// Compare two identity objects by key and value, independent of +// object key insertion order. The two sides come from different sources +// (numeric IdentityType iteration vs. partner-provided / alias-normalized +// order) +const identitiesEqual = ( + a?: UserIdentities, + b?: UserIdentities +): boolean => { + const aKeys = Object.keys(a ?? {}); + const bKeys = Object.keys(b ?? {}); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key) => a[key] === b[key]); +}; + export const hasIdentityRequestChanged = ( currentUser: IMParticleUser, newIdentityRequest: IdentityApiData @@ -332,9 +346,7 @@ export const hasIdentityRequestChanged = ( newIdentityRequest.userIdentities ); - return ( - JSON.stringify(currentUserIdentities) !== JSON.stringify(newIdentities) - ); + return !identitiesEqual(currentUserIdentities, newIdentities); }; /** diff --git a/test/jest/identity-utils.spec.ts b/test/jest/identity-utils.spec.ts index 9acb96702..3ea297491 100644 --- a/test/jest/identity-utils.spec.ts +++ b/test/jest/identity-utils.spec.ts @@ -1,4 +1,13 @@ -import { normalizeUserIdentityKeys } from '../../src/identity-utils'; +import { + hasIdentityRequestChanged, + normalizeUserIdentityKeys, +} from '../../src/identity-utils'; +import { IMParticleUser } from '../../src/identity-user-interfaces'; + +const mockUserWithIdentities = (userIdentities: Record) => + (({ + getUserIdentities: () => ({ userIdentities }), + } as unknown) as IMParticleUser); describe('normalizeUserIdentityKeys', () => { it('maps email_sha256 to other', () => { @@ -70,3 +79,91 @@ describe('normalizeUserIdentityKeys', () => { expect(input).toEqual({ email_sha256: 'hash' }); }); }); + +describe('hasIdentityRequestChanged', () => { + it('returns false when current user is null', () => { + const result = hasIdentityRequestChanged(null, { + userIdentities: { customerid: 'c' }, + }); + expect(result).toBe(false); + }); + + it('returns false when new request has no userIdentities', () => { + const user = mockUserWithIdentities({ customerid: 'c' }); + expect(hasIdentityRequestChanged(user, null)).toBe(false); + expect(hasIdentityRequestChanged(user, {} as any)).toBe(false); + }); + + it('returns false when identities match in value but differ in key order', () => { + // Persisted side comes from numeric IdentityType iteration: + // other(0), customerid(1) + const user = mockUserWithIdentities({ + other: 'hash', + customerid: 'cust123', + }); + // Partner-supplied request in different (input) order + const result = hasIdentityRequestChanged(user, { + userIdentities: { + customerid: 'cust123', + other: 'hash', + }, + }); + expect(result).toBe(false); + }); + + it('returns false when an alias normalizes to a canonical match (regression for bugbot finding)', () => { + // Persisted user has `other` set to a sha256 value at IdentityType.Other(0), + // alongside customerid at IdentityType.CustomerId(1) — so numeric iteration + // order yields { other, customerid }. + const user = mockUserWithIdentities({ + other: 'sha256ofEmail', + customerid: 'cust123', + }); + // Partner config supplies the alias form. After normalization the new + // identities historically had `other` appended at the end, causing a + // spurious mismatch. With order-independent comparison this should match. + const result = hasIdentityRequestChanged(user, { + userIdentities: { + customerid: 'cust123', + email_sha256: 'sha256ofEmail', + } as any, + }); + expect(result).toBe(false); + }); + + it('returns true when an identity value actually differs', () => { + const user = mockUserWithIdentities({ + other: 'oldhash', + customerid: 'cust123', + }); + const result = hasIdentityRequestChanged(user, { + userIdentities: { + customerid: 'cust123', + email_sha256: 'newhash', + } as any, + }); + expect(result).toBe(true); + }); + + it('returns true when the new request adds an identity', () => { + const user = mockUserWithIdentities({ customerid: 'cust123' }); + const result = hasIdentityRequestChanged(user, { + userIdentities: { + customerid: 'cust123', + email: 'user@example.com', + }, + }); + expect(result).toBe(true); + }); + + it('returns true when the new request drops an identity', () => { + const user = mockUserWithIdentities({ + customerid: 'cust123', + email: 'user@example.com', + }); + const result = hasIdentityRequestChanged(user, { + userIdentities: { customerid: 'cust123' }, + }); + expect(result).toBe(true); + }); +});