@mparticle/web-sdk 2.66.0 → 2.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ import { SDKLoggerApi } from '../sdkRuntimeModels';
2
+ import { Environment } from '../utils';
3
+ import { AsyncUploader } from '../uploaders';
4
+ import { IErrorReportingService } from '../reporting/types';
5
+ import { UserIdentities } from '@mparticle/web-sdk';
6
+ /**
7
+ * Body payload returned by the `/v1/search` endpoint, as parsed JSON.
8
+ *
9
+ * The shape mirrors `/v1/identify` responses. All fields are optional because
10
+ * non-200 responses (e.g. 404 NOT_FOUND_ERROR) may include partial or
11
+ * error-shaped bodies, and the consumer should only rely on body fields when
12
+ * `httpCode === 200`.
13
+ */
14
+ export interface IIdentitySearchResponseBody {
15
+ context?: string | null;
16
+ mpid?: string;
17
+ matched_identities?: Record<string, string>;
18
+ is_ephemeral?: boolean;
19
+ is_logged_in?: boolean;
20
+ }
21
+ /**
22
+ * Result delivered to the consumer's callback. `httpCode` is always present;
23
+ * `body` is present whenever the response had a parseable JSON body.
24
+ *
25
+ * For non-network errors (missing API key, validation failures, JSON parse
26
+ * errors) `httpCode` will be `HTTPCodes.noHttpCoverage` (-1) and `body` will
27
+ * be omitted. The consumer is expected to gate behaviour on
28
+ * `httpCode === 200`.
29
+ */
30
+ export interface IIdentitySearchResult {
31
+ httpCode: number;
32
+ body?: IIdentitySearchResponseBody;
33
+ }
34
+ export type IdentitySearchCallback = (result: IIdentitySearchResult) => void;
35
+ /**
36
+ * Body posted to `/v1/search`. Mirrors the `/v1/identify` request envelope so
37
+ * that the IDSync service can correlate requests across endpoints.
38
+ */
39
+ export interface IIdentitySearchRequestBody {
40
+ client_sdk: {
41
+ platform: string;
42
+ sdk_vendor: string;
43
+ sdk_version: string;
44
+ };
45
+ environment: Environment;
46
+ request_id: string;
47
+ request_timestamp_ms: number;
48
+ known_identities: UserIdentities;
49
+ }
50
+ /**
51
+ * Sends a POST to mParticle's IDSync Search endpoint and invokes `callback`
52
+ * with the HTTP status and parsed body.
53
+ *
54
+ * Defensive contract:
55
+ * - No identifier with a non-empty string value -> callback with
56
+ * `{ httpCode: noHttpCoverage }`, no network call.
57
+ * - Missing `apiKey` -> callback with `{ httpCode: noHttpCoverage }`,
58
+ * no network call.
59
+ * - Network/JSON-parse errors are caught and surfaced via the callback,
60
+ * never thrown. Network errors are also reported through the optional
61
+ * `errorReporter` so any registered IErrorReportingService can observe
62
+ * them (matches the pattern used by identifyRequest in identityApiClient).
63
+ */
64
+ export declare const sendSearchRequest: (knownIdentities: UserIdentities, apiKey: string, requestBuilder: () => Omit<IIdentitySearchRequestBody, 'known_identities'>, searchUrl: string, callback: IdentitySearchCallback, logger: SDKLoggerApi, uploader?: AsyncUploader, errorReporter?: IErrorReportingService) => Promise<void>;
@@ -1,9 +1,11 @@
1
- import { Dictionary } from './utils';
1
+ import { Dictionary, Environment } from './utils';
2
2
  import { BaseVault } from './vault';
3
3
  import { IdentityApiData, UserIdentities, IdentityCallback } from '@mparticle/web-sdk';
4
4
  import { IdentityAPIMethod } from './identity.interfaces';
5
5
  import { IIdentityResponse, IMParticleUser } from './identity-user-interfaces';
