@mparticle/web-sdk 3.23.0 → 3.24.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.
@@ -37,57 +37,9 @@ type WindowWithApv = Window & {
37
37
  [WIN_APV_KEY]?: IApvState;
38
38
  };
39
39
 
40
- // The query params an auto page view may carry. An allowlist, not a denylist:
41
- // partner URLs routinely hold order ids, email addresses and session tokens, and
42
- // none of those should reach the event stream because someone forgot to exclude
43
- // them. Anything absent from this list is dropped.
44
- //
45
- // SECURITY: `code`, `state` and `nonce` are the OAuth 2.0 / OIDC authorization
46
- // code and the CSRF/replay tokens. They are credentials until redeemed, and
47
- // attaching them here persists them in the event store and forwards them to
48
- // every configured kit. They are on the list by explicit product decision —
49
- // deleting that line is the whole of the fix if that decision is revisited.
40
+ // Parameters that identify which page a view is of. Authorization-flow and campaign
41
+ // attribution parameters are deliberately absent; an input can add them explicitly.
50
42
  export const ALLOWED_QUERY_PARAMS: string[] = [
51
- // Campaign attribution
52
- 'utm_source',
53
- 'utm_medium',
54
- 'utm_campaign',
55
- 'utm_term',
56
- 'utm_content',
57
- 'utm_id',
58
- 'utm_source_platform',
59
- 'utm_creative_format',
60
- 'utm_marketing_tactic',
61
- 'gad_source',
62
- 'gad_campaignid',
63
- 'mc_cid',
64
-
65
- // Ad-network click ids
66
- 'gclid',
67
- 'gbraid',
68
- 'wbraid',
69
- 'fbclid',
70
- 'msclkid',
71
- 'ttclid',
72
- 'twclid',
73
- 'li_fat_id',
74
- 'dclid',
75
- 'srsltid',
76
- 'epik',
77
- 'ScCid',
78
- 'irclickid',
79
- 'rtid',
80
- 'rclid',
81
-
82
- // OAuth / OIDC — see the SECURITY note above
83
- 'client_id',
84
- 'redirect_uri',
85
- 'response_type',
86
- 'scope',
87
- 'state',
88
- 'code',
89
- 'nonce',
90
-
91
43
  // Pagination
92
44
  'page',
93
45
  'limit',
@@ -132,6 +84,47 @@ export const ALLOWED_QUERY_PARAMS: string[] = [
132
84
  'referrer',
133
85
  ];
134
86
 
87
+ export const AUTO_PAGE_VIEW_ATTRIBUTE = 'is_auto_page_view';
88
+
89
+ const CORE_EVENT_FIELDS = ['hostname', 'title', 'path', AUTO_PAGE_VIEW_ATTRIBUTE];
90
+
91
+ const PROTOTYPE_MEMBER_NAMES = ['constructor', '__proto__', 'prototype'];
92
+
93
+ // Server flag values are the strings 'True' and 'False', so these get typed here by habit.
94
+ const FLAG_VALUE_NAMES = ['true', 'false'];
95
+
96
+ const RESERVED_QUERY_PARAMS = CORE_EVENT_FIELDS.concat(
97
+ PROTOTYPE_MEMBER_NAMES,
98
+ FLAG_VALUE_NAMES
99
+ );
100
+
101
+ const BUILT_IN_NAMES = ALLOWED_QUERY_PARAMS.map(name => name.toLowerCase());
102
+
103
+ const isBuiltIn = (name: string): boolean =>
104
+ BUILT_IN_NAMES.indexOf(name.toLowerCase()) !== -1;
105
+
106
+ // Excludes `&`, `=`, `%` and whitespace, which would distort the dedup key.
107
+ const QUERY_PARAM_NAME = /^[a-z0-9_][a-z0-9_.-]{0,63}$/;
108
+
109
+ export const MAX_CUSTOM_QUERY_PARAMS = 25;
110
+
111
+ export interface IQueryParamAllowlist {
112
+ allowed: string[];
113
+
114
+ // Positions, never names: a rejected entry may be a secret pasted into the wrong field.
115
+ rejectedPositions: number[];
116
+
117
+ overLimit: number;
118
+ }
119
+
120
+ export const MAX_CUSTOM_QUERY_PARAM_VALUE_LENGTH = 512;
121
+
122
+ // A list, not a dictionary: its order is the order the dedup key is built in.
123
+ export interface ICapturedParam {
124
+ name: string;
125
+ value: string;
126
+ }
127
+
135
128
  // Params matched by name prefix, for platforms that put a per-product id in the
136
129
  // name. Salesforce Commerce Cloud spells a variant choice
137
130
  // `dwvar_<productId>_<attribute>`, e.g. `dwvar_12345_color=navy`.
@@ -140,22 +133,13 @@ export const ALLOWED_QUERY_PARAM_PREFIXES: string[] = ['dwvar_'];
140
133
  const hasAllowedPrefix = (name: string): boolean =>
141
134
  ALLOWED_QUERY_PARAM_PREFIXES.some(prefix => name.indexOf(prefix) === 0);
142
135
 
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
- };
136
+ // Whether a query param name, in any case, is one an auto page view captures by default.
137
+ export const isAllowedQueryParam = (name: string): boolean =>
138
+ isBuiltIn(name) || hasAllowedPrefix(name.toLowerCase());
155
139
 
