@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.17.1

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +825 -2
  3. package/dist/hp.min.js +3 -0
  4. package/dist/snippet.min.js +1 -0
  5. package/package.json +83 -3
  6. package/src/assignment.js +75 -0
  7. package/src/auto/attributes.js +16 -0
  8. package/src/auto/exposures.js +45 -0
  9. package/src/auto/lists.js +136 -0
  10. package/src/auto/promotions.js +104 -0
  11. package/src/auto/rails.js +217 -0
  12. package/src/auto/viewability.js +150 -0
  13. package/src/auto.js +13 -0
  14. package/src/bootstrap.js +476 -0
  15. package/src/carry.js +74 -0
  16. package/src/conformance.js +530 -0
  17. package/src/consent-gate.js +324 -0
  18. package/src/consent.js +604 -0
  19. package/src/context.js +439 -0
  20. package/src/datalayer.js +411 -0
  21. package/src/decisions.js +90 -0
  22. package/src/device-storage.js +201 -0
  23. package/src/experiments.js +220 -0
  24. package/src/ga4.js +124 -0
  25. package/src/hash.js +138 -0
  26. package/src/hp.js +135 -0
  27. package/src/index.js +818 -0
  28. package/src/preview.js +44 -0
  29. package/src/recommendation-context.js +101 -0
  30. package/src/segment.js +375 -0
  31. package/src/session/category.js +40 -0
  32. package/src/session/record.js +163 -0
  33. package/src/session/rules.js +195 -0
  34. package/src/session/signals.js +146 -0
  35. package/src/session-id.js +62 -0
  36. package/src/session.js +28 -0
  37. package/src/settings.js +211 -0
  38. package/src/shape.js +111 -0
  39. package/src/social-proof.js +139 -0
  40. package/src/spec-version.js +4 -0
  41. package/src/storage-scope.js +159 -0
  42. package/src/transport.js +436 -0
  43. package/src/version.js +2 -0
  44. package/types/auto.d.ts +51 -0
  45. package/types/bootstrap.d.ts +154 -0
  46. package/types/consent.d.ts +126 -0
  47. package/types/datalayer.d.ts +76 -0
  48. package/types/events.d.ts +680 -0
  49. package/types/hp.d.ts +64 -0
  50. package/types/index.d.ts +610 -0
  51. package/types/segment.d.ts +68 -0
