@mparticle/web-sdk 3.21.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pageUrl.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { isAllowedQueryParam } from './pageViewTracker';
2
+ import { getHref, valueof } from './utils';
3
+
4
+ // The one rule for a URL that leaves the SDK, shared with kits through
5
+ // `mParticle._cleanUrl`. `path` keeps scheme, host and path. `full` adds the
6
+ // query params an auto page view may capture, then the fragment.
7
+ export const UrlMode = {
8
+ Path: 'path',
9
+ Full: 'full',
10
+ } as const;
11
+
12
+ export type UrlMode = valueof<typeof UrlMode>;
13
+
14
+ // OAuth 2.0 / OIDC authorization-flow params. They stay off URLs even where the
15
+ // page view allowlist admits them, because a URL travels on every event.
16
+ export const EXCLUDED_URL_QUERY_PARAMS: string[] = [
17
+ 'client_id',
18
+ 'redirect_uri',
19
+ 'response_type',
20
+ 'scope',
21
+ 'state',
22
+ 'code',
23
+ 'nonce',
24
+ ];
25
+
26
+ // An OAuth implicit or OIDC fragment response puts its tokens in `#k=v&...`.
27
+ export const EXCLUDED_URL_FRAGMENT_PARAMS: string[] = EXCLUDED_URL_QUERY_PARAMS.concat(
28
+ [
29
+ 'access_token',
30
+ 'id_token',
31
+ 'refresh_token',
32
+ 'provider_token',
33
+ 'provider_refresh_token',
34
+ ]
35
+ );
36
+
37
+ // Allowlisted params whose value is itself a URL, so it can carry a query.
38
+ const URL_VALUED_QUERY_PARAMS: string[] = ['ref', 'referrer'];
39
+
40
+ const USERINFO = /^((?:[a-z][a-z0-9+.-]*:)?\/\/)[^/]*@/i;
41
+
42
+ const decodeQueryComponent = (component: string): string =>
43
+ decodeURIComponent(component.replace(/\+/g, ' '));
44
+
45
+ const cleanQueryPair = (pair: string): string | null => {
46
+ const separator = pair.indexOf('=');
47
+ const rawName = separator === -1 ? pair : pair.slice(0, separator);
48
+ let name: string;
49
+ try {
50
+ name = decodeQueryComponent(rawName);
51
+ } catch (e) {
52
+ return null;
53
+ }
54
+
55
+ const lowerName = name.toLowerCase();
56
+ if (
57
+ !name ||
58
+ !isAllowedQueryParam(name) ||
59
+ EXCLUDED_URL_QUERY_PARAMS.indexOf(lowerName) !== -1
60
+ ) {
61
+ return null;
62
+ }
63
+
64
+ if (separator === -1 || URL_VALUED_QUERY_PARAMS.indexOf(lowerName) === -1) {
65
+ return pair;
66
+ }
67
+
68
+ let value: string;
69
+ try {
70
+ value = decodeQueryComponent(pair.slice(separator + 1));
71
+ } catch (e) {
72
+ return null;
73
+ }
74
+ const cleanedValue = cleanUrl(value, UrlMode.Path);
75
+ return cleanedValue === value
76
+ ? pair
77
+ : `${rawName}=${encodeURIComponent(cleanedValue)}`;
78
+ };
79
+
80
+ const cleanQuery = (query: string): string =>
81
+ query
82
+ .split('&')
83
+ .map(cleanQueryPair)
84
+ .filter((pair): pair is string => pair !== null)
85
+ .join('&');
86
+
87
+ const keepsFragmentPair = (pair: string): boolean => {
88
+ try {
89
+ const name = decodeQueryComponent(pair.split('=')[0]);
90
+ return EXCLUDED_URL_FRAGMENT_PARAMS.indexOf(name.toLowerCase()) === -1;
91
+ } catch (e) {
92
+ return false;
93
+ }
94
+ };
95
+
96
+ // A hash route (`#/...`, `#!...`, or a path with no `=` before its `?`) keeps
97
+ // its path; a query inside it gets the same allowlist as the URL's own query.
98
+ // A fragment of bare pairs loses the OAuth/OIDC ones.
99
+ const cleanFragment = (hash: string): string => {
100
+ const queryStart = hash.indexOf('?');
101
+ const route = queryStart === -1 ? hash : hash.slice(0, queryStart);
102
+ const isRoute =
103
+ /^#[/!]/.test(hash) || (queryStart !== -1 && route.indexOf('=') === -1);
104
+
105
+ if (isRoute && queryStart !== -1) {
106
+ const query = cleanQuery(hash.slice(queryStart + 1));
107
+ return route + (query ? `?${query}` : '');
108
+ }
109
+
110
+ if (hash.indexOf('=') === -1) {
111
+ return hash;
112
+ }
113
+
114
+ const pairs = hash
115
+ .slice(1)
116
+ .split('&')
117
+ .filter(keepsFragmentPair)
118
+ .join('&');
119
+ return pairs ? `#${pairs}` : '';
120
+ };
121
+
122
+ // String-based rather than `new URL`, so kept pairs and the fragment go out
123
+ // exactly as the browser reported them and no environment can parse differently.
124
+ // An unknown mode is treated as `path`.
125
+ export const cleanUrl = (href: string, mode: UrlMode): string => {
126
+ if (typeof href !== 'string' || !href) {
127
+ return '';
128
+ }
129
+
130
+ const hashStart = href.indexOf('#');
131
+ const hash = hashStart === -1 ? '' : href.slice(hashStart);
132
+ const beforeHash = hashStart === -1 ? href : href.slice(0, hashStart);
133
+ const queryStart = beforeHash.indexOf('?');
134
+ const path = (queryStart === -1
135
+ ? beforeHash
136
+ : beforeHash.slice(0, queryStart)
137
+ ).replace(USERINFO, '$1');
138
+
139
+ if (mode !== UrlMode.Full) {
140
+ return path;
141
+ }
142
+
143
+ const query =
144
+ queryStart === -1 ? '' : cleanQuery(beforeHash.slice(queryStart + 1));
145
+
146
+ return path + (query ? `?${query}` : '') + cleanFragment(hash);
147
+ };
148
+
149
+ // The current page as it may appear on an event.
150
+ export const cleanPageHref = (): string => cleanUrl(getHref(), UrlMode.Full);
@@ -55,6 +55,12 @@ export const ALLOWED_QUERY_PARAMS: string[] = [
55
55
  'utm_term',
56
56
  'utm_content',
57
57
  'utm_id',
58
+ 'utm_source_platform',
59
+ 'utm_creative_format',
60
+ 'utm_marketing_tactic',
61
+ 'gad_source',
62
+ 'gad_campaignid',
63
+ 'mc_cid',
58
64
 
59
65
  // Ad-network click ids
60
66
  'gclid',
@@ -66,6 +72,12 @@ export const ALLOWED_QUERY_PARAMS: string[] = [
66
72
  'twclid',
67
73
  'li_fat_id',
68
74
  'dclid',
75
+ 'srsltid',
76
+ 'epik',
77
+ 'ScCid',
78
+ 'irclickid',
79
+ 'rtid',
80
+ 'rclid',
69
81
 
70
82
  // OAuth / OIDC — see the SECURITY note above
71
83
  'client_id',
@@ -84,8 +96,11 @@ export const ALLOWED_QUERY_PARAMS: string[] = [
84
96
  'per_page',
85
97
 
86
98
  // Site search term, as the common platforms spell it. Matching is
87
- // case-insensitive; single-letter names other than `q` are too generic.
99
+ // case-insensitive. `s` is WordPress and WooCommerce search; other
100
+ // single-letter names are too generic. `oq` is a storefront search term.
88
101
  'q',
102
+ 's',
103
+ 'oq',
89
104
  'search',
90
105
  'query',
91
106
  'keyword',
@@ -105,6 +120,9 @@ export const ALLOWED_QUERY_PARAMS: string[] = [
105
120
  'sku',
106
121
  'skuId',
107
122
  'variant',
123
+ 'color',
124
+ 'colour',
125
+ 'size',
108
126
 
109
127
  // Category
110
128
  'category',
@@ -114,6 +132,27 @@ export const ALLOWED_QUERY_PARAMS: string[] = [
114
132
  'referrer',
115
133
  ];
116
134
 
135
+ // Params matched by name prefix, for platforms that put a per-product id in the
136
+ // name. Salesforce Commerce Cloud spells a variant choice
137
+ // `dwvar_<productId>_<attribute>`, e.g. `dwvar_12345_color=navy`.
138
+ export const ALLOWED_QUERY_PARAM_PREFIXES: string[] = ['dwvar_'];
139
+
140
+ const hasAllowedPrefix = (name: string): boolean =>
141
+ ALLOWED_QUERY_PARAM_PREFIXES.some(prefix => name.indexOf(prefix) === 0);
142
+
143
+ const ALLOWED_QUERY_PARAMS_LOWER: string[] = ALLOWED_QUERY_PARAMS.map(name =>
144
+ name.toLowerCase()
145
+ );
146
+
147
+ // Whether a query param name, in any case, is one an auto page view captures.
148
+ export const isAllowedQueryParam = (name: string): boolean => {
149
+ const lower = name.toLowerCase();
150
+ return (
151
+ ALLOWED_QUERY_PARAMS_LOWER.indexOf(lower) !== -1 ||
152
+ hasAllowedPrefix(lower)
153
+ );
154
+ };
155
+
117
156
  interface IPageSnapshot {
118
157
  path: string;
119
158
  params: Dictionary<string>;
@@ -146,8 +185,18 @@ interface IPendingNavigation {
146
185
  // attribute), drops empty values, and carries the fallback for browsers without
147
186
  // URLSearchParams. Tolerates an empty href, so SSR yields no params rather than
148
187
  // throwing.
149
- export const allowedQueryParams = (href: string): Dictionary<string> =>
150
- queryStringParser(href, ALLOWED_QUERY_PARAMS);
188
+ export const allowedQueryParams = (href: string): Dictionary<string> => {
189
+ const params = queryStringParser(href, ALLOWED_QUERY_PARAMS);
190
+ const allParams = queryStringParser(href);
191
+
192
+ Object.keys(allParams)
193
+ .filter(name => hasAllowedPrefix(name) && allParams[name])
194
+ .forEach(name => {
195
+ params[name] = allParams[name];
196
+ });
197
+
198
+ return params;
199
+ };
151
200
 
152
201
  export const AUTO_PAGE_VIEW_ATTRIBUTE = 'is_auto_page_view';
153
202
 
@@ -159,10 +208,9 @@ export const autoPageViewAttribute = (
159
208
  ): Dictionary<boolean> =>
160
209
  isAutoPageView ? { [AUTO_PAGE_VIEW_ATTRIBUTE]: true } : {};
161
210
 
162
- // The captured params, in allowlist order. Ordering comes from the constant
163
- // rather than a sort: it is deterministic without needing a comparator, and it
164
- // does not depend on the object's insertion order, so reordering the query string
165
- // cannot produce a different key.
211
+ // The captured params, in allowlist order, then prefix-matched names sorted.
212
+ // Neither depends on the object's insertion order, so reordering the query
213
+ // string cannot produce a different key.
166
214
  //
167
215
  // Membership is an own-property check, not `in`. `in` walks the prototype
168
216
  // chain, so a name matching an Object.prototype member reports as present on
@@ -170,7 +218,11 @@ export const autoPageViewAttribute = (
170
218
  // only thing making `in` safe here — and it stops being a safe assumption the
171
219
  // moment this list can be extended from configuration.
172
220
  const capturedNames = (params: Dictionary<string>): string[] =>
173
- ALLOWED_QUERY_PARAMS.filter(name => hasOwnProp(params, name));
221
+ ALLOWED_QUERY_PARAMS.filter(name => hasOwnProp(params, name)).concat(
222
+ Object.keys(params)
223
+ .filter(hasAllowedPrefix)
224
+ .sort()
225
+ );
174
226
 
175
227
  // The dedup key: pathname plus the allowlisted params in a fixed order, so that
176
228
  // reordering the query string is not a new page. Params outside the allowlist
@@ -15,7 +15,7 @@ import {
15
15
  import { SDKIdentityApi } from "./identity.interfaces";
16
16
  import { SDKLoggerApi } from "./sdkRuntimeModels";
17
17
  import { IStore, LocalSessionAttributes } from "./store";
18
- import { UserIdentities } from "@mparticle/web-sdk";
18
+ import { MPID, UserIdentities } from "@mparticle/web-sdk";
19
19
  import { IdentityType, PerformanceMarkType } from "./types";
20
20
  import { ErrorCodes, IErrorReportingService, ILoggingService, WSDKErrorSeverity } from "./reporting/types";
21
21
  import { IRoktLauncherOptions, normalizeRoktLauncherOptions } from "./roktLauncherOptions";
@@ -23,7 +23,7 @@ import { PARTNER_MODULE_IDS } from "./cookieSyncManager";
23
23
  import IntegrationCapture from "./integrationCapture";
24
24
  import Constants from "./constants";
25
25
  import { subscribeToRouteChange } from "./routeChangeMonitor";
26
- import type { CheckoutIntentKind } from "./buttonTracking/checkoutIntent";
26
+ import type { CheckoutIntentKind, CheckoutIntentSource } from "./buttonTracking/checkoutIntent";
27
27
 
28
28
  const PASSBACK_CONVERSION_TRACKING_ID = 'passbackconversiontrackingid';
29
29
  const CHECKOUT_INTENT_BUFFER_LIMIT = 4;
@@ -74,9 +74,14 @@ export interface RoktKitFilterSettings {
74
74
  filteredUser?: IMParticleUser | null;
75
75
  }
76
76
 
77
+ // What a kit receives on checkout intent: the kind, when the shopper acted,
78
+ // which rule recognised it and the path it happened on. Older kits read only
79
+ // kind and t.
77
80
  export interface ICheckoutIntentSignal {
78
81
  kind: CheckoutIntentKind;
79
82
  t: number;
83
+ source?: CheckoutIntentSource;
84
+ pathname?: string;
80
85
  }
81
86
 
82
87
  export interface IRoktKitSettings {
@@ -131,6 +136,7 @@ export default class RoktManager {
131
136
  public kit: IRoktKit = null;
132
137
  public filters: RoktKitFilterSettings = {};
133
138
  private currentUser: IMParticleUser | null = null;
139
+ private createFilteredUser?: (mpid: MPID) => IMParticleUser;
134
140
  private messageQueue: Map<string, IRoktMessage> = new Map();
135
141
  private sandbox: boolean | null = null;
136
142
  private placementAttributesMapping: Dictionary<string>[] = [];
@@ -173,6 +179,7 @@ export default class RoktManager {
173
179
  * @param {Function} captureTiming - Function to capture performance timing marks
174
180
  * @param {IErrorReportingService} errorReporter - Dispatcher for error/warning reporting
175
181
  * @param {ILoggingService} loggingService - Dispatcher for informational logging
182
+ * @param {Function} createFilteredUser - Builds a user as the Rokt connection's filters and the data plan allow
176
183
  *
177
184
  * @throws Logs error to console if placementAttributesMapping parsing fails
178
185
  */
@@ -187,6 +194,7 @@ export default class RoktManager {
187
194
  errorReporter?: IErrorReportingService,
188
195
  loggingService?: ILoggingService,
189
196
  integrationCapture?: IntegrationCapture,
197
+ createFilteredUser?: (mpid: MPID) => IMParticleUser,
190
198
  ): void {
191
199
  const { userAttributeFilters, settings } = roktConfig || {};
192
200
  const { placementAttributesMapping, hashedEmailUserIdentityType } = settings || {};
@@ -199,6 +207,7 @@ export default class RoktManager {
199
207
  this.loggingService = loggingService;
200
208
  this.captureTiming = captureTiming;
201
209
  this.integrationCapture = integrationCapture;
210
+ this.createFilteredUser = createFilteredUser;
202
211
 
203
212
  this.captureTiming?.(PerformanceMarkType.JointSdkRoktKitInit);
204
213
 
@@ -314,6 +323,16 @@ export default class RoktManager {
314
323
  return !!nav && nav.globalPrivacyControl === true;
315
324
  }
316
325
 
326
+ // True when the kit can wait for the shopper's confirm click: signals are not
327
+ // suppressed and the browser exposes the composed path the detector reads.
328
+ // Decided from config alone, so tracker setup and teardown never change it.
329
+ public isCheckoutIntentLive(): boolean {
330
+ if (this.isCheckoutIntentSuppressed()) {
331
+ return false;
332
+ }
333
+ return typeof Event !== 'undefined' && isFunction(Event.prototype.composedPath);
334
+ }
335
+
317
336
  // Reads `this.kit` at call time; before a kit attaches the signal is buffered
318
337
  // with the path it was raised on.
319
338
  public notifyCheckoutIntent(signal: ICheckoutIntentSignal, pathname: string = window.location.pathname): void {
@@ -489,11 +508,13 @@ export default class RoktManager {
489
508
  }
490
509
  }
491
510
 
492
- // Optimistic merge: apply the pending identity changes locally so
493
- // enrichedAttributes are correct without waiting for the identify round-trip.
494
- // The next call to selectPlacements re-fetches currentUser (see line above),
495
- // so stale identity state does not persist beyond this invocation.
496
- const finalUserIdentities = { ...currentUserIdentities, ...newIdentities };
511
+ // The identify above needs every identity, so currentUserIdentities stays unfiltered.
512
+ // Without createFilteredUser the connection's filters cannot be applied, so no identity is added.
513
+ const roktUser =
514
+ this.currentUser && this.createFilteredUser
515
+ ? this.createFilteredUser(this.currentUser.getMPID())
516
+ : null;
517
+ const roktUserIdentities = roktUser?.getUserIdentities()?.userIdentities || {};
497
518
 
498
519
  const timeOnSite = this.store?.getTimeOnSite?.();
499
520
  const totalTimeOnSite = this.store?.getTotalTimeOnSite?.();
@@ -506,19 +527,25 @@ export default class RoktManager {
506
527
  };
507
528
 
508
529
  // Propagate email from current user identities if not already in attributes
509
- if (finalUserIdentities.email && !enrichedAttributes.email) {
510
- enrichedAttributes.email = finalUserIdentities.email;
530
+ if (!enrichedAttributes.email) {
531
+ if (roktUserIdentities.email) {
532
+ enrichedAttributes.email = roktUserIdentities.email;
533
+ } else if (currentUserIdentities.email) {
534
+ this.logFilteredIdentity('email');
535
+ }
511
536
  }
512
537
 
513
538
  // Propagate emailsha256 from current user identities if not already in attributes
514
- if (isValidHashedEmailIdentityType) {
515
- const hashedEmail = finalUserIdentities[this.mappedEmailShaIdentityType];
516
- if (
517
- hashedEmail &&
518
- !enrichedAttributes.emailsha256 &&
519
- !enrichedAttributes[this.mappedEmailShaIdentityType]
520
- ) {
539
+ if (
540
+ isValidHashedEmailIdentityType &&
541
+ !enrichedAttributes.emailsha256 &&
542
+ !enrichedAttributes[this.mappedEmailShaIdentityType]
543
+ ) {
544
+ const hashedEmail = roktUserIdentities[this.mappedEmailShaIdentityType];
545
+ if (hashedEmail) {
521
546
  enrichedAttributes.emailsha256 = hashedEmail;
547
+ } else if (currentUserIdentities[this.mappedEmailShaIdentityType]) {
548
+ this.logFilteredIdentity(this.mappedEmailShaIdentityType);
522
549
  }
523
550
  }
524
551
 
@@ -531,7 +558,7 @@ export default class RoktManager {
531
558
  }
532
559
  }
533
560
 
534
- this.filters.filteredUser = this.currentUser || this.filters.filteredUser || null;
561
+ this.filters.filteredUser = roktUser || this.filters.filteredUser || null;
535
562
 
536
563
  const enrichedOptions = {
537
564
  ...options,
@@ -544,6 +571,12 @@ export default class RoktManager {
544
571
  }
545
572
  }
546
573
 
574
+ private logFilteredIdentity(identityType: string): void {
575
+ this.logger?.verbose(
576
+ `mParticle.Rokt selectPlacements did not add the ${identityType} user identity to the attributes because the Rokt connection does not allow it`
577
+ );
578
+ }
579
+
547
580
  /**
548
581
  * Hashes attributes and returns both original and hashed versions
549
582
  * with Rokt-compatible key names (like emailsha256, mobilesha256)
@@ -53,6 +53,7 @@ import {
53
53
  import Constants from './constants';
54
54
  import RoktManager, { IRoktLauncherOptions } from './roktManager';
55
55
  import { IConsoleLogger } from './logger';
56
+ import { UrlMode } from './pageUrl';
56
57
  import { ErrorCodes, IErrorReportingService, ILoggingService } from './reporting/types';
57
58
 
58
59
  // TODO: Resolve this with version in @mparticle/web-sdk
@@ -282,6 +283,7 @@ export interface IMParticleInstanceManager extends MParticleWebSDK {
282
283
  _instances: Dictionary<IMParticleWebSDKInstance>;
283
284
  _isTestEnv?: boolean;
284
285
  _forceNoLocalStorage?: boolean;
286
+ _cleanUrl(href: string, mode: UrlMode): string;
285
287
 
286
288
  // Public Properties
287
289
  config: SDKInitConfig;
@@ -17,8 +17,8 @@ import {
17
17
  Dictionary,
18
18
  isValidCustomFlagProperty,
19
19
  extend,
20
- getHref,
21
20
  } from './utils';
21
+ import { cleanPageHref } from './pageUrl';
22
22
  import { IntegrationAttributes, ServerSettings } from './store';
23
23
  import { MPID } from '@mparticle/web-sdk';
24
24
  import {
@@ -310,7 +310,7 @@ export default function ServerModel(
310
310
  ),
311
311
  ActiveTimeOnSite: mpInstance._timeOnSiteTimer?.getTimeInForeground(),
312
312
  TotalTimeOnSite: mpInstance._Store.getTotalTimeOnSite?.(),
313
- PageUrl: getHref() || null,
313
+ PageUrl: cleanPageHref() || null,
314
314
  SourceMessageId:
315
315
  event.sourceMessageId ||
316
316
  mpInstance._Helpers.generateUniqueId(),
@@ -352,7 +352,7 @@ export default function ServerModel(
352
352
 
353
353
  if (eventObject.EventDataType === MessageType.AppStateTransition) {
354
354
  eventObject.IsFirstRun = mpInstance._Store.isFirstRun;
355
- eventObject.LaunchReferral = window.location.href || null;
355
+ eventObject.LaunchReferral = cleanPageHref() || null;
356
356
  }
357
357
 
358
358
  // FIXME: Remove duplicate occurence
package/src/utils.ts CHANGED
@@ -385,7 +385,7 @@ interface URLSearchParamsFallback {
385
385
 
386
386
  const queryStringParserFallback = (url: string): URLSearchParamsFallback => {
387
387
  const params: Dictionary<string> = {};
388
- const queryString = url.split('?')[1] || '';
388
+ const queryString = url.split('#')[0].split('?')[1] || '';
389
389
  const pairs = queryString.split('&');
390
390
 
391
391
  pairs.forEach(pair => {