From cf25bed153f130e08d90eb9a92041ada513c93ba Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 28 Apr 2026 15:28:13 -0400 Subject: [PATCH 01/23] feat: Add Identity.searchAdvertiser for IDSync Search Adds a new public API mParticle.Identity.searchAdvertiser(apiKey, knownIdentities, callback) that POSTs to mParticle's IDSync /v1/search endpoint so kits (initially the Rokt Web Kit) can look up an advertiser identity (today, only email) without affecting the current user. The advertiser API key is supplied explicitly by the caller rather than read from the SDK's workspace token, so advertiser searches can be authorised independently of the host SDK's workspace. Missing apiKey, missing/invalid email, and a non-function callback all return silently (no network call, no callback) so the path is inert when the consumer hasn't opted in. Note: a separate Fastly CORS configuration is required to permit the x-mp-key header on /v1/search; this SDK code is built assuming that allow-list will land separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.interfaces.ts | 29 ++ src/identity.js | 65 ++++ src/mparticle-instance-manager.ts | 7 + src/public-types.ts | 4 + src/searchAdvertiser.ts | 216 +++++++++++ test/src/_test.index.ts | 1 + test/src/tests-mparticle-instance-manager.ts | 1 + test/src/tests-search-advertiser.ts | 361 +++++++++++++++++++ 8 files changed, 684 insertions(+) create mode 100644 src/searchAdvertiser.ts create mode 100644 test/src/tests-search-advertiser.ts diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index 32ad3ea6e..2f8ad4f7d 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -12,6 +12,11 @@ import { mParticleUserCart, IIdentityResponse, } from './identity-user-interfaces'; +import { + ISearchAdvertiserKnownIdentities, + ISearchAdvertiserResult, + SearchAdvertiserCallback, +} from './searchAdvertiser'; const { platform, sdkVendor, sdkVersion, HTTPCodes } = Constants; export type IdentityPreProcessResult = { @@ -156,8 +161,32 @@ export interface SDKIdentityApi { destinationUser: IMParticleUser, scope?: AliasRequestScope ): IAliasRequest; + /** + * Sends a request to mParticle's IDSync `/v1/search` endpoint to look up + * an advertiser identity (today, only `email`) without affecting the + * current user. The callback receives `httpCode` (always) and an optional + * `body` containing the parsed JSON response. Both 200 (match) and 404 + * (no match) are expected steady-state outcomes; consumers should gate + * behaviour on `httpCode === 200`. + * + * `apiKey` is an advertiser-specific workspace API key supplied by the + * caller (typically from a kit's settings). It is sent as the `x-mp-key` + * header. The SDK's own workspace token is intentionally not used. + */ + searchAdvertiser?( + apiKey: string, + knownIdentities: ISearchAdvertiserKnownIdentities, + callback: SearchAdvertiserCallback + ): void; } +export type { + ISearchAdvertiserKnownIdentities, + ISearchAdvertiserResult, + ISearchAdvertiserResponseBody, + SearchAdvertiserCallback, +} from './searchAdvertiser'; + export interface IIdentity { audienceManager: AudienceManager; idCache: BaseVault>; diff --git a/src/identity.js b/src/identity.js index 2a7bb3a67..26097bd9e 100644 --- a/src/identity.js +++ b/src/identity.js @@ -6,6 +6,7 @@ import { tryCacheIdentity, } from './identity-utils'; import AudienceManager from './audienceManager'; +import { sendSearchAdvertiserRequest } from './searchAdvertiser'; const { Messages, HTTPCodes, FeatureFlags, IdentityMethods } = Constants; const { ErrorMessages } = Messages; const { CacheIdentity } = FeatureFlags; @@ -730,6 +731,70 @@ export default function Identity(mpInstance) { } }, + /** + * Search the IDSync Advertiser endpoint for a known identity. + * + * POSTs to mParticle's `/v1/search` endpoint and invokes `callback` + * with `{ httpCode, body? }`. Both 200 (match) and 404 (no match) are + * expected steady-state outcomes. Consumers (e.g. the Rokt Web Kit) + * should gate behaviour on `httpCode === 200`. + * + * v1 only supports `email` in `knownIdentities`. + * + * The `apiKey` is an advertiser-specific workspace API key supplied + * by the caller (typically passed in from a kit's settings). It is + * intentionally NOT read from the SDK's own workspace token, so that + * advertiser searches can be authorised independently of the host + * SDK's workspace. + * + * @method searchAdvertiser + * @param {String} apiKey Advertiser workspace API key (sent as x-mp-key). + * @param {Object} knownIdentities `{ email: string }` + * @param {Function} callback Invoked with the `ISearchAdvertiserResult`. + */ + searchAdvertiser: function(apiKey, knownIdentities, callback) { + // Callback validation, missing apiKey, and missing/invalid + // email are all handled inside sendSearchAdvertiserRequest so + // the contract has a single enforcement point. + + // The Search endpoint is colocated with /v1/identify under + // identityUrl, so we reuse the same service URL builder. We do + // NOT append the apiKey to the URL — auth is done via x-mp-key. + var serviceUrl = mpInstance._Helpers.createServiceUrl( + mpInstance._Store.SDKConfig.identityUrl + ); + var searchUrl = serviceUrl + 'search'; + + var environment = mpInstance._Store.SDKConfig.isDevelopmentMode + ? 'development' + : 'production'; + + // Build the same envelope that /v1/identify uses (client_sdk, + // request_id, request_timestamp_ms, environment) so the IDSync + // service can correlate requests across endpoints. + var requestBuilder = function() { + return { + client_sdk: { + platform: Constants.platform, + sdk_vendor: Constants.sdkVendor, + sdk_version: Constants.sdkVersion, + }, + environment: environment, + request_id: mpInstance._Helpers.generateUniqueId(), + request_timestamp_ms: new Date().getTime(), + }; + }; + + sendSearchAdvertiserRequest( + knownIdentities, + apiKey, + requestBuilder, + searchUrl, + callback, + mpInstance.Logger + ); + }, + /** Create a default AliasRequest for 2 MParticleUsers. This will construct the request using the sourceUser's firstSeenTime as the startTime, and its lastSeenTime as the endTime. diff --git a/src/mparticle-instance-manager.ts b/src/mparticle-instance-manager.ts index f9312a662..b22792509 100644 --- a/src/mparticle-instance-manager.ts +++ b/src/mparticle-instance-manager.ts @@ -394,6 +394,13 @@ function mParticleInstanceManager(this: IMParticleInstanceManager) { modify: function(identityApiData, callback) { self.getInstance().Identity.modify(identityApiData, callback); }, + searchAdvertiser: function(apiKey, knownIdentities, callback) { + self.getInstance().Identity.searchAdvertiser( + apiKey, + knownIdentities, + callback + ); + }, }; this.sessionManager = { diff --git a/src/public-types.ts b/src/public-types.ts index 15796f6cd..a8a398643 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -56,6 +56,10 @@ export type { IAliasCallback, IAliasResult, SDKIdentityTypeEnum, + ISearchAdvertiserKnownIdentities, + ISearchAdvertiserResult, + ISearchAdvertiserResponseBody, + SearchAdvertiserCallback, } from './identity.interfaces'; // eCommerce diff --git a/src/searchAdvertiser.ts b/src/searchAdvertiser.ts new file mode 100644 index 000000000..48fd7b39a --- /dev/null +++ b/src/searchAdvertiser.ts @@ -0,0 +1,216 @@ +import Constants, { HTTP_OK, HTTP_NOT_FOUND } from './constants'; +import { SDKLoggerApi } from './sdkRuntimeModels'; +import { + AsyncUploader, + FetchUploader, + IFetchPayload, + XHRUploader, +} from './uploaders'; + +const { HTTPCodes } = Constants; + +/** + * Shape of `known_identities` accepted by `searchAdvertiser`. + * + * 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 ISearchAdvertiserKnownIdentities { + email: string; +} + +/** + * Body payload returned by the `/v1/search` endpoint, as parsed JSON. + * + * The shape mirrors `/v1/identify` responses. All fields are optional because + * non-200 responses (e.g. 404 NOT_FOUND_ERROR) may include partial or + * error-shaped bodies, and the consumer should only rely on body fields when + * `httpCode === 200`. + */ +export interface ISearchAdvertiserResponseBody { + context?: string | null; + mpid?: string; + matched_identities?: Record; + is_ephemeral?: boolean; + is_logged_in?: boolean; +} + +/** + * Result delivered to the consumer's callback. `httpCode` is always present; + * `body` is present whenever the response had a parseable JSON body. + * + * For non-network errors (missing API key, validation failures, JSON parse + * errors) `httpCode` will be `HTTPCodes.noHttpCoverage` (-1) and `body` will + * be omitted. The consumer is expected to gate behaviour on + * `httpCode === 200`. + */ +export interface ISearchAdvertiserResult { + httpCode: number; + body?: ISearchAdvertiserResponseBody; +} + +export type SearchAdvertiserCallback = (result: ISearchAdvertiserResult) => void; + +/** + * Body posted to `/v1/search`. Mirrors the `/v1/identify` request envelope so + * that the IDSync service can correlate requests across endpoints. + */ +export interface ISearchAdvertiserRequestBody { + client_sdk: { + platform: string; + sdk_vendor: string; + sdk_version: string; + }; + environment: 'development' | 'production'; + request_id: string; + request_timestamp_ms: number; + known_identities: ISearchAdvertiserKnownIdentities; +} + +interface ISearchAdvertiserPayload extends IFetchPayload { + headers: { + Accept: string; + 'Content-Type': string; + 'x-mp-key': string; + }; +} + +/** + * Sends a POST to mParticle's IDSync Search endpoint and invokes `callback` + * 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 network call. + * - Network/JSON-parse errors are caught and surfaced via the callback, + * never thrown. + * + * NOTE: There is a known CORS limitation at the Fastly edge in front of + * `/v1/search`: it currently only allows `authorization,content-type` in + * `Access-Control-Allow-Headers`, which means browsers will block requests + * carrying `x-mp-key`. This is being addressed separately by the team that + * owns the Fastly config. This SDK code is written assuming `x-mp-key` will + * be allowed. + */ +export const sendSearchAdvertiserRequest = async ( + knownIdentities: ISearchAdvertiserKnownIdentities, + apiKey: string, + requestBuilder: () => Omit, + searchUrl: string, + callback: SearchAdvertiserCallback, + logger: SDKLoggerApi, + uploader?: AsyncUploader, +): Promise => { + // Validate the callback up front. If it isn't a function we have nowhere + // to deliver a result to, so log and bail out without invoking anything. + if (typeof callback !== 'function') { + logger.error( + 'searchAdvertiser called without a callback function; skipping request.', + ); + return; + } + + const safeInvoke = (result: ISearchAdvertiserResult): void => { + try { + callback(result); + } catch (e) { + logger.error( + 'Error invoking searchAdvertiser callback: ' + + ((e as Error)?.message || String(e)), + ); + } + }; + + // No valid email -> no request, and no callback. The consumer (Rokt kit) + // only reacts on httpCode === 200, so missing inputs are silently inert. + if (!knownIdentities || typeof knownIdentities.email !== 'string' || !knownIdentities.email) { + logger.verbose( + 'searchAdvertiser called without a valid email; skipping request.', + ); + return; + } + + // No API key -> no request, and no callback. Same rationale as above. + if (!apiKey) { + logger.verbose( + 'searchAdvertiser called without a workspace API key; skipping request.', + ); + return; + } + + const requestEnvelope = requestBuilder(); + const requestBody: ISearchAdvertiserRequestBody = { + ...requestEnvelope, + known_identities: { + email: knownIdentities.email, + }, + }; + + const fetchPayload: ISearchAdvertiserPayload = { + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-mp-key': apiKey, + }, + body: JSON.stringify(requestBody), + }; + + const api: AsyncUploader = + uploader || + (window.fetch + ? new FetchUploader(searchUrl) + : new XHRUploader(searchUrl)); + + try { + logger.verbose('Sending searchAdvertiser request to ' + searchUrl); + const response: Response = await api.upload(fetchPayload, searchUrl); + + let body: ISearchAdvertiserResponseBody | undefined; + + // FetchUploader returns a real Response with .json(); XHRUploader + // returns an XHR-shaped object with `responseText`. We tolerate both. + if (typeof (response as Response).json === 'function') { + try { + body = (await (response as Response).json()) as ISearchAdvertiserResponseBody; + } catch (e) { + logger.verbose( + 'searchAdvertiser response had no parseable JSON body.', + ); + } + } else { + const xhrLike = (response as unknown) as XMLHttpRequest; + if (xhrLike?.responseText) { + try { + body = JSON.parse(xhrLike.responseText) as ISearchAdvertiserResponseBody; + } catch (e) { + logger.verbose( + 'searchAdvertiser XHR response was not valid JSON.', + ); + } + } + } + + if (response.status === HTTP_OK) { + logger.verbose('searchAdvertiser received 200 OK.'); + } else if (response.status === HTTP_NOT_FOUND) { + // 404 NOT_FOUND_ERROR is an expected steady-state outcome and is + // intentionally not logged as an error. + logger.verbose('searchAdvertiser received 404 (no match).'); + } else { + logger.verbose( + 'searchAdvertiser received non-success status ' + response.status, + ); + } + + safeInvoke({ httpCode: response.status, body }); + } catch (e) { + const message = (e as Error)?.message || String(e); + logger.error('Error sending searchAdvertiser request: ' + message); + safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); + } +}; diff --git a/test/src/_test.index.ts b/test/src/_test.index.ts index 81513029e..6d968d3a7 100644 --- a/test/src/_test.index.ts +++ b/test/src/_test.index.ts @@ -39,4 +39,5 @@ import './tests-identityApiClient'; import './tests-integration-capture'; import './tests-batchUploader_4'; import './tests-identity'; +import './tests-search-advertiser'; diff --git a/test/src/tests-mparticle-instance-manager.ts b/test/src/tests-mparticle-instance-manager.ts index 0fcd9134c..fc79c5955 100644 --- a/test/src/tests-mparticle-instance-manager.ts +++ b/test/src/tests-mparticle-instance-manager.ts @@ -124,6 +124,7 @@ describe('mParticle instance manager', () => { 'getUsers', 'aliasUsers', 'createAliasRequest', + 'searchAdvertiser', ]); expect(mParticle.Identity.HTTPCodes, 'HTTP Codes').to.have.keys([ 'noHttpCoverage', diff --git a/test/src/tests-search-advertiser.ts b/test/src/tests-search-advertiser.ts new file mode 100644 index 000000000..849d0d093 --- /dev/null +++ b/test/src/tests-search-advertiser.ts @@ -0,0 +1,361 @@ +import sinon from 'sinon'; +import fetchMock from 'fetch-mock/esm/client'; +import { expect } from 'chai'; +import { apiKey, MPConfig, urls, testMPID } from './config/constants'; +import Constants from '../../src/constants'; +import { Logger } from '../../src/logger'; +import { IMParticleInstanceManager, SDKLoggerApi } from '../../src/sdkRuntimeModels'; +import { + ISearchAdvertiserResult, + sendSearchAdvertiserRequest, +} from '../../src/searchAdvertiser'; +import Utils from './config/utils'; +const { fetchMockSuccess } = Utils; + +const { HTTPCodes } = Constants; + +declare global { + interface Window { + mParticle: IMParticleInstanceManager; + fetchMock: any; + } +} + +const searchUrl = `https://identity.mparticle.com/v1/search`; + +const buildEnvelope = () => ({ + client_sdk: { + platform: 'web', + sdk_vendor: 'mparticle', + sdk_version: '2.66.0', + }, + environment: 'development' as const, + request_id: 'fixed-request-id', + request_timestamp_ms: 1735689600000, +}); + +describe('searchAdvertiser', () => { + let logger: SDKLoggerApi; + + beforeEach(() => { + // Some tests below boot up window.mParticle to verify the public + // Identity.searchAdvertiser surface; reset between tests so they + // don't interfere with each other. + window.mParticle._resetForTests(MPConfig); + fetchMockSuccess(urls.identify, { + mpid: testMPID, + is_logged_in: false, + }); + + logger = new Logger(window.mParticle.config); + }); + + afterEach(() => { + sinon.restore(); + fetchMock.restore(); + }); + + describe('sendSearchAdvertiserRequest (network layer)', () => { + it('invokes the callback with httpCode 200 and the parsed body on success', async () => { + const responseBody = { + context: 'ctx-123', + mpid: 'matched-mpid', + matched_identities: { email: 'hashed-email' }, + is_ephemeral: false, + is_logged_in: true, + }; + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify(responseBody), + }); + + const callback = sinon.spy(); + await sendSearchAdvertiserRequest( + { email: 'user@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + expect(result.httpCode).to.equal(200); + expect(result.body).to.deep.equal(responseBody); + }); + + it('forwards x-mp-key, content-type, and a JSON body matching the /v1/identify envelope', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: 'm' }), + }); + + await sendSearchAdvertiserRequest( + { email: 'user@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + () => undefined, + logger, + ); + + const lastCall = fetchMock.lastCall(searchUrl); + expect(lastCall, 'POST was issued to the search URL').to.be.ok; + const init = lastCall![1] as RequestInit; + const headers = init.headers as Record; + expect(headers['x-mp-key']).to.equal(apiKey); + expect(headers['Content-Type']).to.equal('application/json'); + + const sentBody = JSON.parse(init.body as string); + expect(sentBody).to.have.keys( + 'client_sdk', + 'environment', + 'request_id', + 'request_timestamp_ms', + 'known_identities', + ); + expect(sentBody.known_identities).to.deep.equal({ + email: 'user@example.com', + }); + expect(sentBody.client_sdk).to.deep.equal({ + platform: 'web', + sdk_vendor: 'mparticle', + sdk_version: '2.66.0', + }); + expect(sentBody.environment).to.equal('development'); + }); + + it('surfaces httpCode 404 cleanly and parses NOT_FOUND_ERROR body without throwing', async () => { + const notFoundBody = { + Errors: [{ code: 'NOT_FOUND_ERROR', message: 'No match' }], + }; + fetchMock.post(searchUrl, { + status: 404, + body: JSON.stringify(notFoundBody), + }); + + const callback = sinon.spy(); + await sendSearchAdvertiserRequest( + { email: 'unknown@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + expect(result.httpCode).to.equal(404); + // Body is best-effort parsed; we don't assert its exact shape + // beyond "it didn't throw". + expect(result.body).to.deep.equal(notFoundBody); + }); + + it('returns silently when the API key is missing (no network call, no callback)', async () => { + const callback = sinon.spy(); + const requestBuilderSpy = sinon.spy(buildEnvelope); + + await sendSearchAdvertiserRequest( + { email: 'user@example.com' }, + '', + requestBuilderSpy, + searchUrl, + callback, + logger, + ); + + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(requestBuilderSpy.called).to.eq(false); + // Missing apiKey is silently inert: no network, no callback. + expect(callback.called).to.eq(false); + }); + + it('returns silently and does not throw when the callback is not a function', async () => { + const requestBuilderSpy = sinon.spy(buildEnvelope); + let threw = false; + try { + await sendSearchAdvertiserRequest( + { email: 'user@example.com' }, + apiKey, + requestBuilderSpy, + searchUrl, + (undefined as unknown) as any, + logger, + ); + } catch (e) { + threw = true; + } + expect(threw, 'should not throw on missing callback').to.eq(false); + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(requestBuilderSpy.called).to.eq(false); + }); + + it('returns silently when knownIdentities.email is missing or invalid (no network, no callback)', async () => { + const callback = sinon.spy(); + + await sendSearchAdvertiserRequest( + ({} as any), + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + await sendSearchAdvertiserRequest( + ({ email: '' } as any), + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + await sendSearchAdvertiserRequest( + ({ email: 12345 } as any), + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + // Missing/invalid email is silently inert: no network, no callback. + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(callback.called).to.eq(false); + }); + + it('catches network errors and surfaces noHttpCoverage via the callback (not thrown)', async () => { + fetchMock.post(searchUrl, { throws: new Error('network down') }); + + const callback = sinon.spy(); + let threw = false; + try { + await sendSearchAdvertiserRequest( + { email: 'user@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + } catch (e) { + threw = true; + } + + expect(threw, 'should not throw on network error').to.eq(false); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); + }); + + it('handles a non-JSON response body without throwing', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: 'not-json', + headers: { 'Content-Type': 'text/plain' }, + }); + + const callback = sinon.spy(); + await sendSearchAdvertiserRequest( + { email: 'user@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + expect(result.httpCode).to.equal(200); + expect(result.body).to.be.undefined; + }); + }); + + describe('mParticle.Identity.searchAdvertiser (public surface)', () => { + const advertiserApiKey = 'advertiser_api_key'; + + beforeEach(() => { + window.mParticle.init(apiKey, window.mParticle.config); + }); + + it('is exposed on the Identity namespace', () => { + expect(typeof (window.mParticle.Identity as any).searchAdvertiser).to.equal( + 'function', + ); + }); + + it('issues a POST to /v1/search with the caller-supplied x-mp-key and a known_identities email', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: 'matched' }), + }); + + const callback = sinon.spy(); + (window.mParticle.Identity as any).searchAdvertiser( + advertiserApiKey, + { email: 'user@example.com' }, + callback, + ); + + // fetch-mock + the response.json() await chain need a few ticks + // before the callback resolves; flush the microtask queue. + await fetchMock.flush(true); + await new Promise(resolve => setTimeout(resolve, 10)); + + const lastCall = fetchMock.lastCall(searchUrl); + expect(lastCall, 'POST was issued to /v1/search').to.be.ok; + + const init = lastCall![1] as RequestInit; + const headers = init.headers as Record; + // Must use the advertiser-supplied key, NOT the SDK's workspace token. + expect(headers['x-mp-key']).to.equal(advertiserApiKey); + expect(headers['x-mp-key']).to.not.equal(apiKey); + + const sentBody = JSON.parse(init.body as string); + expect(sentBody.known_identities).to.deep.equal({ + email: 'user@example.com', + }); + expect(sentBody.client_sdk.platform).to.equal('web'); + expect(sentBody.client_sdk.sdk_vendor).to.equal('mparticle'); + expect(typeof sentBody.request_id).to.equal('string'); + expect(typeof sentBody.request_timestamp_ms).to.equal('number'); + + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + expect(result.httpCode).to.equal(200); + expect(result.body).to.deep.equal({ mpid: 'matched' }); + }); + + it('does not throw and logs an error when called without a callback', () => { + expect(() => + (window.mParticle.Identity as any).searchAdvertiser( + advertiserApiKey, + { email: 'user@example.com' }, + ), + ).to.not.throw(); + }); + + it('returns silently (no network call, no callback) when the caller passes an empty apiKey', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: 'should-not-be-called' }), + }); + + const callback = sinon.spy(); + (window.mParticle.Identity as any).searchAdvertiser( + '', + { email: 'user@example.com' }, + callback, + ); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(callback.called).to.eq(false); + }); + }); +}); From 5382278ac6408bbe6e973940df01efe877301bc3 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 28 Apr 2026 15:42:00 -0400 Subject: [PATCH 02/23] feat: Switch Identity.searchAdvertiser to Basic auth (apiKey + secret) The IDSync /v1/search endpoint requires Basic auth (or HMAC); key-only auth via x-mp-key is not provisioned at the Fastly edge. Update the public API to take an explicit `secret` argument and send `Authorization: Basic ` instead of `x-mp-key`. Public signature: Identity.searchAdvertiser(apiKey, secret, knownIdentities, callback) Both apiKey and secret are required; either being missing/empty makes the call silently inert (no network, no callback). For Web workspaces the "secret" ships in the browser bundle and is not actually a secret, but the Basic scheme still requires it as the password component. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.interfaces.ts | 9 ++- src/identity.js | 21 +++--- src/mparticle-instance-manager.ts | 3 +- src/searchAdvertiser.ts | 56 +++++++++++----- test/src/tests-search-advertiser.ts | 100 ++++++++++++++++++++++------ 5 files changed, 139 insertions(+), 50 deletions(-) diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index 2f8ad4f7d..2f0e39cff 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -169,12 +169,15 @@ export interface SDKIdentityApi { * (no match) are expected steady-state outcomes; consumers should gate * behaviour on `httpCode === 200`. * - * `apiKey` is an advertiser-specific workspace API key supplied by the - * caller (typically from a kit's settings). It is sent as the `x-mp-key` - * header. The SDK's own workspace token is intentionally not used. + * `apiKey` and `secret` are advertiser-specific workspace credentials + * supplied by the caller (typically parsed from a kit's settings). They + * are sent as `Authorization: Basic `. The SDK's + * own workspace token is intentionally not used. For Web workspaces the + * "secret" ships in the browser bundle and is not actually a secret. */ searchAdvertiser?( apiKey: string, + secret: string, knownIdentities: ISearchAdvertiserKnownIdentities, callback: SearchAdvertiserCallback ): void; diff --git a/src/identity.js b/src/identity.js index 26097bd9e..1a1118e86 100644 --- a/src/identity.js +++ b/src/identity.js @@ -741,25 +741,27 @@ export default function Identity(mpInstance) { * * v1 only supports `email` in `knownIdentities`. * - * The `apiKey` is an advertiser-specific workspace API key supplied - * by the caller (typically passed in from a kit's settings). It is - * intentionally NOT read from the SDK's own workspace token, so that - * advertiser searches can be authorised independently of the host - * SDK's workspace. + * Auth uses HTTP Basic with the advertiser-specific workspace key + * and secret supplied by the caller (typically parsed from a kit's + * settings). The SDK's own workspace token is intentionally NOT used, + * so advertiser searches can be authorised independently of the host + * SDK's workspace. For Web workspaces the "secret" ships in the + * browser bundle and is not actually a secret. * * @method searchAdvertiser - * @param {String} apiKey Advertiser workspace API key (sent as x-mp-key). + * @param {String} apiKey Advertiser workspace API key. + * @param {String} secret Advertiser workspace API secret. * @param {Object} knownIdentities `{ email: string }` * @param {Function} callback Invoked with the `ISearchAdvertiserResult`. */ - searchAdvertiser: function(apiKey, knownIdentities, callback) { - // Callback validation, missing apiKey, and missing/invalid + searchAdvertiser: function(apiKey, secret, knownIdentities, callback) { + // Callback validation, missing credentials, and missing/invalid // email are all handled inside sendSearchAdvertiserRequest so // the contract has a single enforcement point. // The Search endpoint is colocated with /v1/identify under // identityUrl, so we reuse the same service URL builder. We do - // NOT append the apiKey to the URL — auth is done via x-mp-key. + // NOT append the apiKey to the URL — auth is via Basic header. var serviceUrl = mpInstance._Helpers.createServiceUrl( mpInstance._Store.SDKConfig.identityUrl ); @@ -788,6 +790,7 @@ export default function Identity(mpInstance) { sendSearchAdvertiserRequest( knownIdentities, apiKey, + secret, requestBuilder, searchUrl, callback, diff --git a/src/mparticle-instance-manager.ts b/src/mparticle-instance-manager.ts index b22792509..21e4113b0 100644 --- a/src/mparticle-instance-manager.ts +++ b/src/mparticle-instance-manager.ts @@ -394,9 +394,10 @@ function mParticleInstanceManager(this: IMParticleInstanceManager) { modify: function(identityApiData, callback) { self.getInstance().Identity.modify(identityApiData, callback); }, - searchAdvertiser: function(apiKey, knownIdentities, callback) { + searchAdvertiser: function(apiKey, secret, knownIdentities, callback) { self.getInstance().Identity.searchAdvertiser( apiKey, + secret, knownIdentities, callback ); diff --git a/src/searchAdvertiser.ts b/src/searchAdvertiser.ts index 48fd7b39a..ee02b5862 100644 --- a/src/searchAdvertiser.ts +++ b/src/searchAdvertiser.ts @@ -73,32 +73,52 @@ interface ISearchAdvertiserPayload extends IFetchPayload { headers: { Accept: string; 'Content-Type': string; - 'x-mp-key': string; + Authorization: string; }; } +/** + * Encode a UTF-8 string as base64. Browsers expose `btoa`, but it only handles + * Latin-1; for the IDSync use case the inputs are workspace API keys/secrets + * (ASCII), so `btoa` is sufficient. We fall back to a manual table only in the + * unlikely event `btoa` is unavailable. + */ +const toBase64 = (input: string): string => { + if (typeof btoa === 'function') { + return btoa(input); + } + // Minimal fallback for non-DOM hosts; the SDK runs in browsers, so this + // path should never execute in practice. + /* istanbul ignore next */ + if (typeof Buffer !== 'undefined') { + return Buffer.from(input, 'utf-8').toString('base64'); + } + /* istanbul ignore next */ + throw new Error('No base64 encoder available.'); +}; + /** * Sends a POST to mParticle's IDSync Search endpoint and invokes `callback` * 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 network call. - * - Network/JSON-parse errors are caught and surfaced via the callback, - * never thrown. + * Auth: HTTP Basic with `Authorization: Basic `. + * Per https://docs.mparticle.com/developers/apis/idsync/#search the Search + * endpoint requires Basic (key+secret) or HMAC auth; key-only auth is not + * generally provisioned. For Web workspaces the "secret" ships in the + * browser bundle and is not actually a secret. * - * NOTE: There is a known CORS limitation at the Fastly edge in front of - * `/v1/search`: it currently only allows `authorization,content-type` in - * `Access-Control-Allow-Headers`, which means browsers will block requests - * carrying `x-mp-key`. This is being addressed separately by the team that - * owns the Fastly config. This SDK code is written assuming `x-mp-key` will - * be allowed. + * Defensive contract: + * - Missing/invalid `email` -> no network call, no callback. + * - Missing `apiKey` or `secret` -> no network call, no callback. + * - Non-function `callback` -> no network call, logged. + * - Network/JSON-parse errors -> callback with + * `{ httpCode: noHttpCoverage }`, + * never thrown. */ export const sendSearchAdvertiserRequest = async ( knownIdentities: ISearchAdvertiserKnownIdentities, apiKey: string, + secret: string, requestBuilder: () => Omit, searchUrl: string, callback: SearchAdvertiserCallback, @@ -134,10 +154,10 @@ export const sendSearchAdvertiserRequest = async ( return; } - // No API key -> no request, and no callback. Same rationale as above. - if (!apiKey) { + // Both halves of the Basic auth credential are required. + if (!apiKey || !secret) { logger.verbose( - 'searchAdvertiser called without a workspace API key; skipping request.', + 'searchAdvertiser called without a complete apiKey+secret credential; skipping request.', ); return; } @@ -155,7 +175,7 @@ export const sendSearchAdvertiserRequest = async ( headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-mp-key': apiKey, + Authorization: 'Basic ' + toBase64(apiKey + ':' + secret), }, body: JSON.stringify(requestBody), }; diff --git a/test/src/tests-search-advertiser.ts b/test/src/tests-search-advertiser.ts index 849d0d093..60c9d5ad6 100644 --- a/test/src/tests-search-advertiser.ts +++ b/test/src/tests-search-advertiser.ts @@ -34,6 +34,13 @@ const buildEnvelope = () => ({ request_timestamp_ms: 1735689600000, }); +const TEST_API_KEY = 'advertiser-api-key'; +const TEST_API_SECRET = 'advertiser-api-secret'; + +// Pre-computed expected base64 for `${TEST_API_KEY}:${TEST_API_SECRET}`. Tests +// also recompute via btoa() so a refactor of toBase64 is caught. +const EXPECTED_BASIC = 'Basic ' + btoa(`${TEST_API_KEY}:${TEST_API_SECRET}`); + describe('searchAdvertiser', () => { let logger: SDKLoggerApi; @@ -72,7 +79,8 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); await sendSearchAdvertiserRequest( { email: 'user@example.com' }, - apiKey, + TEST_API_KEY, + TEST_API_SECRET, buildEnvelope, searchUrl, callback, @@ -85,7 +93,7 @@ describe('searchAdvertiser', () => { expect(result.body).to.deep.equal(responseBody); }); - it('forwards x-mp-key, content-type, and a JSON body matching the /v1/identify envelope', async () => { + it('forwards Basic auth, content-type, and a JSON body matching the /v1/identify envelope', async () => { fetchMock.post(searchUrl, { status: 200, body: JSON.stringify({ mpid: 'm' }), @@ -93,7 +101,8 @@ describe('searchAdvertiser', () => { await sendSearchAdvertiserRequest( { email: 'user@example.com' }, - apiKey, + TEST_API_KEY, + TEST_API_SECRET, buildEnvelope, searchUrl, () => undefined, @@ -104,8 +113,10 @@ describe('searchAdvertiser', () => { expect(lastCall, 'POST was issued to the search URL').to.be.ok; const init = lastCall![1] as RequestInit; const headers = init.headers as Record; - expect(headers['x-mp-key']).to.equal(apiKey); + expect(headers['Authorization']).to.equal(EXPECTED_BASIC); expect(headers['Content-Type']).to.equal('application/json'); + // Should NOT send the deprecated x-mp-key header. + expect(headers['x-mp-key']).to.be.undefined; const sentBody = JSON.parse(init.body as string); expect(sentBody).to.have.keys( @@ -138,7 +149,8 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); await sendSearchAdvertiserRequest( { email: 'unknown@example.com' }, - apiKey, + TEST_API_KEY, + TEST_API_SECRET, buildEnvelope, searchUrl, callback, @@ -160,6 +172,7 @@ describe('searchAdvertiser', () => { await sendSearchAdvertiserRequest( { email: 'user@example.com' }, '', + TEST_API_SECRET, requestBuilderSpy, searchUrl, callback, @@ -172,13 +185,34 @@ describe('searchAdvertiser', () => { expect(callback.called).to.eq(false); }); + it('returns silently when the secret is missing (no network call, no callback)', async () => { + const callback = sinon.spy(); + const requestBuilderSpy = sinon.spy(buildEnvelope); + + await sendSearchAdvertiserRequest( + { email: 'user@example.com' }, + TEST_API_KEY, + '', + requestBuilderSpy, + searchUrl, + callback, + logger, + ); + + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(requestBuilderSpy.called).to.eq(false); + // Missing secret is silently inert: no network, no callback. + expect(callback.called).to.eq(false); + }); + it('returns silently and does not throw when the callback is not a function', async () => { const requestBuilderSpy = sinon.spy(buildEnvelope); let threw = false; try { await sendSearchAdvertiserRequest( { email: 'user@example.com' }, - apiKey, + TEST_API_KEY, + TEST_API_SECRET, requestBuilderSpy, searchUrl, (undefined as unknown) as any, @@ -197,7 +231,8 @@ describe('searchAdvertiser', () => { await sendSearchAdvertiserRequest( ({} as any), - apiKey, + TEST_API_KEY, + TEST_API_SECRET, buildEnvelope, searchUrl, callback, @@ -206,7 +241,8 @@ describe('searchAdvertiser', () => { await sendSearchAdvertiserRequest( ({ email: '' } as any), - apiKey, + TEST_API_KEY, + TEST_API_SECRET, buildEnvelope, searchUrl, callback, @@ -215,7 +251,8 @@ describe('searchAdvertiser', () => { await sendSearchAdvertiserRequest( ({ email: 12345 } as any), - apiKey, + TEST_API_KEY, + TEST_API_SECRET, buildEnvelope, searchUrl, callback, @@ -235,7 +272,8 @@ describe('searchAdvertiser', () => { try { await sendSearchAdvertiserRequest( { email: 'user@example.com' }, - apiKey, + TEST_API_KEY, + TEST_API_SECRET, buildEnvelope, searchUrl, callback, @@ -261,7 +299,8 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); await sendSearchAdvertiserRequest( { email: 'user@example.com' }, - apiKey, + TEST_API_KEY, + TEST_API_SECRET, buildEnvelope, searchUrl, callback, @@ -276,8 +315,6 @@ describe('searchAdvertiser', () => { }); describe('mParticle.Identity.searchAdvertiser (public surface)', () => { - const advertiserApiKey = 'advertiser_api_key'; - beforeEach(() => { window.mParticle.init(apiKey, window.mParticle.config); }); @@ -288,7 +325,7 @@ describe('searchAdvertiser', () => { ); }); - it('issues a POST to /v1/search with the caller-supplied x-mp-key and a known_identities email', async () => { + it('issues a POST to /v1/search with Basic auth from the caller-supplied apiKey + secret', async () => { fetchMock.post(searchUrl, { status: 200, body: JSON.stringify({ mpid: 'matched' }), @@ -296,7 +333,8 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); (window.mParticle.Identity as any).searchAdvertiser( - advertiserApiKey, + TEST_API_KEY, + TEST_API_SECRET, { email: 'user@example.com' }, callback, ); @@ -311,9 +349,11 @@ describe('searchAdvertiser', () => { const init = lastCall![1] as RequestInit; const headers = init.headers as Record; - // Must use the advertiser-supplied key, NOT the SDK's workspace token. - expect(headers['x-mp-key']).to.equal(advertiserApiKey); - expect(headers['x-mp-key']).to.not.equal(apiKey); + // Must use the advertiser-supplied credentials, NOT the SDK's + // workspace token. + expect(headers['Authorization']).to.equal(EXPECTED_BASIC); + // The SDK's own workspace token must not leak into this request. + expect(headers['Authorization']).to.not.contain(apiKey); const sentBody = JSON.parse(init.body as string); expect(sentBody.known_identities).to.deep.equal({ @@ -333,7 +373,8 @@ describe('searchAdvertiser', () => { it('does not throw and logs an error when called without a callback', () => { expect(() => (window.mParticle.Identity as any).searchAdvertiser( - advertiserApiKey, + TEST_API_KEY, + TEST_API_SECRET, { email: 'user@example.com' }, ), ).to.not.throw(); @@ -347,6 +388,27 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); (window.mParticle.Identity as any).searchAdvertiser( + '', + TEST_API_SECRET, + { email: 'user@example.com' }, + callback, + ); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(callback.called).to.eq(false); + }); + + it('returns silently (no network call, no callback) when the caller passes an empty secret', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: 'should-not-be-called' }), + }); + + const callback = sinon.spy(); + (window.mParticle.Identity as any).searchAdvertiser( + TEST_API_KEY, '', { email: 'user@example.com' }, callback, From 765332360ebdd62b6f47eae74a0a2372e092a558 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 28 Apr 2026 16:07:00 -0400 Subject: [PATCH 03/23] Revert "feat: Switch Identity.searchAdvertiser to Basic auth (apiKey + secret)" This reverts commit 5382278ac6408bbe6e973940df01efe877301bc3. --- src/identity.interfaces.ts | 9 +-- src/identity.js | 21 +++--- src/mparticle-instance-manager.ts | 3 +- src/searchAdvertiser.ts | 56 +++++----------- test/src/tests-search-advertiser.ts | 100 ++++++---------------------- 5 files changed, 50 insertions(+), 139 deletions(-) diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index 2f0e39cff..2f8ad4f7d 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -169,15 +169,12 @@ export interface SDKIdentityApi { * (no match) are expected steady-state outcomes; consumers should gate * behaviour on `httpCode === 200`. * - * `apiKey` and `secret` are advertiser-specific workspace credentials - * supplied by the caller (typically parsed from a kit's settings). They - * are sent as `Authorization: Basic `. The SDK's - * own workspace token is intentionally not used. For Web workspaces the - * "secret" ships in the browser bundle and is not actually a secret. + * `apiKey` is an advertiser-specific workspace API key supplied by the + * caller (typically from a kit's settings). It is sent as the `x-mp-key` + * header. The SDK's own workspace token is intentionally not used. */ searchAdvertiser?( apiKey: string, - secret: string, knownIdentities: ISearchAdvertiserKnownIdentities, callback: SearchAdvertiserCallback ): void; diff --git a/src/identity.js b/src/identity.js index 1a1118e86..26097bd9e 100644 --- a/src/identity.js +++ b/src/identity.js @@ -741,27 +741,25 @@ export default function Identity(mpInstance) { * * v1 only supports `email` in `knownIdentities`. * - * Auth uses HTTP Basic with the advertiser-specific workspace key - * and secret supplied by the caller (typically parsed from a kit's - * settings). The SDK's own workspace token is intentionally NOT used, - * so advertiser searches can be authorised independently of the host - * SDK's workspace. For Web workspaces the "secret" ships in the - * browser bundle and is not actually a secret. + * The `apiKey` is an advertiser-specific workspace API key supplied + * by the caller (typically passed in from a kit's settings). It is + * intentionally NOT read from the SDK's own workspace token, so that + * advertiser searches can be authorised independently of the host + * SDK's workspace. * * @method searchAdvertiser - * @param {String} apiKey Advertiser workspace API key. - * @param {String} secret Advertiser workspace API secret. + * @param {String} apiKey Advertiser workspace API key (sent as x-mp-key). * @param {Object} knownIdentities `{ email: string }` * @param {Function} callback Invoked with the `ISearchAdvertiserResult`. */ - searchAdvertiser: function(apiKey, secret, knownIdentities, callback) { - // Callback validation, missing credentials, and missing/invalid + searchAdvertiser: function(apiKey, knownIdentities, callback) { + // Callback validation, missing apiKey, and missing/invalid // email are all handled inside sendSearchAdvertiserRequest so // the contract has a single enforcement point. // The Search endpoint is colocated with /v1/identify under // identityUrl, so we reuse the same service URL builder. We do - // NOT append the apiKey to the URL — auth is via Basic header. + // NOT append the apiKey to the URL — auth is done via x-mp-key. var serviceUrl = mpInstance._Helpers.createServiceUrl( mpInstance._Store.SDKConfig.identityUrl ); @@ -790,7 +788,6 @@ export default function Identity(mpInstance) { sendSearchAdvertiserRequest( knownIdentities, apiKey, - secret, requestBuilder, searchUrl, callback, diff --git a/src/mparticle-instance-manager.ts b/src/mparticle-instance-manager.ts index 21e4113b0..b22792509 100644 --- a/src/mparticle-instance-manager.ts +++ b/src/mparticle-instance-manager.ts @@ -394,10 +394,9 @@ function mParticleInstanceManager(this: IMParticleInstanceManager) { modify: function(identityApiData, callback) { self.getInstance().Identity.modify(identityApiData, callback); }, - searchAdvertiser: function(apiKey, secret, knownIdentities, callback) { + searchAdvertiser: function(apiKey, knownIdentities, callback) { self.getInstance().Identity.searchAdvertiser( apiKey, - secret, knownIdentities, callback ); diff --git a/src/searchAdvertiser.ts b/src/searchAdvertiser.ts index ee02b5862..48fd7b39a 100644 --- a/src/searchAdvertiser.ts +++ b/src/searchAdvertiser.ts @@ -73,52 +73,32 @@ interface ISearchAdvertiserPayload extends IFetchPayload { headers: { Accept: string; 'Content-Type': string; - Authorization: string; + 'x-mp-key': string; }; } -/** - * Encode a UTF-8 string as base64. Browsers expose `btoa`, but it only handles - * Latin-1; for the IDSync use case the inputs are workspace API keys/secrets - * (ASCII), so `btoa` is sufficient. We fall back to a manual table only in the - * unlikely event `btoa` is unavailable. - */ -const toBase64 = (input: string): string => { - if (typeof btoa === 'function') { - return btoa(input); - } - // Minimal fallback for non-DOM hosts; the SDK runs in browsers, so this - // path should never execute in practice. - /* istanbul ignore next */ - if (typeof Buffer !== 'undefined') { - return Buffer.from(input, 'utf-8').toString('base64'); - } - /* istanbul ignore next */ - throw new Error('No base64 encoder available.'); -}; - /** * Sends a POST to mParticle's IDSync Search endpoint and invokes `callback` * with the HTTP status and parsed body. * - * Auth: HTTP Basic with `Authorization: Basic `. - * Per https://docs.mparticle.com/developers/apis/idsync/#search the Search - * endpoint requires Basic (key+secret) or HMAC auth; key-only auth is not - * generally provisioned. For Web workspaces the "secret" ships in the - * browser bundle and is not actually a secret. - * * Defensive contract: - * - Missing/invalid `email` -> no network call, no callback. - * - Missing `apiKey` or `secret` -> no network call, no callback. - * - Non-function `callback` -> no network call, logged. - * - Network/JSON-parse errors -> callback with - * `{ httpCode: noHttpCoverage }`, - * never thrown. + * - Missing/invalid `email` -> 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. + * + * NOTE: There is a known CORS limitation at the Fastly edge in front of + * `/v1/search`: it currently only allows `authorization,content-type` in + * `Access-Control-Allow-Headers`, which means browsers will block requests + * carrying `x-mp-key`. This is being addressed separately by the team that + * owns the Fastly config. This SDK code is written assuming `x-mp-key` will + * be allowed. */ export const sendSearchAdvertiserRequest = async ( knownIdentities: ISearchAdvertiserKnownIdentities, apiKey: string, - secret: string, requestBuilder: () => Omit, searchUrl: string, callback: SearchAdvertiserCallback, @@ -154,10 +134,10 @@ export const sendSearchAdvertiserRequest = async ( return; } - // Both halves of the Basic auth credential are required. - if (!apiKey || !secret) { + // No API key -> no request, and no callback. Same rationale as above. + if (!apiKey) { logger.verbose( - 'searchAdvertiser called without a complete apiKey+secret credential; skipping request.', + 'searchAdvertiser called without a workspace API key; skipping request.', ); return; } @@ -175,7 +155,7 @@ export const sendSearchAdvertiserRequest = async ( headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: 'Basic ' + toBase64(apiKey + ':' + secret), + 'x-mp-key': apiKey, }, body: JSON.stringify(requestBody), }; diff --git a/test/src/tests-search-advertiser.ts b/test/src/tests-search-advertiser.ts index 60c9d5ad6..849d0d093 100644 --- a/test/src/tests-search-advertiser.ts +++ b/test/src/tests-search-advertiser.ts @@ -34,13 +34,6 @@ const buildEnvelope = () => ({ request_timestamp_ms: 1735689600000, }); -const TEST_API_KEY = 'advertiser-api-key'; -const TEST_API_SECRET = 'advertiser-api-secret'; - -// Pre-computed expected base64 for `${TEST_API_KEY}:${TEST_API_SECRET}`. Tests -// also recompute via btoa() so a refactor of toBase64 is caught. -const EXPECTED_BASIC = 'Basic ' + btoa(`${TEST_API_KEY}:${TEST_API_SECRET}`); - describe('searchAdvertiser', () => { let logger: SDKLoggerApi; @@ -79,8 +72,7 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); await sendSearchAdvertiserRequest( { email: 'user@example.com' }, - TEST_API_KEY, - TEST_API_SECRET, + apiKey, buildEnvelope, searchUrl, callback, @@ -93,7 +85,7 @@ describe('searchAdvertiser', () => { expect(result.body).to.deep.equal(responseBody); }); - it('forwards Basic auth, content-type, and a JSON body matching the /v1/identify envelope', async () => { + it('forwards x-mp-key, content-type, and a JSON body matching the /v1/identify envelope', async () => { fetchMock.post(searchUrl, { status: 200, body: JSON.stringify({ mpid: 'm' }), @@ -101,8 +93,7 @@ describe('searchAdvertiser', () => { await sendSearchAdvertiserRequest( { email: 'user@example.com' }, - TEST_API_KEY, - TEST_API_SECRET, + apiKey, buildEnvelope, searchUrl, () => undefined, @@ -113,10 +104,8 @@ describe('searchAdvertiser', () => { expect(lastCall, 'POST was issued to the search URL').to.be.ok; const init = lastCall![1] as RequestInit; const headers = init.headers as Record; - expect(headers['Authorization']).to.equal(EXPECTED_BASIC); + expect(headers['x-mp-key']).to.equal(apiKey); expect(headers['Content-Type']).to.equal('application/json'); - // Should NOT send the deprecated x-mp-key header. - expect(headers['x-mp-key']).to.be.undefined; const sentBody = JSON.parse(init.body as string); expect(sentBody).to.have.keys( @@ -149,8 +138,7 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); await sendSearchAdvertiserRequest( { email: 'unknown@example.com' }, - TEST_API_KEY, - TEST_API_SECRET, + apiKey, buildEnvelope, searchUrl, callback, @@ -172,7 +160,6 @@ describe('searchAdvertiser', () => { await sendSearchAdvertiserRequest( { email: 'user@example.com' }, '', - TEST_API_SECRET, requestBuilderSpy, searchUrl, callback, @@ -185,34 +172,13 @@ describe('searchAdvertiser', () => { expect(callback.called).to.eq(false); }); - it('returns silently when the secret is missing (no network call, no callback)', async () => { - const callback = sinon.spy(); - const requestBuilderSpy = sinon.spy(buildEnvelope); - - await sendSearchAdvertiserRequest( - { email: 'user@example.com' }, - TEST_API_KEY, - '', - requestBuilderSpy, - searchUrl, - callback, - logger, - ); - - expect(fetchMock.calls(searchUrl).length).to.equal(0); - expect(requestBuilderSpy.called).to.eq(false); - // Missing secret is silently inert: no network, no callback. - expect(callback.called).to.eq(false); - }); - it('returns silently and does not throw when the callback is not a function', async () => { const requestBuilderSpy = sinon.spy(buildEnvelope); let threw = false; try { await sendSearchAdvertiserRequest( { email: 'user@example.com' }, - TEST_API_KEY, - TEST_API_SECRET, + apiKey, requestBuilderSpy, searchUrl, (undefined as unknown) as any, @@ -231,8 +197,7 @@ describe('searchAdvertiser', () => { await sendSearchAdvertiserRequest( ({} as any), - TEST_API_KEY, - TEST_API_SECRET, + apiKey, buildEnvelope, searchUrl, callback, @@ -241,8 +206,7 @@ describe('searchAdvertiser', () => { await sendSearchAdvertiserRequest( ({ email: '' } as any), - TEST_API_KEY, - TEST_API_SECRET, + apiKey, buildEnvelope, searchUrl, callback, @@ -251,8 +215,7 @@ describe('searchAdvertiser', () => { await sendSearchAdvertiserRequest( ({ email: 12345 } as any), - TEST_API_KEY, - TEST_API_SECRET, + apiKey, buildEnvelope, searchUrl, callback, @@ -272,8 +235,7 @@ describe('searchAdvertiser', () => { try { await sendSearchAdvertiserRequest( { email: 'user@example.com' }, - TEST_API_KEY, - TEST_API_SECRET, + apiKey, buildEnvelope, searchUrl, callback, @@ -299,8 +261,7 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); await sendSearchAdvertiserRequest( { email: 'user@example.com' }, - TEST_API_KEY, - TEST_API_SECRET, + apiKey, buildEnvelope, searchUrl, callback, @@ -315,6 +276,8 @@ describe('searchAdvertiser', () => { }); describe('mParticle.Identity.searchAdvertiser (public surface)', () => { + const advertiserApiKey = 'advertiser_api_key'; + beforeEach(() => { window.mParticle.init(apiKey, window.mParticle.config); }); @@ -325,7 +288,7 @@ describe('searchAdvertiser', () => { ); }); - it('issues a POST to /v1/search with Basic auth from the caller-supplied apiKey + secret', async () => { + it('issues a POST to /v1/search with the caller-supplied x-mp-key and a known_identities email', async () => { fetchMock.post(searchUrl, { status: 200, body: JSON.stringify({ mpid: 'matched' }), @@ -333,8 +296,7 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); (window.mParticle.Identity as any).searchAdvertiser( - TEST_API_KEY, - TEST_API_SECRET, + advertiserApiKey, { email: 'user@example.com' }, callback, ); @@ -349,11 +311,9 @@ describe('searchAdvertiser', () => { const init = lastCall![1] as RequestInit; const headers = init.headers as Record; - // Must use the advertiser-supplied credentials, NOT the SDK's - // workspace token. - expect(headers['Authorization']).to.equal(EXPECTED_BASIC); - // The SDK's own workspace token must not leak into this request. - expect(headers['Authorization']).to.not.contain(apiKey); + // Must use the advertiser-supplied key, NOT the SDK's workspace token. + expect(headers['x-mp-key']).to.equal(advertiserApiKey); + expect(headers['x-mp-key']).to.not.equal(apiKey); const sentBody = JSON.parse(init.body as string); expect(sentBody.known_identities).to.deep.equal({ @@ -373,8 +333,7 @@ describe('searchAdvertiser', () => { it('does not throw and logs an error when called without a callback', () => { expect(() => (window.mParticle.Identity as any).searchAdvertiser( - TEST_API_KEY, - TEST_API_SECRET, + advertiserApiKey, { email: 'user@example.com' }, ), ).to.not.throw(); @@ -388,27 +347,6 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); (window.mParticle.Identity as any).searchAdvertiser( - '', - TEST_API_SECRET, - { email: 'user@example.com' }, - callback, - ); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(fetchMock.calls(searchUrl).length).to.equal(0); - expect(callback.called).to.eq(false); - }); - - it('returns silently (no network call, no callback) when the caller passes an empty secret', async () => { - fetchMock.post(searchUrl, { - status: 200, - body: JSON.stringify({ mpid: 'should-not-be-called' }), - }); - - const callback = sinon.spy(); - (window.mParticle.Identity as any).searchAdvertiser( - TEST_API_KEY, '', { email: 'user@example.com' }, callback, From b366bbd9876107169f1bf56c8d83eedc48221de0 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 28 Apr 2026 16:20:00 -0400 Subject: [PATCH 04/23] feat: Report searchAdvertiser network errors via ErrorReportingDispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the pattern in identityApiClient.sendIdentityRequest: on a thrown network error, log to console AND push a structured ISDKError through the dispatcher so any registered listener (e.g. the Rokt Web Kit's ErrorReportingService, registered via _registerErrorReportingService) can observe the failure. Adds an optional `errorReporter?: IErrorReportingService` parameter to sendSearchAdvertiserRequest. The Identity.searchAdvertiser public wrapper passes mpInstance._ErrorReportingDispatcher through. Mirrors identifyRequest's error code and severity: code: ErrorCodes.IDENTITY_REQUEST severity: WSDKErrorSeverity.ERROR Validation paths (missing apiKey, missing/invalid email, non-function callback) remain silently inert — they're consumer-contract failures, not infrastructure errors, and the existing identity routes don't report on those either. Tests: - reports a structured error through errorReporter on network failure - does not throw when errorReporter is omitted on network failure Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.js | 4 ++- src/searchAdvertiser.ts | 22 ++++++++++++-- test/src/tests-search-advertiser.ts | 47 +++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/identity.js b/src/identity.js index 26097bd9e..f12a8178b 100644 --- a/src/identity.js +++ b/src/identity.js @@ -791,7 +791,9 @@ export default function Identity(mpInstance) { requestBuilder, searchUrl, callback, - mpInstance.Logger + mpInstance.Logger, + undefined, + mpInstance._ErrorReportingDispatcher ); }, diff --git a/src/searchAdvertiser.ts b/src/searchAdvertiser.ts index 48fd7b39a..40e52dd07 100644 --- a/src/searchAdvertiser.ts +++ b/src/searchAdvertiser.ts @@ -6,6 +6,11 @@ import { IFetchPayload, XHRUploader, } from './uploaders'; +import { + ErrorCodes, + IErrorReportingService, + WSDKErrorSeverity, +} from './reporting/types'; const { HTTPCodes } = Constants; @@ -87,7 +92,9 @@ interface ISearchAdvertiserPayload extends IFetchPayload { * - Missing `apiKey` -> callback with `{ httpCode: noHttpCoverage }`, * no network call. * - Network/JSON-parse errors are caught and surfaced via the callback, - * never thrown. + * never thrown. Network errors are also reported through the optional + * `errorReporter` so any registered IErrorReportingService can observe + * them (matches the pattern used by identifyRequest in identityApiClient). * * NOTE: There is a known CORS limitation at the Fastly edge in front of * `/v1/search`: it currently only allows `authorization,content-type` in @@ -104,6 +111,7 @@ export const sendSearchAdvertiserRequest = async ( callback: SearchAdvertiserCallback, logger: SDKLoggerApi, uploader?: AsyncUploader, + errorReporter?: IErrorReportingService, ): Promise => { // Validate the callback up front. If it isn't a function we have nowhere // to deliver a result to, so log and bail out without invoking anything. @@ -210,7 +218,17 @@ export const sendSearchAdvertiserRequest = async ( safeInvoke({ httpCode: response.status, body }); } catch (e) { const message = (e as Error)?.message || String(e); - logger.error('Error sending searchAdvertiser request: ' + message); + const reportMessage = 'Error sending searchAdvertiser request: ' + message; + logger.error(reportMessage); + // Mirror the identity-route pattern in identityApiClient.ts: log to + // console AND push a structured report through the dispatcher so any + // registered IErrorReportingService (e.g. the Rokt kit's) can observe + // the failure. + errorReporter?.report({ + message: reportMessage, + code: ErrorCodes.IDENTITY_REQUEST, + severity: WSDKErrorSeverity.ERROR, + }); safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); } }; diff --git a/test/src/tests-search-advertiser.ts b/test/src/tests-search-advertiser.ts index 849d0d093..688752c5d 100644 --- a/test/src/tests-search-advertiser.ts +++ b/test/src/tests-search-advertiser.ts @@ -251,6 +251,53 @@ describe('searchAdvertiser', () => { expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); + it('reports a structured error through the supplied errorReporter on network failure', async () => { + fetchMock.post(searchUrl, { throws: new Error('network down') }); + + const callback = sinon.spy(); + const errorReporter = { report: sinon.spy() }; + + await sendSearchAdvertiserRequest( + { email: 'user@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + undefined, + errorReporter, + ); + + expect(errorReporter.report.calledOnce).to.eq(true); + const reported = errorReporter.report.getCall(0).args[0]; + expect(reported.severity).to.equal('ERROR'); + expect(reported.code).to.equal('IDENTITY_REQUEST'); + expect(reported.message).to.match(/searchAdvertiser/); + expect(reported.message).to.match(/network down/); + }); + + it('does not throw when errorReporter is omitted on network failure', async () => { + fetchMock.post(searchUrl, { throws: new Error('network down') }); + + const callback = sinon.spy(); + let threw = false; + try { + await sendSearchAdvertiserRequest( + { email: 'user@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + } catch (e) { + threw = true; + } + + expect(threw, 'should not throw without errorReporter').to.eq(false); + expect(callback.calledOnce).to.eq(true); + }); + it('handles a non-JSON response body without throwing', async () => { fetchMock.post(searchUrl, { status: 200, From bb58f23d51e872adc3a3584d22c337ad9665acc0 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Tue, 28 Apr 2026 17:01:04 -0400 Subject: [PATCH 05/23] fix: Address cursor-bot review on searchAdvertiser Two findings from the automated review on PR #1255: 1. Validation paths now invoke the callback with `httpCode: noHttpCoverage` instead of returning silently. The earlier silent-return refactor contradicted the function's documented contract and broke parity with other identity methods. A consumer holding a loading state on the callback would have hung indefinitely on missing email or apiKey. 2. `Identity.searchAdvertiser` now gates on `_Helpers.canLog()` before making the network call, mirroring identify/login/logout/modify. Without this, an opted-out user (`setOptOut(true)`) would still POST their email to /v1/search. On guard fail the callback fires with `httpCode: loggingDisabledOrMissingAPIKey`, matching the existing identity-route precedent. Tests updated to assert callback invocation on validation failures and on the opt-out path (1077 passing on ChromeHeadless, +1 net). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.js | 15 ++++++++ src/searchAdvertiser.ts | 8 +++-- test/src/tests-search-advertiser.ts | 56 ++++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/identity.js b/src/identity.js index f12a8178b..24c43ac4f 100644 --- a/src/identity.js +++ b/src/identity.js @@ -753,6 +753,21 @@ export default function Identity(mpInstance) { * @param {Function} callback Invoked with the `ISearchAdvertiserResult`. */ searchAdvertiser: function(apiKey, knownIdentities, callback) { + // Honour the SDK's opt-out / disabled state the same way every + // other identity method does. If logging is disabled (e.g. user + // called setOptOut(true)) we must not POST identifiers. + if (!mpInstance._Helpers.canLog()) { + mpInstance._Helpers.invokeCallback( + callback, + HTTPCodes.loggingDisabledOrMissingAPIKey, + Messages.InformationMessages.AbandonLogEvent + ); + mpInstance.Logger.verbose( + Messages.InformationMessages.AbandonLogEvent + ); + return; + } + // Callback validation, missing apiKey, and missing/invalid // email are all handled inside sendSearchAdvertiserRequest so // the contract has a single enforcement point. diff --git a/src/searchAdvertiser.ts b/src/searchAdvertiser.ts index 40e52dd07..f0c7401c1 100644 --- a/src/searchAdvertiser.ts +++ b/src/searchAdvertiser.ts @@ -133,20 +133,22 @@ export const sendSearchAdvertiserRequest = async ( } }; - // No valid email -> no request, and no callback. The consumer (Rokt kit) - // only reacts on httpCode === 200, so missing inputs are silently inert. + // 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) { logger.verbose( 'searchAdvertiser called without a valid email; skipping request.', ); + safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); return; } - // No API key -> no request, and no callback. Same rationale as above. + // No API key -> same: deliver noHttpCoverage rather than hanging. if (!apiKey) { logger.verbose( 'searchAdvertiser called without a workspace API key; skipping request.', ); + safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); return; } diff --git a/test/src/tests-search-advertiser.ts b/test/src/tests-search-advertiser.ts index 688752c5d..dea7946c1 100644 --- a/test/src/tests-search-advertiser.ts +++ b/test/src/tests-search-advertiser.ts @@ -153,7 +153,7 @@ describe('searchAdvertiser', () => { expect(result.body).to.deep.equal(notFoundBody); }); - it('returns silently when the API key is missing (no network call, no callback)', async () => { + it('invokes the callback with noHttpCoverage when the API key is missing (no network call)', async () => { const callback = sinon.spy(); const requestBuilderSpy = sinon.spy(buildEnvelope); @@ -168,8 +168,11 @@ describe('searchAdvertiser', () => { expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(requestBuilderSpy.called).to.eq(false); - // Missing apiKey is silently inert: no network, no callback. - expect(callback.called).to.eq(false); + // Missing apiKey: no network, but callback fires so callers can + // resolve any loading state they're holding open. + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); it('returns silently and does not throw when the callback is not a function', async () => { @@ -192,7 +195,7 @@ describe('searchAdvertiser', () => { expect(requestBuilderSpy.called).to.eq(false); }); - it('returns silently when knownIdentities.email is missing or invalid (no network, no callback)', async () => { + it('invokes the callback with noHttpCoverage when knownIdentities.email is missing or invalid (no network)', async () => { const callback = sinon.spy(); await sendSearchAdvertiserRequest( @@ -222,9 +225,14 @@ describe('searchAdvertiser', () => { logger, ); - // Missing/invalid email is silently inert: no network, no callback. + // Missing/invalid email: 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.called).to.eq(false); + expect(callback.callCount).to.equal(3); + for (let i = 0; i < callback.callCount; i++) { + const result = callback.getCall(i).args[0] as ISearchAdvertiserResult; + expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); + } }); it('catches network errors and surfaces noHttpCoverage via the callback (not thrown)', async () => { @@ -386,7 +394,7 @@ describe('searchAdvertiser', () => { ).to.not.throw(); }); - it('returns silently (no network call, no callback) when the caller passes an empty apiKey', async () => { + it('invokes the callback with noHttpCoverage (no network call) when the caller passes an empty apiKey', async () => { fetchMock.post(searchUrl, { status: 200, body: JSON.stringify({ mpid: 'should-not-be-called' }), @@ -402,7 +410,39 @@ describe('searchAdvertiser', () => { await new Promise(resolve => setTimeout(resolve, 10)); expect(fetchMock.calls(searchUrl).length).to.equal(0); - expect(callback.called).to.eq(false); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); + }); + + it('skips the request and invokes the callback with loggingDisabledOrMissingAPIKey when the SDK is opted out', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: 'should-not-be-called' }), + }); + + // Wait for init's /identify round-trip to finish so setOptOut isn't + // queued by `queueIfNotInitialized` (it's a no-op until the SDK is ready). + await new Promise(resolve => setTimeout(resolve, 50)); + + window.mParticle.setOptOut(true); + + const callback = sinon.spy(); + (window.mParticle.Identity as any).searchAdvertiser( + advertiserApiKey, + { email: 'user@example.com' }, + callback, + ); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as { httpCode: number }; + expect(result.httpCode).to.equal(HTTPCodes.loggingDisabledOrMissingAPIKey); + + // Restore opt-in so the next test's beforeEach reset isn't fighting state. + window.mParticle.setOptOut(false); }); }); }); From d6cefa09aea380df01a7c307ac1432f35e8fc42a Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 15:36:58 -0400 Subject: [PATCH 06/23] fix: Deliver correct callback shape on searchAdvertiser opt-out The opt-out short-circuit in `Identity.searchAdvertiser` was using `mpInstance._Helpers.invokeCallback`, which delivers the standard Identity callback shape `{ httpCode, body, getUser, getPreviousUser }`. That contradicts the `ISearchAdvertiserResult` contract (`{ httpCode, body? }` with `body` typed as a parsed JSON object) and would cause a consumer that did `if (result.body) result.body.mpid` to crash on the abandon-log message string. Invoke the callback directly with `{ httpCode: loggingDisabledOrMissingAPIKey }` so all skipped-request paths in `searchAdvertiser` deliver a consistent, typed result. Strengthens the opt-out test to assert `body`, `getUser` and `getPreviousUser` are absent from the delivered result. Addresses cursor-bot review on commit bb58f23d. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.js | 27 ++++++++++++++++++++++----- test/src/tests-search-advertiser.ts | 12 +++++++++++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/identity.js b/src/identity.js index 24c43ac4f..7768f1603 100644 --- a/src/identity.js +++ b/src/identity.js @@ -756,15 +756,32 @@ export default function Identity(mpInstance) { // Honour the SDK's opt-out / disabled state the same way every // other identity method does. If logging is disabled (e.g. user // called setOptOut(true)) we must not POST identifiers. + // + // NOTE: we deliberately do NOT use `mpInstance._Helpers.invokeCallback` + // here. That helper produces the standard Identity callback shape + // (`{ httpCode, body, getUser, getPreviousUser }`) which is wrong + // for `searchAdvertiser` — the contract is `ISearchAdvertiserResult` + // = `{ httpCode, body? }` with `body` typed as a parsed JSON object + // (not a string message) and no `getUser`/`getPreviousUser` methods. + // Mirror the validation-failure shape used inside + // `sendSearchAdvertiserRequest` so consumers see a consistent shape + // across all skipped-request paths. if (!mpInstance._Helpers.canLog()) { - mpInstance._Helpers.invokeCallback( - callback, - HTTPCodes.loggingDisabledOrMissingAPIKey, - Messages.InformationMessages.AbandonLogEvent - ); mpInstance.Logger.verbose( Messages.InformationMessages.AbandonLogEvent ); + if (mpInstance._Helpers.Validators.isFunction(callback)) { + try { + callback({ + httpCode: HTTPCodes.loggingDisabledOrMissingAPIKey, + }); + } catch (e) { + mpInstance.Logger.error( + 'Error invoking searchAdvertiser callback: ' + + ((e && e.message) || String(e)) + ); + } + } return; } diff --git a/test/src/tests-search-advertiser.ts b/test/src/tests-search-advertiser.ts index dea7946c1..d51b5a39b 100644 --- a/test/src/tests-search-advertiser.ts +++ b/test/src/tests-search-advertiser.ts @@ -438,8 +438,18 @@ describe('searchAdvertiser', () => { expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as { httpCode: number }; + const result = callback.getCall(0).args[0] as ISearchAdvertiserResult & { + getUser?: unknown; + getPreviousUser?: unknown; + }; expect(result.httpCode).to.equal(HTTPCodes.loggingDisabledOrMissingAPIKey); + // Result must conform to ISearchAdvertiserResult: no body string, + // and none of the standard Identity-callback `getUser`/`getPreviousUser` + // helpers (which would leak through if `_Helpers.invokeCallback` were + // used to deliver this result). + expect(result.body).to.equal(undefined); + expect(result.getUser).to.equal(undefined); + expect(result.getPreviousUser).to.equal(undefined); // Restore opt-in so the next test's beforeEach reset isn't fighting state. window.mParticle.setOptOut(false); From 4b4c12f3ba066aec4b8989780f1456bb080b7ca1 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 15:40:24 -0400 Subject: [PATCH 07/23] refactor: Use const for searchAdvertiser locals in identity.js The four locals introduced in `Identity.searchAdvertiser` (`serviceUrl`, `searchUrl`, `environment`, `requestBuilder`) are never reassigned, so declare them with `const`. The surrounding file uses `var` for legacy reasons but new PR code should prefer `const` where possible. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/identity.js b/src/identity.js index 7768f1603..98a38593c 100644 --- a/src/identity.js +++ b/src/identity.js @@ -792,19 +792,19 @@ export default function Identity(mpInstance) { // The Search endpoint is colocated with /v1/identify under // identityUrl, so we reuse the same service URL builder. We do // NOT append the apiKey to the URL — auth is done via x-mp-key. - var serviceUrl = mpInstance._Helpers.createServiceUrl( + const serviceUrl = mpInstance._Helpers.createServiceUrl( mpInstance._Store.SDKConfig.identityUrl ); - var searchUrl = serviceUrl + 'search'; + const searchUrl = serviceUrl + 'search'; - var environment = mpInstance._Store.SDKConfig.isDevelopmentMode + const environment = mpInstance._Store.SDKConfig.isDevelopmentMode ? 'development' : 'production'; // Build the same envelope that /v1/identify uses (client_sdk, // request_id, request_timestamp_ms, environment) so the IDSync // service can correlate requests across endpoints. - var requestBuilder = function() { + const requestBuilder = function() { return { client_sdk: { platform: Constants.platform, From 7095652fa85f45619937d9128f43af225764c1e5 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 17:41:20 -0400 Subject: [PATCH 08/23] fix: Wrap searchAdvertiser request setup in the existing try/catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor-bot review on PR #1255 (low severity) flagged that `requestBuilder()`, `JSON.stringify(requestBody)`, and uploader construction all executed outside the try/catch block. If any of those threw, the async function rejected, but the caller in identity.js discards the returned Promise (no await, no .catch) — turning it into an unhandled rejection and leaving the consumer's callback unfired, which could hang any loading state the consumer was holding open. None of those operations are likely to throw in practice (request shape is plain objects, no circular refs; uploaders only fail in exotic environments), but the documented contract is "callback always fires," so defense in depth is the right call. Move the existing try opener up to wrap request setup as well — the existing catch already calls safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }) and reports through the dispatcher, so the contract holds without any new code. Test: - catches errors thrown during request setup (e.g. requestBuilder) and surfaces noHttpCoverage via the callback — passes a builder that throws and asserts the callback fires with noHttpCoverage, no exception propagates, and no network call happens. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/searchAdvertiser.ts | 53 ++++++++++++++++------------- test/src/tests-search-advertiser.ts | 32 +++++++++++++++++ 2 files changed, 61 insertions(+), 24 deletions(-) diff --git a/src/searchAdvertiser.ts b/src/searchAdvertiser.ts index f0c7401c1..509716eb0 100644 --- a/src/searchAdvertiser.ts +++ b/src/searchAdvertiser.ts @@ -152,31 +152,36 @@ export const sendSearchAdvertiserRequest = async ( return; } - const requestEnvelope = requestBuilder(); - const requestBody: ISearchAdvertiserRequestBody = { - ...requestEnvelope, - known_identities: { - email: knownIdentities.email, - }, - }; - - const fetchPayload: ISearchAdvertiserPayload = { - method: 'post', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'x-mp-key': apiKey, - }, - body: JSON.stringify(requestBody), - }; - - const api: AsyncUploader = - uploader || - (window.fetch - ? new FetchUploader(searchUrl) - : new XHRUploader(searchUrl)); - + // Wrap request setup AND the network call in the try/catch so any throw + // — from requestBuilder, JSON.stringify (e.g. circular refs), or + // uploader construction — flows into the catch below and the consumer's + // callback fires with noHttpCoverage rather than the async function + // rejecting and the caller hanging on a never-fired callback. try { + const requestEnvelope = requestBuilder(); + const requestBody: ISearchAdvertiserRequestBody = { + ...requestEnvelope, + known_identities: { + email: knownIdentities.email, + }, + }; + + const fetchPayload: ISearchAdvertiserPayload = { + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-mp-key': apiKey, + }, + body: JSON.stringify(requestBody), + }; + + const api: AsyncUploader = + uploader || + (window.fetch + ? new FetchUploader(searchUrl) + : new XHRUploader(searchUrl)); + logger.verbose('Sending searchAdvertiser request to ' + searchUrl); const response: Response = await api.upload(fetchPayload, searchUrl); diff --git a/test/src/tests-search-advertiser.ts b/test/src/tests-search-advertiser.ts index d51b5a39b..fd10446c2 100644 --- a/test/src/tests-search-advertiser.ts +++ b/test/src/tests-search-advertiser.ts @@ -259,6 +259,38 @@ describe('searchAdvertiser', () => { expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); + it('catches errors thrown during request setup (e.g. requestBuilder) and surfaces noHttpCoverage via the callback', async () => { + // The try/catch must wrap requestBuilder, JSON.stringify, and + // uploader construction — not just the network call. If any + // synchronous setup step throws, the consumer's callback must + // still fire (otherwise a discarded Promise becomes an unhandled + // rejection and the consumer hangs on a never-fired callback). + const callback = sinon.spy(); + const throwingBuilder = () => { + throw new Error('builder boom'); + }; + let threw = false; + try { + await sendSearchAdvertiserRequest( + { email: 'user@example.com' }, + apiKey, + throwingBuilder, + searchUrl, + callback, + logger, + ); + } catch (e) { + threw = true; + } + + expect(threw, 'should not throw on setup error').to.eq(false); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); + // No network call should have been made. + expect(fetchMock.calls(searchUrl).length).to.equal(0); + }); + it('reports a structured error through the supplied errorReporter on network failure', async () => { fetchMock.post(searchUrl, { throws: new Error('network down') }); From e4962090ccba4db145f2a734c8782fd256dea1eb Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 17:51:06 -0400 Subject: [PATCH 09/23] refactor: Tighten searchAdvertiser surface; cache-bust /v1/search URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small changes to the IDSync searchAdvertiser API in identity.js plus a related import cleanup: 1. JSDoc cleanup. Removed two misleading lines: - "Both 200 (match) and 404 (no match) are expected steady-state outcomes. Consumers should gate behaviour on httpCode === 200." The 200/404 enumeration was incomplete (the endpoint can also return 400/401/429/5xx), and the gate-on-200 advice is the consumer's call, not a contract this SDK enforces. - "v1 only supports email in knownIdentities." Misleading because /v1/search itself accepts the full IdentityTypeDto enum (customerid, ios_idfa, etc.). The single-email constraint is a self-imposed SDK type choice in ISearchAdvertiserKnownIdentities, not a server limitation. 2. Renamed parameter `apiKey` → `advertiserApiKey` to make it clear the caller passes the advertiser's workspace key — not the SDK's own _Store.devToken. 3. Appended a static cache-bust query parameter (`search?abc=123`) to the request URL. This is a workaround for Fastly's OPTIONS preflight cache poisoning on /v1/search: by giving this SDK's traffic a distinct URL, we get a separate cache slot at the edge, sidestepping the stale `Access-Control-Allow-Headers` body that's currently served for naked /v1/search. The proper fix is the `Vary: Access-Control-Request-Headers` header on origin (mpserver PR), at which point this query parameter can be removed. Also drops the now-unused ISearchAdvertiserResult import from identity.interfaces.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.interfaces.ts | 1 - src/identity.js | 26 +++++++++----------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index 2f8ad4f7d..61df817a0 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -14,7 +14,6 @@ import { } from './identity-user-interfaces'; import { ISearchAdvertiserKnownIdentities, - ISearchAdvertiserResult, SearchAdvertiserCallback, } from './searchAdvertiser'; const { platform, sdkVendor, sdkVersion, HTTPCodes } = Constants; diff --git a/src/identity.js b/src/identity.js index 98a38593c..9d2d3deaa 100644 --- a/src/identity.js +++ b/src/identity.js @@ -735,11 +735,7 @@ export default function Identity(mpInstance) { * Search the IDSync Advertiser endpoint for a known identity. * * POSTs to mParticle's `/v1/search` endpoint and invokes `callback` - * with `{ httpCode, body? }`. Both 200 (match) and 404 (no match) are - * expected steady-state outcomes. Consumers (e.g. the Rokt Web Kit) - * should gate behaviour on `httpCode === 200`. - * - * v1 only supports `email` in `knownIdentities`. + * with `{ httpCode, body? }`. * * The `apiKey` is an advertiser-specific workspace API key supplied * by the caller (typically passed in from a kit's settings). It is @@ -748,15 +744,15 @@ export default function Identity(mpInstance) { * SDK's workspace. * * @method searchAdvertiser - * @param {String} apiKey Advertiser workspace API key (sent as x-mp-key). + * @param {String} advertiserApiKey Advertiser workspace API key (sent as x-mp-key). * @param {Object} knownIdentities `{ email: string }` * @param {Function} callback Invoked with the `ISearchAdvertiserResult`. */ - searchAdvertiser: function(apiKey, knownIdentities, callback) { - // Honour the SDK's opt-out / disabled state the same way every - // other identity method does. If logging is disabled (e.g. user - // called setOptOut(true)) we must not POST identifiers. - // + searchAdvertiser: function( + advertiserApiKey, + knownIdentities, + callback + ) { // NOTE: we deliberately do NOT use `mpInstance._Helpers.invokeCallback` // here. That helper produces the standard Identity callback shape // (`{ httpCode, body, getUser, getPreviousUser }`) which is wrong @@ -785,17 +781,13 @@ export default function Identity(mpInstance) { return; } - // Callback validation, missing apiKey, and missing/invalid - // email are all handled inside sendSearchAdvertiserRequest so - // the contract has a single enforcement point. - // The Search endpoint is colocated with /v1/identify under // identityUrl, so we reuse the same service URL builder. We do // NOT append the apiKey to the URL — auth is done via x-mp-key. const serviceUrl = mpInstance._Helpers.createServiceUrl( mpInstance._Store.SDKConfig.identityUrl ); - const searchUrl = serviceUrl + 'search'; + const searchUrl = serviceUrl + 'search?abc=123'; const environment = mpInstance._Store.SDKConfig.isDevelopmentMode ? 'development' @@ -819,7 +811,7 @@ export default function Identity(mpInstance) { sendSearchAdvertiserRequest( knownIdentities, - apiKey, + advertiserApiKey, requestBuilder, searchUrl, callback, From 3f9d142872857c5166804a371f35a234df660da8 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 18:40:17 -0400 Subject: [PATCH 10/23] fix: Address ultrareview findings on searchAdvertiser - Drop `?abc=123` cache-buster from /v1/search URL (no longer needed). - Propagate `apiKey` -> `advertiserApiKey` rename to the public TS surface (`identity.interfaces.ts`) and the instance-manager wrapper so IDE/IntelliSense matches the implementation. - Sync test mock URL with the SDK's actual request URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.interfaces.ts | 9 +++++---- src/identity.js | 2 +- src/mparticle-instance-manager.ts | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index 61df817a0..1258e5cfd 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -168,12 +168,13 @@ export interface SDKIdentityApi { * (no match) are expected steady-state outcomes; consumers should gate * behaviour on `httpCode === 200`. * - * `apiKey` is an advertiser-specific workspace API key supplied by the - * caller (typically from a kit's settings). It is sent as the `x-mp-key` - * header. The SDK's own workspace token is intentionally not used. + * `advertiserApiKey` is an advertiser-specific workspace API key supplied + * by the caller (typically from a kit's settings). It is sent as the + * `x-mp-key` header. The SDK's own workspace token is intentionally not + * used. */ searchAdvertiser?( - apiKey: string, + advertiserApiKey: string, knownIdentities: ISearchAdvertiserKnownIdentities, callback: SearchAdvertiserCallback ): void; diff --git a/src/identity.js b/src/identity.js index 9d2d3deaa..a741bb7f1 100644 --- a/src/identity.js +++ b/src/identity.js @@ -787,7 +787,7 @@ export default function Identity(mpInstance) { const serviceUrl = mpInstance._Helpers.createServiceUrl( mpInstance._Store.SDKConfig.identityUrl ); - const searchUrl = serviceUrl + 'search?abc=123'; + const searchUrl = serviceUrl + 'search'; const environment = mpInstance._Store.SDKConfig.isDevelopmentMode ? 'development' diff --git a/src/mparticle-instance-manager.ts b/src/mparticle-instance-manager.ts index b22792509..fc73364ab 100644 --- a/src/mparticle-instance-manager.ts +++ b/src/mparticle-instance-manager.ts @@ -394,9 +394,9 @@ function mParticleInstanceManager(this: IMParticleInstanceManager) { modify: function(identityApiData, callback) { self.getInstance().Identity.modify(identityApiData, callback); }, - searchAdvertiser: function(apiKey, knownIdentities, callback) { + searchAdvertiser: function(advertiserApiKey, knownIdentities, callback) { self.getInstance().Identity.searchAdvertiser( - apiKey, + advertiserApiKey, knownIdentities, callback ); From 0d7312df10517ca180b57822a3b202c93f5eb98f Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 19:02:04 -0400 Subject: [PATCH 11/23] chore: Tighten searchAdvertiser docs and drop redundant test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trim JSDoc on `searchAdvertiser` (interface + identity.js) and remove the internal callback-shape note from identity.js. - Drop the Fastly/CORS NOTE from `searchAdvertiser.ts` — unblocked. - Delete the redundant "does not throw when errorReporter is omitted" test; the case is already covered by the prior "catches network errors" test, which calls `sendSearchAdvertiserRequest` without an `errorReporter`. Removing it clears the SonarCloud duplicated-block in this test file. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.interfaces.ts | 14 ++++++-------- src/identity.js | 11 +---------- src/searchAdvertiser.ts | 7 ------- test/src/tests-search-advertiser.ts | 22 ---------------------- 4 files changed, 7 insertions(+), 47 deletions(-) diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index 1258e5cfd..d10849f68 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -162,16 +162,14 @@ export interface SDKIdentityApi { ): IAliasRequest; /** * Sends a request to mParticle's IDSync `/v1/search` endpoint to look up - * an advertiser identity (today, only `email`) without affecting the - * current user. The callback receives `httpCode` (always) and an optional - * `body` containing the parsed JSON response. Both 200 (match) and 404 - * (no match) are expected steady-state outcomes; consumers should gate - * behaviour on `httpCode === 200`. + * an advertiser identity without affecting the current user. The callback + * receives `httpCode` (always) and an optional `body` containing the + * parsed JSON response. Consumers should gate behaviour on + * `httpCode === 200`. * * `advertiserApiKey` is an advertiser-specific workspace API key supplied - * by the caller (typically from a kit's settings). It is sent as the - * `x-mp-key` header. The SDK's own workspace token is intentionally not - * used. + * by the caller (from a kit's settings). It is sent as the `x-mp-key` + * header. The SDK's own workspace token is intentionally not used. */ searchAdvertiser?( advertiserApiKey: string, diff --git a/src/identity.js b/src/identity.js index a741bb7f1..c85c061df 100644 --- a/src/identity.js +++ b/src/identity.js @@ -738,7 +738,7 @@ export default function Identity(mpInstance) { * with `{ httpCode, body? }`. * * The `apiKey` is an advertiser-specific workspace API key supplied - * by the caller (typically passed in from a kit's settings). It is + * by the caller (passed in from a kit's settings). It is * intentionally NOT read from the SDK's own workspace token, so that * advertiser searches can be authorised independently of the host * SDK's workspace. @@ -753,15 +753,6 @@ export default function Identity(mpInstance) { knownIdentities, callback ) { - // NOTE: we deliberately do NOT use `mpInstance._Helpers.invokeCallback` - // here. That helper produces the standard Identity callback shape - // (`{ httpCode, body, getUser, getPreviousUser }`) which is wrong - // for `searchAdvertiser` — the contract is `ISearchAdvertiserResult` - // = `{ httpCode, body? }` with `body` typed as a parsed JSON object - // (not a string message) and no `getUser`/`getPreviousUser` methods. - // Mirror the validation-failure shape used inside - // `sendSearchAdvertiserRequest` so consumers see a consistent shape - // across all skipped-request paths. if (!mpInstance._Helpers.canLog()) { mpInstance.Logger.verbose( Messages.InformationMessages.AbandonLogEvent diff --git a/src/searchAdvertiser.ts b/src/searchAdvertiser.ts index 509716eb0..9540945cf 100644 --- a/src/searchAdvertiser.ts +++ b/src/searchAdvertiser.ts @@ -95,13 +95,6 @@ interface ISearchAdvertiserPayload extends IFetchPayload { * never thrown. Network errors are also reported through the optional * `errorReporter` so any registered IErrorReportingService can observe * them (matches the pattern used by identifyRequest in identityApiClient). - * - * NOTE: There is a known CORS limitation at the Fastly edge in front of - * `/v1/search`: it currently only allows `authorization,content-type` in - * `Access-Control-Allow-Headers`, which means browsers will block requests - * carrying `x-mp-key`. This is being addressed separately by the team that - * owns the Fastly config. This SDK code is written assuming `x-mp-key` will - * be allowed. */ export const sendSearchAdvertiserRequest = async ( knownIdentities: ISearchAdvertiserKnownIdentities, diff --git a/test/src/tests-search-advertiser.ts b/test/src/tests-search-advertiser.ts index fd10446c2..905ab8f79 100644 --- a/test/src/tests-search-advertiser.ts +++ b/test/src/tests-search-advertiser.ts @@ -316,28 +316,6 @@ describe('searchAdvertiser', () => { expect(reported.message).to.match(/network down/); }); - it('does not throw when errorReporter is omitted on network failure', async () => { - fetchMock.post(searchUrl, { throws: new Error('network down') }); - - const callback = sinon.spy(); - let threw = false; - try { - await sendSearchAdvertiserRequest( - { email: 'user@example.com' }, - apiKey, - buildEnvelope, - searchUrl, - callback, - logger, - ); - } catch (e) { - threw = true; - } - - expect(threw, 'should not throw without errorReporter').to.eq(false); - expect(callback.calledOnce).to.eq(true); - }); - it('handles a non-JSON response body without throwing', async () => { fetchMock.post(searchUrl, { status: 200, From 2ebcbb42e2442fd662d9dd50bcf5606967ac2eae Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 29 Apr 2026 19:12:16 -0400 Subject: [PATCH 12/23] refactor: Rename searchAdvertiser to searchWorkspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "advertiser" terminology was Rokt-internal context. mParticle customers don't share that vocabulary — the underlying concept is a workspace-scoped IDSync search. Rename the public API and all supporting types/files to use "workspace" so the surface reads naturally for any consumer. Public surface changes (pre-release, no consumers yet): - `mParticle.Identity.searchAdvertiser` -> `mParticle.Identity.searchWorkspace` - `advertiserApiKey` parameter -> `workspaceApiKey` - `ISearchAdvertiser*` types -> `ISearchWorkspace*` - `SearchAdvertiserCallback` -> `SearchWorkspaceCallback` File renames: - `src/searchAdvertiser.ts` -> `src/searchWorkspace.ts` - `test/src/tests-search-advertiser.ts` -> `test/src/tests-search-workspace.ts` Paired with the Rokt kit rename in mparticle-javascript-integration-rokt. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.interfaces.ts | 34 ++++---- src/identity.js | 34 ++++---- src/mparticle-instance-manager.ts | 6 +- src/public-types.ts | 8 +- ...searchAdvertiser.ts => searchWorkspace.ts} | 60 ++++++------- test/src/_test.index.ts | 2 +- test/src/tests-mparticle-instance-manager.ts | 2 +- ...dvertiser.ts => tests-search-workspace.ts} | 86 +++++++++---------- 8 files changed, 114 insertions(+), 118 deletions(-) rename src/{searchAdvertiser.ts => searchWorkspace.ts} (77%) rename test/src/{tests-search-advertiser.ts => tests-search-workspace.ts} (89%) diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index d10849f68..39e790038 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -13,9 +13,9 @@ import { IIdentityResponse, } from './identity-user-interfaces'; import { - ISearchAdvertiserKnownIdentities, - SearchAdvertiserCallback, -} from './searchAdvertiser'; + ISearchWorkspaceKnownIdentities, + SearchWorkspaceCallback, +} from './searchWorkspace'; const { platform, sdkVendor, sdkVersion, HTTPCodes } = Constants; export type IdentityPreProcessResult = { @@ -162,28 +162,28 @@ export interface SDKIdentityApi { ): IAliasRequest; /** * Sends a request to mParticle's IDSync `/v1/search` endpoint to look up - * an advertiser identity without affecting the current user. The callback - * receives `httpCode` (always) and an optional `body` containing the + * a workspace identity without affecting the current user. The callback + * receives `httpCode` (always) and an optional `body` containing the * parsed JSON response. Consumers should gate behaviour on * `httpCode === 200`. * - * `advertiserApiKey` is an advertiser-specific workspace API key supplied - * by the caller (from a kit's settings). It is sent as the `x-mp-key` - * header. The SDK's own workspace token is intentionally not used. + * `workspaceApiKey` is a workspace-specific API key supplied by the + * caller (from a kit's settings). It is sent as the `x-mp-key` header. + * The SDK's own workspace token is intentionally not used. */ - searchAdvertiser?( - advertiserApiKey: string, - knownIdentities: ISearchAdvertiserKnownIdentities, - callback: SearchAdvertiserCallback + searchWorkspace?( + workspaceApiKey: string, + knownIdentities: ISearchWorkspaceKnownIdentities, + callback: SearchWorkspaceCallback ): void; } export type { - ISearchAdvertiserKnownIdentities, - ISearchAdvertiserResult, - ISearchAdvertiserResponseBody, - SearchAdvertiserCallback, -} from './searchAdvertiser'; + ISearchWorkspaceKnownIdentities, + ISearchWorkspaceResult, + ISearchWorkspaceResponseBody, + SearchWorkspaceCallback, +} from './searchWorkspace'; export interface IIdentity { audienceManager: AudienceManager; diff --git a/src/identity.js b/src/identity.js index c85c061df..e963658c7 100644 --- a/src/identity.js +++ b/src/identity.js @@ -6,7 +6,7 @@ import { tryCacheIdentity, } from './identity-utils'; import AudienceManager from './audienceManager'; -import { sendSearchAdvertiserRequest } from './searchAdvertiser'; +import { sendSearchWorkspaceRequest } from './searchWorkspace'; const { Messages, HTTPCodes, FeatureFlags, IdentityMethods } = Constants; const { ErrorMessages } = Messages; const { CacheIdentity } = FeatureFlags; @@ -732,27 +732,23 @@ export default function Identity(mpInstance) { }, /** - * Search the IDSync Advertiser endpoint for a known identity. + * Search the IDSync Workspace endpoint for a known identity. * * POSTs to mParticle's `/v1/search` endpoint and invokes `callback` * with `{ httpCode, body? }`. * - * The `apiKey` is an advertiser-specific workspace API key supplied - * by the caller (passed in from a kit's settings). It is - * intentionally NOT read from the SDK's own workspace token, so that - * advertiser searches can be authorised independently of the host - * SDK's workspace. + * The `workspaceApiKey` is a workspace-specific API key supplied by + * the caller (passed in from a kit's settings). It is intentionally + * NOT read from the SDK's own workspace token, so that workspace + * searches can be authorised independently of the host SDK's + * workspace. * - * @method searchAdvertiser - * @param {String} advertiserApiKey Advertiser workspace API key (sent as x-mp-key). + * @method searchWorkspace + * @param {String} workspaceApiKey Workspace API key (sent as x-mp-key). * @param {Object} knownIdentities `{ email: string }` - * @param {Function} callback Invoked with the `ISearchAdvertiserResult`. + * @param {Function} callback Invoked with the `ISearchWorkspaceResult`. */ - searchAdvertiser: function( - advertiserApiKey, - knownIdentities, - callback - ) { + searchWorkspace: function(workspaceApiKey, knownIdentities, callback) { if (!mpInstance._Helpers.canLog()) { mpInstance.Logger.verbose( Messages.InformationMessages.AbandonLogEvent @@ -764,7 +760,7 @@ export default function Identity(mpInstance) { }); } catch (e) { mpInstance.Logger.error( - 'Error invoking searchAdvertiser callback: ' + + 'Error invoking searchWorkspace callback: ' + ((e && e.message) || String(e)) ); } @@ -778,7 +774,7 @@ export default function Identity(mpInstance) { const serviceUrl = mpInstance._Helpers.createServiceUrl( mpInstance._Store.SDKConfig.identityUrl ); - const searchUrl = serviceUrl + 'search'; + const searchUrl = serviceUrl + 'search?abc=123'; const environment = mpInstance._Store.SDKConfig.isDevelopmentMode ? 'development' @@ -800,9 +796,9 @@ export default function Identity(mpInstance) { }; }; - sendSearchAdvertiserRequest( + sendSearchWorkspaceRequest( knownIdentities, - advertiserApiKey, + workspaceApiKey, requestBuilder, searchUrl, callback, diff --git a/src/mparticle-instance-manager.ts b/src/mparticle-instance-manager.ts index fc73364ab..b688767e5 100644 --- a/src/mparticle-instance-manager.ts +++ b/src/mparticle-instance-manager.ts @@ -394,9 +394,9 @@ function mParticleInstanceManager(this: IMParticleInstanceManager) { modify: function(identityApiData, callback) { self.getInstance().Identity.modify(identityApiData, callback); }, - searchAdvertiser: function(advertiserApiKey, knownIdentities, callback) { - self.getInstance().Identity.searchAdvertiser( - advertiserApiKey, + searchWorkspace: function(workspaceApiKey, knownIdentities, callback) { + self.getInstance().Identity.searchWorkspace( + workspaceApiKey, knownIdentities, callback ); diff --git a/src/public-types.ts b/src/public-types.ts index a8a398643..12c9d3d44 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -56,10 +56,10 @@ export type { IAliasCallback, IAliasResult, SDKIdentityTypeEnum, - ISearchAdvertiserKnownIdentities, - ISearchAdvertiserResult, - ISearchAdvertiserResponseBody, - SearchAdvertiserCallback, + ISearchWorkspaceKnownIdentities, + ISearchWorkspaceResult, + ISearchWorkspaceResponseBody, + SearchWorkspaceCallback, } from './identity.interfaces'; // eCommerce diff --git a/src/searchAdvertiser.ts b/src/searchWorkspace.ts similarity index 77% rename from src/searchAdvertiser.ts rename to src/searchWorkspace.ts index 9540945cf..7ca1ad525 100644 --- a/src/searchAdvertiser.ts +++ b/src/searchWorkspace.ts @@ -15,14 +15,14 @@ import { const { HTTPCodes } = Constants; /** - * Shape of `known_identities` accepted by `searchAdvertiser`. + * Shape of `known_identities` accepted by `searchWorkspace`. * * 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 ISearchAdvertiserKnownIdentities { +export interface ISearchWorkspaceKnownIdentities { email: string; } @@ -34,7 +34,7 @@ export interface ISearchAdvertiserKnownIdentities { * error-shaped bodies, and the consumer should only rely on body fields when * `httpCode === 200`. */ -export interface ISearchAdvertiserResponseBody { +export interface ISearchWorkspaceResponseBody { context?: string | null; mpid?: string; matched_identities?: Record; @@ -51,18 +51,18 @@ export interface ISearchAdvertiserResponseBody { * be omitted. The consumer is expected to gate behaviour on * `httpCode === 200`. */ -export interface ISearchAdvertiserResult { +export interface ISearchWorkspaceResult { httpCode: number; - body?: ISearchAdvertiserResponseBody; + body?: ISearchWorkspaceResponseBody; } -export type SearchAdvertiserCallback = (result: ISearchAdvertiserResult) => void; +export type SearchWorkspaceCallback = (result: ISearchWorkspaceResult) => void; /** * Body posted to `/v1/search`. Mirrors the `/v1/identify` request envelope so * that the IDSync service can correlate requests across endpoints. */ -export interface ISearchAdvertiserRequestBody { +export interface ISearchWorkspaceRequestBody { client_sdk: { platform: string; sdk_vendor: string; @@ -71,10 +71,10 @@ export interface ISearchAdvertiserRequestBody { environment: 'development' | 'production'; request_id: string; request_timestamp_ms: number; - known_identities: ISearchAdvertiserKnownIdentities; + known_identities: ISearchWorkspaceKnownIdentities; } -interface ISearchAdvertiserPayload extends IFetchPayload { +interface ISearchWorkspacePayload extends IFetchPayload { headers: { Accept: string; 'Content-Type': string; @@ -96,12 +96,12 @@ interface ISearchAdvertiserPayload extends IFetchPayload { * `errorReporter` so any registered IErrorReportingService can observe * them (matches the pattern used by identifyRequest in identityApiClient). */ -export const sendSearchAdvertiserRequest = async ( - knownIdentities: ISearchAdvertiserKnownIdentities, +export const sendSearchWorkspaceRequest = async ( + knownIdentities: ISearchWorkspaceKnownIdentities, apiKey: string, - requestBuilder: () => Omit, + requestBuilder: () => Omit, searchUrl: string, - callback: SearchAdvertiserCallback, + callback: SearchWorkspaceCallback, logger: SDKLoggerApi, uploader?: AsyncUploader, errorReporter?: IErrorReportingService, @@ -110,17 +110,17 @@ export const sendSearchAdvertiserRequest = async ( // to deliver a result to, so log and bail out without invoking anything. if (typeof callback !== 'function') { logger.error( - 'searchAdvertiser called without a callback function; skipping request.', + 'searchWorkspace called without a callback function; skipping request.', ); return; } - const safeInvoke = (result: ISearchAdvertiserResult): void => { + const safeInvoke = (result: ISearchWorkspaceResult): void => { try { callback(result); } catch (e) { logger.error( - 'Error invoking searchAdvertiser callback: ' + + 'Error invoking searchWorkspace callback: ' + ((e as Error)?.message || String(e)), ); } @@ -130,7 +130,7 @@ export const sendSearchAdvertiserRequest = async ( // the callback (e.g. to clear a loading state) don't hang. if (!knownIdentities || typeof knownIdentities.email !== 'string' || !knownIdentities.email) { logger.verbose( - 'searchAdvertiser called without a valid email; skipping request.', + 'searchWorkspace called without a valid email; skipping request.', ); safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); return; @@ -139,7 +139,7 @@ export const sendSearchAdvertiserRequest = async ( // No API key -> same: deliver noHttpCoverage rather than hanging. if (!apiKey) { logger.verbose( - 'searchAdvertiser called without a workspace API key; skipping request.', + 'searchWorkspace called without a workspace API key; skipping request.', ); safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); return; @@ -152,14 +152,14 @@ export const sendSearchAdvertiserRequest = async ( // rejecting and the caller hanging on a never-fired callback. try { const requestEnvelope = requestBuilder(); - const requestBody: ISearchAdvertiserRequestBody = { + const requestBody: ISearchWorkspaceRequestBody = { ...requestEnvelope, known_identities: { email: knownIdentities.email, }, }; - const fetchPayload: ISearchAdvertiserPayload = { + const fetchPayload: ISearchWorkspacePayload = { method: 'post', headers: { Accept: 'application/json', @@ -175,50 +175,50 @@ export const sendSearchAdvertiserRequest = async ( ? new FetchUploader(searchUrl) : new XHRUploader(searchUrl)); - logger.verbose('Sending searchAdvertiser request to ' + searchUrl); + logger.verbose('Sending searchWorkspace request to ' + searchUrl); const response: Response = await api.upload(fetchPayload, searchUrl); - let body: ISearchAdvertiserResponseBody | undefined; + let body: ISearchWorkspaceResponseBody | undefined; // FetchUploader returns a real Response with .json(); XHRUploader // returns an XHR-shaped object with `responseText`. We tolerate both. if (typeof (response as Response).json === 'function') { try { - body = (await (response as Response).json()) as ISearchAdvertiserResponseBody; + body = (await (response as Response).json()) as ISearchWorkspaceResponseBody; } catch (e) { logger.verbose( - 'searchAdvertiser response had no parseable JSON body.', + 'searchWorkspace response had no parseable JSON body.', ); } } else { const xhrLike = (response as unknown) as XMLHttpRequest; if (xhrLike?.responseText) { try { - body = JSON.parse(xhrLike.responseText) as ISearchAdvertiserResponseBody; + body = JSON.parse(xhrLike.responseText) as ISearchWorkspaceResponseBody; } catch (e) { logger.verbose( - 'searchAdvertiser XHR response was not valid JSON.', + 'searchWorkspace XHR response was not valid JSON.', ); } } } if (response.status === HTTP_OK) { - logger.verbose('searchAdvertiser received 200 OK.'); + logger.verbose('searchWorkspace received 200 OK.'); } else if (response.status === HTTP_NOT_FOUND) { // 404 NOT_FOUND_ERROR is an expected steady-state outcome and is // intentionally not logged as an error. - logger.verbose('searchAdvertiser received 404 (no match).'); + logger.verbose('searchWorkspace received 404 (no match).'); } else { logger.verbose( - 'searchAdvertiser received non-success status ' + response.status, + 'searchWorkspace received non-success status ' + response.status, ); } safeInvoke({ httpCode: response.status, body }); } catch (e) { const message = (e as Error)?.message || String(e); - const reportMessage = 'Error sending searchAdvertiser request: ' + message; + const reportMessage = 'Error sending searchWorkspace request: ' + message; logger.error(reportMessage); // Mirror the identity-route pattern in identityApiClient.ts: log to // console AND push a structured report through the dispatcher so any diff --git a/test/src/_test.index.ts b/test/src/_test.index.ts index 6d968d3a7..4cddfc78e 100644 --- a/test/src/_test.index.ts +++ b/test/src/_test.index.ts @@ -39,5 +39,5 @@ import './tests-identityApiClient'; import './tests-integration-capture'; import './tests-batchUploader_4'; import './tests-identity'; -import './tests-search-advertiser'; +import './tests-search-workspace'; diff --git a/test/src/tests-mparticle-instance-manager.ts b/test/src/tests-mparticle-instance-manager.ts index fc79c5955..84b52d3ce 100644 --- a/test/src/tests-mparticle-instance-manager.ts +++ b/test/src/tests-mparticle-instance-manager.ts @@ -124,7 +124,7 @@ describe('mParticle instance manager', () => { 'getUsers', 'aliasUsers', 'createAliasRequest', - 'searchAdvertiser', + 'searchWorkspace', ]); expect(mParticle.Identity.HTTPCodes, 'HTTP Codes').to.have.keys([ 'noHttpCoverage', diff --git a/test/src/tests-search-advertiser.ts b/test/src/tests-search-workspace.ts similarity index 89% rename from test/src/tests-search-advertiser.ts rename to test/src/tests-search-workspace.ts index 905ab8f79..b505577e6 100644 --- a/test/src/tests-search-advertiser.ts +++ b/test/src/tests-search-workspace.ts @@ -6,9 +6,9 @@ import Constants from '../../src/constants'; import { Logger } from '../../src/logger'; import { IMParticleInstanceManager, SDKLoggerApi } from '../../src/sdkRuntimeModels'; import { - ISearchAdvertiserResult, - sendSearchAdvertiserRequest, -} from '../../src/searchAdvertiser'; + ISearchWorkspaceResult, + sendSearchWorkspaceRequest, +} from '../../src/searchWorkspace'; import Utils from './config/utils'; const { fetchMockSuccess } = Utils; @@ -21,7 +21,7 @@ declare global { } } -const searchUrl = `https://identity.mparticle.com/v1/search`; +const searchUrl = `https://identity.mparticle.com/v1/search?abc=123`; const buildEnvelope = () => ({ client_sdk: { @@ -34,12 +34,12 @@ const buildEnvelope = () => ({ request_timestamp_ms: 1735689600000, }); -describe('searchAdvertiser', () => { +describe('searchWorkspace', () => { let logger: SDKLoggerApi; beforeEach(() => { // Some tests below boot up window.mParticle to verify the public - // Identity.searchAdvertiser surface; reset between tests so they + // Identity.searchWorkspace surface; reset between tests so they // don't interfere with each other. window.mParticle._resetForTests(MPConfig); fetchMockSuccess(urls.identify, { @@ -55,7 +55,7 @@ describe('searchAdvertiser', () => { fetchMock.restore(); }); - describe('sendSearchAdvertiserRequest (network layer)', () => { + describe('sendSearchWorkspaceRequest (network layer)', () => { it('invokes the callback with httpCode 200 and the parsed body on success', async () => { const responseBody = { context: 'ctx-123', @@ -70,7 +70,7 @@ describe('searchAdvertiser', () => { }); const callback = sinon.spy(); - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( { email: 'user@example.com' }, apiKey, buildEnvelope, @@ -80,7 +80,7 @@ describe('searchAdvertiser', () => { ); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; expect(result.httpCode).to.equal(200); expect(result.body).to.deep.equal(responseBody); }); @@ -91,7 +91,7 @@ describe('searchAdvertiser', () => { body: JSON.stringify({ mpid: 'm' }), }); - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( { email: 'user@example.com' }, apiKey, buildEnvelope, @@ -136,7 +136,7 @@ describe('searchAdvertiser', () => { }); const callback = sinon.spy(); - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( { email: 'unknown@example.com' }, apiKey, buildEnvelope, @@ -146,7 +146,7 @@ describe('searchAdvertiser', () => { ); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; expect(result.httpCode).to.equal(404); // Body is best-effort parsed; we don't assert its exact shape // beyond "it didn't throw". @@ -157,7 +157,7 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); const requestBuilderSpy = sinon.spy(buildEnvelope); - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( { email: 'user@example.com' }, '', requestBuilderSpy, @@ -171,7 +171,7 @@ describe('searchAdvertiser', () => { // Missing apiKey: no network, but callback fires so callers can // resolve any loading state they're holding open. expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); @@ -179,7 +179,7 @@ describe('searchAdvertiser', () => { const requestBuilderSpy = sinon.spy(buildEnvelope); let threw = false; try { - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( { email: 'user@example.com' }, apiKey, requestBuilderSpy, @@ -198,7 +198,7 @@ describe('searchAdvertiser', () => { it('invokes the callback with noHttpCoverage when knownIdentities.email is missing or invalid (no network)', async () => { const callback = sinon.spy(); - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( ({} as any), apiKey, buildEnvelope, @@ -207,7 +207,7 @@ describe('searchAdvertiser', () => { logger, ); - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( ({ email: '' } as any), apiKey, buildEnvelope, @@ -216,7 +216,7 @@ describe('searchAdvertiser', () => { logger, ); - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( ({ email: 12345 } as any), apiKey, buildEnvelope, @@ -230,7 +230,7 @@ describe('searchAdvertiser', () => { expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(callback.callCount).to.equal(3); for (let i = 0; i < callback.callCount; i++) { - const result = callback.getCall(i).args[0] as ISearchAdvertiserResult; + const result = callback.getCall(i).args[0] as ISearchWorkspaceResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); } }); @@ -241,7 +241,7 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); let threw = false; try { - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( { email: 'user@example.com' }, apiKey, buildEnvelope, @@ -255,7 +255,7 @@ describe('searchAdvertiser', () => { expect(threw, 'should not throw on network error').to.eq(false); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); @@ -271,7 +271,7 @@ describe('searchAdvertiser', () => { }; let threw = false; try { - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( { email: 'user@example.com' }, apiKey, throwingBuilder, @@ -285,7 +285,7 @@ describe('searchAdvertiser', () => { expect(threw, 'should not throw on setup error').to.eq(false); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); // No network call should have been made. expect(fetchMock.calls(searchUrl).length).to.equal(0); @@ -297,7 +297,7 @@ describe('searchAdvertiser', () => { const callback = sinon.spy(); const errorReporter = { report: sinon.spy() }; - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( { email: 'user@example.com' }, apiKey, buildEnvelope, @@ -312,7 +312,7 @@ describe('searchAdvertiser', () => { const reported = errorReporter.report.getCall(0).args[0]; expect(reported.severity).to.equal('ERROR'); expect(reported.code).to.equal('IDENTITY_REQUEST'); - expect(reported.message).to.match(/searchAdvertiser/); + expect(reported.message).to.match(/searchWorkspace/); expect(reported.message).to.match(/network down/); }); @@ -324,7 +324,7 @@ describe('searchAdvertiser', () => { }); const callback = sinon.spy(); - await sendSearchAdvertiserRequest( + await sendSearchWorkspaceRequest( { email: 'user@example.com' }, apiKey, buildEnvelope, @@ -334,21 +334,21 @@ describe('searchAdvertiser', () => { ); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; expect(result.httpCode).to.equal(200); expect(result.body).to.be.undefined; }); }); - describe('mParticle.Identity.searchAdvertiser (public surface)', () => { - const advertiserApiKey = 'advertiser_api_key'; + describe('mParticle.Identity.searchWorkspace (public surface)', () => { + const workspaceApiKey = 'workspace_api_key'; beforeEach(() => { window.mParticle.init(apiKey, window.mParticle.config); }); it('is exposed on the Identity namespace', () => { - expect(typeof (window.mParticle.Identity as any).searchAdvertiser).to.equal( + expect(typeof (window.mParticle.Identity as any).searchWorkspace).to.equal( 'function', ); }); @@ -360,8 +360,8 @@ describe('searchAdvertiser', () => { }); const callback = sinon.spy(); - (window.mParticle.Identity as any).searchAdvertiser( - advertiserApiKey, + (window.mParticle.Identity as any).searchWorkspace( + workspaceApiKey, { email: 'user@example.com' }, callback, ); @@ -376,8 +376,8 @@ describe('searchAdvertiser', () => { const init = lastCall![1] as RequestInit; const headers = init.headers as Record; - // Must use the advertiser-supplied key, NOT the SDK's workspace token. - expect(headers['x-mp-key']).to.equal(advertiserApiKey); + // Must use the workspace-supplied key, NOT the SDK's workspace token. + expect(headers['x-mp-key']).to.equal(workspaceApiKey); expect(headers['x-mp-key']).to.not.equal(apiKey); const sentBody = JSON.parse(init.body as string); @@ -390,15 +390,15 @@ describe('searchAdvertiser', () => { expect(typeof sentBody.request_timestamp_ms).to.equal('number'); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; expect(result.httpCode).to.equal(200); expect(result.body).to.deep.equal({ mpid: 'matched' }); }); it('does not throw and logs an error when called without a callback', () => { expect(() => - (window.mParticle.Identity as any).searchAdvertiser( - advertiserApiKey, + (window.mParticle.Identity as any).searchWorkspace( + workspaceApiKey, { email: 'user@example.com' }, ), ).to.not.throw(); @@ -411,7 +411,7 @@ describe('searchAdvertiser', () => { }); const callback = sinon.spy(); - (window.mParticle.Identity as any).searchAdvertiser( + (window.mParticle.Identity as any).searchWorkspace( '', { email: 'user@example.com' }, callback, @@ -421,7 +421,7 @@ describe('searchAdvertiser', () => { expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchAdvertiserResult; + const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); @@ -438,8 +438,8 @@ describe('searchAdvertiser', () => { window.mParticle.setOptOut(true); const callback = sinon.spy(); - (window.mParticle.Identity as any).searchAdvertiser( - advertiserApiKey, + (window.mParticle.Identity as any).searchWorkspace( + workspaceApiKey, { email: 'user@example.com' }, callback, ); @@ -448,12 +448,12 @@ describe('searchAdvertiser', () => { expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchAdvertiserResult & { + const result = callback.getCall(0).args[0] as ISearchWorkspaceResult & { getUser?: unknown; getPreviousUser?: unknown; }; expect(result.httpCode).to.equal(HTTPCodes.loggingDisabledOrMissingAPIKey); - // Result must conform to ISearchAdvertiserResult: no body string, + // Result must conform to ISearchWorkspaceResult: no body string, // and none of the standard Identity-callback `getUser`/`getPreviousUser` // helpers (which would leak through if `_Helpers.invokeCallback` were // used to deliver this result). From fdfc04af28089827b46508fe5fb3bf96dc08ba3b Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 09:50:11 -0400 Subject: [PATCH 13/23] update query parameter --- src/identity.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/identity.js b/src/identity.js index e963658c7..d36cd156b 100644 --- a/src/identity.js +++ b/src/identity.js @@ -774,7 +774,7 @@ export default function Identity(mpInstance) { const serviceUrl = mpInstance._Helpers.createServiceUrl( mpInstance._Store.SDKConfig.identityUrl ); - const searchUrl = serviceUrl + 'search?abc=123'; + const searchUrl = serviceUrl + 'search?cb=1'; const environment = mpInstance._Store.SDKConfig.isDevelopmentMode ? 'development' From 3a17b19c40fa333857467977c8f91d128f31fece Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 11:33:22 -0400 Subject: [PATCH 14/23] refactor: Rename Identity.searchWorkspace to Identity.search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the new IDSync search primitive with the existing public Identity API surface (`identify`/`login`/`logout`/`modify`/`aliasUsers` /`search`). The "Workspace" qualifier on the method/types/file leaked the kit-level feature name into the SDK primitive — the SDK doesn't need to be told *what* the caller is searching, only *how*. The caller's workspace API key is still surfaced via the `workspaceApiKey` parameter, which keeps the auth contract explicit without coloring the method name. - `mParticle.Identity.searchWorkspace` -> `mParticle.Identity.search` - `sendSearchWorkspaceRequest` -> `sendSearchRequest` - `ISearchWorkspace*` types -> `ISearch*`; `SearchWorkspaceCallback` -> `SearchCallback` - `src/searchWorkspace.ts` -> `src/search.ts` - `test/src/tests-search-workspace.ts` -> `test/src/tests-search.ts` Paired with the Rokt kit rename in mparticle-javascript-integration-rokt. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity.interfaces.ts | 22 +++--- src/identity.js | 12 +-- src/mparticle-instance-manager.ts | 4 +- src/public-types.ts | 8 +- src/{searchWorkspace.ts => search.ts} | 60 +++++++-------- test/src/_test.index.ts | 2 +- test/src/tests-mparticle-instance-manager.ts | 2 +- ...ts-search-workspace.ts => tests-search.ts} | 74 +++++++++---------- 8 files changed, 92 insertions(+), 92 deletions(-) rename src/{searchWorkspace.ts => search.ts} (78%) rename test/src/{tests-search-workspace.ts => tests-search.ts} (91%) diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index 39e790038..29bac72e8 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -13,9 +13,9 @@ import { IIdentityResponse, } from './identity-user-interfaces'; import { - ISearchWorkspaceKnownIdentities, - SearchWorkspaceCallback, -} from './searchWorkspace'; + ISearchKnownIdentities, + SearchCallback, +} from './search'; const { platform, sdkVendor, sdkVersion, HTTPCodes } = Constants; export type IdentityPreProcessResult = { @@ -171,19 +171,19 @@ export interface SDKIdentityApi { * caller (from a kit's settings). It is sent as the `x-mp-key` header. * The SDK's own workspace token is intentionally not used. */ - searchWorkspace?( + search?( workspaceApiKey: string, - knownIdentities: ISearchWorkspaceKnownIdentities, - callback: SearchWorkspaceCallback + knownIdentities: ISearchKnownIdentities, + callback: SearchCallback ): void; } export type { - ISearchWorkspaceKnownIdentities, - ISearchWorkspaceResult, - ISearchWorkspaceResponseBody, - SearchWorkspaceCallback, -} from './searchWorkspace'; + ISearchKnownIdentities, + ISearchResult, + ISearchResponseBody, + SearchCallback, +} from './search'; export interface IIdentity { audienceManager: AudienceManager; diff --git a/src/identity.js b/src/identity.js index d36cd156b..1bda9ec1d 100644 --- a/src/identity.js +++ b/src/identity.js @@ -6,7 +6,7 @@ import { tryCacheIdentity, } from './identity-utils'; import AudienceManager from './audienceManager'; -import { sendSearchWorkspaceRequest } from './searchWorkspace'; +import { sendSearchRequest } from './search'; const { Messages, HTTPCodes, FeatureFlags, IdentityMethods } = Constants; const { ErrorMessages } = Messages; const { CacheIdentity } = FeatureFlags; @@ -743,12 +743,12 @@ export default function Identity(mpInstance) { * searches can be authorised independently of the host SDK's * workspace. * - * @method searchWorkspace + * @method search * @param {String} workspaceApiKey Workspace API key (sent as x-mp-key). * @param {Object} knownIdentities `{ email: string }` - * @param {Function} callback Invoked with the `ISearchWorkspaceResult`. + * @param {Function} callback Invoked with the `ISearchResult`. */ - searchWorkspace: function(workspaceApiKey, knownIdentities, callback) { + search: function(workspaceApiKey, knownIdentities, callback) { if (!mpInstance._Helpers.canLog()) { mpInstance.Logger.verbose( Messages.InformationMessages.AbandonLogEvent @@ -760,7 +760,7 @@ export default function Identity(mpInstance) { }); } catch (e) { mpInstance.Logger.error( - 'Error invoking searchWorkspace callback: ' + + 'Error invoking search callback: ' + ((e && e.message) || String(e)) ); } @@ -796,7 +796,7 @@ export default function Identity(mpInstance) { }; }; - sendSearchWorkspaceRequest( + sendSearchRequest( knownIdentities, workspaceApiKey, requestBuilder, diff --git a/src/mparticle-instance-manager.ts b/src/mparticle-instance-manager.ts index b688767e5..5c51b51bb 100644 --- a/src/mparticle-instance-manager.ts +++ b/src/mparticle-instance-manager.ts @@ -394,8 +394,8 @@ function mParticleInstanceManager(this: IMParticleInstanceManager) { modify: function(identityApiData, callback) { self.getInstance().Identity.modify(identityApiData, callback); }, - searchWorkspace: function(workspaceApiKey, knownIdentities, callback) { - self.getInstance().Identity.searchWorkspace( + search: function(workspaceApiKey, knownIdentities, callback) { + self.getInstance().Identity.search( workspaceApiKey, knownIdentities, callback diff --git a/src/public-types.ts b/src/public-types.ts index 12c9d3d44..06d989332 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -56,10 +56,10 @@ export type { IAliasCallback, IAliasResult, SDKIdentityTypeEnum, - ISearchWorkspaceKnownIdentities, - ISearchWorkspaceResult, - ISearchWorkspaceResponseBody, - SearchWorkspaceCallback, + ISearchKnownIdentities, + ISearchResult, + ISearchResponseBody, + SearchCallback, } from './identity.interfaces'; // eCommerce diff --git a/src/searchWorkspace.ts b/src/search.ts similarity index 78% rename from src/searchWorkspace.ts rename to src/search.ts index 7ca1ad525..20d0201ef 100644 --- a/src/searchWorkspace.ts +++ b/src/search.ts @@ -15,14 +15,14 @@ import { const { HTTPCodes } = Constants; /** - * Shape of `known_identities` accepted by `searchWorkspace`. + * 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 ISearchWorkspaceKnownIdentities { +export interface ISearchKnownIdentities { email: string; } @@ -34,7 +34,7 @@ export interface ISearchWorkspaceKnownIdentities { * error-shaped bodies, and the consumer should only rely on body fields when * `httpCode === 200`. */ -export interface ISearchWorkspaceResponseBody { +export interface ISearchResponseBody { context?: string | null; mpid?: string; matched_identities?: Record; @@ -51,18 +51,18 @@ export interface ISearchWorkspaceResponseBody { * be omitted. The consumer is expected to gate behaviour on * `httpCode === 200`. */ -export interface ISearchWorkspaceResult { +export interface ISearchResult { httpCode: number; - body?: ISearchWorkspaceResponseBody; + body?: ISearchResponseBody; } -export type SearchWorkspaceCallback = (result: ISearchWorkspaceResult) => void; +export type SearchCallback = (result: ISearchResult) => void; /** * Body posted to `/v1/search`. Mirrors the `/v1/identify` request envelope so * that the IDSync service can correlate requests across endpoints. */ -export interface ISearchWorkspaceRequestBody { +export interface ISearchRequestBody { client_sdk: { platform: string; sdk_vendor: string; @@ -71,10 +71,10 @@ export interface ISearchWorkspaceRequestBody { environment: 'development' | 'production'; request_id: string; request_timestamp_ms: number; - known_identities: ISearchWorkspaceKnownIdentities; + known_identities: ISearchKnownIdentities; } -interface ISearchWorkspacePayload extends IFetchPayload { +interface ISearchPayload extends IFetchPayload { headers: { Accept: string; 'Content-Type': string; @@ -96,12 +96,12 @@ interface ISearchWorkspacePayload extends IFetchPayload { * `errorReporter` so any registered IErrorReportingService can observe * them (matches the pattern used by identifyRequest in identityApiClient). */ -export const sendSearchWorkspaceRequest = async ( - knownIdentities: ISearchWorkspaceKnownIdentities, +export const sendSearchRequest = async ( + knownIdentities: ISearchKnownIdentities, apiKey: string, - requestBuilder: () => Omit, + requestBuilder: () => Omit, searchUrl: string, - callback: SearchWorkspaceCallback, + callback: SearchCallback, logger: SDKLoggerApi, uploader?: AsyncUploader, errorReporter?: IErrorReportingService, @@ -110,17 +110,17 @@ export const sendSearchWorkspaceRequest = async ( // to deliver a result to, so log and bail out without invoking anything. if (typeof callback !== 'function') { logger.error( - 'searchWorkspace called without a callback function; skipping request.', + 'search called without a callback function; skipping request.', ); return; } - const safeInvoke = (result: ISearchWorkspaceResult): void => { + const safeInvoke = (result: ISearchResult): void => { try { callback(result); } catch (e) { logger.error( - 'Error invoking searchWorkspace callback: ' + + 'Error invoking search callback: ' + ((e as Error)?.message || String(e)), ); } @@ -130,7 +130,7 @@ export const sendSearchWorkspaceRequest = async ( // the callback (e.g. to clear a loading state) don't hang. if (!knownIdentities || typeof knownIdentities.email !== 'string' || !knownIdentities.email) { logger.verbose( - 'searchWorkspace called without a valid email; skipping request.', + 'search called without a valid email; skipping request.', ); safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); return; @@ -139,7 +139,7 @@ export const sendSearchWorkspaceRequest = async ( // No API key -> same: deliver noHttpCoverage rather than hanging. if (!apiKey) { logger.verbose( - 'searchWorkspace called without a workspace API key; skipping request.', + 'search called without a workspace API key; skipping request.', ); safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); return; @@ -152,14 +152,14 @@ export const sendSearchWorkspaceRequest = async ( // rejecting and the caller hanging on a never-fired callback. try { const requestEnvelope = requestBuilder(); - const requestBody: ISearchWorkspaceRequestBody = { + const requestBody: ISearchRequestBody = { ...requestEnvelope, known_identities: { email: knownIdentities.email, }, }; - const fetchPayload: ISearchWorkspacePayload = { + const fetchPayload: ISearchPayload = { method: 'post', headers: { Accept: 'application/json', @@ -175,50 +175,50 @@ export const sendSearchWorkspaceRequest = async ( ? new FetchUploader(searchUrl) : new XHRUploader(searchUrl)); - logger.verbose('Sending searchWorkspace request to ' + searchUrl); + logger.verbose('Sending search request to ' + searchUrl); const response: Response = await api.upload(fetchPayload, searchUrl); - let body: ISearchWorkspaceResponseBody | undefined; + let body: ISearchResponseBody | undefined; // FetchUploader returns a real Response with .json(); XHRUploader // returns an XHR-shaped object with `responseText`. We tolerate both. if (typeof (response as Response).json === 'function') { try { - body = (await (response as Response).json()) as ISearchWorkspaceResponseBody; + body = (await (response as Response).json()) as ISearchResponseBody; } catch (e) { logger.verbose( - 'searchWorkspace response had no parseable JSON body.', + 'search response had no parseable JSON body.', ); } } else { const xhrLike = (response as unknown) as XMLHttpRequest; if (xhrLike?.responseText) { try { - body = JSON.parse(xhrLike.responseText) as ISearchWorkspaceResponseBody; + body = JSON.parse(xhrLike.responseText) as ISearchResponseBody; } catch (e) { logger.verbose( - 'searchWorkspace XHR response was not valid JSON.', + 'search XHR response was not valid JSON.', ); } } } if (response.status === HTTP_OK) { - logger.verbose('searchWorkspace received 200 OK.'); + logger.verbose('search received 200 OK.'); } else if (response.status === HTTP_NOT_FOUND) { // 404 NOT_FOUND_ERROR is an expected steady-state outcome and is // intentionally not logged as an error. - logger.verbose('searchWorkspace received 404 (no match).'); + logger.verbose('search received 404 (no match).'); } else { logger.verbose( - 'searchWorkspace received non-success status ' + response.status, + 'search received non-success status ' + response.status, ); } safeInvoke({ httpCode: response.status, body }); } catch (e) { const message = (e as Error)?.message || String(e); - const reportMessage = 'Error sending searchWorkspace request: ' + message; + const reportMessage = 'Error sending search request: ' + message; logger.error(reportMessage); // Mirror the identity-route pattern in identityApiClient.ts: log to // console AND push a structured report through the dispatcher so any diff --git a/test/src/_test.index.ts b/test/src/_test.index.ts index 4cddfc78e..5668cb68e 100644 --- a/test/src/_test.index.ts +++ b/test/src/_test.index.ts @@ -39,5 +39,5 @@ import './tests-identityApiClient'; import './tests-integration-capture'; import './tests-batchUploader_4'; import './tests-identity'; -import './tests-search-workspace'; +import './tests-search'; diff --git a/test/src/tests-mparticle-instance-manager.ts b/test/src/tests-mparticle-instance-manager.ts index 84b52d3ce..a0fe0dc73 100644 --- a/test/src/tests-mparticle-instance-manager.ts +++ b/test/src/tests-mparticle-instance-manager.ts @@ -124,7 +124,7 @@ describe('mParticle instance manager', () => { 'getUsers', 'aliasUsers', 'createAliasRequest', - 'searchWorkspace', + 'search', ]); expect(mParticle.Identity.HTTPCodes, 'HTTP Codes').to.have.keys([ 'noHttpCoverage', diff --git a/test/src/tests-search-workspace.ts b/test/src/tests-search.ts similarity index 91% rename from test/src/tests-search-workspace.ts rename to test/src/tests-search.ts index b505577e6..f578c65f3 100644 --- a/test/src/tests-search-workspace.ts +++ b/test/src/tests-search.ts @@ -6,9 +6,9 @@ import Constants from '../../src/constants'; import { Logger } from '../../src/logger'; import { IMParticleInstanceManager, SDKLoggerApi } from '../../src/sdkRuntimeModels'; import { - ISearchWorkspaceResult, - sendSearchWorkspaceRequest, -} from '../../src/searchWorkspace'; + ISearchResult, + sendSearchRequest, +} from '../../src/search'; import Utils from './config/utils'; const { fetchMockSuccess } = Utils; @@ -21,7 +21,7 @@ declare global { } } -const searchUrl = `https://identity.mparticle.com/v1/search?abc=123`; +const searchUrl = `https://identity.mparticle.com/v1/search?cb=1`; const buildEnvelope = () => ({ client_sdk: { @@ -34,12 +34,12 @@ const buildEnvelope = () => ({ request_timestamp_ms: 1735689600000, }); -describe('searchWorkspace', () => { +describe('search', () => { let logger: SDKLoggerApi; beforeEach(() => { // Some tests below boot up window.mParticle to verify the public - // Identity.searchWorkspace surface; reset between tests so they + // Identity.search surface; reset between tests so they // don't interfere with each other. window.mParticle._resetForTests(MPConfig); fetchMockSuccess(urls.identify, { @@ -55,7 +55,7 @@ describe('searchWorkspace', () => { fetchMock.restore(); }); - describe('sendSearchWorkspaceRequest (network layer)', () => { + describe('sendSearchRequest (network layer)', () => { it('invokes the callback with httpCode 200 and the parsed body on success', async () => { const responseBody = { context: 'ctx-123', @@ -70,7 +70,7 @@ describe('searchWorkspace', () => { }); const callback = sinon.spy(); - await sendSearchWorkspaceRequest( + await sendSearchRequest( { email: 'user@example.com' }, apiKey, buildEnvelope, @@ -80,7 +80,7 @@ describe('searchWorkspace', () => { ); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; + const result = callback.getCall(0).args[0] as ISearchResult; expect(result.httpCode).to.equal(200); expect(result.body).to.deep.equal(responseBody); }); @@ -91,7 +91,7 @@ describe('searchWorkspace', () => { body: JSON.stringify({ mpid: 'm' }), }); - await sendSearchWorkspaceRequest( + await sendSearchRequest( { email: 'user@example.com' }, apiKey, buildEnvelope, @@ -136,7 +136,7 @@ describe('searchWorkspace', () => { }); const callback = sinon.spy(); - await sendSearchWorkspaceRequest( + await sendSearchRequest( { email: 'unknown@example.com' }, apiKey, buildEnvelope, @@ -146,7 +146,7 @@ describe('searchWorkspace', () => { ); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; + const result = callback.getCall(0).args[0] as ISearchResult; expect(result.httpCode).to.equal(404); // Body is best-effort parsed; we don't assert its exact shape // beyond "it didn't throw". @@ -157,7 +157,7 @@ describe('searchWorkspace', () => { const callback = sinon.spy(); const requestBuilderSpy = sinon.spy(buildEnvelope); - await sendSearchWorkspaceRequest( + await sendSearchRequest( { email: 'user@example.com' }, '', requestBuilderSpy, @@ -171,7 +171,7 @@ describe('searchWorkspace', () => { // Missing apiKey: no network, but callback fires so callers can // resolve any loading state they're holding open. expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; + const result = callback.getCall(0).args[0] as ISearchResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); @@ -179,7 +179,7 @@ describe('searchWorkspace', () => { const requestBuilderSpy = sinon.spy(buildEnvelope); let threw = false; try { - await sendSearchWorkspaceRequest( + await sendSearchRequest( { email: 'user@example.com' }, apiKey, requestBuilderSpy, @@ -198,7 +198,7 @@ describe('searchWorkspace', () => { it('invokes the callback with noHttpCoverage when knownIdentities.email is missing or invalid (no network)', async () => { const callback = sinon.spy(); - await sendSearchWorkspaceRequest( + await sendSearchRequest( ({} as any), apiKey, buildEnvelope, @@ -207,7 +207,7 @@ describe('searchWorkspace', () => { logger, ); - await sendSearchWorkspaceRequest( + await sendSearchRequest( ({ email: '' } as any), apiKey, buildEnvelope, @@ -216,7 +216,7 @@ describe('searchWorkspace', () => { logger, ); - await sendSearchWorkspaceRequest( + await sendSearchRequest( ({ email: 12345 } as any), apiKey, buildEnvelope, @@ -230,7 +230,7 @@ describe('searchWorkspace', () => { expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(callback.callCount).to.equal(3); for (let i = 0; i < callback.callCount; i++) { - const result = callback.getCall(i).args[0] as ISearchWorkspaceResult; + const result = callback.getCall(i).args[0] as ISearchResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); } }); @@ -241,7 +241,7 @@ describe('searchWorkspace', () => { const callback = sinon.spy(); let threw = false; try { - await sendSearchWorkspaceRequest( + await sendSearchRequest( { email: 'user@example.com' }, apiKey, buildEnvelope, @@ -255,7 +255,7 @@ describe('searchWorkspace', () => { expect(threw, 'should not throw on network error').to.eq(false); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; + const result = callback.getCall(0).args[0] as ISearchResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); @@ -271,7 +271,7 @@ describe('searchWorkspace', () => { }; let threw = false; try { - await sendSearchWorkspaceRequest( + await sendSearchRequest( { email: 'user@example.com' }, apiKey, throwingBuilder, @@ -285,7 +285,7 @@ describe('searchWorkspace', () => { expect(threw, 'should not throw on setup error').to.eq(false); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; + const result = callback.getCall(0).args[0] as ISearchResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); // No network call should have been made. expect(fetchMock.calls(searchUrl).length).to.equal(0); @@ -297,7 +297,7 @@ describe('searchWorkspace', () => { const callback = sinon.spy(); const errorReporter = { report: sinon.spy() }; - await sendSearchWorkspaceRequest( + await sendSearchRequest( { email: 'user@example.com' }, apiKey, buildEnvelope, @@ -312,7 +312,7 @@ describe('searchWorkspace', () => { const reported = errorReporter.report.getCall(0).args[0]; expect(reported.severity).to.equal('ERROR'); expect(reported.code).to.equal('IDENTITY_REQUEST'); - expect(reported.message).to.match(/searchWorkspace/); + expect(reported.message).to.match(/search/); expect(reported.message).to.match(/network down/); }); @@ -324,7 +324,7 @@ describe('searchWorkspace', () => { }); const callback = sinon.spy(); - await sendSearchWorkspaceRequest( + await sendSearchRequest( { email: 'user@example.com' }, apiKey, buildEnvelope, @@ -334,13 +334,13 @@ describe('searchWorkspace', () => { ); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; + const result = callback.getCall(0).args[0] as ISearchResult; expect(result.httpCode).to.equal(200); expect(result.body).to.be.undefined; }); }); - describe('mParticle.Identity.searchWorkspace (public surface)', () => { + describe('mParticle.Identity.search (public surface)', () => { const workspaceApiKey = 'workspace_api_key'; beforeEach(() => { @@ -348,7 +348,7 @@ describe('searchWorkspace', () => { }); it('is exposed on the Identity namespace', () => { - expect(typeof (window.mParticle.Identity as any).searchWorkspace).to.equal( + expect(typeof (window.mParticle.Identity as any).search).to.equal( 'function', ); }); @@ -360,7 +360,7 @@ describe('searchWorkspace', () => { }); const callback = sinon.spy(); - (window.mParticle.Identity as any).searchWorkspace( + (window.mParticle.Identity as any).search( workspaceApiKey, { email: 'user@example.com' }, callback, @@ -390,14 +390,14 @@ describe('searchWorkspace', () => { expect(typeof sentBody.request_timestamp_ms).to.equal('number'); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; + const result = callback.getCall(0).args[0] as ISearchResult; expect(result.httpCode).to.equal(200); expect(result.body).to.deep.equal({ mpid: 'matched' }); }); it('does not throw and logs an error when called without a callback', () => { expect(() => - (window.mParticle.Identity as any).searchWorkspace( + (window.mParticle.Identity as any).search( workspaceApiKey, { email: 'user@example.com' }, ), @@ -411,7 +411,7 @@ describe('searchWorkspace', () => { }); const callback = sinon.spy(); - (window.mParticle.Identity as any).searchWorkspace( + (window.mParticle.Identity as any).search( '', { email: 'user@example.com' }, callback, @@ -421,7 +421,7 @@ describe('searchWorkspace', () => { expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchWorkspaceResult; + const result = callback.getCall(0).args[0] as ISearchResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); @@ -438,7 +438,7 @@ describe('searchWorkspace', () => { window.mParticle.setOptOut(true); const callback = sinon.spy(); - (window.mParticle.Identity as any).searchWorkspace( + (window.mParticle.Identity as any).search( workspaceApiKey, { email: 'user@example.com' }, callback, @@ -448,12 +448,12 @@ describe('searchWorkspace', () => { expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchWorkspaceResult & { + const result = callback.getCall(0).args[0] as ISearchResult & { getUser?: unknown; getPreviousUser?: unknown; }; expect(result.httpCode).to.equal(HTTPCodes.loggingDisabledOrMissingAPIKey); - // Result must conform to ISearchWorkspaceResult: no body string, + // Result must conform to ISearchResult: no body string, // and none of the standard Identity-callback `getUser`/`getPreviousUser` // helpers (which would leak through if `_Helpers.invokeCallback` were // used to deliver this result). From e3e190f0ea5746accd6e20150996801b652c576f Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 11:55:28 -0400 Subject: [PATCH 15/23] refactor: Extract executeSearchRequest into identity-utils.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Identity.search wrapper body lived in identity.js — opt-out gating, URL construction, request envelope building, and dispatch to sendSearchRequest, all in plain JS. Move that glue into a typed `executeSearchRequest(mpInstance, workspaceApiKey, knownIdentities, callback)` helper in identity-utils.ts so the SDK wiring is type-checked against `IMParticleWebSDKInstance`, `ISearchKnownIdentities`, `ISearchRequestBody`, and `SearchCallback` instead of being implicit. Behaviour is unchanged — identity.js's search method now delegates in one line. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-utils.ts | 79 +++++++++++++++++++++++++++++++++++++++++++ src/identity.js | 61 +++------------------------------ 2 files changed, 84 insertions(+), 56 deletions(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 14ac5753e..16459e1bc 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -14,8 +14,16 @@ import { IMParticleUser, } from './identity-user-interfaces'; import { IStore } from './store'; +import type { IMParticleWebSDKInstance } from './mp-instance'; +import { + ISearchKnownIdentities, + ISearchRequestBody, + SearchCallback, + sendSearchRequest, +} from './search'; const { Identify, Modify, Login, Logout } = Constants.IdentityMethods; +const { HTTPCodes, Messages } = Constants; export const CACHE_HEADER = 'x-mp-max-age' as const; export type IdentityCache = BaseVault>; @@ -321,3 +329,74 @@ export const hasExplicitIdentifier = (store: IStore | undefined | null): boolean } return !!store?.SDKConfig?.deviceId; }; + +/** + * Wires the SDK instance into `sendSearchRequest`: gates on `canLog`, + * builds the `/v1/search` URL and request envelope, and dispatches. + * Lives here so the SDK glue (URL building, opt-out gate, dispatcher + * plumbing) is type-checked instead of being expressed in plain JS. + */ +export const executeSearchRequest = ( + mpInstance: IMParticleWebSDKInstance, + workspaceApiKey: string, + knownIdentities: ISearchKnownIdentities, + callback: SearchCallback, +): void => { + if (!mpInstance._Helpers.canLog()) { + mpInstance.Logger.verbose(Messages.InformationMessages.AbandonLogEvent); + if (mpInstance._Helpers.Validators.isFunction(callback)) { + try { + callback({ + httpCode: HTTPCodes.loggingDisabledOrMissingAPIKey, + }); + } catch (e) { + mpInstance.Logger.error( + 'Error invoking search callback: ' + + ((e as Error)?.message || String(e)), + ); + } + } + return; + } + + // The Search endpoint is colocated with /v1/identify under + // identityUrl, so we reuse the same service URL builder. We do + // NOT append the apiKey to the URL — auth is done via x-mp-key. + const serviceUrl: string = mpInstance._Helpers.createServiceUrl( + mpInstance._Store.SDKConfig.identityUrl, + ); + const searchUrl: string = serviceUrl + 'search?cb=1'; + + const environment: 'development' | 'production' = mpInstance._Store + .SDKConfig.isDevelopmentMode + ? 'development' + : 'production'; + + // Build the same envelope that /v1/identify uses (client_sdk, + // request_id, request_timestamp_ms, environment) so the IDSync + // service can correlate requests across endpoints. + const requestBuilder = (): Omit< + ISearchRequestBody, + 'known_identities' + > => ({ + client_sdk: { + platform: Constants.platform, + sdk_vendor: Constants.sdkVendor, + sdk_version: Constants.sdkVersion, + }, + environment, + request_id: mpInstance._Helpers.generateUniqueId(), + request_timestamp_ms: new Date().getTime(), + }); + + sendSearchRequest( + knownIdentities, + workspaceApiKey, + requestBuilder, + searchUrl, + callback, + mpInstance.Logger, + undefined, + mpInstance._ErrorReportingDispatcher, + ); +}; diff --git a/src/identity.js b/src/identity.js index 1bda9ec1d..168255e74 100644 --- a/src/identity.js +++ b/src/identity.js @@ -3,10 +3,10 @@ import Types, { IdentityType } from './types'; import { cacheOrClearIdCache, createKnownIdentities, + executeSearchRequest, tryCacheIdentity, } from './identity-utils'; import AudienceManager from './audienceManager'; -import { sendSearchRequest } from './search'; const { Messages, HTTPCodes, FeatureFlags, IdentityMethods } = Constants; const { ErrorMessages } = Messages; const { CacheIdentity } = FeatureFlags; @@ -749,62 +749,11 @@ export default function Identity(mpInstance) { * @param {Function} callback Invoked with the `ISearchResult`. */ search: function(workspaceApiKey, knownIdentities, callback) { - if (!mpInstance._Helpers.canLog()) { - mpInstance.Logger.verbose( - Messages.InformationMessages.AbandonLogEvent - ); - if (mpInstance._Helpers.Validators.isFunction(callback)) { - try { - callback({ - httpCode: HTTPCodes.loggingDisabledOrMissingAPIKey, - }); - } catch (e) { - mpInstance.Logger.error( - 'Error invoking search callback: ' + - ((e && e.message) || String(e)) - ); - } - } - return; - } - - // The Search endpoint is colocated with /v1/identify under - // identityUrl, so we reuse the same service URL builder. We do - // NOT append the apiKey to the URL — auth is done via x-mp-key. - const serviceUrl = mpInstance._Helpers.createServiceUrl( - mpInstance._Store.SDKConfig.identityUrl - ); - const searchUrl = serviceUrl + 'search?cb=1'; - - const environment = mpInstance._Store.SDKConfig.isDevelopmentMode - ? 'development' - : 'production'; - - // Build the same envelope that /v1/identify uses (client_sdk, - // request_id, request_timestamp_ms, environment) so the IDSync - // service can correlate requests across endpoints. - const requestBuilder = function() { - return { - client_sdk: { - platform: Constants.platform, - sdk_vendor: Constants.sdkVendor, - sdk_version: Constants.sdkVersion, - }, - environment: environment, - request_id: mpInstance._Helpers.generateUniqueId(), - request_timestamp_ms: new Date().getTime(), - }; - }; - - sendSearchRequest( - knownIdentities, + executeSearchRequest( + mpInstance, workspaceApiKey, - requestBuilder, - searchUrl, - callback, - mpInstance.Logger, - undefined, - mpInstance._ErrorReportingDispatcher + knownIdentities, + callback ); }, From d73b680913649adf38e4b4d1580629db2f74353c Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 12:54:23 -0400 Subject: [PATCH 16/23] refactor: Prefix Search type names with Identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review nit (alexs-mparticle on #1255): the `ISearch*` / `SearchCallback` family is too generic for the public types surface — scope them to `Identity` so they read clearly alongside the rest of the IDSync types. - ISearchKnownIdentities -> IIdentitySearchKnownIdentities - ISearchResponseBody -> IIdentitySearchResponseBody - ISearchResult -> IIdentitySearchResult - ISearchRequestBody -> IIdentitySearchRequestBody - ISearchPayload -> IIdentitySearchPayload - SearchCallback -> IdentitySearchCallback Function names (`sendSearchRequest`, `executeSearchRequest`) and the public method (`Identity.search`) are unchanged — they're already contextually scoped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-utils.ts | 12 ++++++------ src/identity.interfaces.ts | 16 ++++++++-------- src/identity.js | 2 +- src/public-types.ts | 8 ++++---- src/search.ts | 34 +++++++++++++++++----------------- test/src/tests-search.ts | 24 ++++++++++++------------ 6 files changed, 48 insertions(+), 48 deletions(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 16459e1bc..8a1cf8178 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -16,9 +16,9 @@ import { import { IStore } from './store'; import type { IMParticleWebSDKInstance } from './mp-instance'; import { - ISearchKnownIdentities, - ISearchRequestBody, - SearchCallback, + IIdentitySearchKnownIdentities, + IIdentitySearchRequestBody, + IdentitySearchCallback, sendSearchRequest, } from './search'; @@ -339,8 +339,8 @@ export const hasExplicitIdentifier = (store: IStore | undefined | null): boolean export const executeSearchRequest = ( mpInstance: IMParticleWebSDKInstance, workspaceApiKey: string, - knownIdentities: ISearchKnownIdentities, - callback: SearchCallback, + knownIdentities: IIdentitySearchKnownIdentities, + callback: IdentitySearchCallback, ): void => { if (!mpInstance._Helpers.canLog()) { mpInstance.Logger.verbose(Messages.InformationMessages.AbandonLogEvent); @@ -376,7 +376,7 @@ export const executeSearchRequest = ( // request_id, request_timestamp_ms, environment) so the IDSync // service can correlate requests across endpoints. const requestBuilder = (): Omit< - ISearchRequestBody, + IIdentitySearchRequestBody, 'known_identities' > => ({ client_sdk: { diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index 29bac72e8..e38c29492 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -13,8 +13,8 @@ import { IIdentityResponse, } from './identity-user-interfaces'; import { - ISearchKnownIdentities, - SearchCallback, + IIdentitySearchKnownIdentities, + IdentitySearchCallback, } from './search'; const { platform, sdkVendor, sdkVersion, HTTPCodes } = Constants; @@ -173,16 +173,16 @@ export interface SDKIdentityApi { */ search?( workspaceApiKey: string, - knownIdentities: ISearchKnownIdentities, - callback: SearchCallback + knownIdentities: IIdentitySearchKnownIdentities, + callback: IdentitySearchCallback ): void; } export type { - ISearchKnownIdentities, - ISearchResult, - ISearchResponseBody, - SearchCallback, + IIdentitySearchKnownIdentities, + IIdentitySearchResult, + IIdentitySearchResponseBody, + IdentitySearchCallback, } from './search'; export interface IIdentity { diff --git a/src/identity.js b/src/identity.js index 168255e74..65cebd111 100644 --- a/src/identity.js +++ b/src/identity.js @@ -746,7 +746,7 @@ export default function Identity(mpInstance) { * @method search * @param {String} workspaceApiKey Workspace API key (sent as x-mp-key). * @param {Object} knownIdentities `{ email: string }` - * @param {Function} callback Invoked with the `ISearchResult`. + * @param {Function} callback Invoked with the `IIdentitySearchResult`. */ search: function(workspaceApiKey, knownIdentities, callback) { executeSearchRequest( diff --git a/src/public-types.ts b/src/public-types.ts index 06d989332..4b485ed48 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -56,10 +56,10 @@ export type { IAliasCallback, IAliasResult, SDKIdentityTypeEnum, - ISearchKnownIdentities, - ISearchResult, - ISearchResponseBody, - SearchCallback, + IIdentitySearchKnownIdentities, + IIdentitySearchResult, + IIdentitySearchResponseBody, + IdentitySearchCallback, } from './identity.interfaces'; // eCommerce diff --git a/src/search.ts b/src/search.ts index 20d0201ef..59dd06cac 100644 --- a/src/search.ts +++ b/src/search.ts @@ -22,7 +22,7 @@ const { HTTPCodes } = Constants; * Additional identity types can be added here in the future without breaking * existing consumers. */ -export interface ISearchKnownIdentities { +export interface IIdentitySearchKnownIdentities { email: string; } @@ -34,7 +34,7 @@ export interface ISearchKnownIdentities { * error-shaped bodies, and the consumer should only rely on body fields when * `httpCode === 200`. */ -export interface ISearchResponseBody { +export interface IIdentitySearchResponseBody { context?: string | null; mpid?: string; matched_identities?: Record; @@ -51,18 +51,18 @@ export interface ISearchResponseBody { * be omitted. The consumer is expected to gate behaviour on * `httpCode === 200`. */ -export interface ISearchResult { +export interface IIdentitySearchResult { httpCode: number; - body?: ISearchResponseBody; + body?: IIdentitySearchResponseBody; } -export type SearchCallback = (result: ISearchResult) => void; +export type IdentitySearchCallback = (result: IIdentitySearchResult) => void; /** * Body posted to `/v1/search`. Mirrors the `/v1/identify` request envelope so * that the IDSync service can correlate requests across endpoints. */ -export interface ISearchRequestBody { +export interface IIdentitySearchRequestBody { client_sdk: { platform: string; sdk_vendor: string; @@ -71,10 +71,10 @@ export interface ISearchRequestBody { environment: 'development' | 'production'; request_id: string; request_timestamp_ms: number; - known_identities: ISearchKnownIdentities; + known_identities: IIdentitySearchKnownIdentities; } -interface ISearchPayload extends IFetchPayload { +interface IIdentitySearchPayload extends IFetchPayload { headers: { Accept: string; 'Content-Type': string; @@ -97,11 +97,11 @@ interface ISearchPayload extends IFetchPayload { * them (matches the pattern used by identifyRequest in identityApiClient). */ export const sendSearchRequest = async ( - knownIdentities: ISearchKnownIdentities, + knownIdentities: IIdentitySearchKnownIdentities, apiKey: string, - requestBuilder: () => Omit, + requestBuilder: () => Omit, searchUrl: string, - callback: SearchCallback, + callback: IdentitySearchCallback, logger: SDKLoggerApi, uploader?: AsyncUploader, errorReporter?: IErrorReportingService, @@ -115,7 +115,7 @@ export const sendSearchRequest = async ( return; } - const safeInvoke = (result: ISearchResult): void => { + const safeInvoke = (result: IIdentitySearchResult): void => { try { callback(result); } catch (e) { @@ -152,14 +152,14 @@ export const sendSearchRequest = async ( // rejecting and the caller hanging on a never-fired callback. try { const requestEnvelope = requestBuilder(); - const requestBody: ISearchRequestBody = { + const requestBody: IIdentitySearchRequestBody = { ...requestEnvelope, known_identities: { email: knownIdentities.email, }, }; - const fetchPayload: ISearchPayload = { + const fetchPayload: IIdentitySearchPayload = { method: 'post', headers: { Accept: 'application/json', @@ -178,13 +178,13 @@ export const sendSearchRequest = async ( logger.verbose('Sending search request to ' + searchUrl); const response: Response = await api.upload(fetchPayload, searchUrl); - let body: ISearchResponseBody | undefined; + let body: IIdentitySearchResponseBody | undefined; // FetchUploader returns a real Response with .json(); XHRUploader // returns an XHR-shaped object with `responseText`. We tolerate both. if (typeof (response as Response).json === 'function') { try { - body = (await (response as Response).json()) as ISearchResponseBody; + body = (await (response as Response).json()) as IIdentitySearchResponseBody; } catch (e) { logger.verbose( 'search response had no parseable JSON body.', @@ -194,7 +194,7 @@ export const sendSearchRequest = async ( const xhrLike = (response as unknown) as XMLHttpRequest; if (xhrLike?.responseText) { try { - body = JSON.parse(xhrLike.responseText) as ISearchResponseBody; + body = JSON.parse(xhrLike.responseText) as IIdentitySearchResponseBody; } catch (e) { logger.verbose( 'search XHR response was not valid JSON.', diff --git a/test/src/tests-search.ts b/test/src/tests-search.ts index f578c65f3..312d89ea3 100644 --- a/test/src/tests-search.ts +++ b/test/src/tests-search.ts @@ -6,7 +6,7 @@ import Constants from '../../src/constants'; import { Logger } from '../../src/logger'; import { IMParticleInstanceManager, SDKLoggerApi } from '../../src/sdkRuntimeModels'; import { - ISearchResult, + IIdentitySearchResult, sendSearchRequest, } from '../../src/search'; import Utils from './config/utils'; @@ -80,7 +80,7 @@ describe('search', () => { ); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchResult; + const result = callback.getCall(0).args[0] as IIdentitySearchResult; expect(result.httpCode).to.equal(200); expect(result.body).to.deep.equal(responseBody); }); @@ -146,7 +146,7 @@ describe('search', () => { ); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchResult; + const result = callback.getCall(0).args[0] as IIdentitySearchResult; expect(result.httpCode).to.equal(404); // Body is best-effort parsed; we don't assert its exact shape // beyond "it didn't throw". @@ -171,7 +171,7 @@ describe('search', () => { // Missing apiKey: no network, but callback fires so callers can // resolve any loading state they're holding open. expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchResult; + const result = callback.getCall(0).args[0] as IIdentitySearchResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); @@ -230,7 +230,7 @@ describe('search', () => { expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(callback.callCount).to.equal(3); for (let i = 0; i < callback.callCount; i++) { - const result = callback.getCall(i).args[0] as ISearchResult; + const result = callback.getCall(i).args[0] as IIdentitySearchResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); } }); @@ -255,7 +255,7 @@ describe('search', () => { expect(threw, 'should not throw on network error').to.eq(false); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchResult; + const result = callback.getCall(0).args[0] as IIdentitySearchResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); @@ -285,7 +285,7 @@ describe('search', () => { expect(threw, 'should not throw on setup error').to.eq(false); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchResult; + const result = callback.getCall(0).args[0] as IIdentitySearchResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); // No network call should have been made. expect(fetchMock.calls(searchUrl).length).to.equal(0); @@ -334,7 +334,7 @@ describe('search', () => { ); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchResult; + const result = callback.getCall(0).args[0] as IIdentitySearchResult; expect(result.httpCode).to.equal(200); expect(result.body).to.be.undefined; }); @@ -390,7 +390,7 @@ describe('search', () => { expect(typeof sentBody.request_timestamp_ms).to.equal('number'); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchResult; + const result = callback.getCall(0).args[0] as IIdentitySearchResult; expect(result.httpCode).to.equal(200); expect(result.body).to.deep.equal({ mpid: 'matched' }); }); @@ -421,7 +421,7 @@ describe('search', () => { expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchResult; + const result = callback.getCall(0).args[0] as IIdentitySearchResult; expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); }); @@ -448,12 +448,12 @@ describe('search', () => { expect(fetchMock.calls(searchUrl).length).to.equal(0); expect(callback.calledOnce).to.eq(true); - const result = callback.getCall(0).args[0] as ISearchResult & { + const result = callback.getCall(0).args[0] as IIdentitySearchResult & { getUser?: unknown; getPreviousUser?: unknown; }; expect(result.httpCode).to.equal(HTTPCodes.loggingDisabledOrMissingAPIKey); - // Result must conform to ISearchResult: no body string, + // Result must conform to IIdentitySearchResult: no body string, // and none of the standard Identity-callback `getUser`/`getPreviousUser` // helpers (which would leak through if `_Helpers.invokeCallback` were // used to deliver this result). From d7501b3766fdb9da8e7fc73285aa81fcd0e10ef3 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 13:24:51 -0400 Subject: [PATCH 17/23] refactor: Address PR #1255 review nits on Identity.search - Use `isFunction` from utils.ts instead of `Validators.isFunction` (r3169167597). - Use `generateUniqueId` from utils.ts instead of `mpInstance._Helpers.generateUniqueId()` (r3169189020). - Destructure `_Helpers`/`_Store`/`Logger`/`_ErrorReportingDispatcher` and `identityUrl`/`isDevelopmentMode` from `mpInstance`/`SDKConfig` in `executeSearchRequest` for readability (r3169201394). - Extract the IDSync envelope construction into a standalone `buildIdentitySearchEnvelope(environment)` helper so it can be unit-tested independently of the full request flow. - Add four unit tests for `buildIdentitySearchEnvelope` (SDK identifiers + supplied environment, development branch, fresh request_id per call, no `known_identities` leakage). - Move `src/search.ts` to `src/identity/search.ts` so future identity primitives can be grouped under one namespace (r3169209572); update all import paths and the relative imports inside the moved file. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-utils.ts | 62 +++++++++++++++++++----------------- src/identity.interfaces.ts | 4 +-- src/{ => identity}/search.ts | 8 ++--- test/src/tests-search.ts | 38 +++++++++++++++++++++- 4 files changed, 75 insertions(+), 37 deletions(-) rename src/{ => identity}/search.ts (97%) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 8a1cf8178..fee80f612 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -1,5 +1,5 @@ import Constants, { ONE_DAY_IN_SECONDS, MILLIS_IN_ONE_SEC } from './constants'; -import { Dictionary, parseNumber, isObject, generateHash, isEmpty } from './utils'; +import { Dictionary, parseNumber, isObject, generateHash, generateUniqueId, isEmpty, isFunction } from './utils'; import { BaseVault } from './vault'; import Types from './types'; import { @@ -20,7 +20,7 @@ import { IIdentitySearchRequestBody, IdentitySearchCallback, sendSearchRequest, -} from './search'; +} from './identity/search'; const { Identify, Modify, Login, Logout } = Constants.IdentityMethods; const { HTTPCodes, Messages } = Constants; @@ -330,6 +330,25 @@ export const hasExplicitIdentifier = (store: IStore | undefined | null): boolean return !!store?.SDKConfig?.deviceId; }; +/** + * Builds the /v1/identify-style envelope (client_sdk, environment, + * request_id, request_timestamp_ms) used to correlate IDSync requests + * across endpoints. `known_identities` is omitted so the caller can + * fold in the search-specific identifiers alongside the envelope. + */ +export const buildIdentitySearchEnvelope = ( + environment: 'development' | 'production', +): Omit => ({ + client_sdk: { + platform: Constants.platform, + sdk_vendor: Constants.sdkVendor, + sdk_version: Constants.sdkVersion, + }, + environment, + request_id: generateUniqueId(), + request_timestamp_ms: new Date().getTime(), +}); + /** * Wires the SDK instance into `sendSearchRequest`: gates on `canLog`, * builds the `/v1/search` URL and request envelope, and dispatches. @@ -342,15 +361,18 @@ export const executeSearchRequest = ( knownIdentities: IIdentitySearchKnownIdentities, callback: IdentitySearchCallback, ): void => { - if (!mpInstance._Helpers.canLog()) { - mpInstance.Logger.verbose(Messages.InformationMessages.AbandonLogEvent); - if (mpInstance._Helpers.Validators.isFunction(callback)) { + const { _Helpers, _Store, Logger, _ErrorReportingDispatcher } = mpInstance; + const { identityUrl, isDevelopmentMode } = _Store.SDKConfig; + + if (!_Helpers.canLog()) { + Logger.verbose(Messages.InformationMessages.AbandonLogEvent); + if (isFunction(callback)) { try { callback({ httpCode: HTTPCodes.loggingDisabledOrMissingAPIKey, }); } catch (e) { - mpInstance.Logger.error( + Logger.error( 'Error invoking search callback: ' + ((e as Error)?.message || String(e)), ); @@ -362,40 +384,20 @@ export const executeSearchRequest = ( // The Search endpoint is colocated with /v1/identify under // identityUrl, so we reuse the same service URL builder. We do // NOT append the apiKey to the URL — auth is done via x-mp-key. - const serviceUrl: string = mpInstance._Helpers.createServiceUrl( - mpInstance._Store.SDKConfig.identityUrl, - ); + const serviceUrl: string = _Helpers.createServiceUrl(identityUrl); const searchUrl: string = serviceUrl + 'search?cb=1'; - const environment: 'development' | 'production' = mpInstance._Store - .SDKConfig.isDevelopmentMode + const environment: 'development' | 'production' = isDevelopmentMode ? 'development' : 'production'; - // Build the same envelope that /v1/identify uses (client_sdk, - // request_id, request_timestamp_ms, environment) so the IDSync - // service can correlate requests across endpoints. - const requestBuilder = (): Omit< - IIdentitySearchRequestBody, - 'known_identities' - > => ({ - client_sdk: { - platform: Constants.platform, - sdk_vendor: Constants.sdkVendor, - sdk_version: Constants.sdkVersion, - }, - environment, - request_id: mpInstance._Helpers.generateUniqueId(), - request_timestamp_ms: new Date().getTime(), - }); - sendSearchRequest( knownIdentities, workspaceApiKey, - requestBuilder, + () => buildIdentitySearchEnvelope(environment), searchUrl, callback, - mpInstance.Logger, + Logger, undefined, mpInstance._ErrorReportingDispatcher, ); diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index e38c29492..1a02400e6 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -15,7 +15,7 @@ import { import { IIdentitySearchKnownIdentities, IdentitySearchCallback, -} from './search'; +} from './identity/search'; const { platform, sdkVendor, sdkVersion, HTTPCodes } = Constants; export type IdentityPreProcessResult = { @@ -183,7 +183,7 @@ export type { IIdentitySearchResult, IIdentitySearchResponseBody, IdentitySearchCallback, -} from './search'; +} from './identity/search'; export interface IIdentity { audienceManager: AudienceManager; diff --git a/src/search.ts b/src/identity/search.ts similarity index 97% rename from src/search.ts rename to src/identity/search.ts index 59dd06cac..29a97784d 100644 --- a/src/search.ts +++ b/src/identity/search.ts @@ -1,16 +1,16 @@ -import Constants, { HTTP_OK, HTTP_NOT_FOUND } from './constants'; -import { SDKLoggerApi } from './sdkRuntimeModels'; +import Constants, { HTTP_OK, HTTP_NOT_FOUND } from '../constants'; +import { SDKLoggerApi } from '../sdkRuntimeModels'; import { AsyncUploader, FetchUploader, IFetchPayload, XHRUploader, -} from './uploaders'; +} from '../uploaders'; import { ErrorCodes, IErrorReportingService, WSDKErrorSeverity, -} from './reporting/types'; +} from '../reporting/types'; const { HTTPCodes } = Constants; diff --git a/test/src/tests-search.ts b/test/src/tests-search.ts index 312d89ea3..852dc3ff6 100644 --- a/test/src/tests-search.ts +++ b/test/src/tests-search.ts @@ -8,7 +8,8 @@ import { IMParticleInstanceManager, SDKLoggerApi } from '../../src/sdkRuntimeMod import { IIdentitySearchResult, sendSearchRequest, -} from '../../src/search'; +} from '../../src/identity/search'; +import { buildIdentitySearchEnvelope } from '../../src/identity-utils'; import Utils from './config/utils'; const { fetchMockSuccess } = Utils; @@ -340,6 +341,41 @@ describe('search', () => { }); }); + describe('buildIdentitySearchEnvelope', () => { + it('returns the SDK identifiers and the supplied environment with a generated request_id and timestamp', () => { + const before = new Date().getTime(); + const envelope = buildIdentitySearchEnvelope('production'); + const after = new Date().getTime(); + + expect(envelope.client_sdk).to.deep.equal({ + platform: Constants.platform, + sdk_vendor: Constants.sdkVendor, + sdk_version: Constants.sdkVersion, + }); + expect(envelope.environment).to.equal('production'); + expect(typeof envelope.request_id).to.equal('string'); + expect(envelope.request_id.length).to.be.greaterThan(0); + expect(typeof envelope.request_timestamp_ms).to.equal('number'); + expect(envelope.request_timestamp_ms).to.be.at.least(before); + expect(envelope.request_timestamp_ms).to.be.at.most(after); + }); + + it('forwards the development environment when called with development', () => { + expect(buildIdentitySearchEnvelope('development').environment).to.equal('development'); + }); + + it('returns a fresh request_id on every call', () => { + const a = buildIdentitySearchEnvelope('development').request_id; + const b = buildIdentitySearchEnvelope('development').request_id; + expect(a).to.not.equal(b); + }); + + it('does not include known_identities (caller folds those in)', () => { + const envelope = buildIdentitySearchEnvelope('development') as Record; + expect(envelope).to.not.have.property('known_identities'); + }); + }); + describe('mParticle.Identity.search (public surface)', () => { const workspaceApiKey = 'workspace_api_key'; From 0feca7a728f00aa52b054b271911502aa39d7328 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 14:29:01 -0400 Subject: [PATCH 18/23] refactor: Address PR #1255 review nits + snippet stub for Identity.search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use `isFunction` from utils.ts at the two `typeof X === 'function'` call sites in `src/identity/search.ts` (lines 111 and 185), per Alex's nits on the latest review pass. - Add `'search'` to the snippet pre-init `identityMethods` arrays in `snippet.js` and `snippet.rokt.js`, and add `search: voidFunction` to `src/stub/mparticle.stub.js` — without these, `Identity.search` doesn't get a queued stub, so a kit calling `mParticle.Identity.search(...)` before the SDK loads would throw `TypeError` instead of being replayed after init (cursor-bot bug flagged on identity.js:758). Email validation (Alex's nit on line 131) is intentionally not added: the IDSync backend accepts any string and validation belongs at the client site. Co-Authored-By: Claude Opus 4.7 (1M context) --- snippet.js | 2 +- snippet.rokt.js | 2 +- src/identity/search.ts | 5 +++-- src/stub/mparticle.stub.js | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/snippet.js b/snippet.js index d73640ed0..6ee2306ad 100644 --- a/snippet.js +++ b/snippet.js @@ -44,7 +44,7 @@ 'stopTrackingLocation', ]; var ecommerceMethods = ['setCurrencyCode', 'logCheckout']; - var identityMethods = ['identify', 'login', 'logout', 'modify']; + var identityMethods = ['identify', 'login', 'logout', 'modify', 'search']; var roktMethods = [ 'selectPlacements', 'hashAttributes', diff --git a/snippet.rokt.js b/snippet.rokt.js index e731253b2..ba6b4e854 100644 --- a/snippet.rokt.js +++ b/snippet.rokt.js @@ -44,7 +44,7 @@ 'stopTrackingLocation', ]; var ecommerceMethods = ['setCurrencyCode', 'logCheckout']; - var identityMethods = ['identify', 'login', 'logout', 'modify']; + var identityMethods = ['identify', 'login', 'logout', 'modify', 'search']; var roktMethods = [ 'selectPlacements', 'hashAttributes', diff --git a/src/identity/search.ts b/src/identity/search.ts index 29a97784d..a2449b2ec 100644 --- a/src/identity/search.ts +++ b/src/identity/search.ts @@ -1,5 +1,6 @@ import Constants, { HTTP_OK, HTTP_NOT_FOUND } from '../constants'; import { SDKLoggerApi } from '../sdkRuntimeModels'; +import { isFunction } from '../utils'; import { AsyncUploader, FetchUploader, @@ -108,7 +109,7 @@ export const sendSearchRequest = async ( ): Promise => { // Validate the callback up front. If it isn't a function we have nowhere // to deliver a result to, so log and bail out without invoking anything. - if (typeof callback !== 'function') { + if (!isFunction(callback)) { logger.error( 'search called without a callback function; skipping request.', ); @@ -182,7 +183,7 @@ export const sendSearchRequest = async ( // FetchUploader returns a real Response with .json(); XHRUploader // returns an XHR-shaped object with `responseText`. We tolerate both. - if (typeof (response as Response).json === 'function') { + if (isFunction((response as Response).json)) { try { body = (await (response as Response).json()) as IIdentitySearchResponseBody; } catch (e) { diff --git a/src/stub/mparticle.stub.js b/src/stub/mparticle.stub.js index 237e4ac23..5a91c4a19 100644 --- a/src/stub/mparticle.stub.js +++ b/src/stub/mparticle.stub.js @@ -56,6 +56,7 @@ let mParticle = { login: voidFunction, logout: voidFunction, modify: voidFunction, + search: voidFunction, }, }; From 4246883d20bb646fbffc53dc8c15588d51196318 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 14:57:54 -0400 Subject: [PATCH 19/23] fix: Use destructured _ErrorReportingDispatcher in executeSearchRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor-bot caught (PR #1255 r3170092708): the destructured local `_ErrorReportingDispatcher` was unused — the call to `sendSearchRequest` still went through `mpInstance._ErrorReportingDispatcher`. Use the local now that it's been destructured, matching the pattern in `identityApiClient.ts` (`_ErrorReportingDispatcher: errorReporter`). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index fee80f612..40538e83b 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -399,6 +399,6 @@ export const executeSearchRequest = ( callback, Logger, undefined, - mpInstance._ErrorReportingDispatcher, + _ErrorReportingDispatcher, ); }; From 5f123674c9ba3d7b8158cbb63d8f7712dd1750d7 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 15:00:35 -0400 Subject: [PATCH 20/23] test: Add Identity.search coverage for snippet + stub Two new tests exercising the pre-init queueing path and the offline stub for the new `Identity.search` method: - `test/snippet/tests-snippet.js`: extends the "mParticle object should proxy Identity methods" case to call `mParticle.Identity.search(...)` before init and assert the queued entry lands in `config.rq` as `Identity.search` with the workspace key, identities object, and callback function intact. - `test/stub/tests-mParticle-stub.js`: extends the Identity stub case to call `Identity.identify`/`login`/`logout`/`modify`/`search` and verify they don't throw (matching the void-function pattern). Also regenerates `snippet.min.js` and `snippet.rokt.min.js` so the minified bundles include the new `'search'` entry in `identityMethods`. Co-Authored-By: Claude Opus 4.7 (1M context) --- snippet.min.js | 2 +- snippet.rokt.min.js | 2 +- test/snippet/tests-snippet.js | 9 +++++++++ test/stub/tests-mParticle-stub.js | 10 ++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/snippet.min.js b/snippet.min.js index 282452c38..2eb286665 100644 --- a/snippet.min.js +++ b/snippet.min.js @@ -1 +1 @@ -(function(e){window.mParticle=window.mParticle||{};window.mParticle.EventType={Unknown:0,Navigation:1,Location:2,Search:3,Transaction:4,UserContent:5,UserPreference:6,Social:7,Other:8,Media:9};window.mParticle.eCommerce={Cart:{}};window.mParticle.Identity={};window.mParticle.Rokt={};window.mParticle.config=window.mParticle.config||{};window.mParticle.config.rq=[];window.mParticle.config.snippetVersion=2.8;window.mParticle.ready=function(e){window.mParticle.config.rq.push(e)};var t=["endSession","logError","logBaseEvent","logEvent","logForm","logLink","logPageView","setSessionAttribute","setAppName","setAppVersion","setOptOut","setPosition","startNewSession","startTrackingLocation","stopTrackingLocation"];var i=["setCurrencyCode","logCheckout"];var n=["identify","login","logout","modify"];var o=["selectPlacements","hashAttributes","hashSha256","setExtensionData","use","getVersion","terminate"];t.forEach(function(e){window.mParticle[e]=r(e)});i.forEach(function(e){window.mParticle.eCommerce[e]=r(e,"eCommerce")});n.forEach(function(e){window.mParticle.Identity[e]=r(e,"Identity")});o.forEach(function(e){window.mParticle.Rokt[e]=r(e,"Rokt")});function r(t,i){return function(){if(i){t=i+"."+t}var e=Array.prototype.slice.call(arguments);e.unshift(t);window.mParticle.config.rq.push(e)}}var a,c,s=window.mParticle.config,l=s.isDevelopmentMode?1:0,w="?env="+l,d=window.mParticle.config.dataPlan;if(d){a=d.planId;c=d.planVersion;if(a){if(c&&(c<1||c>1e3)){c=null}w+="&plan_id="+a+(c?"&plan_version="+c:"")}}var m=window.mParticle.config.versions;var f=[];if(m){Object.keys(m).forEach(function(e){f.push(e+"="+m[e])})}var p=document.createElement("script");p.type="text/javascript";p.async=true;p.src=("https:"==document.location.protocol?"https://jssdkcdns":"http://jssdkcdn")+".mparticle.com/js/v2/"+e+"/mparticle.js"+w+"&"+f.join("&");var P=document.getElementsByTagName("script")[0];P.parentNode.insertBefore(p,P)})("REPLACE WITH API KEY"); \ No newline at end of file +(function(e){window.mParticle=window.mParticle||{};window.mParticle.EventType={Unknown:0,Navigation:1,Location:2,Search:3,Transaction:4,UserContent:5,UserPreference:6,Social:7,Other:8,Media:9};window.mParticle.eCommerce={Cart:{}};window.mParticle.Identity={};window.mParticle.Rokt={};window.mParticle.config=window.mParticle.config||{};window.mParticle.config.rq=[];window.mParticle.config.snippetVersion=2.8;window.mParticle.ready=function(e){window.mParticle.config.rq.push(e)};var t=["endSession","logError","logBaseEvent","logEvent","logForm","logLink","logPageView","setSessionAttribute","setAppName","setAppVersion","setOptOut","setPosition","startNewSession","startTrackingLocation","stopTrackingLocation"];var i=["setCurrencyCode","logCheckout"];var n=["identify","login","logout","modify","search"];var o=["selectPlacements","hashAttributes","hashSha256","setExtensionData","use","getVersion","terminate","onShoppableAdsReady"];t.forEach(function(e){window.mParticle[e]=r(e)});i.forEach(function(e){window.mParticle.eCommerce[e]=r(e,"eCommerce")});n.forEach(function(e){window.mParticle.Identity[e]=r(e,"Identity")});o.forEach(function(e){window.mParticle.Rokt[e]=r(e,"Rokt")});function r(t,i){return function(){if(i){t=i+"."+t}var e=Array.prototype.slice.call(arguments);e.unshift(t);window.mParticle.config.rq.push(e)}}var a,c,s=window.mParticle.config,l=s.isDevelopmentMode?1:0,d="?env="+l,w=window.mParticle.config.dataPlan;if(w){a=w.planId;c=w.planVersion;if(a){if(c&&(c<1||c>1e3)){c=null}d+="&plan_id="+a+(c?"&plan_version="+c:"")}}var m=window.mParticle.config.versions;var f=[];if(m){Object.keys(m).forEach(function(e){f.push(e+"="+m[e])})}var p=document.createElement("script");p.type="text/javascript";p.async=true;p.src=("https:"==document.location.protocol?"https://jssdkcdns":"http://jssdkcdn")+".mparticle.com/js/v2/"+e+"/mparticle.js"+d+"&"+f.join("&");var P=document.getElementsByTagName("script")[0];P.parentNode.insertBefore(p,P)})("REPLACE WITH API KEY"); \ No newline at end of file diff --git a/snippet.rokt.min.js b/snippet.rokt.min.js index 5a534fee6..cdb462b1d 100644 --- a/snippet.rokt.min.js +++ b/snippet.rokt.min.js @@ -1 +1 @@ -(function(e){window.mParticle=window.mParticle||{};window.mParticle.EventType={Unknown:0,Navigation:1,Location:2,Search:3,Transaction:4,UserContent:5,UserPreference:6,Social:7,Other:8,Media:9};window.mParticle.eCommerce={Cart:{}};window.mParticle.Identity={};window.mParticle.Rokt={};window.mParticle.config=window.mParticle.config||{};window.mParticle.config.rq=[];window.mParticle.config.snippetVersion=2.8;window.mParticle.ready=function(e){window.mParticle.config.rq.push(e)};var i=["endSession","logError","logBaseEvent","logEvent","logForm","logLink","logPageView","setSessionAttribute","setAppName","setAppVersion","setOptOut","setPosition","startNewSession","startTrackingLocation","stopTrackingLocation"];var t=["setCurrencyCode","logCheckout"];var n=["identify","login","logout","modify"];var o=["selectPlacements","hashAttributes","hashSha256","setExtensionData","use","getVersion","terminate"];i.forEach(function(e){window.mParticle[e]=r(e)});t.forEach(function(e){window.mParticle.eCommerce[e]=r(e,"eCommerce")});n.forEach(function(e){window.mParticle.Identity[e]=r(e,"Identity")});o.forEach(function(e){window.mParticle.Rokt[e]=r(e,"Rokt")});function r(i,t){return function(){if(t){i=t+"."+i}var e=Array.prototype.slice.call(arguments);e.unshift(i);window.mParticle.config.rq.push(e)}}var a=window.mParticle.config,c=a.isDevelopmentMode?1:0,s="?env="+c,l=a.dataPlan;if(l){var w=l.planId,m=l.planVersion;if(w){if(m&&(m<1||m>1e3)){m=null}s+="&plan_id="+w+(m?"&plan_version="+m:"")}}var d=a.versions;var f=[];if(d){Object.keys(d).forEach(function(e){f.push(e+"="+d[e])})}var p=document.createElement("script");p.type="text/javascript";p.async=true;window.ROKT_DOMAIN=ROKT_DOMAIN||"https://apps.rokt-api.com";window.mParticle.config.domain=ROKT_DOMAIN.split("//")[1];p.src=ROKT_DOMAIN+"/js/v2/"+e+"/app.js"+s+"&"+f.join("&");var P=document.getElementsByTagName("script")[0];P.parentNode.insertBefore(p,P)})("REPLACE WITH API KEY"); \ No newline at end of file +(function(e){window.mParticle=window.mParticle||{};window.mParticle.EventType={Unknown:0,Navigation:1,Location:2,Search:3,Transaction:4,UserContent:5,UserPreference:6,Social:7,Other:8,Media:9};window.mParticle.eCommerce={Cart:{}};window.mParticle.Identity={};window.mParticle.Rokt={};window.mParticle.config=window.mParticle.config||{};window.mParticle.config.rq=[];window.mParticle.config.snippetVersion=2.8;window.mParticle.ready=function(e){window.mParticle.config.rq.push(e)};var i=["endSession","logError","logBaseEvent","logEvent","logForm","logLink","logPageView","setSessionAttribute","setAppName","setAppVersion","setOptOut","setPosition","startNewSession","startTrackingLocation","stopTrackingLocation"];var t=["setCurrencyCode","logCheckout"];var n=["identify","login","logout","modify","search"];var o=["selectPlacements","hashAttributes","hashSha256","setExtensionData","use","getVersion","terminate","onShoppableAdsReady"];i.forEach(function(e){window.mParticle[e]=r(e)});t.forEach(function(e){window.mParticle.eCommerce[e]=r(e,"eCommerce")});n.forEach(function(e){window.mParticle.Identity[e]=r(e,"Identity")});o.forEach(function(e){window.mParticle.Rokt[e]=r(e,"Rokt")});function r(i,t){return function(){if(t){i=t+"."+i}var e=Array.prototype.slice.call(arguments);e.unshift(i);window.mParticle.config.rq.push(e)}}var a=window.mParticle.config,c=a.isDevelopmentMode?1:0,s="?env="+c,l=a.dataPlan;if(l){var w=l.planId,d=l.planVersion;if(w){if(d&&(d<1||d>1e3)){d=null}s+="&plan_id="+w+(d?"&plan_version="+d:"")}}var m=a.versions;var p=[];if(m){Object.keys(m).forEach(function(e){p.push(e+"="+m[e])})}var f=document.createElement("script");f.type="text/javascript";f.async=true;window.ROKT_DOMAIN=ROKT_DOMAIN||"https://apps.rokt-api.com";window.mParticle.config.domain=ROKT_DOMAIN.split("//")[1];f.src=ROKT_DOMAIN+"/js/v2/"+e+"/app.js"+s+"&"+p.join("&");var P=document.getElementsByTagName("script")[0];P.parentNode.insertBefore(f,P)})("REPLACE WITH API KEY"); \ No newline at end of file diff --git a/test/snippet/tests-snippet.js b/test/snippet/tests-snippet.js index de678fffe..9969d9471 100644 --- a/test/snippet/tests-snippet.js +++ b/test/snippet/tests-snippet.js @@ -81,6 +81,11 @@ describe('snippet', function() { mParticle.Identity.logout(userIdentities); mParticle.Identity.modify(userIdentities); mParticle.Identity.identify(userIdentities); + mParticle.Identity.search( + 'workspace_api_key', + { email: 'user@example.com' }, + function() {} + ); mParticle.config.rq[0][0].should.equal('Identity.login'); mParticle.config.rq[0][1].userIdentities.customerid.should.equal( 'test' @@ -97,6 +102,10 @@ describe('snippet', function() { mParticle.config.rq[3][1].userIdentities.customerid.should.equal( 'test' ); + mParticle.config.rq[4][0].should.equal('Identity.search'); + mParticle.config.rq[4][1].should.equal('workspace_api_key'); + mParticle.config.rq[4][2].email.should.equal('user@example.com'); + (typeof mParticle.config.rq[4][3]).should.equal('function'); done(); }); diff --git a/test/stub/tests-mParticle-stub.js b/test/stub/tests-mParticle-stub.js index ee85da019..db37ce122 100644 --- a/test/stub/tests-mParticle-stub.js +++ b/test/stub/tests-mParticle-stub.js @@ -125,6 +125,16 @@ describe('mParticle stubs', function() { (typeof aliasRequest.startTime).should.equal('number'); (typeof aliasRequest.endTime).should.equal('number'); + mParticle.Identity.identify(); + mParticle.Identity.login(); + mParticle.Identity.logout(); + mParticle.Identity.modify(); + mParticle.Identity.search( + 'workspace_api_key', + { email: 'user@example.com' }, + function() {} + ); + done(); }); From b60528459e5c62e84b647b9118d1114dc1983235 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 15:29:32 -0400 Subject: [PATCH 21/23] refactor: Use Date.now() in buildIdentitySearchEnvelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marginal cleanup — `Date.now()` is the idiomatic way to get the current millisecond timestamp; `new Date().getTime()` allocates a Date object just to immediately read its time. Same value, no allocation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 40538e83b..291abd143 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -346,7 +346,7 @@ export const buildIdentitySearchEnvelope = ( }, environment, request_id: generateUniqueId(), - request_timestamp_ms: new Date().getTime(), + request_timestamp_ms: Date.now(), }); /** From 723e7e854b5e9822cb974cbf7c09752e34aa6e93 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 15:46:29 -0400 Subject: [PATCH 22/23] Apply suggestion from @alexs-mparticle Co-authored-by: Alex S <49695018+alexs-mparticle@users.noreply.github.com> --- src/identity-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 291abd143..b5cbe7ed8 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -387,7 +387,7 @@ export const executeSearchRequest = ( const serviceUrl: string = _Helpers.createServiceUrl(identityUrl); const searchUrl: string = serviceUrl + 'search?cb=1'; - const environment: 'development' | 'production' = isDevelopmentMode + const environment: Environment = isDevelopmentMode ? 'development' : 'production'; From e47d52adb83e1671826cb64fddf85f4c7c6fcdca Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 30 Apr 2026 15:58:10 -0400 Subject: [PATCH 23/23] refactor: Propagate Environment type and drop redundant Response casts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from Alex's review pass on the latest commits: - Fix the unresolved `Environment` reference in `executeSearchRequest` (suggestion 723e7e85 didn't add the import) and propagate the named type to `buildIdentitySearchEnvelope`'s parameter and to `IIdentitySearchRequestBody.environment`. `Environment` is the same `'development' | 'production'` union as before, just centralized through `Constants.Environment`. - Drop redundant `(response as Response)` assertions in `identity/search.ts` — `response` is already typed `Response` from the awaited `api.upload(...)` return, so the cast is a no-op (Sonar S4325). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/identity-utils.ts | 4 ++-- src/identity/search.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/identity-utils.ts b/src/identity-utils.ts index b5cbe7ed8..6a84b5485 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -1,5 +1,5 @@ import Constants, { ONE_DAY_IN_SECONDS, MILLIS_IN_ONE_SEC } from './constants'; -import { Dictionary, parseNumber, isObject, generateHash, generateUniqueId, isEmpty, isFunction } from './utils'; +import { Dictionary, Environment, parseNumber, isObject, generateHash, generateUniqueId, isEmpty, isFunction } from './utils'; import { BaseVault } from './vault'; import Types from './types'; import { @@ -337,7 +337,7 @@ export const hasExplicitIdentifier = (store: IStore | undefined | null): boolean * fold in the search-specific identifiers alongside the envelope. */ export const buildIdentitySearchEnvelope = ( - environment: 'development' | 'production', + environment: Environment, ): Omit => ({ client_sdk: { platform: Constants.platform, diff --git a/src/identity/search.ts b/src/identity/search.ts index a2449b2ec..9be25e956 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 { isFunction } from '../utils'; +import { Environment, isFunction } from '../utils'; import { AsyncUploader, FetchUploader, @@ -69,7 +69,7 @@ export interface IIdentitySearchRequestBody { sdk_vendor: string; sdk_version: string; }; - environment: 'development' | 'production'; + environment: Environment; request_id: string; request_timestamp_ms: number; known_identities: IIdentitySearchKnownIdentities; @@ -183,9 +183,9 @@ export const sendSearchRequest = async ( // FetchUploader returns a real Response with .json(); XHRUploader // returns an XHR-shaped object with `responseText`. We tolerate both. - if (isFunction((response as Response).json)) { + if (isFunction(response.json)) { try { - body = (await (response as Response).json()) as IIdentitySearchResponseBody; + body = (await response.json()) as IIdentitySearchResponseBody; } catch (e) { logger.verbose( 'search response had no parseable JSON body.',