6
6
  import { IStore } from './store';
7
+ import type { IMParticleWebSDKInstance } from './mp-instance';
8
+ import { IIdentitySearchRequestBody, IdentitySearchCallback } from './identity/search';
7
9
  export declare const CACHE_HEADER: "x-mp-max-age";
8
10
  export type IdentityCache = BaseVault<Dictionary<ICachedIdentityCall>>;
9
11
  export type IParseCachedIdentityResponse = (cachedIdentity: IIdentityResponse, mpid: string, callback: IdentityCallback, identityApiData: IdentityApiData, identityMethod: string, knownIdentities: IKnownIdentities, fromCachedIdentity: boolean) => void;
@@ -23,6 +25,9 @@ export declare const getCachedIdentity: (method: IdentityAPIMethod, proposedUser
23
25
  export declare const createKnownIdentities: (identityApiData: IdentityApiData, deviceId: string) => IKnownIdentities;
24
26
  export declare const removeExpiredIdentityCacheDates: (idCache: BaseVault<Dictionary<ICachedIdentityCall>>) => void;
25
27
  export declare const tryCacheIdentity: (knownIdentities: IKnownIdentities, idCache: IdentityCache, parseIdentityResponse: IParseCachedIdentityResponse, mpid: string, callback: IdentityCallback, identityApiData: IdentityApiData, identityMethod: IdentityAPIMethod) => boolean;
28
+ type Sha256IdentityAlias = 'email_sha256' | 'mobile_sha256';
29
+ type UserIdentitiesWithAliases = UserIdentities & Partial<Record<Sha256IdentityAlias, string | null>>;
30
+ export declare const normalizeUserIdentityKeys: (userIdentities: UserIdentitiesWithAliases) => UserIdentities;
26
31
  export declare const hasIdentityRequestChanged: (currentUser: IMParticleUser, newIdentityRequest: IdentityApiData) => boolean;
27
32
  /**
28
33
  * Checks if deviceId or other user identifiers (like email) were explicitly provided
@@ -34,3 +39,18 @@ export declare const hasIdentityRequestChanged: (currentUser: IMParticleUser, ne
34
39
  * @returns true if deviceId or other identifiers were explicitly provided in config, false otherwise
35
40
  */
36
41
  export declare const hasExplicitIdentifier: (store: IStore | undefined | null) => boolean;
42
+ /**
43
+ * Builds the /v1/identify-style envelope (client_sdk, environment,
44
+ * request_id, request_timestamp_ms) used to correlate IDSync requests
45
+ * across endpoints. `known_identities` is omitted so the caller can
46
+ * fold in the search-specific identifiers alongside the envelope.
47
+ */
48
+ export declare const buildIdentitySearchEnvelope: (environment: Environment) => Omit<IIdentitySearchRequestBody, 'known_identities'>;
49
+ /**
50
+ * Wires the SDK instance into `sendSearchRequest`: gates on `canLog`,
51
+ * builds the `/v1/search` URL and request envelope, and dispatches.
52
+ * Lives here so the SDK glue (URL building, opt-out gate, dispatcher
53
+ * plumbing) is type-checked instead of being expressed in plain JS.
54
+ */
55
+ export declare const executeSearchRequest: (mpInstance: IMParticleWebSDKInstance, workspaceApiKey: string, knownIdentities: UserIdentities, callback: IdentitySearchCallback) => void;
56
+ export {};
@@ -5,6 +5,7 @@ import { BaseVault } from './vault';
5
5
  import { Dictionary, Environment, valueof } from './utils';
6
6
  import Constants from './constants';
7
7
  import { IdentityCallback, IUserAttributeChangeEvent, IUserIdentityChangeEvent, IMParticleUser, mParticleUserCart, IIdentityResponse } from './identity-user-interfaces';
8
+ import { IdentitySearchCallback } from './identity/search';
8
9
  declare const platform: "web", sdkVendor: "mparticle", sdkVersion: string, HTTPCodes: {
9
10
  readonly noHttpCoverage: -1;
10
11
  readonly activeIdentityRequest: -2;
@@ -97,6 +98,42 @@ export interface SDKIdentityApi {
97
98
  getUsers?(): IMParticleUser[];
98
99
  aliasUsers?(aliasRequest?: IAliasRequest, callback?: IdentityCallback): void;
99
100
  createAliasRequest?(sourceUser: IMParticleUser, destinationUser: IMParticleUser, scope?: AliasRequestScope): IAliasRequest;
101
+ /**
102
+ * Sends a request to mParticle's IDSync `/v1/search` endpoint to look up
103
+ * a workspace identity without affecting the current user. The callback
104
+ * receives `httpCode` (always) and an optional `body` containing the
105
+ * parsed JSON response. Consumers should gate behaviour on
106
+ * `httpCode === 200`.
107
+ *
108
+ * `workspaceApiKey` is a workspace-specific API key supplied by the
109
+ * caller (from a kit's settings). It is sent as the `x-mp-key` header.
110
+ * The SDK's own workspace token is intentionally not used.
111
+ */
112
+ search?(workspaceApiKey: string, knownIdentities: UserIdentities, callback: IdentitySearchCallback): void;
113
+ }
114
+ export type { IIdentitySearchResult, IIdentitySearchResponseBody, IdentitySearchCallback, } from './identity/search';
115
+ export interface IUserIdentities {
116
+ customerid?: string;
117
+ email?: string;
118
+ other?: string;
119
+ other2?: string;
120
+ other3?: string;
121
+ other4?: string;
122
+ other5?: string;
123
+ other6?: string;
124
+ other7?: string;
125
+ other8?: string;
126
+ other9?: string;
127
+ other10?: string;
128
+ mobile_number?: string;
129
+ phone_number_2?: string;
130
+ phone_number_3?: string;
131
+ facebook?: string;
132
+ facebookcustomaudienceid?: string;
133
+ google?: string;
134
+ twitter?: string;
135
+ microsoft?: string;
136
+ yahoo?: string;
100
137
  }
101
138
  export interface IIdentity {
102
139
  audienceManager: AudienceManager;
@@ -114,4 +151,3 @@ export interface IIdentity {
114
151
  */
115
152
  mParticleUserCart(): mParticleUserCart;
116
153
  }
117
- export {};
@@ -16,7 +16,7 @@ export type { Batch, } from '@mparticle/event-models';
16
16
  export type { EventType, CommerceEventType, IdentityType, ProductActionType, PromotionActionType, MessageType, } from './types';
17
17
  export type { SDKInitConfig, DataPlanConfig, BaseEvent, SDKEventCustomFlags, LogLevelType, MParticleWebSDK, } from './sdkRuntimeModels';
18
18
  export type { IMParticleUser, IdentityCallback, IdentityResult, IdentityResultBody, IdentityModifyResultBody, ISDKUserIdentity, ISDKUserAttributes, } from './identity-user-interfaces';
19
- export type { SDKIdentityApi, IAliasRequest, IAliasCallback, IAliasResult, SDKIdentityTypeEnum, } from './identity.interfaces';
19
+ export type { SDKIdentityApi, IAliasRequest, IAliasCallback, IAliasResult, SDKIdentityTypeEnum, IIdentitySearchResult, IIdentitySearchResponseBody, IdentitySearchCallback, IUserIdentities } from './identity.interfaces';
20
20
  export type { SDKECommerceAPI, SDKCart, } from './ecommerce.interfaces';
21
21
  export type { SDKProduct, SDKPromotion, SDKImpression, SDKProductImpression, } from './sdkRuntimeModels';
22
22
  export type { SDKConsentApi, SDKConsentState, SDKConsentStateData, SDKGDPRConsentState, SDKCCPAConsentState, } from './consent';
@@ -118,9 +118,9 @@ export interface SDKProductAction {
118
118
  TaxAmount?: number;
119
119
  }
120
120
  export interface SDKProduct {
121
- Sku?: string;
122
- Name?: string;
123
- Price?: number;
121
+ Sku: string;
122
+ Name: string;
123
+ Price: number;
124
124
  Quantity?: number;
125
125
  Brand?: string;
126
126
  Variant?: string;
@@ -128,7 +128,7 @@ export interface SDKProduct {
128
128
  Position?: number;
129
129
  CouponCode?: string;
130
130
  TotalAmount?: number;
131
- Attributes?: Record<string, unknown> | null;
131
+ Attributes?: Record<string, unknown>;
132
132
  }
133
133
  export interface MParticleWebSDK {
134
134
  addForwarder(forwarder: UnregisteredKit): void;
@@ -203,7 +203,7 @@ export declare const LogLevelType: {
203
203
  readonly Warning: "warning";
204
204
  readonly Error: "error";
205
205
  };
206
- export interface SDKInitConfig extends Omit<MPConfiguration, 'dataPlan' | 'logLevel'> {
206
+ export interface SDKInitConfig extends Omit<MPConfiguration, 'dataPlan' | 'logLevel' | 'identityCallback'> {
207
207
  dataPlan?: DataPlanConfig | KitBlockerDataPlan;
208
208
  logLevel?: LogLevelType;
209
209
  kitConfigs?: IKitConfigs[];
@@ -57,6 +57,7 @@ declare namespace mParticle {
57
57
  export { voidFunction as login };
58
58
  export { voidFunction as logout };
59
59
  export { voidFunction as modify };
60
+ export { voidFunction as search };
60
61
  }
61
62
  }
62
63
  declare function voidFunction(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mparticle/web-sdk",
3
- "version": "2.66.0",
3
+ "version": "2.68.0",
4
4
  "description": "mParticle core SDK for web applications",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -118,7 +118,7 @@
118
118
  "@types/jest": "^29.5.1",
119
119
  "@types/jest-expect-message": "^1.1.0",
120
120
  "@types/mocha": "^9.0.0",
121
- "@types/mparticle__web-sdk": "^2.16.1",
121
+ "@types/mparticle__web-sdk": "^2.66.0",
122
122
  "@types/node": "^20.1.0",
123
123
  "babel-preset-minify": "^0.5.1",
124
124
  "browser-sync": "^2.26.3",
@@ -0,0 +1,233 @@
1
+ import Constants, { HTTP_OK, HTTP_NOT_FOUND } from '../constants';
2
+ import { SDKLoggerApi } from '../sdkRuntimeModels';
3
+ import { Environment, isFunction } from '../utils';
4
+ import {
5
+ AsyncUploader,
6
+ FetchUploader,
7
+ IFetchPayload,
8
+ XHRUploader,
9
+ } from '../uploaders';
10
+ import {
11
+ ErrorCodes,
12
+ IErrorReportingService,
13
+ WSDKErrorSeverity,
14
+ } from '../reporting/types';
15
+ import { IdentityApiData, UserIdentities } from '@mparticle/web-sdk';
16
+ import Validators from '../validators';
17
+
18
+ const { HTTPCodes } = Constants;
19
+
20
+ /**
21
+ * Body payload returned by the `/v1/search` endpoint, as parsed JSON.
22
+ *
23
+ * The shape mirrors `/v1/identify` responses. All fields are optional because
24
+ * non-200 responses (e.g. 404 NOT_FOUND_ERROR) may include partial or
25
+ * error-shaped bodies, and the consumer should only rely on body fields when
26
+ * `httpCode === 200`.
27
+ */
28
+ export interface IIdentitySearchResponseBody {
29
+ context?: string | null;
30
+ mpid?: string;
31
+ matched_identities?: Record<string, string>;
32
+ is_ephemeral?: boolean;
33
+ is_logged_in?: boolean;
34
+ }
35
+
36
+ /**
37
+ * Result delivered to the consumer's callback. `httpCode` is always present;
38
+ * `body` is present whenever the response had a parseable JSON body.
39
+ *
40
+ * For non-network errors (missing API key, validation failures, JSON parse
41
+ * errors) `httpCode` will be `HTTPCodes.noHttpCoverage` (-1) and `body` will
42
+ * be omitted. The consumer is expected to gate behaviour on
43
+ * `httpCode === 200`.
44
+ */
45
+ export interface IIdentitySearchResult {
46
+ httpCode: number;
47
+ body?: IIdentitySearchResponseBody;
48
+ }
49
+
50
+ export type IdentitySearchCallback = (result: IIdentitySearchResult) => void;
51
+
52
+ /**
53
+ * Body posted to `/v1/search`. Mirrors the `/v1/identify` request envelope so
54
+ * that the IDSync service can correlate requests across endpoints.
55
+ */
56
+ export interface IIdentitySearchRequestBody {
57
+ client_sdk: {
58
+ platform: string;
59
+ sdk_vendor: string;
60
+ sdk_version: string;
61
+ };
62
+ environment: Environment;
63
+ request_id: string;
64
+ request_timestamp_ms: number;
65
+ known_identities: UserIdentities;
66
+ }
67
+
68
+ interface IIdentitySearchPayload extends IFetchPayload {
69
+ headers: {
70
+ Accept: string;
71
+ 'Content-Type': string;
72
+ 'x-mp-key': string;
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Sends a POST to mParticle's IDSync Search endpoint and invokes `callback`
78
+ * with the HTTP status and parsed body.
79
+ *
80
+ * Defensive contract:
81
+ * - No identifier with a non-empty string value -> callback with
82
+ * `{ httpCode: noHttpCoverage }`, no network call.
83
+ * - Missing `apiKey` -> callback with `{ httpCode: noHttpCoverage }`,
84
+ * no network call.
85
+ * - Network/JSON-parse errors are caught and surfaced via the callback,
86
+ * never thrown. Network errors are also reported through the optional
87
+ * `errorReporter` so any registered IErrorReportingService can observe
88
+ * them (matches the pattern used by identifyRequest in identityApiClient).
89
+ */
90
+ export const sendSearchRequest = async (
91
+ knownIdentities: UserIdentities,
92
+ apiKey: string,
93
+ requestBuilder: () => Omit<IIdentitySearchRequestBody, 'known_identities'>,
94
+ searchUrl: string,
95
+ callback: IdentitySearchCallback,
96
+ logger: SDKLoggerApi,
97
+ uploader?: AsyncUploader,
98
+ errorReporter?: IErrorReportingService,
99
+ ): Promise<void> => {
100
+ // Validate the callback up front. If it isn't a function we have nowhere
101
+ // to deliver a result to, so log and bail out without invoking anything.
102
+ if (!isFunction(callback)) {
103
+ logger.error(
104
+ 'search called without a callback function; skipping request.',
105
+ );
106
+ return;
107
+ }
108
+
109
+ const safeInvoke = (result: IIdentitySearchResult): void => {
110
+ try {
111
+ callback(result);
112
+ } catch (e) {
113
+ logger.error(
114
+ 'Error invoking search callback: ' +
115
+ ((e as Error)?.message || String(e)),
116
+ );
117
+ }
118
+ };
119
+
120
+ const cleanedKnownIdentities: UserIdentities = Validators.removeFalsyIdentityValues(
121
+ { userIdentities: knownIdentities ?? {} } as IdentityApiData,
122
+ logger,
123
+ ).userIdentities;
124
+
125
+ // No usable identifier -> deliver httpCode: noHttpCoverage so callers
126
+ // waiting on the callback (e.g. to clear a loading state) don't hang.
127
+ if (
128
+ !Object.values(cleanedKnownIdentities ?? {}).some(
129
+ (v) => typeof v === 'string' && v.length > 0,
130
+ )
131
+ ) {
132
+ logger.verbose(
133
+ 'Identity search called with empty identifiers; skipping request.',
134
+ );
135
+ safeInvoke({ httpCode: HTTPCodes.noHttpCoverage });
136
+ return;
137
+ }
138
+
139
+ // No API key -> same: deliver noHttpCoverage rather than hanging.
140
+ if (!apiKey) {
141
+ logger.verbose(
142
+ 'search called without a workspace API key; skipping request.',
143
+ );
144
+ safeInvoke({ httpCode: HTTPCodes.noHttpCoverage });
145
+ return;
146
+ }
147
+
148
+ // Wrap request setup AND the network call in the try/catch so any throw
149
+ // — from requestBuilder, JSON.stringify (e.g. circular refs), or
150
+ // uploader construction — flows into the catch below and the consumer's
151
+ // callback fires with noHttpCoverage rather than the async function
152
+ // rejecting and the caller hanging on a never-fired callback.
153
+ try {
154
+ const requestEnvelope = requestBuilder();
155
+
156
+ const requestBody: IIdentitySearchRequestBody = {
157
+ ...requestEnvelope,
158
+ known_identities: { ...cleanedKnownIdentities },
159
+ };
160
+
161
+ const fetchPayload: IIdentitySearchPayload = {
162
+ method: 'post',
163
+ headers: {
164
+ Accept: 'application/json',
165
+ 'Content-Type': 'application/json',
166
+ 'x-mp-key': apiKey,
167
+ },
168
+ body: JSON.stringify(requestBody),
169
+ };
170
+
171
+ const api: AsyncUploader =
172
+ uploader ||
173
+ (window.fetch
174
+ ? new FetchUploader(searchUrl)
175
+ : new XHRUploader(searchUrl));
176
+
177
+ logger.verbose('Sending search request to ' + searchUrl);
178
+ const response: Response = await api.upload(fetchPayload, searchUrl);
179
+
180
+ let body: IIdentitySearchResponseBody | undefined;
181
+
182
+ // FetchUploader returns a real Response with .json(); XHRUploader
183
+ // returns an XHR-shaped object with `responseText`. We tolerate both.
184
+ if (isFunction(response.json)) {
185
+ try {
186
+ body = (await response.json()) as IIdentitySearchResponseBody;
187
+ } catch (e) {
188
+ logger.verbose(
189
+ 'search response had no parseable JSON body.',
190
+ );
191
+ }
192
+ } else {
193
+ const xhrLike = (response as unknown) as XMLHttpRequest;
194
+ if (xhrLike?.responseText) {
195
+ try {
196
+ body = JSON.parse(xhrLike.responseText) as IIdentitySearchResponseBody;
197
+ } catch (e) {
198
+ logger.verbose(
199
+ 'search XHR response was not valid JSON.',
200
+ );
201
+ }
202
+ }
203
+ }
204
+
205
+ if (response.status === HTTP_OK) {
206
+ logger.verbose('search received 200 OK.');
207
+ } else if (response.status === HTTP_NOT_FOUND) {
208
+ // 404 NOT_FOUND_ERROR is an expected steady-state outcome and is
209
+ // intentionally not logged as an error.
210
+ logger.verbose('search received 404 (no match).');
211
+ } else {
212
+ logger.verbose(
213
+ 'search received non-success status ' + response.status,
214
+ );
215
+ }
216
+
217
+ safeInvoke({ httpCode: response.status, body });
218
+ } catch (e) {
219
+ const message = (e as Error)?.message || String(e);
220
+ const reportMessage = 'Error sending search request: ' + message;
221
+ logger.error(reportMessage);
222
+ // Mirror the identity-route pattern in identityApiClient.ts: log to
223
+ // console AND push a structured report through the dispatcher so any
224
+ // registered IErrorReportingService (e.g. the Rokt kit's) can observe
225
+ // the failure.
226
+ errorReporter?.report({
227
+ message: reportMessage,
228
+ code: ErrorCodes.IDENTITY_REQUEST,
229
+ severity: WSDKErrorSeverity.ERROR,
230
+ });
231
+ safeInvoke({ httpCode: HTTPCodes.noHttpCoverage });
232
+ }
233
+ };
@@ -1,5 +1,5 @@
1
1
  import Constants, { ONE_DAY_IN_SECONDS, MILLIS_IN_ONE_SEC } from './constants';
2
- import { Dictionary, parseNumber, isObject, generateHash, isEmpty } from './utils';
2
+ import { Dictionary, Environment, parseNumber, isObject, generateHash, generateUniqueId, isEmpty, isFunction } from './utils';
3
3
  import { BaseVault } from './vault';
4
4
  import Types from './types';
5
5
  import {
@@ -14,8 +14,15 @@ import {
14
14
  IMParticleUser,
15
15
  } from './identity-user-interfaces';
16
16
  import { IStore } from './store';
17
+ import type { IMParticleWebSDKInstance } from './mp-instance';
18
+ import {
19
+ IIdentitySearchRequestBody,
20
+ IdentitySearchCallback,
21
+ sendSearchRequest,
22
+ } from './identity/search';
17
23
 
18
24
  const { Identify, Modify, Login, Logout } = Constants.IdentityMethods;
25
+ const { HTTPCodes, Messages } = Constants;
19
26
  export const CACHE_HEADER = 'x-mp-max-age' as const;
20
27
 
21
28
  export type IdentityCache = BaseVault<Dictionary<ICachedIdentityCall>>;
@@ -282,6 +289,48 @@ const getExpireTimestamp = (maxAge: number = ONE_DAY_IN_SECONDS): number =>
282
289
  const parseIdentityResponse = (responseText: string): IdentityResultBody =>
283
290
  responseText ? JSON.parse(responseText) : ({} as IdentityResultBody);
284
291
 
292
+ type Sha256IdentityAlias = 'email_sha256' | 'mobile_sha256';
293
+
294
+ const SHA256_IDENTITY_ALIASES: Readonly<
295
+ Record<Sha256IdentityAlias, keyof UserIdentities>
296
+ > = {
297
+ email_sha256: 'other',
298
+ mobile_sha256: 'other2',
299
+ };
300
+
301
+ type UserIdentitiesWithAliases = UserIdentities &
302
+ Partial<Record<Sha256IdentityAlias, string | null>>;
303
+
304
+ export const normalizeUserIdentityKeys = (
305
+ userIdentities: UserIdentitiesWithAliases
306
+ ): UserIdentities => {
307
+ const normalized: UserIdentitiesWithAliases = { ...userIdentities };
308
+ (Object.keys(SHA256_IDENTITY_ALIASES) as Sha256IdentityAlias[]).forEach(
309
+ (alias) => {
310
+ if (alias in normalized) {
311
+ const value = normalized[alias];
312
+ delete normalized[alias];
313
+ normalized[SHA256_IDENTITY_ALIASES[alias]] = value;
314
+ }
315
+ }
316
+ );
317
+ return normalized;
318
+ };
319
+
320
+ // Compare two identity objects by key and value, independent of
321
+ // object key insertion order. The two sides come from different sources
322
+ // (numeric IdentityType iteration vs. partner-provided / alias-normalized
323
+ // order)
324
+ const identitiesEqual = (
325
+ a?: UserIdentities,
326
+ b?: UserIdentities
327
+ ): boolean => {
328
+ const aKeys = Object.keys(a ?? {});
329
+ const bKeys = Object.keys(b ?? {});
330
+ if (aKeys.length !== bKeys.length) return false;
331
+ return aKeys.every((key) => a[key] === b[key]);
332
+ };
333
+
285
334
  export const hasIdentityRequestChanged = (
286
335
  currentUser: IMParticleUser,
287
336
  newIdentityRequest: IdentityApiData
@@ -293,11 +342,11 @@ export const hasIdentityRequestChanged = (
293
342
  const currentUserIdentities =
294
343
  currentUser.getUserIdentities().userIdentities;
295
344
 
296
- const newIdentities = newIdentityRequest.userIdentities;
297
-
298
- return (
299
- JSON.stringify(currentUserIdentities) !== JSON.stringify(newIdentities)
345
+ const newIdentities = normalizeUserIdentityKeys(
346
+ newIdentityRequest.userIdentities
300
347
  );
348
+
349
+ return !identitiesEqual(currentUserIdentities, newIdentities);
301
350
  };
302
351
 
303
352
  /**
@@ -321,3 +370,76 @@ export const hasExplicitIdentifier = (store: IStore | undefined | null): boolean
321
370
  }
322
371
  return !!store?.SDKConfig?.deviceId;
323
372
  };
373
+
374
+ /**
375
+ * Builds the /v1/identify-style envelope (client_sdk, environment,
376
+ * request_id, request_timestamp_ms) used to correlate IDSync requests
377
+ * across endpoints. `known_identities` is omitted so the caller can
378
+ * fold in the search-specific identifiers alongside the envelope.
379
+ */
380
+ export const buildIdentitySearchEnvelope = (
381
+ environment: Environment,
382
+ ): Omit<IIdentitySearchRequestBody, 'known_identities'> => ({
383
+ client_sdk: {
384
+ platform: Constants.platform,
385
+ sdk_vendor: Constants.sdkVendor,
386
+ sdk_version: Constants.sdkVersion,
387
+ },
388
+ environment,
389
+ request_id: generateUniqueId(),
390
+ request_timestamp_ms: Date.now(),
391
+ });
392
+
393
+ /**
394
+ * Wires the SDK instance into `sendSearchRequest`: gates on `canLog`,
395
+ * builds the `/v1/search` URL and request envelope, and dispatches.
396
+ * Lives here so the SDK glue (URL building, opt-out gate, dispatcher
397
+ * plumbing) is type-checked instead of being expressed in plain JS.
398
+ */
399
+ export const executeSearchRequest = (
400
+ mpInstance: IMParticleWebSDKInstance,
401
+ workspaceApiKey: string,
402
+ knownIdentities: UserIdentities,
403
+ callback: IdentitySearchCallback,
404
+ ): void => {
405
+ const { _Helpers, _Store, Logger, _ErrorReportingDispatcher } = mpInstance;
406
+ const { identityUrl, isDevelopmentMode } = _Store.SDKConfig;
407
+
408
+ if (!_Helpers.canLog()) {
409
+ Logger.verbose(Messages.InformationMessages.AbandonLogEvent);
410
+ if (isFunction(callback)) {
411
+ try {
412
+ callback({
413
+ httpCode: HTTPCodes.loggingDisabledOrMissingAPIKey,
414
+ });
415
+ } catch (e) {
416
+ Logger.error(
417
+ 'Error invoking search callback: ' +
418
+ ((e as Error)?.message || String(e)),
419
+ );
420
+ }
421
+ }
422
+ return;
423
+ }
424
+
425
+ // The Search endpoint is colocated with /v1/identify under
426
+ // identityUrl, so we reuse the same service URL builder. We do
427
+ // NOT append the apiKey to the URL — auth is done via x-mp-key.
428
+ const serviceUrl: string = _Helpers.createServiceUrl(identityUrl);
429
+ const searchUrl: string = serviceUrl + 'search?cb=1';
430
+
431
+ const environment: Environment = isDevelopmentMode
432
+ ? 'development'
433
+ : 'production';
434
+
435
+ sendSearchRequest(
436
+ knownIdentities,
437
+ workspaceApiKey,
438
+ () => buildIdentitySearchEnvelope(environment),
439
+ searchUrl,
440
+ callback,
441
+ Logger,
442
+ undefined,
443
+ _ErrorReportingDispatcher,
444
+ );
445
+ };