From 3d10076281ab6c63522ab8ca93270c23effbbcf5 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 5 May 2026 09:06:03 -0400 Subject: [PATCH 1/5] fix: Allow all user identitiy types to send to search --- src/identity-utils.ts | 3 +-- src/identity.interfaces.ts | 31 ++++++++++++++++++++++------ src/identity.js | 6 +++++- src/identity/search.ts | 41 ++++++++++++++++---------------------- src/public-types.ts | 2 +- test/src/tests-search.ts | 35 +++++++++++++++++++++++++++++--- 6 files changed, 81 insertions(+), 37 deletions(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 6a84b5485..929e5be47 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -16,7 +16,6 @@ import { import { IStore } from './store'; import type { IMParticleWebSDKInstance } from './mp-instance'; import { - IIdentitySearchKnownIdentities, IIdentitySearchRequestBody, IdentitySearchCallback, sendSearchRequest, @@ -358,7 +357,7 @@ export const buildIdentitySearchEnvelope = ( export const executeSearchRequest = ( mpInstance: IMParticleWebSDKInstance, workspaceApiKey: string, - knownIdentities: IIdentitySearchKnownIdentities, + knownIdentities: UserIdentities, callback: IdentitySearchCallback, ): void => { const { _Helpers, _Store, Logger, _ErrorReportingDispatcher } = mpInstance; diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index 1a02400e6..d1873cadf 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -12,10 +12,7 @@ import { mParticleUserCart, IIdentityResponse, } from './identity-user-interfaces'; -import { - IIdentitySearchKnownIdentities, - IdentitySearchCallback, -} from './identity/search'; +import { IdentitySearchCallback } from './identity/search'; const { platform, sdkVendor, sdkVersion, HTTPCodes } = Constants; export type IdentityPreProcessResult = { @@ -173,18 +170,40 @@ export interface SDKIdentityApi { */ search?( workspaceApiKey: string, - knownIdentities: IIdentitySearchKnownIdentities, + knownIdentities: UserIdentities, callback: IdentitySearchCallback ): void; } export type { - IIdentitySearchKnownIdentities, IIdentitySearchResult, IIdentitySearchResponseBody, IdentitySearchCallback, } from './identity/search'; +export interface IUserIdentities { + customerid?: string | undefined; + email?: string | undefined; + other?: string | undefined; + other2?: string | undefined; + other3?: string | undefined; + other4?: string | undefined; + other5?: string | undefined; + other6?: string | undefined; + other7?: string | undefined; + other8?: string | undefined; + other9?: string | undefined; + other10?: string | undefined; + mobile_number?: string | undefined; + phone_number_2?: string | undefined; + phone_number_3?: string | undefined; + facebook?: string | undefined; + facebookcustomaudienceid?: string | undefined; + google?: string | undefined; + twitter?: string | undefined; + microsoft?: string | undefined; + yahoo?: string | undefined; +} export interface IIdentity { audienceManager: AudienceManager; idCache: BaseVault>; diff --git a/src/identity.js b/src/identity.js index 65cebd111..ffd18231e 100644 --- a/src/identity.js +++ b/src/identity.js @@ -745,7 +745,11 @@ export default function Identity(mpInstance) { * * @method search * @param {String} workspaceApiKey Workspace API key (sent as x-mp-key). - * @param {Object} knownIdentities `{ email: string }` + * @param {Object} knownIdentities A `UserIdentities` map. Any of the + * identifiers accepted by `/v1/identify` may be supplied — e.g. + * `email`, `customerid`, `other`/`other2-10` (commonly used for + * hashed email), `email_sha256`, `mobile_number`, `facebook`, etc. + * At least one non-empty string identifier must be present. * @param {Function} callback Invoked with the `IIdentitySearchResult`. */ search: function(workspaceApiKey, knownIdentities, callback) { diff --git a/src/identity/search.ts b/src/identity/search.ts index 9be25e956..83dfa2a4b 100644 --- a/src/identity/search.ts +++ b/src/identity/search.ts @@ -12,21 +12,10 @@ import { IErrorReportingService, WSDKErrorSeverity, } from '../reporting/types'; +import { UserIdentities } from '@mparticle/web-sdk'; const { HTTPCodes } = Constants; -/** - * Shape of `known_identities` accepted by `search`. - * - * The IDSync `/v1/search` endpoint accepts the same identity keys as - * `/v1/identify`, but for v1 of this client API we only support `email`. - * Additional identity types can be added here in the future without breaking - * existing consumers. - */ -export interface IIdentitySearchKnownIdentities { - email: string; -} - /** * Body payload returned by the `/v1/search` endpoint, as parsed JSON. * @@ -72,7 +61,7 @@ export interface IIdentitySearchRequestBody { environment: Environment; request_id: string; request_timestamp_ms: number; - known_identities: IIdentitySearchKnownIdentities; + known_identities: UserIdentities; } interface IIdentitySearchPayload extends IFetchPayload { @@ -88,9 +77,9 @@ interface IIdentitySearchPayload extends IFetchPayload { * with the HTTP status and parsed body. * * Defensive contract: - * - Missing/invalid `email` -> callback with `{ httpCode: noHttpCoverage }`, - * no network call. - * - Missing `apiKey` -> callback with `{ httpCode: noHttpCoverage }`, + * - No identifier with a non-empty string value -> callback with + * `{ httpCode: noHttpCoverage }`, no network call. + * - Missing `apiKey` -> callback with `{ httpCode: noHttpCoverage }`, * no network call. * - Network/JSON-parse errors are caught and surfaced via the callback, * never thrown. Network errors are also reported through the optional @@ -98,7 +87,7 @@ interface IIdentitySearchPayload extends IFetchPayload { * them (matches the pattern used by identifyRequest in identityApiClient). */ export const sendSearchRequest = async ( - knownIdentities: IIdentitySearchKnownIdentities, + knownIdentities: UserIdentities, apiKey: string, requestBuilder: () => Omit, searchUrl: string, @@ -127,11 +116,17 @@ export const sendSearchRequest = async ( } }; - // No valid email -> deliver httpCode: noHttpCoverage so callers waiting on - // the callback (e.g. to clear a loading state) don't hang. - if (!knownIdentities || typeof knownIdentities.email !== 'string' || !knownIdentities.email) { + // No usable identifier -> deliver httpCode: noHttpCoverage so callers + // waiting on the callback (e.g. to clear a loading state) don't hang. + const hasIdentifier = + knownIdentities && + typeof knownIdentities === 'object' && + Object.values(knownIdentities).some( + (v) => typeof v === 'string' && v.length > 0, + ); + if (!hasIdentifier) { logger.verbose( - 'search called without a valid email; skipping request.', + 'search called without any non-empty identifier; skipping request.', ); safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); return; @@ -155,9 +150,7 @@ export const sendSearchRequest = async ( const requestEnvelope = requestBuilder(); const requestBody: IIdentitySearchRequestBody = { ...requestEnvelope, - known_identities: { - email: knownIdentities.email, - }, + known_identities: { ...knownIdentities }, }; const fetchPayload: IIdentitySearchPayload = { diff --git a/src/public-types.ts b/src/public-types.ts index 4b485ed48..d84f52ab4 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -56,10 +56,10 @@ export type { IAliasCallback, IAliasResult, SDKIdentityTypeEnum, - IIdentitySearchKnownIdentities, IIdentitySearchResult, IIdentitySearchResponseBody, IdentitySearchCallback, + IUserIdentities } from './identity.interfaces'; // eCommerce diff --git a/test/src/tests-search.ts b/test/src/tests-search.ts index 852dc3ff6..1160c8daa 100644 --- a/test/src/tests-search.ts +++ b/test/src/tests-search.ts @@ -196,7 +196,7 @@ describe('search', () => { expect(requestBuilderSpy.called).to.eq(false); }); - it('invokes the callback with noHttpCoverage when knownIdentities.email is missing or invalid (no network)', async () => { + it('invokes the callback with noHttpCoverage when no identifier has a non-empty string value (no network)', async () => { const callback = sinon.spy(); await sendSearchRequest( @@ -218,7 +218,7 @@ describe('search', () => { ); await sendSearchRequest( - ({ email: 12345 } as any), + ({ email: 12345, other: null } as any), apiKey, buildEnvelope, searchUrl, @@ -226,7 +226,7 @@ describe('search', () => { logger, ); - // Missing/invalid email: no network, but callback fires for each + // No usable identifier: no network, but callback fires for each // call so callers can resolve any pending loading state. expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(callback.callCount).to.equal(3); @@ -236,6 +236,35 @@ describe('search', () => { } }); + it('forwards non-email identifiers (e.g. hashed email in `other`) to the wire', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: '42' }), + }); + + const callback = sinon.spy(); + await sendSearchRequest( + { other: 'sha256:abc123', customerid: 'cust-1' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + expect(fetchMock.calls(searchUrl).length).to.equal(1); + const sentBody = JSON.parse( + fetchMock.calls(searchUrl)[0][1].body as string, + ); + expect(sentBody.known_identities).to.deep.equal({ + other: 'sha256:abc123', + customerid: 'cust-1', + }); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(200); + }); + it('catches network errors and surfaces noHttpCoverage via the callback (not thrown)', async () => { fetchMock.post(searchUrl, { throws: new Error('network down') }); From 4dcf5a74269397df8261eb6d858819fc3d2eacb2 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 5 May 2026 10:53:13 -0400 Subject: [PATCH 2/5] refactor: Address PR #1256 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IUserIdentities: drop redundant `| undefined` from each property — the `?` modifier already implies undefined (per @alexs-mparticle review). - search.ts: route knownIdentities through `Validators.removeFalsyIdentityValues` before the guard and the wire spread. Strips undefined/0/false/'' (with a caller-side warning), preserves null (server accepts it as a "no value" sentinel). Resolves the Cursor bot concern about unvalidated values reaching the wire while matching `Identity.identify/login/modify` behavior. - search.ts: simplify the no-usable-identifier guard with `isEmpty` plus a string-value check (per @alexs-mparticle review). - identity.js: trim the `Identity.search` JSDoc to a one-liner — the long identifier-list inventory drifts from the type and adds noise without helping readers. - tests-search.ts: add coverage for the falsy-stripping + null-preservation behavior — `{ email: 'valid@example.com', customerid: null, other: '' }` hits the wire as `{ email: 'valid@example.com', customerid: null }`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.interfaces.ts | 42 +++++++++++++++++++------------------- src/identity.js | 6 +----- src/identity/search.ts | 24 ++++++++++++++-------- test/src/tests-search.ts | 38 ++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 35 deletions(-) diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index d1873cadf..a223b77d2 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -182,27 +182,27 @@ export type { } from './identity/search'; export interface IUserIdentities { - customerid?: string | undefined; - email?: string | undefined; - other?: string | undefined; - other2?: string | undefined; - other3?: string | undefined; - other4?: string | undefined; - other5?: string | undefined; - other6?: string | undefined; - other7?: string | undefined; - other8?: string | undefined; - other9?: string | undefined; - other10?: string | undefined; - mobile_number?: string | undefined; - phone_number_2?: string | undefined; - phone_number_3?: string | undefined; - facebook?: string | undefined; - facebookcustomaudienceid?: string | undefined; - google?: string | undefined; - twitter?: string | undefined; - microsoft?: string | undefined; - yahoo?: string | undefined; + customerid?: string; + email?: string; + other?: string; + other2?: string; + other3?: string; + other4?: string; + other5?: string; + other6?: string; + other7?: string; + other8?: string; + other9?: string; + other10?: string; + mobile_number?: string; + phone_number_2?: string; + phone_number_3?: string; + facebook?: string; + facebookcustomaudienceid?: string; + google?: string; + twitter?: string; + microsoft?: string; + yahoo?: string; } export interface IIdentity { audienceManager: AudienceManager; diff --git a/src/identity.js b/src/identity.js index ffd18231e..99e767e19 100644 --- a/src/identity.js +++ b/src/identity.js @@ -745,11 +745,7 @@ export default function Identity(mpInstance) { * * @method search * @param {String} workspaceApiKey Workspace API key (sent as x-mp-key). - * @param {Object} knownIdentities A `UserIdentities` map. Any of the - * identifiers accepted by `/v1/identify` may be supplied — e.g. - * `email`, `customerid`, `other`/`other2-10` (commonly used for - * hashed email), `email_sha256`, `mobile_number`, `facebook`, etc. - * At least one non-empty string identifier must be present. + * @param {Object} knownIdentities A `UserIdentities` map. * @param {Function} callback Invoked with the `IIdentitySearchResult`. */ search: function(workspaceApiKey, knownIdentities, callback) { diff --git a/src/identity/search.ts b/src/identity/search.ts index 83dfa2a4b..8de9aeda2 100644 --- a/src/identity/search.ts +++ b/src/identity/search.ts @@ -1,6 +1,6 @@ import Constants, { HTTP_OK, HTTP_NOT_FOUND } from '../constants'; import { SDKLoggerApi } from '../sdkRuntimeModels'; -import { Environment, isFunction } from '../utils'; +import { Environment, isEmpty, isFunction } from '../utils'; import { AsyncUploader, FetchUploader, @@ -12,7 +12,8 @@ import { IErrorReportingService, WSDKErrorSeverity, } from '../reporting/types'; -import { UserIdentities } from '@mparticle/web-sdk'; +import { IdentityApiData, UserIdentities } from '@mparticle/web-sdk'; +import Validators from '../validators'; const { HTTPCodes } = Constants; @@ -116,15 +117,19 @@ export const sendSearchRequest = async ( } }; + const cleanedKnownIdentities: UserIdentities = Validators.removeFalsyIdentityValues( + { userIdentities: knownIdentities ?? {} } as IdentityApiData, + logger, + ).userIdentities; + // No usable identifier -> deliver httpCode: noHttpCoverage so callers // waiting on the callback (e.g. to clear a loading state) don't hang. - const hasIdentifier = - knownIdentities && - typeof knownIdentities === 'object' && - Object.values(knownIdentities).some( + if ( + isEmpty(cleanedKnownIdentities) || + !Object.values(cleanedKnownIdentities).some( (v) => typeof v === 'string' && v.length > 0, - ); - if (!hasIdentifier) { + ) + ) { logger.verbose( 'search called without any non-empty identifier; skipping request.', ); @@ -148,9 +153,10 @@ export const sendSearchRequest = async ( // rejecting and the caller hanging on a never-fired callback. try { const requestEnvelope = requestBuilder(); + const requestBody: IIdentitySearchRequestBody = { ...requestEnvelope, - known_identities: { ...knownIdentities }, + known_identities: { ...cleanedKnownIdentities }, }; const fetchPayload: IIdentitySearchPayload = { diff --git a/test/src/tests-search.ts b/test/src/tests-search.ts index 1160c8daa..011fa5083 100644 --- a/test/src/tests-search.ts +++ b/test/src/tests-search.ts @@ -265,6 +265,44 @@ describe('search', () => { expect(result.httpCode).to.equal(200); }); + it('strips falsy identity values but preserves null on the wire', async () => { + // removeFalsyIdentityValues drops undefined / 0 / false / '' but + // explicitly preserves null (the server accepts it as a "no value" + // sentinel). Anything with at least one non-empty string value + // still triggers the network call. + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: '99' }), + }); + + const callback = sinon.spy(); + await sendSearchRequest( + ({ + email: 'valid@example.com', + customerid: null, + other: '', + } as any), + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + expect(fetchMock.calls(searchUrl).length).to.equal(1); + const sentBody = JSON.parse( + fetchMock.calls(searchUrl)[0][1].body as string, + ); + // `other: ''` stripped, `customerid: null` preserved on the wire. + expect(sentBody.known_identities).to.deep.equal({ + email: 'valid@example.com', + customerid: null, + }); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(200); + }); + it('catches network errors and surfaces noHttpCoverage via the callback (not thrown)', async () => { fetchMock.post(searchUrl, { throws: new Error('network down') }); From 7ef842e3b530d3bf53fed0938eceddd1dc700dc3 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 5 May 2026 11:25:13 -0400 Subject: [PATCH 3/5] Apply suggestion from @alexs-mparticle Co-authored-by: Alex S <49695018+alexs-mparticle@users.noreply.github.com> --- src/identity/search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/identity/search.ts b/src/identity/search.ts index 8de9aeda2..edfdffb07 100644 --- a/src/identity/search.ts +++ b/src/identity/search.ts @@ -131,7 +131,7 @@ export const sendSearchRequest = async ( ) ) { logger.verbose( - 'search called without any non-empty identifier; skipping request.', + 'Identity search called with non empty identifiers; skipping request.', ); safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); return; From a6672c98709f4ddee1b6f9d0e6ff143f2480c4c0 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 5 May 2026 11:25:53 -0400 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Alex S <49695018+alexs-mparticle@users.noreply.github.com> --- src/identity/search.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/identity/search.ts b/src/identity/search.ts index edfdffb07..f4868b3d7 100644 --- a/src/identity/search.ts +++ b/src/identity/search.ts @@ -125,8 +125,7 @@ export const sendSearchRequest = async ( // No usable identifier -> deliver httpCode: noHttpCoverage so callers // waiting on the callback (e.g. to clear a loading state) don't hang. if ( - isEmpty(cleanedKnownIdentities) || - !Object.values(cleanedKnownIdentities).some( + !Object.values(cleanedKnownIdentities ?? {}).some( (v) => typeof v === 'string' && v.length > 0, ) ) { From a0fb93cbf1d942b8d76e9cd16ddffa8a141ac4bb Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 5 May 2026 11:32:51 -0400 Subject: [PATCH 5/5] update comment --- src/identity/search.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/identity/search.ts b/src/identity/search.ts index f4868b3d7..934cfae1b 100644 --- a/src/identity/search.ts +++ b/src/identity/search.ts @@ -1,6 +1,6 @@ import Constants, { HTTP_OK, HTTP_NOT_FOUND } from '../constants'; import { SDKLoggerApi } from '../sdkRuntimeModels'; -import { Environment, isEmpty, isFunction } from '../utils'; +import { Environment, isFunction } from '../utils'; import { AsyncUploader, FetchUploader, @@ -130,7 +130,7 @@ export const sendSearchRequest = async ( ) ) { logger.verbose( - 'Identity search called with non empty identifiers; skipping request.', + 'Identity search called with empty identifiers; skipping request.', ); safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); return;