package/src/context.js ADDED
@@ -0,0 +1,439 @@
1
+ import { realId } from './shape.js';
2
+ import { SPEC_VERSION } from './spec-version.js';
3
+
4
+ /**
5
+ * Context assembly: everything ambient that hangs off each event. What the server must own (ip,
6
+ * bot classification, parsed user agent) is left null here.
7
+ */
8
+
9
+ export function getCookie(name) {
10
+ if (typeof document === 'undefined') return null;
11
+ try {
12
+ const match = document.cookie
13
+ .split(';')
14
+ .map((s) => s.trim())
15
+ .find((row) => row.startsWith(`${name}=`));
16
+ if (!match) return null;
17
+ return decodeURIComponent(match.slice(name.length + 1)) || null;
18
+ } catch {
19
+ // Malformed cookie values can throw in decodeURIComponent.
20
+ return null;
21
+ }
22
+ }
23
+
24
+ const CLICK_IDS = { gclid: 'gclid', fbclid: 'fbclid', ttclid: 'ttclid' };
25
+
26
+ const EMAIL = /[a-z0-9._%+-]+@[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}/i;
27
+ /** 13 or more digits, optionally grouped by single spaces or dashes: a card number's shape. */
28
+ const LONG_DIGITS = /\d(?:[ -]?\d){12,}/;
29
+
30
+ /** Opaque ad-platform tokens: their values are random, so a digit run in one proves nothing. */
31
+ const OPAQUE_PARAMS = new Set(['gclid', 'fbclid', 'ttclid', 'msclkid']);
32
+
33
+ /**
34
+ * Whether a query value carries personal data whatever its parameter is called: an email address,
35
+ * or a digit run long enough to be a card number. Checked on the decoded value and, for a value
36
+ * that was encoded twice on its way here, on the value decoded once more.
37
+ */
38
+ export function isSensitiveValue(value, name = '') {
39
+ if (typeof value !== 'string' || value === '') return false;
40
+ const forms = [value];
41
+ try {
42
+ const again = decodeURIComponent(value.replace(/\+/g, ' '));
43
+ if (again !== value) forms.push(again);
44
+ } catch {
45
+ /* not double-encoded */
46
+ }
47
+ const digits = !OPAQUE_PARAMS.has(String(name).toLowerCase());
48
+ return forms.some(
49
+ (form) => EMAIL.test(form) || (digits && LONG_DIGITS.test(form)),
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Build context.campaign from the current URL, click ids included. Values get the same personal
55
+ * data check as any URL value (an ESP can merge the recipient into utm_content).
56
+ */
57
+ export function readCampaign(search) {
58
+ if (!search) return null;
59
+ const p = new URLSearchParams(search);
60
+ const out = {};
61
+ const utm = {
62
+ utm_source: 'source',
63
+ utm_medium: 'medium',
64
+ utm_campaign: 'name',
65
+ utm_term: 'term',
66
+ utm_content: 'content',
67
+ };
68
+ for (const [param, field] of [
69
+ ...Object.entries(utm),
70
+ ...Object.entries(CLICK_IDS),
71
+ ]) {
72
+ const v = p.get(param);
73
+ if (v && !isSensitiveValue(v, param)) out[field] = v;
74
+ }
75
+ return Object.keys(out).length ? out : null;
76
+ }
77
+
78
+ /**
79
+ * Strip params matching the integrator's blocklist before the query string is ever
80
+ * sent. Patterns are anchored and case-insensitive; an invalid pattern degrades to
81
+ * a literal match rather than throwing.
82
+ */
83
+ export function filterSearch(search, blocklist = []) {
84
+ if (!search) return null;
85
+ if (!blocklist.length) return search;
86
+ try {
87
+ const p = new URLSearchParams(search);
88
+ const matchers = blocklist.map((raw) => {
89
+ try {
90
+ return new RegExp(`^${raw}$`, 'i');
91
+ } catch {
92
+ return { test: (s) => s.toLowerCase() === String(raw).toLowerCase() };
93
+ }
94
+ });
95
+ for (const key of [...p.keys()]) {
96
+ if (matchers.some((m) => m.test(key))) p.delete(key);
97
+ }
98
+ const s = p.toString();
99
+ return s ? `?${s}` : '';
100
+ } catch {
101
+ // Drop the whole query string rather than risk leaking a suppressed param.
102
+ return '';
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Params stripped from every URL the SDK reports, whatever the integrator configured. Each is a
108
+ * name that, on some storefront, carries an email address, a credential or an address fragment.
109
+ * Matched exactly (case-insensitively); the patterns below catch the names built around them.
110
+ */
111
+ export const SENSITIVE_PARAMS = [
112
+ 'email',
113
+ 'e-mail',
114
+ 'password',
115
+ 'pass',
116
+ 'pwd',
117
+ 'token',
118
+ 'auth',
119
+ 'code',
120
+ 'uid',
121
+ 'customer_id',
122
+ 'postcode',
123
+ 'zip',
124
+ 'phone',
125
+ 'applyCode',
126
+ ];
127
+
128
+ /**
129
+ * Fragments that make a param name sensitive wherever they appear in it (`resetToken`,
130
+ * `customerEmail`), compared with case and the separators `-`, `_` and `.` removed.
131
+ */
132
+ export const SENSITIVE_PARAM_FRAGMENTS = [
133
+ 'email',
134
+ 'password',
135
+ 'passwd',
136
+ 'pwd',
137
+ 'token',
138
+ 'secret',
139
+ 'auth',
140
+ 'session',
141
+ 'phone',
142
+ 'postcode',
143
+ 'zip',
144
+ 'address',
145
+ 'birth',
146
+ 'card',
147
+ 'cvv',
148
+ 'iban',
149
+ 'ssn',
150
+ 'customerid',
151
+ 'userid',
152
+ ];
153
+
154
+ /**
155
+ * Too short to match inside a word — `hotel`, `guid` and `adobe` are not personal data — so these
156
+ * count only as a whole word of the name: `tel`, `customer_tel`, `userUid`, `dob`.
157
+ */
158
+ export const SENSITIVE_PARAM_WORDS = ['tel', 'uid', 'dob'];
159
+
160
+ /** Kept under an allowlist regardless, because attribution depends on them. */
161
+ export const CAMPAIGN_PARAMS = [
162
+ 'utm_source',
163
+ 'utm_medium',
164
+ 'utm_campaign',
165
+ 'utm_term',
166
+ 'utm_content',
167
+ 'gclid',
168
+ 'fbclid',
169
+ 'ttclid',
170
+ 'msclkid',
171
+ ];
172
+
173
+ const SENSITIVE = new Set(SENSITIVE_PARAMS.map((p) => p.toLowerCase()));
174
+
175
+ /** Whether a param NAME is sensitive by the built-in rules. */
176
+ export function isSensitiveParam(name) {
177
+ const lower = String(name).toLowerCase();
178
+ if (SENSITIVE.has(lower)) return true;
179
+ const compact = lower.replace(/[-_.]/g, '');
180
+ if (SENSITIVE_PARAM_FRAGMENTS.some((fragment) => compact.includes(fragment)))
181
+ return true;
182
+ const words = String(name)
183
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
184
+ .toLowerCase()
185
+ .split(/[^a-z0-9]+/);
186
+ return words.some((word) => SENSITIVE_PARAM_WORDS.includes(word));
187
+ }
188
+
189
+ function matchers(patterns) {
190
+ return (patterns ?? []).map((raw) => {
191
+ try {
192
+ return new RegExp(`^${raw}$`, 'i');
193
+ } catch {
194
+ return { test: (s) => s.toLowerCase() === String(raw).toLowerCase() };
195
+ }
196
+ });
197
+ }
198
+
199
+ /**
200
+ * The query string as it may leave the browser: built-in denylist always applied, then either the
201
+ * allowlist (plus campaign params) or the legacy blocklist.
202
+ *
203
+ * Two built-in checks, and only one of them can be waived:
204
+ * - a param whose NAME matches the built-in rules is dropped, unless `sensitiveParamAllowlist`
205
+ * names it — the escape hatch for a false positive such as a storefront's own `author` filter;
206
+ * - a param whose VALUE is an email address or a card-length digit run is dropped whatever it is
207
+ * called, campaign params included, and nothing re-admits it.
208
+ *
209
+ * @param {string} search
210
+ * @param {{ queryParamAllowlist?: string[] | null, queryParamBlocklist?: string[], sensitiveParamAllowlist?: string[] }} [cfg]
211
+ */
212
+ export function scrubSearch(search, cfg = {}) {
213
+ if (!search) return null;
214
+ try {
215
+ const params = new URLSearchParams(search);
216
+ const allow = cfg.queryParamAllowlist
217
+ ? matchers([...cfg.queryParamAllowlist, ...CAMPAIGN_PARAMS])
218
+ : null;
219
+ const block = matchers(cfg.queryParamBlocklist);
220
+ const exempt = matchers(cfg.sensitiveParamAllowlist);
221
+ for (const key of new Set(params.keys())) {
222
+ const drop =
223
+ (isSensitiveParam(key) && !exempt.some((m) => m.test(key))) ||
224
+ params.getAll(key).some((value) => isSensitiveValue(value, key)) ||
225
+ (allow
226
+ ? !allow.some((m) => m.test(key))
227
+ : block.some((m) => m.test(key)));
228
+ if (drop) params.delete(key);
229
+ }
230
+ const out = params.toString();
231
+ return out ? `?${out}` : '';
232
+ } catch {
233
+ return '';
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Same-origin referrers are scrubbed like the page URL; cross-origin ones keep origin and path
239
+ * only, because another site's query string is not ours to collect.
240
+ */
241
+ export function scrubReferrer(referrer, origin, cfg = {}) {
242
+ if (!referrer) return null;
243
+ try {
244
+ const url = new URL(referrer);
245
+ if (origin && url.origin === origin) {
246
+ return url.origin + url.pathname + (scrubSearch(url.search, cfg) ?? '');
247
+ }
248
+ return url.origin + url.pathname;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ /** ISO-4217, uppercased; anything else is dropped rather than sent to fail validation. */
255
+ export function currencyCode(value) {
256
+ if (typeof value !== 'string') return null;
257
+ const code = value.trim().toUpperCase();
258
+ return /^[A-Z]{3}$/.test(code) ? code : null;
259
+ }
260
+
261
+ /** ISO-3166-1 alpha-2, uppercased; anything else is dropped. */
262
+ export function countryCode(value) {
263
+ if (typeof value !== 'string') return null;
264
+ const code = value.trim().toUpperCase();
265
+ return /^[A-Z]{2}$/.test(code) ? code : null;
266
+ }
267
+
268
+ /**
269
+ * A BCP-47 locale tag (`en`, `en-GB`, `zh-Hant-TW`). Platform spellings such as `en_GB` are
270
+ * normalised, so one site's locale is one string whichever system reported it; anything that is
271
+ * not a locale is dropped.
272
+ */
273
+ export function localeTag(value) {
274
+ if (typeof value !== 'string') return null;
275
+ const tag = value.trim().replace(/_/g, '-');
276
+ if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(tag)) return null;
277
+ try {
278
+ return Intl.getCanonicalLocales(tag)[0];
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * A per-request value: the host's getter when it has one and it answers, else the static option.
286
+ * Every host getter is read through this, per event, and one that throws costs its field, never the
287
+ * event: multi-currency, multi-destination tenants change these mid-visit.
288
+ */
289
+ export function perRequest(getter, fallback) {
290
+ if (typeof getter === 'function') {
291
+ try {
292
+ const value = getter();
293
+ if (value != null) return value;
294
+ } catch {
295
+ /* a failing getter must not cost the event */
296
+ }
297
+ }
298
+ return fallback;
299
+ }
300
+
301
+ /** Top-level keys of the core context schema; anything else is refused by the ingest. */
302
+ export const CONTEXT_KEYS = [
303
+ 'spec',
304
+ 'library',
305
+ 'source',
306
+ 'session_id',
307
+ 'render_id',
308
+ 'identity_trust',
309
+ 'page',
310
+ 'campaign',
311
+ 'device',
312
+ 'currency',
313
+ 'locale',
314
+ 'timezone',
315
+ 'country',
316
+ 'ship_to_country',
317
+ 'ip',
318
+ 'original_timestamp',
319
+ 'consent',
320
+ 'experiments',
321
+ 'bot',
322
+ 'extensions',
323
+ 'audiences',
324
+ 'decisions',
325
+ 'recommendations',
326
+ 'is_impersonated',
327
+ ];
328
+
329
+ export function sourceContext(cfg) {
330
+ return {
331
+ property_id: String(cfg.propertyId),
332
+ channel: cfg.channel ?? null,
333
+ market: cfg.market ?? null,
334
+ environment: cfg.environment ?? null,
335
+ };
336
+ }
337
+
338
+ /** context.page with every URL scrubbed. Shared by both delivery paths. */
339
+ export function pageContext(cfg) {
340
+ const loc = typeof window !== 'undefined' ? window.location : null;
341
+ const search = loc ? scrubSearch(loc.search, cfg) : null;
342
+ return {
343
+ url: loc
344
+ ? loc.origin + loc.pathname + (search ?? '')
345
+ : (cfg.pageUrl ?? null),
346
+ path: loc ? loc.pathname : (cfg.pagePath ?? null),
347
+ search: search ?? null,
348
+ referrer:
349
+ typeof document !== 'undefined'
350
+ ? scrubReferrer(document.referrer, loc?.origin, cfg)
351
+ : null,
352
+ title: typeof document !== 'undefined' ? document.title || null : null,
353
+ type: cfg.pageType ?? null,
354
+ };
355
+ }
356
+
357
+ /**
358
+ * @param {object} cfg resolved SDK config
359
+ * @param {object} [overrides] per-call context overrides
360
+ */
361
+ export function buildContext(cfg, overrides = {}) {
362
+ const loc = typeof window !== 'undefined' ? window.location : null;
363
+
364
+ const ctx = {
365
+ library: { name: cfg.libraryName, version: cfg.libraryVersion },
366
+ spec: { version: SPEC_VERSION },
367
+ source: sourceContext(cfg),
368
+ session_id: perRequest(cfg.getSessionId, null),
369
+ render_id: perRequest(cfg.getRenderId, null),
370
+ // The browser is always a real device. Only a cacheable server render is not.
371
+ identity_trust: realId(perRequest(cfg.getAnonymousId, null))
372
+ ? 'device'
373
+ : 'none',
374
+ // Emitted even with no `window`: under SSR the integrator still knows the page type and path.
375
+ page: pageContext(cfg),
376
+ campaign: loc ? readCampaign(loc.search) : null,
377
+ device: {
378
+ user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : null,
379
+ // type/os/browser are derived server-side at ingest from the UA.
380
+ type: null,
381
+ viewport_width: typeof window !== 'undefined' ? window.innerWidth : null,
382
+ viewport_height:
383
+ typeof window !== 'undefined' ? window.innerHeight : null,
384
+ },
385
+ currency: currencyCode(perRequest(cfg.getCurrency, cfg.currency)),
386
+ // The site's locale (which storefront was served), not navigator.language, so it agrees with
387
+ // the backend's for the same render.
388
+ locale: localeTag(perRequest(cfg.getLocale, cfg.locale)),
389
+ timezone: (() => {
390
+ try {
391
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
392
+ } catch {
393
+ return null;
394
+ }
395
+ })(),
396
+ country: countryCode(cfg.country),
397
+ ship_to_country: countryCode(
398
+ perRequest(cfg.getShipToCountry, cfg.shipToCountry),
399
+ ),
400
+ // Server-owned. Explicitly null so a violation is visible if anyone sets them.
401
+ ip: null,
402
+ bot: null,
403
+ consent: cfg.getConsent?.() ?? null,
404
+ experiments: perRequest(cfg.getExperiments, null),
405
+ // Memberships at this moment, read per event because session membership changes mid-visit.
406
+ // Kept apart from `experiments`: a membership is a fact about the visitor, a holdout a choice
407
+ // made to them, and measuring lift needs both separately.
408
+ audiences: perRequest(cfg.getAudiences, null),
409
+ // Decisions in force, on every event so later outcomes (a basket, an order) join to them. The
410
+ // decision and its propensity are logged server-side; only the ids travel here.
411
+ decisions: perRequest(cfg.getDecisions, null),
412
+ // Rails in force, host-supplied and read per event. Kept apart from `decisions`: a rail has no
413
+ // propensity, and estimators downstream of the decision log weight by one.
414
+ recommendations: perRequest(cfg.getRecommendations, null),
415
+ extensions: perRequest(cfg.getExtensions, null),
416
+ };
417
+
418
+ return deepMerge(ctx, overrides);
419
+ }
420
+
421
+ function deepMerge(base, extra) {
422
+ if (!extra) return base;
423
+ const out = { ...base };
424
+ for (const [k, v] of Object.entries(extra)) {
425
+ if (
426
+ v &&
427
+ typeof v === 'object' &&
428
+ !Array.isArray(v) &&
429
+ base[k] &&
430
+ typeof base[k] === 'object' &&
431
+ !Array.isArray(base[k])
432
+ ) {
433
+ out[k] = deepMerge(base[k], v);
434
+ } else if (v !== undefined) {
435
+ out[k] = v;
436
+ }
437
+ }
438
+ return out;
439
+ }