156
140
  interface IPageSnapshot {
157
141
  path: string;
158
- params: Dictionary<string>;
142
+ params: ICapturedParam[];
159
143
  }
160
144
 
161
145
  interface IPageViewData extends IPageSnapshot {
@@ -180,25 +164,114 @@ interface IPendingNavigation {
180
164
  // on their own, which is where the interesting rules live.
181
165
  // ---------------------------------------------------------------------------
182
166
 
183
- // Pulls the allowlisted query params off a URL. Delegates to the SDK's own
184
- // parser, which lowercases keys (so `?UTM_Source=` and `?utm_source=` land on one
185
- // attribute), drops empty values, and carries the fallback for browsers without
186
- // URLSearchParams. Tolerates an empty href, so SSR yields no params rather than
187
- // throwing.
188
- export const allowedQueryParams = (href: string): Dictionary<string> => {
189
- const params = queryStringParser(href, ALLOWED_QUERY_PARAMS);
167
+ // Validated here as well as in the dashboard, because remote config is untrusted input.
168
+ export const parseQueryParamAllowlist = (
169
+ configured: unknown
170
+ ): IQueryParamAllowlist => {
171
+ const allowed: string[] = [];
172
+ const rejectedPositions: number[] = [];
173
+ let overLimit = 0;
174
+
175
+ if (!configured) {
176
+ return { allowed, rejectedPositions, overLimit };
177
+ }
178
+
179
+ const entries: unknown[] = Array.isArray(configured)
180
+ ? configured
181
+ : String(configured).split(',');
182
+
183
+ entries.forEach((entry, index) => {
184
+ const position = index + 1;
185
+ const name = String(entry)
186
+ .trim()
187
+ .toLowerCase();
188
+
189
+ if (!name) {
190
+ return;
191
+ }
192
+
193
+ if (
194
+ !QUERY_PARAM_NAME.test(name) ||
195
+ RESERVED_QUERY_PARAMS.indexOf(name) !== -1
196
+ ) {
197
+ rejectedPositions.push(position);
198
+ return;
199
+ }
200
+
201
+ if (isBuiltIn(name) || allowed.indexOf(name) !== -1) {
202
+ return;
203
+ }
204
+
205
+ if (allowed.length >= MAX_CUSTOM_QUERY_PARAMS) {
206
+ overLimit++;
207
+ return;
208
+ }
209
+
210
+ allowed.push(name);
211
+ });
212
+
213
+ return { allowed, rejectedPositions, overLimit };
214
+ };
215
+
216
+ // Not localeCompare: this order feeds the dedup key, so it must be identical in every
217
+ // browser, and localeCompare is locale-dependent.
218
+ const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
219
+
220
+ // Built-ins keep their positions and additions sort after them, so a dedup key depends on
221
+ // which params are configured, not the order they were typed.
222
+ export const effectiveAllowlist = (extras: string[] = []): string[] =>
223
+ ALLOWED_QUERY_PARAMS.concat(
224
+ extras.filter(name => !isBuiltIn(name)).sort(byName)
225
+ );
226
+
227
+ // Over-long custom values are dropped rather than truncated: two truncated values could
228
+ // share a dedup key and swallow a real page view.
229
+ const isWithinValueLimit = (name: string, value: string): boolean =>
230
+ isBuiltIn(name) || value.length <= MAX_CUSTOM_QUERY_PARAM_VALUE_LENGTH;
231
+
232
+ // A prefix match is a platform name, not a configured extra, so the custom
233
+ // value limit does not apply. A name already on the allowlist keeps that
234
+ // position instead of being appended again.
235
+ const prefixMatchedParams = (
236
+ href: string,
237
+ allowlist: string[]
238
+ ): ICapturedParam[] => {
190
239
  const allParams = queryStringParser(href);
191
240
 
192
- Object.keys(allParams)
193
- .filter(name => hasAllowedPrefix(name) && allParams[name])
194
- .forEach(name => {
195
- params[name] = allParams[name];
196
- });
241
+ return Object.keys(allParams)
242
+ .filter(
243
+ name =>
244
+ hasAllowedPrefix(name) &&
245
+ allParams[name] &&
246
+ allowlist.indexOf(name) === -1
247
+ )
248
+ .sort(byName)
249
+ .map(name => ({ name, value: allParams[name] }));
250
+ };
197
251
 
198
- return params;
252
+ export const allowedQueryParams = (
253
+ href: string,
254
+ extras: string[] = []
255
+ ): ICapturedParam[] => {
256
+ const allowlist = effectiveAllowlist(extras);
257
+ const found = queryStringParser(href, allowlist);
258
+ const listed = allowlist
259
+ .filter(name => hasOwnProp(found, name))
260
+ .filter(name => isWithinValueLimit(name, found[name]))
261
+ .map(name => ({ name, value: found[name] }));
262
+
263
+ return listed.concat(prefixMatchedParams(href, allowlist));
199
264
  };
200
265
 
201
- export const AUTO_PAGE_VIEW_ATTRIBUTE = 'is_auto_page_view';
266
+ export const paramsToAttributes = (
267
+ params: ICapturedParam[]
268
+ ): Dictionary<string> => {
269
+ const attributes: Dictionary<string> = {};
270
+ params.forEach(({ name, value }) => {
271
+ attributes[name] = value;
272
+ });
273
+ return attributes;
274
+ };
202
275
 
203
276
  // Absent rather than false on a manual page view, so the attribute is only ever
204
277
  // added by the automatic emitters. Shared by both of them so the key and that
@@ -208,35 +281,11 @@ export const autoPageViewAttribute = (
208
281
  ): Dictionary<boolean> =>
209
282
  isAutoPageView ? { [AUTO_PAGE_VIEW_ATTRIBUTE]: true } : {};
210
283
 
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.
214
- //
215
- // Membership is an own-property check, not `in`. `in` walks the prototype
216
- // chain, so a name matching an Object.prototype member reports as present on
217
- // any plain object. ALLOWED_QUERY_PARAMS contains no such name, which was the
218
- // only thing making `in` safe here — and it stops being a safe assumption the
219
- // moment this list can be extended from configuration.
220
- const capturedNames = (params: Dictionary<string>): string[] =>
221
- ALLOWED_QUERY_PARAMS.filter(name => hasOwnProp(params, name)).concat(
222
- Object.keys(params)
223
- .filter(hasAllowedPrefix)
224
- .sort()
225
- );
226
-
227
- // The dedup key: pathname plus the allowlisted params in a fixed order, so that
228
- // reordering the query string is not a new page. Params outside the allowlist
229
- // never make it into `page.params` and so cannot key a view — nor can the hash.
230
- //
231
- // Values are re-encoded because queryStringParser hands them back DECODED. A
232
- // value holding the pair delimiters would otherwise serialize exactly like two
233
- // separate params — `{q: 'a&search=b'}` and `{q: 'a', search: 'b'}` both becoming
234
- // `q=a&search=b` — and dedup would treat a real navigation between them as the
235
- // same page and drop the view. `q`, `search` and `redirect_uri` carry `&` and `=`
236
- // routinely, so this is reachable rather than theoretical.
284
+ // Values are re-encoded because they arrive decoded: `{q: 'a&search=b'}` would otherwise
285
+ // key the same as `{q: 'a', search: 'b'}` and dedup a real navigation away.
237
286
  export const pageKey = (page: IPageSnapshot): string => {
238
- const query = capturedNames(page.params)
239
- .map(name => `${name}=${encodeURIComponent(page.params[name])}`)
287
+ const query = page.params
288
+ .map(({ name, value }) => `${name}=${encodeURIComponent(value)}`)
240
289
  .join('&');
241
290
 
242
291
  return query ? `${page.path}?${query}` : page.path;
@@ -266,7 +315,7 @@ export const buildPageViewEvent = ({
266
315
  // wins. No allowlist entry collides with hostname/title/path today; naming
267
316
  // them here is what keeps a later addition from silently overwriting one.
268
317
  data: {
269
- ...params,
318
+ ...paramsToAttributes(params),
270
319
  hostname,
271
320
  title,
272
321
  path,
@@ -356,9 +405,9 @@ const clearActiveTracker = (tracker: PageViewTracker): void => {
356
405
  }
357
406
  };
358
407
 
359
- const currentPage = (): IPageSnapshot => ({
408
+ const currentPage = (extras: string[]): IPageSnapshot => ({
360
409
  path: window.location.pathname,
361
- params: allowedQueryParams(getHref()),
410
+ params: allowedQueryParams(getHref(), extras),
362
411
  });
363
412
 
364
413
  // Log-safe description of a page: the path, plus the NAMES of the captured
@@ -372,7 +421,7 @@ const describePage = (page: IPageSnapshot | null): string => {
372
421
  return '';
373
422
  }
374
423
 
375
- const names = capturedNames(page.params);
424
+ const names = page.params.map(({ name }) => name);
376
425
  return names.length ? `${page.path} (params: ${names.join()})` : page.path;
377
426
  };
378
427
 
@@ -390,6 +439,8 @@ export class PageViewTracker {
390
439
 
391
440
  private lastPage: IPageSnapshot | null = null;
392
441
  private active = false;
442
+
443
+ private customQueryParams: string[] = [];
393
444
  private pendingNavigations: IPendingNavigation[] = [];
394
445
 
395
446
  private undoHistoryPatch: (() => void) | null = null;
@@ -434,7 +485,8 @@ export class PageViewTracker {
434
485
  }
435
486
 
436
487
  this.active = true;
437
- this.lastPage = currentPage();
488
+ this.customQueryParams = this.readCustomQueryParams();
489
+ this.lastPage = currentPage(this.customQueryParams);
438
490
  this.log(`[init] seeded lastPage: ${describePage(this.lastPage)}`);
439
491
 
440
492
  this.undoHistoryPatch = subscribeToRouteChange(
@@ -467,6 +519,36 @@ export class PageViewTracker {
467
519
  clearActiveTracker(this);
468
520
  }
469
521
 
522
+ private readCustomQueryParams(): string[] {
523
+ const {
524
+ allowed = [],
525
+ rejectedPositions = [],
526
+ overLimit = 0,
527
+ } = (this.mpInstance._Store.SDKConfig.autoLogPageViewQueryParams ||
528
+ {}) as IQueryParamAllowlist;
529
+
530
+ if (rejectedPositions.length) {
531
+ this.mpInstance.Logger.warning(
532
+ 'mParticle APV: ignoring invalid additional page view query ' +
533
+ `parameters at positions ${rejectedPositions.join(', ')}`
534
+ );
535
+ }
536
+
537
+ if (overLimit) {
538
+ this.mpInstance.Logger.warning(
539
+ `mParticle APV: ignoring ${overLimit} additional page view ` +
540
+ `query parameters beyond the limit of ` +
541
+ `${MAX_CUSTOM_QUERY_PARAMS}`
542
+ );
543
+ }
544
+
545
+ if (allowed.length) {
546
+ this.log(`[init] additional query params: ${allowed.join()}`);
547
+ }
548
+
549
+ return allowed;
550
+ }
551
+
470
552
  // Stops the outgoing tracker and returns the pages it had queued so this
471
553
  // tracker can re-schedule them once it is active. Private, but reachable
472
554
  // across instances: the handoff protocol stays internal to the class.
@@ -514,7 +596,7 @@ export class PageViewTracker {
514
596
  }
515
597
 
516
598
  private handleNavigation(source: NavigationSource): void {
517
- const candidate = currentPage();
599
+ const candidate = currentPage(this.customQueryParams);
518
600
  const lastKey = this.lastPage ? pageKey(this.lastPage) : null;
519
601
 
520
602
  this.log(
@@ -26,7 +26,8 @@ import { subscribeToRouteChange } from "./routeChangeMonitor";
26
26
  import type { CheckoutIntentKind, CheckoutIntentSource } from "./buttonTracking/checkoutIntent";
27
27
 
28
28
  const PASSBACK_CONVERSION_TRACKING_ID = 'passbackconversiontrackingid';
29
- const CHECKOUT_INTENT_BUFFER_LIMIT = 4;
29
+ // One signal per kind (commit, wallet, checkout_step, frame and cart), so a new kind never evicts another.
30
+ const CHECKOUT_INTENT_BUFFER_LIMIT = 5;
30
31
  const CHECKOUT_INTENT_MAX_AGE_MS = 30000;
31
32
 
32
33
  type AttributeTargetUser = Pick<IMParticleUser, 'getUserIdentities' | 'setUserAttributes'>;
@@ -156,6 +157,8 @@ export default class RoktManager {
156
157
  private stopRouteChangeWatch: (() => void) | null = null;
157
158
  // Checkout intent raised before the kit attached: latest per kind.
158
159
  private pendingCheckoutIntents: Array<{ signal: ICheckoutIntentSignal; pathname: string }> = [];
160
+ // Off until SDK init finds the Rokt connection receives auto button clicks.
161
+ private autoButtonClicksForwarded = false;
159
162
 
160
163
  // Keys this manager's route subscription, so a re-executed bundle's manager replaces
161
164
  // it while a second named instance keeps its own.
@@ -308,11 +311,21 @@ export default class RoktManager {
308
311
  }
309
312
  }
310
313
 
314
+ public setAutoButtonClicksForwarded(forwarded: boolean): void {
315
+ this.autoButtonClicksForwarded = forwarded;
316
+ }
317
+
311
318
  // Checkout intent is off unless the flag is on, and under opt-out, any Rokt
312
- // privacy launcher option, or Global Privacy Control.
319
+ // privacy launcher option, Global Privacy Control, or a Rokt connection that
320
+ // does not receive auto button clicks.
313
321
  public isCheckoutIntentSuppressed(): boolean {
314
322
  const flags = this.store?.SDKConfig?.flags;
315
- if (!flags || flags[Constants.FeatureFlags.AutoButtonTracking] !== true || !this.store.isEnabled) {
323
+ if (
324
+ !flags ||
325
+ flags[Constants.FeatureFlags.AutoButtonTracking] !== true ||
326
+ !this.store.isEnabled ||
327
+ !this.autoButtonClicksForwarded
328
+ ) {
316
329
  return true;
317
330
  }
318
331
  const options = this.launcherOptions || {};
@@ -381,7 +394,9 @@ export default class RoktManager {
381
394
  .forEach(({ signal }) => this.notifyCheckoutIntent(signal));
382
395
  }
383
396
 
384
- private isAutoLogPageViewEnabled(): boolean {
397
+ // The Rokt kit reads this to tell core's page views, whose title and utm
398
+ // attributes a filter may have removed, from ones logged by hand.
399
+ public isAutoLogPageViewEnabled(): boolean {
385
400
  return (
386
401
  this.store?.SDKConfig?.flags?.[
387
402
  Constants.FeatureFlags.AutoLogPageView
@@ -328,6 +328,7 @@ export interface SDKInitConfig
328
328
  dataPlanOptions?: KitBlockerOptions;
329
329
  flags?: Dictionary;
330
330
  pixelConfigs?: IPixelConfiguration[];
331
+ autoLogPageViewQueryParams?: string;
331
332
 
332
333
  aliasMaxWindow?: number;
333
334
  deviceId?: string;
package/src/store.ts CHANGED
@@ -9,6 +9,10 @@ import {
9
9
  } from '@mparticle/web-sdk';
10
10
  import { IKitConfigs } from './configAPIClient';
11
11
  import Constants from './constants';
12
+ import {
13
+ IQueryParamAllowlist,
14
+ parseQueryParamAllowlist,
15
+ } from './pageViewTracker';
12
16
  import {
13
17
  DataPlanResult,
14
18
  KitBlockerOptions,
@@ -65,6 +69,7 @@ export interface SDKConfig {
65
69
  appVersion?: string;
66
70
  package?: string;
67
71
  flags?: IFeatureFlags;
72
+ autoLogPageViewQueryParams?: IQueryParamAllowlist;
68
73
  kitConfigs: IKitConfigs[];
69
74
  kits: Dictionary<UnregisteredKit>;
70
75
  logLevel?: LogLevelType;
@@ -332,6 +337,9 @@ export default function Store(
332
337
  }
333
338
 
334
339
  this.SDKConfig.flags = processFlags(config);
340
+ this.SDKConfig.autoLogPageViewQueryParams = parseQueryParamAllowlist(
341
+ config.autoLogPageViewQueryParams
342
+ );
335
343
 
336
344
  if (config.deviceId) {
337
345
  this.deviceId = config.deviceId;
@@ -728,6 +736,12 @@ export default function Store(
728
736
  this.SDKConfig.flags = processFlags(config);
729
737
  }
730
738
 
739
+ // Stored whole: the tracker reports the rejections, and cannot recover them by
740
+ // re-parsing a list that has already had them removed.
741
+ this.SDKConfig.autoLogPageViewQueryParams = parseQueryParamAllowlist(
742
+ config.autoLogPageViewQueryParams
743
+ );
744
+
731
745
  const baseUrls: Dictionary<string> = processBaseUrls(
732
746
  config,
733
747
  this.SDKConfig.flags,