@datalyr/web 1.0.7 → 1.1.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.
package/dist/datalyr.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @datalyr/web v1.0.7
2
+ * @datalyr/web v1.1.1
3
3
  * Datalyr Web SDK - Modern attribution tracking for web applications
4
4
  * (c) 2025 Datalyr Inc.
5
5
  * Released under the MIT License
@@ -39,17 +39,214 @@ var Datalyr = (function () {
39
39
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
40
40
  };
41
41
 
42
+ /**
43
+ * Encryption Module (SEC-03 Fix)
44
+ *
45
+ * Encrypts sensitive PII data before storing in localStorage
46
+ * Uses Web Crypto API for AES-GCM encryption
47
+ *
48
+ * SECURITY NOTE: This protects against casual localStorage reads,
49
+ * but determined XSS attacks can still access the encryption key in memory.
50
+ * Primary defense is preventing XSS (see container.ts sandboxing).
51
+ */
52
+ class DataEncryption {
53
+ constructor() {
54
+ this.key = null;
55
+ this.salt = null;
56
+ }
57
+ /**
58
+ * Initialize encryption with workspace-specific key
59
+ *
60
+ * FIXED (SEC-03): Now uses random per-device salt stored in localStorage
61
+ *
62
+ * @param workspaceId - Used to derive encryption key
63
+ * @param deviceId - Device-specific identifier for key derivation
64
+ */
65
+ initialize(workspaceId, deviceId) {
66
+ return __awaiter(this, void 0, void 0, function* () {
67
+ if (!crypto.subtle) {
68
+ const error = new Error('Web Crypto API not available - encryption cannot be initialized');
69
+ console.error('[Datalyr Encryption]', error.message);
70
+ throw error; // FIXED: Fail loudly instead of silently
71
+ }
72
+ try {
73
+ const encoder = new TextEncoder();
74
+ // FIXED (SEC-03): Generate or retrieve random per-device salt
75
+ const saltKey = 'dl_encryption_salt';
76
+ let saltBase64 = localStorage.getItem(saltKey);
77
+ if (!saltBase64) {
78
+ // Generate new random 32-byte salt
79
+ this.salt = crypto.getRandomValues(new Uint8Array(32));
80
+ saltBase64 = this.arrayBufferToBase64(this.salt);
81
+ localStorage.setItem(saltKey, saltBase64);
82
+ }
83
+ else {
84
+ // Use existing salt
85
+ this.salt = this.base64ToArrayBuffer(saltBase64);
86
+ }
87
+ // Derive key material from workspace ID + device ID
88
+ const keyString = `datalyr:${workspaceId}:${deviceId}`;
89
+ const keyData = encoder.encode(keyString);
90
+ // Import key material
91
+ const baseKey = yield crypto.subtle.importKey('raw', keyData, 'PBKDF2', false, ['deriveKey']);
92
+ // Derive actual encryption key using PBKDF2 with random salt
93
+ this.key = yield crypto.subtle.deriveKey({
94
+ name: 'PBKDF2',
95
+ salt: this.salt, // FIXED: Random per-device salt instead of static
96
+ iterations: 100000, // 100k iterations for security
97
+ hash: 'SHA-256'
98
+ }, baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
99
+ }
100
+ catch (error) {
101
+ console.error('[Datalyr Encryption] Failed to initialize:', error);
102
+ this.key = null;
103
+ throw error; // FIXED: Fail loudly
104
+ }
105
+ });
106
+ }
107
+ /**
108
+ * Encrypt sensitive data
109
+ *
110
+ * FIXED (SEC-03): No longer silently falls back to unencrypted storage
111
+ *
112
+ * @param data - Plain text or object to encrypt
113
+ * @returns Base64-encoded encrypted data with IV
114
+ * @throws Error if encryption is not available or fails
115
+ */
116
+ encrypt(data) {
117
+ return __awaiter(this, void 0, void 0, function* () {
118
+ if (!this.key || !crypto.subtle) {
119
+ // FIXED: Fail loudly instead of silently storing unencrypted
120
+ throw new Error('Encryption not initialized - cannot encrypt sensitive data');
121
+ }
122
+ try {
123
+ const plaintext = typeof data === 'string' ? data : JSON.stringify(data);
124
+ const encoder = new TextEncoder();
125
+ const plaintextBytes = encoder.encode(plaintext);
126
+ // Generate random IV (Initialization Vector)
127
+ const iv = crypto.getRandomValues(new Uint8Array(12)); // 12 bytes for GCM
128
+ // Encrypt
129
+ const ciphertext = yield crypto.subtle.encrypt({ name: 'AES-GCM', iv }, this.key, plaintextBytes);
130
+ // Combine IV + ciphertext for storage
131
+ const combined = new Uint8Array(iv.length + ciphertext.byteLength);
132
+ combined.set(iv, 0);
133
+ combined.set(new Uint8Array(ciphertext), iv.length);
134
+ // Return base64-encoded
135
+ return this.arrayBufferToBase64(combined);
136
+ }
137
+ catch (error) {
138
+ console.error('[Datalyr Encryption] Encryption failed:', error);
139
+ throw error; // FIXED: Fail loudly
140
+ }
141
+ });
142
+ }
143
+ /**
144
+ * Decrypt sensitive data
145
+ *
146
+ * FIXED (SEC-03): Still allows backwards compatibility for migration,
147
+ * but logs warnings when unencrypted data is detected
148
+ *
149
+ * @param encryptedData - Base64-encoded encrypted data
150
+ * @returns Decrypted data (parsed as JSON if possible)
151
+ */
152
+ decrypt(encryptedData) {
153
+ return __awaiter(this, void 0, void 0, function* () {
154
+ if (!this.key || !crypto.subtle) {
155
+ // Backwards compatibility: Try to parse as unencrypted JSON
156
+ console.warn('[Datalyr Encryption] Decryption not available - reading potentially unencrypted data');
157
+ try {
158
+ return JSON.parse(encryptedData);
159
+ }
160
+ catch (_a) {
161
+ return encryptedData;
162
+ }
163
+ }
164
+ try {
165
+ // Decode base64
166
+ const combined = this.base64ToArrayBuffer(encryptedData);
167
+ // Extract IV (first 12 bytes) and ciphertext
168
+ const iv = combined.slice(0, 12);
169
+ const ciphertext = combined.slice(12);
170
+ // Decrypt
171
+ const plaintextBytes = yield crypto.subtle.decrypt({ name: 'AES-GCM', iv }, this.key, ciphertext);
172
+ // Decode and parse
173
+ const decoder = new TextDecoder();
174
+ const plaintext = decoder.decode(plaintextBytes);
175
+ // Try to parse as JSON
176
+ try {
177
+ return JSON.parse(plaintext);
178
+ }
179
+ catch (_b) {
180
+ return plaintext;
181
+ }
182
+ }
183
+ catch (error) {
184
+ // Backwards compatibility: Try to parse as unencrypted (for migration)
185
+ console.warn('[Datalyr Encryption] Decryption failed - attempting to read as unencrypted data (migration mode)');
186
+ try {
187
+ return JSON.parse(encryptedData);
188
+ }
189
+ catch (_c) {
190
+ return encryptedData;
191
+ }
192
+ }
193
+ });
194
+ }
195
+ /**
196
+ * Check if encryption is available and initialized
197
+ */
198
+ isAvailable() {
199
+ return this.key !== null && crypto.subtle !== undefined;
200
+ }
201
+ /**
202
+ * Helper: Convert ArrayBuffer to Base64
203
+ */
204
+ arrayBufferToBase64(buffer) {
205
+ let binary = '';
206
+ const bytes = new Uint8Array(buffer);
207
+ const len = bytes.byteLength;
208
+ for (let i = 0; i < len; i++) {
209
+ binary += String.fromCharCode(bytes[i]);
210
+ }
211
+ return btoa(binary);
212
+ }
213
+ /**
214
+ * Helper: Convert Base64 to ArrayBuffer
215
+ */
216
+ base64ToArrayBuffer(base64) {
217
+ const binary = atob(base64);
218
+ const len = binary.length;
219
+ const bytes = new Uint8Array(len);
220
+ for (let i = 0; i < len; i++) {
221
+ bytes[i] = binary.charCodeAt(i);
222
+ }
223
+ return bytes;
224
+ }
225
+ /**
226
+ * Destroy encryption keys (called on SDK destroy)
227
+ *
228
+ * FIXED (SEC-03): Also clears salt reference
229
+ */
230
+ destroy() {
231
+ this.key = null;
232
+ this.salt = null;
233
+ }
234
+ }
235
+ // Singleton instance
236
+ const dataEncryption = new DataEncryption();
237
+
42
238
  /**
43
239
  * Storage Module
44
240
  * Safe storage wrapper with fallbacks for Safari private mode
241
+ * SEC-03 Fix: Added encryption support for PII data
45
242
  */
46
243
  class SafeStorage {
47
244
  constructor(storage) {
48
245
  this.memory = new Map();
49
- this.prefix = '__dl_';
246
+ this.prefix = 'dl_';
50
247
  // Test if storage is available
51
248
  try {
52
- const testKey = '__dl_test__' + Math.random();
249
+ const testKey = 'dl_test__' + Math.random();
53
250
  storage.setItem(testKey, '1');
54
251
  storage.removeItem(testKey);
55
252
  this.storage = storage;
@@ -150,6 +347,117 @@ var Datalyr = (function () {
150
347
  return [];
151
348
  }
152
349
  }
350
+ /**
351
+ * Migrate data from legacy '__dl_dl_*' keys to new 'dl_*' keys
352
+ *
353
+ * This fixes the double-prefix issue from earlier SDK versions.
354
+ * Called automatically during SDK initialization.
355
+ *
356
+ * @returns Number of keys migrated
357
+ */
358
+ migrateFromLegacyPrefix() {
359
+ if (!this.storage) {
360
+ return 0; // Can't migrate in memory-only mode
361
+ }
362
+ let migratedCount = 0;
363
+ const legacyPrefix = '__dl_dl_';
364
+ try {
365
+ // Find all legacy keys
366
+ const legacyKeys = [];
367
+ for (let i = 0; i < this.storage.length; i++) {
368
+ const key = this.storage.key(i);
369
+ if (key && key.startsWith(legacyPrefix)) {
370
+ legacyKeys.push(key);
371
+ }
372
+ }
373
+ // Migrate each legacy key
374
+ legacyKeys.forEach(legacyKey => {
375
+ try {
376
+ // Get value from legacy key
377
+ const value = this.storage.getItem(legacyKey);
378
+ if (value) {
379
+ // Extract the actual key name (remove '__dl_' prefix, keep 'dl_' part)
380
+ const actualKey = legacyKey.slice('__dl_'.length); // e.g., '__dl_dl_anonymous_id' -> 'dl_anonymous_id'
381
+ // Only migrate if new key doesn't already exist (don't overwrite newer data)
382
+ if (!this.storage.getItem(actualKey)) {
383
+ this.storage.setItem(actualKey, value);
384
+ migratedCount++;
385
+ }
386
+ // Remove legacy key after successful migration
387
+ this.storage.removeItem(legacyKey);
388
+ }
389
+ }
390
+ catch (e) {
391
+ console.warn(`[Datalyr Storage] Failed to migrate legacy key: ${legacyKey}`, e);
392
+ }
393
+ });
394
+ if (migratedCount > 0) {
395
+ console.log(`[Datalyr Storage] Migrated ${migratedCount} keys from legacy prefix`);
396
+ }
397
+ }
398
+ catch (e) {
399
+ console.warn('[Datalyr Storage] Error during legacy key migration:', e);
400
+ }
401
+ return migratedCount;
402
+ }
403
+ /**
404
+ * Get encrypted value from storage (SEC-03 Fix)
405
+ *
406
+ * @param key - Storage key
407
+ * @param defaultValue - Default value if not found
408
+ * @returns Decrypted value
409
+ */
410
+ getEncrypted(key_1) {
411
+ return __awaiter(this, arguments, void 0, function* (key, defaultValue = null) {
412
+ const fullKey = this.prefix + key;
413
+ try {
414
+ // Get encrypted data
415
+ let encryptedData = null;
416
+ if (this.storage) {
417
+ encryptedData = this.storage.getItem(fullKey);
418
+ }
419
+ else {
420
+ encryptedData = this.memory.get(fullKey) || null;
421
+ }
422
+ if (!encryptedData) {
423
+ return defaultValue;
424
+ }
425
+ // Decrypt
426
+ const decrypted = yield dataEncryption.decrypt(encryptedData);
427
+ return decrypted;
428
+ }
429
+ catch (error) {
430
+ console.warn('[Datalyr Storage] Failed to decrypt:', key, error);
431
+ return defaultValue;
432
+ }
433
+ });
434
+ }
435
+ /**
436
+ * Set encrypted value in storage (SEC-03 Fix)
437
+ *
438
+ * FIXED (SEC-03): Now throws error if encryption fails instead of silent fallback
439
+ *
440
+ * @param key - Storage key
441
+ * @param value - Value to encrypt and store
442
+ * @returns Success boolean
443
+ * @throws Error if encryption is not available or fails
444
+ */
445
+ setEncrypted(key, value) {
446
+ return __awaiter(this, void 0, void 0, function* () {
447
+ const fullKey = this.prefix + key;
448
+ // FIXED: encrypt() now throws instead of returning null
449
+ const encrypted = yield dataEncryption.encrypt(value);
450
+ // Store encrypted data
451
+ if (this.storage) {
452
+ this.storage.setItem(fullKey, encrypted);
453
+ return true;
454
+ }
455
+ else {
456
+ this.memory.set(fullKey, encrypted);
457
+ return true;
458
+ }
459
+ });
460
+ }
153
461
  }
154
462
  // Cookie operations
155
463
  class CookieStorage {
@@ -530,13 +838,9 @@ var Datalyr = (function () {
530
838
  document.cookie = `${name}=${encodedValue}; domain=${rootDomain}; path=/; max-age=31536000; SameSite=Lax${secure}`;
531
839
  // Verify cookie was set successfully (cookies.get already decodes)
532
840
  const verifyValue = cookies.get(name);
533
- if (verifyValue === value) {
534
- console.log(`[Datalyr] Set root domain cookie: ${name} on domain: ${rootDomain}`);
535
- }
536
- else {
841
+ if (verifyValue !== value) {
537
842
  // Fallback: try without domain (current subdomain only)
538
843
  document.cookie = `${name}=${encodedValue}; path=/; max-age=31536000; SameSite=Lax${secure}`;
539
- console.log(`[Datalyr] Set cookie without domain (fallback): ${name}`);
540
844
  }
541
845
  }
542
846
  catch (e) {
@@ -675,13 +979,14 @@ var Datalyr = (function () {
675
979
  * Session Management Module
676
980
  */
677
981
  class SessionManager {
678
- constructor(timeout = 30 * 60 * 1000) {
982
+ constructor(timeout = 60 * 60 * 1000) {
679
983
  this.sessionId = null;
680
984
  this.sessionData = null;
681
985
  this.lastActivity = Date.now();
682
986
  this.SESSION_KEY = 'dl_session_data';
683
987
  this.activityCheckInterval = null;
684
988
  this.activityListeners = [];
989
+ this.sessionCreationLock = false; // FIXED (DATA-02): Mutex to prevent race conditions
685
990
  this.sessionTimeout = timeout;
686
991
  this.initSession();
687
992
  this.setupActivityMonitor();
@@ -712,21 +1017,66 @@ var Datalyr = (function () {
712
1017
  }
713
1018
  /**
714
1019
  * Create a new session
1020
+ *
1021
+ * FIXED (DATA-02): Added mutex lock to prevent race conditions
715
1022
  */
716
1023
  createNewSession() {
1024
+ // FIXED (DATA-02): Check if session creation already in progress
1025
+ if (this.sessionCreationLock) {
1026
+ // Wait for ongoing session creation
1027
+ console.log('[Datalyr Session] Session creation already in progress, returning existing ID');
1028
+ return this.sessionId || '';
1029
+ }
1030
+ // Acquire lock
1031
+ this.sessionCreationLock = true;
1032
+ try {
1033
+ const now = Date.now();
1034
+ this.sessionId = `sess_${generateUUID()}`;
1035
+ this.sessionData = {
1036
+ id: this.sessionId,
1037
+ startTime: now,
1038
+ lastActivity: now,
1039
+ pageViews: 0,
1040
+ events: 0,
1041
+ duration: 0,
1042
+ isActive: true
1043
+ };
1044
+ this.saveSession();
1045
+ // Issue #25: Increment AFTER session created successfully
1046
+ this.incrementSessionCount();
1047
+ return this.sessionId;
1048
+ }
1049
+ finally {
1050
+ // Always release lock
1051
+ this.sessionCreationLock = false;
1052
+ }
1053
+ }
1054
+ /**
1055
+ * Rotate session ID (security measure)
1056
+ *
1057
+ * FIXED (DATA-05): Called when user identifies to prevent session fixation attacks
1058
+ * Keeps session data but generates new session ID
1059
+ */
1060
+ rotateSessionId() {
717
1061
  const now = Date.now();
1062
+ const oldSessionId = this.sessionId;
1063
+ // Generate new session ID
718
1064
  this.sessionId = `sess_${generateUUID()}`;
719
- this.sessionData = {
720
- id: this.sessionId,
721
- startTime: now,
722
- lastActivity: now,
723
- pageViews: 0,
724
- events: 0,
725
- duration: 0,
726
- isActive: true
727
- };
728
- this.incrementSessionCount();
729
- this.saveSession();
1065
+ // Preserve session data but update ID
1066
+ if (this.sessionData) {
1067
+ this.sessionData.id = this.sessionId;
1068
+ this.sessionData.lastActivity = now;
1069
+ this.saveSession();
1070
+ // Clean up old session data
1071
+ if (oldSessionId) {
1072
+ storage.remove(`dl_session_${oldSessionId}_attribution`);
1073
+ }
1074
+ console.log(`[Datalyr Session] Rotated session ID from ${oldSessionId} to ${this.sessionId}`);
1075
+ }
1076
+ else {
1077
+ // No existing session, create new one
1078
+ this.createNewSession();
1079
+ }
730
1080
  return this.sessionId;
731
1081
  }
732
1082
  /**
@@ -746,18 +1096,22 @@ var Datalyr = (function () {
746
1096
  }
747
1097
  /**
748
1098
  * Update session activity
1099
+ * FIXED (CRITICAL-04): Check session validity BEFORE updating lastActivity
1100
+ * This ensures sessions actually timeout after the configured period
749
1101
  */
750
1102
  updateActivity(eventType) {
751
1103
  const now = Date.now();
752
- // Check if we need a new session
1104
+ // CRITICAL FIX: Check session validity BEFORE updating lastActivity
1105
+ // If we update lastActivity first, sessions will never timeout!
753
1106
  if (!this.sessionData || !this.isSessionValid(this.sessionData, now)) {
754
1107
  this.createNewSession();
755
1108
  return;
756
1109
  }
1110
+ // Session is valid - update activity timestamp
757
1111
  this.lastActivity = now;
758
1112
  this.sessionData.lastActivity = now;
759
1113
  this.sessionData.duration = now - this.sessionData.startTime;
760
- // Update counters
1114
+ // Update counters (only if session is valid)
761
1115
  if (eventType === 'pageview' || eventType === 'page_view') {
762
1116
  this.sessionData.pageViews++;
763
1117
  }
@@ -775,6 +1129,7 @@ var Datalyr = (function () {
775
1129
  }
776
1130
  /**
777
1131
  * End the current session
1132
+ * Fixed Issue #23: Stop activity monitor when session ends
778
1133
  */
779
1134
  endSession() {
780
1135
  if (this.sessionData) {
@@ -783,6 +1138,8 @@ var Datalyr = (function () {
783
1138
  }
784
1139
  this.sessionId = null;
785
1140
  this.sessionData = null;
1141
+ // Issue #23: Clean up activity monitor when session ends
1142
+ this.destroy();
786
1143
  }
787
1144
  /**
788
1145
  * Save session to storage
@@ -806,19 +1163,29 @@ var Datalyr = (function () {
806
1163
  }
807
1164
  /**
808
1165
  * Store session attribution
1166
+ * Fixed Issue #24: Use single key instead of one per session
809
1167
  */
810
1168
  storeAttribution(attribution) {
811
- const key = `dl_session_${this.sessionId}_attribution`;
812
- storage.set(key, Object.assign(Object.assign({}, attribution), { sessionId: this.sessionId, timestamp: Date.now() }));
1169
+ storage.set('dl_current_session_attribution', Object.assign(Object.assign({}, attribution), { sessionId: this.sessionId, timestamp: Date.now() }));
813
1170
  }
814
1171
  /**
815
1172
  * Get session attribution
1173
+ * Fixed Issue #24: Use single key instead of one per session
1174
+ * FIXED (CRITICAL-04): Verify session is still active before returning attribution
816
1175
  */
817
1176
  getAttribution() {
818
1177
  if (!this.sessionId)
819
1178
  return null;
820
- const key = `dl_session_${this.sessionId}_attribution`;
821
- return storage.get(key);
1179
+ // CRITICAL FIX: Verify session is still active
1180
+ if (!this.isSessionActive()) {
1181
+ return null;
1182
+ }
1183
+ const data = storage.get('dl_current_session_attribution');
1184
+ // Only return if it matches current session
1185
+ if (data && data.sessionId === this.sessionId) {
1186
+ return data;
1187
+ }
1188
+ return null;
822
1189
  }
823
1190
  /**
824
1191
  * Get session metrics
@@ -895,6 +1262,7 @@ var Datalyr = (function () {
895
1262
  */
896
1263
  class AttributionManager {
897
1264
  constructor(options = {}) {
1265
+ this.queryParamsCache = null;
898
1266
  this.UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
899
1267
  // Updated to match dl.js - includes ALL ad platform click IDs
900
1268
  this.CLICK_IDS = [
@@ -923,15 +1291,27 @@ var Datalyr = (function () {
923
1291
  'medium', // Generic medium (non-UTM)
924
1292
  'gad_source' // Google Ads source parameter
925
1293
  ];
926
- this.attributionWindow = options.attributionWindow || 30 * 24 * 60 * 60 * 1000; // 30 days
1294
+ this.attributionWindow = options.attributionWindow || 90 * 24 * 60 * 60 * 1000; // 90 days (increased from 30 for B2B sales cycles)
927
1295
  // Merge default tracked params with user-provided ones
928
1296
  this.trackedParams = [...this.DEFAULT_TRACKED_PARAMS, ...(options.trackedParams || [])];
929
1297
  }
1298
+ /**
1299
+ * Clear query params cache (called on page navigation)
1300
+ * FIXED: Prevents stale attribution data on SPA navigation
1301
+ */
1302
+ clearCache() {
1303
+ this.queryParamsCache = null;
1304
+ }
930
1305
  /**
931
1306
  * Capture current attribution from URL
932
1307
  */
933
1308
  captureAttribution() {
934
- const params = getAllQueryParams();
1309
+ // Cache query params to avoid multiple parses within same page load (Issue #3)
1310
+ // NOTE: Cache is cleared on page navigation to prevent stale data
1311
+ const params = this.queryParamsCache || getAllQueryParams();
1312
+ if (!this.queryParamsCache) {
1313
+ this.queryParamsCache = params;
1314
+ }
935
1315
  const attribution = {
936
1316
  timestamp: Date.now()
937
1317
  };
@@ -978,31 +1358,57 @@ var Datalyr = (function () {
978
1358
  return attribution;
979
1359
  }
980
1360
  /**
981
- * Store first touch attribution
1361
+ * Store first touch attribution with 90-day expiration
1362
+ *
1363
+ * FIXED (DATA-01): Removed paid priority logic that was corrupting first-touch attribution.
1364
+ * First-touch is now IMMUTABLE except for expiration - this ensures accurate revenue attribution.
982
1365
  */
983
1366
  storeFirstTouch(attribution) {
984
1367
  const existing = storage.get('dl_first_touch');
1368
+ let shouldStore = false;
985
1369
  if (!existing) {
986
- storage.set('dl_first_touch', Object.assign(Object.assign({}, attribution), { timestamp: Date.now() }));
1370
+ // No existing attribution - store first touch
1371
+ shouldStore = true;
1372
+ }
1373
+ else if (existing.expires_at && Date.now() >= existing.expires_at) {
1374
+ // Existing attribution expired - replace it
1375
+ shouldStore = true;
1376
+ }
1377
+ // REMOVED PAID PRIORITY LOGIC - First-touch must be immutable for accurate attribution
1378
+ // If there's existing valid attribution, keep it (true first-touch strategy)
1379
+ if (shouldStore) {
1380
+ storage.set('dl_first_touch', Object.assign(Object.assign({}, attribution), { captured_at: Date.now(), expires_at: Date.now() + this.attributionWindow }));
987
1381
  }
988
1382
  }
989
1383
  /**
990
1384
  * Get first touch attribution
1385
+ * Checks expiry and removes if expired (Issue #4)
991
1386
  */
992
1387
  getFirstTouch() {
993
- return storage.get('dl_first_touch');
1388
+ const data = storage.get('dl_first_touch');
1389
+ if (data && data.expires_at && Date.now() >= data.expires_at) {
1390
+ storage.remove('dl_first_touch');
1391
+ return null;
1392
+ }
1393
+ return data;
994
1394
  }
995
1395
  /**
996
- * Store last touch attribution
1396
+ * Store last touch attribution with 90-day expiration
997
1397
  */
998
1398
  storeLastTouch(attribution) {
999
- storage.set('dl_last_touch', Object.assign(Object.assign({}, attribution), { timestamp: Date.now() }));
1399
+ storage.set('dl_last_touch', Object.assign(Object.assign({}, attribution), { captured_at: Date.now(), expires_at: Date.now() + this.attributionWindow }));
1000
1400
  }
1001
1401
  /**
1002
1402
  * Get last touch attribution
1403
+ * Checks expiry and removes if expired (Issue #4)
1003
1404
  */
1004
1405
  getLastTouch() {
1005
- return storage.get('dl_last_touch');
1406
+ const data = storage.get('dl_last_touch');
1407
+ if (data && data.expires_at && Date.now() >= data.expires_at) {
1408
+ storage.remove('dl_last_touch');
1409
+ return null;
1410
+ }
1411
+ return data;
1006
1412
  }
1007
1413
  /**
1008
1414
  * Add touchpoint to customer journey
@@ -1070,16 +1476,18 @@ var Datalyr = (function () {
1070
1476
  }
1071
1477
  /**
1072
1478
  * Check if we have a specific click ID in current params
1479
+ * Uses cached params to avoid multiple URL parses (Issue #3)
1073
1480
  */
1074
1481
  hasClickId(clickIdType) {
1075
- const params = getAllQueryParams();
1482
+ const params = this.queryParamsCache || getAllQueryParams();
1076
1483
  return !!params[clickIdType];
1077
1484
  }
1078
1485
  /**
1079
1486
  * Get current fbclid from URL if present
1487
+ * Uses cached params to avoid multiple URL parses (Issue #3)
1080
1488
  */
1081
1489
  getCurrentFbclid() {
1082
- const params = getAllQueryParams();
1490
+ const params = this.queryParamsCache || getAllQueryParams();
1083
1491
  return params.fbclid || null;
1084
1492
  }
1085
1493
  /**
@@ -1089,7 +1497,17 @@ var Datalyr = (function () {
1089
1497
  const firstTouch = this.getFirstTouch();
1090
1498
  const lastTouch = this.getLastTouch();
1091
1499
  const journey = this.getJourney();
1092
- const current = this.captureAttribution();
1500
+ let current = this.captureAttribution();
1501
+ // CRITICAL FIX: If current session has no attribution (direct/organic),
1502
+ // fallback to persistent attribution from localStorage (90-day window)
1503
+ const hasCurrentAttribution = !!(current.source || current.medium || current.clickId || current.campaign);
1504
+ if (!hasCurrentAttribution && firstTouch) {
1505
+ // Check if persistent attribution is still valid
1506
+ if (!firstTouch.expires_at || Date.now() < firstTouch.expires_at) {
1507
+ // Use persistent attribution but keep current page context
1508
+ current = Object.assign(Object.assign({}, firstTouch), { referrer: current.referrer, referrerHost: current.referrerHost, landingPage: current.landingPage, landingPath: current.landingPath });
1509
+ }
1510
+ }
1093
1511
  // Capture advertising cookies automatically
1094
1512
  const adCookies = this.captureAdCookies();
1095
1513
  // Update first/last touch if needed
@@ -1241,6 +1659,8 @@ var Datalyr = (function () {
1241
1659
  this.recentEventIds = new Set();
1242
1660
  this.MAX_RECENT_EVENT_IDS = 1000;
1243
1661
  this.OFFLINE_QUEUE_KEY = 'dl_offline_queue';
1662
+ this.flushLock = false; // FIXED (DATA-03): Mutex to prevent race conditions
1663
+ this.offlineQueueLock = false; // FIXED (DATA-03): Separate lock for offline queue operations
1244
1664
  this.config = {
1245
1665
  batchSize: config.batchSize || 10,
1246
1666
  flushInterval: config.flushInterval || 5000,
@@ -1267,16 +1687,23 @@ var Datalyr = (function () {
1267
1687
  * Add event to queue
1268
1688
  */
1269
1689
  enqueue(event) {
1270
- const eventName = event.eventName;
1690
+ const eventName = event.event_name; // Use snake_case
1271
1691
  // Check for duplicates (within 500ms window)
1272
1692
  if (this.isDuplicateEvent(event)) {
1273
1693
  this.log('Duplicate event suppressed:', eventName);
1274
1694
  return;
1275
1695
  }
1276
- // Critical events bypass queue
1696
+ // CRITICAL FIX (CRITICAL-05): Critical events need proper error handling
1697
+ // Instead of calling sendBatch() without await, we add them to queue
1698
+ // with immediate flush AND move to offline queue on failure
1277
1699
  if (this.config.criticalEvents.includes(eventName)) {
1278
1700
  this.log('Critical event, sending immediately:', eventName);
1279
- this.sendBatch([event]);
1701
+ // Send immediately with proper error handling
1702
+ this.sendBatch([event]).catch((error) => {
1703
+ this.log('Critical event send failed, adding to offline queue:', eventName, error);
1704
+ // Move to offline queue to ensure it's not lost
1705
+ this.moveToOfflineQueue([event]);
1706
+ });
1280
1707
  return;
1281
1708
  }
1282
1709
  // Add to queue
@@ -1289,13 +1716,15 @@ var Datalyr = (function () {
1289
1716
  }
1290
1717
  /**
1291
1718
  * Check if event is duplicate
1719
+ * Fixed Issue #32: Use content-based hash instead of UUID
1292
1720
  */
1293
1721
  isDuplicateEvent(event) {
1294
- const eventId = event.eventId;
1295
- if (this.recentEventIds.has(eventId)) {
1722
+ // Create content-based hash from eventName + timestamp + key properties
1723
+ const contentHash = this.createEventHash(event);
1724
+ if (this.recentEventIds.has(contentHash)) {
1296
1725
  return true;
1297
1726
  }
1298
- this.recentEventIds.add(eventId);
1727
+ this.recentEventIds.add(contentHash);
1299
1728
  // Clean up old event IDs
1300
1729
  if (this.recentEventIds.size > this.MAX_RECENT_EVENT_IDS) {
1301
1730
  const toDelete = this.recentEventIds.size - this.MAX_RECENT_EVENT_IDS;
@@ -1309,6 +1738,24 @@ var Datalyr = (function () {
1309
1738
  }
1310
1739
  return false;
1311
1740
  }
1741
+ /**
1742
+ * Create content-based hash for duplicate detection (Issue #32)
1743
+ */
1744
+ createEventHash(event) {
1745
+ const content = [
1746
+ event.event_name, // Use snake_case
1747
+ event.timestamp,
1748
+ JSON.stringify(event.event_data || {}) // Use snake_case
1749
+ ].join('|');
1750
+ // Simple hash function
1751
+ let hash = 0;
1752
+ for (let i = 0; i < content.length; i++) {
1753
+ const char = content.charCodeAt(i);
1754
+ hash = ((hash << 5) - hash) + char;
1755
+ hash = hash & hash; // Convert to 32bit integer
1756
+ }
1757
+ return hash.toString(36);
1758
+ }
1312
1759
  /**
1313
1760
  * Check if we should flush the queue
1314
1761
  */
@@ -1334,20 +1781,29 @@ var Datalyr = (function () {
1334
1781
  }
1335
1782
  /**
1336
1783
  * Flush the queue
1784
+ * FIXED (DATA-03): Enhanced protection against concurrent flushes
1337
1785
  */
1338
1786
  flush() {
1339
1787
  return __awaiter(this, void 0, void 0, function* () {
1340
- // Prevent concurrent flushes
1341
- if (this.flushPromise) {
1342
- return this.flushPromise;
1788
+ // FIXED (DATA-03): Check both promise and lock for concurrent flush protection
1789
+ if (this.flushPromise || this.flushLock) {
1790
+ return this.flushPromise || Promise.resolve();
1791
+ }
1792
+ // Acquire lock
1793
+ this.flushLock = true;
1794
+ try {
1795
+ this.flushPromise = this._flush();
1796
+ yield this.flushPromise;
1797
+ }
1798
+ finally {
1799
+ this.flushPromise = null;
1800
+ this.flushLock = false;
1343
1801
  }
1344
- this.flushPromise = this._flush();
1345
- yield this.flushPromise;
1346
- this.flushPromise = null;
1347
1802
  });
1348
1803
  }
1349
1804
  /**
1350
1805
  * Internal flush implementation
1806
+ * FIXED (CRITICAL-06): Don't remove events from queue until send succeeds
1351
1807
  */
1352
1808
  _flush() {
1353
1809
  return __awaiter(this, void 0, void 0, function* () {
@@ -1366,16 +1822,21 @@ var Datalyr = (function () {
1366
1822
  this.moveToOfflineQueue();
1367
1823
  return;
1368
1824
  }
1369
- // Get events to send
1370
- const events = this.queue.splice(0, this.config.batchSize);
1825
+ // CRITICAL FIX: Use slice() to COPY events, don't remove yet
1826
+ // Only remove after successful send to prevent data loss
1827
+ const batchSize = Math.min(this.config.batchSize, this.queue.length);
1828
+ const events = this.queue.slice(0, batchSize);
1371
1829
  try {
1372
1830
  yield this.sendBatch(events);
1831
+ // SUCCESS: Now it's safe to remove events from queue
1832
+ this.queue.splice(0, batchSize);
1833
+ this.log(`Successfully sent and removed ${batchSize} events from queue`);
1373
1834
  }
1374
1835
  catch (error) {
1375
1836
  this.log('Failed to send batch:', error);
1376
- // Move to offline queue for retry
1377
- this.offlineQueue.push(...events);
1378
- this.saveOfflineQueue();
1837
+ // Don't remove from queue - events stay for retry
1838
+ // Move to offline queue for persistent storage
1839
+ this.moveToOfflineQueue(events);
1379
1840
  }
1380
1841
  });
1381
1842
  }
@@ -1407,10 +1868,14 @@ var Datalyr = (function () {
1407
1868
  if (response.status === 429) {
1408
1869
  const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
1409
1870
  this.log(`Rate limited, retrying after ${retryAfter}s`);
1871
+ // FIXED (CRITICAL-06): Don't unshift() - events are still in queue!
1872
+ // With the new fix, events aren't removed until success, so they're
1873
+ // already in the queue. Just schedule a retry flush.
1410
1874
  setTimeout(() => {
1411
- this.queue.unshift(...events);
1875
+ this.flush();
1412
1876
  }, retryAfter * 1000);
1413
- return;
1877
+ // Throw to trigger catch block which moves events to offline queue
1878
+ throw new Error(`Rate limited (429), retry after ${retryAfter}s`);
1414
1879
  }
1415
1880
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1416
1881
  }
@@ -1471,11 +1936,39 @@ var Datalyr = (function () {
1471
1936
  }
1472
1937
  /**
1473
1938
  * Move events to offline queue
1474
- */
1475
- moveToOfflineQueue() {
1476
- this.offlineQueue.push(...this.queue);
1477
- this.queue = [];
1478
- this.saveOfflineQueue();
1939
+ * Fixed Issue #33: Enforce size limit when adding, not just when saving
1940
+ * FIXED (DATA-03): Added mutex lock to prevent race conditions
1941
+ * FIXED (CRITICAL-06): Can now accept specific events or move entire queue
1942
+ */
1943
+ moveToOfflineQueue(events) {
1944
+ // FIXED (DATA-03): Check if offline queue operation already in progress
1945
+ if (this.offlineQueueLock) {
1946
+ console.warn('[Datalyr Queue] Offline queue operation already in progress');
1947
+ return;
1948
+ }
1949
+ // Acquire lock
1950
+ this.offlineQueueLock = true;
1951
+ try {
1952
+ if (events) {
1953
+ // Move specific events to offline queue
1954
+ this.offlineQueue.push(...events);
1955
+ }
1956
+ else {
1957
+ // Move entire queue to offline queue
1958
+ this.offlineQueue.push(...this.queue);
1959
+ this.queue = [];
1960
+ }
1961
+ // Issue #33: Enforce limit here, not just in saveOfflineQueue
1962
+ if (this.offlineQueue.length > this.config.maxOfflineQueueSize) {
1963
+ const excess = this.offlineQueue.length - this.config.maxOfflineQueueSize;
1964
+ this.offlineQueue.splice(0, excess); // Remove oldest events
1965
+ }
1966
+ this.saveOfflineQueue();
1967
+ }
1968
+ finally {
1969
+ // Always release lock
1970
+ this.offlineQueueLock = false;
1971
+ }
1479
1972
  }
1480
1973
  /**
1481
1974
  * Load offline queue from storage
@@ -1607,8 +2100,6 @@ var Datalyr = (function () {
1607
2100
  */
1608
2101
  class FingerprintCollector {
1609
2102
  constructor(options = {}) {
1610
- this.heavyFingerprintDone = false;
1611
- this.fingerprintCache = {};
1612
2103
  this.privacyMode = options.privacyMode || 'standard';
1613
2104
  this.enableFingerprinting = options.enableFingerprinting !== false;
1614
2105
  }
@@ -1626,24 +2117,40 @@ var Datalyr = (function () {
1626
2117
  }
1627
2118
  /**
1628
2119
  * Collect minimal fingerprint data (privacy-friendly)
2120
+ * Matches browser tag minimal fingerprinting approach
1629
2121
  */
1630
2122
  collectMinimal() {
1631
- return {
2123
+ const fp = {
1632
2124
  timezone: this.getTimezone(),
1633
2125
  language: navigator.language || null,
1634
- platform: navigator.platform || null,
1635
- canvasEnabled: false,
1636
- localStorageAvailable: this.testStorage('localStorage'),
1637
- sessionStorageAvailable: this.testStorage('sessionStorage')
2126
+ screen_bucket: this.getScreenBucket(),
2127
+ dnt: (navigator.doNotTrack === '1' || window.globalPrivacyControl === true) || null,
2128
+ userAgent: navigator.userAgent || null
1638
2129
  };
2130
+ // User-Agent Client Hints (modern browsers)
2131
+ if ('userAgentData' in navigator) {
2132
+ const uaData = navigator.userAgentData;
2133
+ fp.userAgentData = {
2134
+ brands: uaData.brands || [],
2135
+ mobile: uaData.mobile || false,
2136
+ platform: uaData.platform || null
2137
+ };
2138
+ }
2139
+ return fp;
1639
2140
  }
1640
2141
  /**
1641
2142
  * Collect standard fingerprint data
2143
+ * PRIVACY: Minimal data collection matching browser tag approach
2144
+ * Only collects data necessary for basic analytics and attribution
1642
2145
  */
1643
2146
  collectStandard() {
1644
2147
  const fingerprint = {};
1645
2148
  try {
1646
- // Basic browser data
2149
+ // PRIVACY: Only collect minimal data needed for attribution
2150
+ fingerprint.timezone = this.getTimezone();
2151
+ fingerprint.language = navigator.language || null;
2152
+ fingerprint.screen_bucket = this.getScreenBucket();
2153
+ fingerprint.dnt = (navigator.doNotTrack === '1' || window.globalPrivacyControl === true) || null;
1647
2154
  fingerprint.userAgent = navigator.userAgent || null;
1648
2155
  // User-Agent Client Hints (modern browsers)
1649
2156
  if ('userAgentData' in navigator) {
@@ -1654,125 +2161,14 @@ var Datalyr = (function () {
1654
2161
  platform: uaData.platform || null
1655
2162
  };
1656
2163
  }
1657
- // Language settings
1658
- fingerprint.language = navigator.language || null;
1659
- fingerprint.languages = navigator.languages ?
1660
- navigator.languages.slice(0, 2) : null; // Limit to 2 for privacy
1661
- // Platform and browser features
1662
- fingerprint.platform = navigator.platform || null;
1663
- fingerprint.cookieEnabled = navigator.cookieEnabled || null;
1664
- fingerprint.doNotTrack = navigator.doNotTrack || null;
1665
- // Hardware (coarsened for privacy)
1666
- fingerprint.hardwareConcurrency = this.coarsenHardwareConcurrency();
1667
- fingerprint.deviceMemory = this.coarsenDeviceMemory();
1668
- fingerprint.maxTouchPoints = navigator.maxTouchPoints > 0 ? 'touch' : 'no-touch';
1669
- // Screen (coarsened)
1670
- fingerprint.screenResolution = this.getScreenResolution();
1671
- fingerprint.colorDepth = screen.colorDepth || null;
1672
- fingerprint.pixelRatio = this.coarsenPixelRatio();
1673
- // Timezone
1674
- fingerprint.timezone = this.getTimezone();
1675
- fingerprint.timezoneOffset = this.coarsenTimezoneOffset();
1676
- // Canvas fingerprinting disabled for privacy
1677
- fingerprint.canvasEnabled = false;
1678
- // Plugins count only (not details for privacy)
1679
- fingerprint.pluginsCount = navigator.plugins ? navigator.plugins.length : null;
1680
- // Storage availability
1681
- fingerprint.localStorageAvailable = this.testStorage('localStorage');
1682
- fingerprint.sessionStorageAvailable = this.testStorage('sessionStorage');
1683
- fingerprint.indexedDBAvailable = this.testIndexedDB();
1684
- // Add cached heavy fingerprint data if available
1685
- if (this.heavyFingerprintDone) {
1686
- Object.assign(fingerprint, this.fingerprintCache);
1687
- }
1688
2164
  }
1689
2165
  catch (e) {
1690
2166
  console.warn('[Datalyr] Error collecting fingerprint:', e);
1691
2167
  }
1692
2168
  return fingerprint;
1693
2169
  }
1694
- /**
1695
- * Collect heavy fingerprint data (WebGL, Audio)
1696
- * Called lazily on first event to improve page load performance
1697
- */
1698
- collectHeavyFingerprint() {
1699
- return __awaiter(this, void 0, void 0, function* () {
1700
- if (this.heavyFingerprintDone || this.privacyMode === 'strict') {
1701
- return;
1702
- }
1703
- try {
1704
- // WebGL fingerprinting
1705
- const webglData = this.getWebGLFingerprint();
1706
- if (webglData) {
1707
- this.fingerprintCache.webglVendor = webglData.vendor;
1708
- this.fingerprintCache.webglRenderer = webglData.renderer;
1709
- }
1710
- // Audio fingerprinting
1711
- const audioData = yield this.getAudioFingerprint();
1712
- if (audioData) {
1713
- this.fingerprintCache.audioSampleRate = audioData.sampleRate;
1714
- this.fingerprintCache.audioState = audioData.state;
1715
- this.fingerprintCache.audioMaxChannels = audioData.maxChannels;
1716
- }
1717
- this.heavyFingerprintDone = true;
1718
- }
1719
- catch (e) {
1720
- console.warn('[Datalyr] Heavy fingerprinting failed:', e);
1721
- }
1722
- });
1723
- }
1724
- /**
1725
- * Get WebGL fingerprint
1726
- */
1727
- getWebGLFingerprint() {
1728
- try {
1729
- const canvas = document.createElement('canvas');
1730
- const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
1731
- if (!gl)
1732
- return null;
1733
- const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
1734
- if (!debugInfo) {
1735
- return {
1736
- vendor: gl.getParameter(gl.VENDOR) || 'unknown',
1737
- renderer: gl.getParameter(gl.RENDERER) || 'unknown'
1738
- };
1739
- }
1740
- return {
1741
- vendor: gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) || 'unknown',
1742
- renderer: gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || 'unknown'
1743
- };
1744
- }
1745
- catch (_a) {
1746
- return null;
1747
- }
1748
- }
1749
- /**
1750
- * Get audio fingerprint
1751
- */
1752
- getAudioFingerprint() {
1753
- return __awaiter(this, void 0, void 0, function* () {
1754
- var _a;
1755
- try {
1756
- const AudioContext = window.AudioContext || window.webkitAudioContext;
1757
- if (!AudioContext)
1758
- return null;
1759
- const audioCtx = new AudioContext();
1760
- const result = {
1761
- sampleRate: audioCtx.sampleRate || null,
1762
- state: audioCtx.state || null,
1763
- maxChannels: ((_a = audioCtx.destination) === null || _a === void 0 ? void 0 : _a.maxChannelCount) || null
1764
- };
1765
- // Close context to free resources
1766
- if (audioCtx.close) {
1767
- yield audioCtx.close();
1768
- }
1769
- return result;
1770
- }
1771
- catch (_b) {
1772
- return null;
1773
- }
1774
- });
1775
- }
2170
+ // PRIVACY: Heavy fingerprinting (WebGL, Audio) removed
2171
+ // Minimal fingerprinting only for privacy compliance
1776
2172
  /**
1777
2173
  * Get timezone
1778
2174
  */
@@ -1785,10 +2181,13 @@ var Datalyr = (function () {
1785
2181
  }
1786
2182
  }
1787
2183
  /**
1788
- * Get screen resolution (coarsened)
2184
+ * Get coarse screen bucket for basic device classification
2185
+ * Rounds to nearest 100px for privacy
1789
2186
  */
1790
- getScreenResolution() {
2187
+ getScreenBucket() {
1791
2188
  try {
2189
+ if (!window.screen)
2190
+ return null;
1792
2191
  const width = Math.round(screen.width / 100) * 100;
1793
2192
  const height = Math.round(screen.height / 100) * 100;
1794
2193
  return `${width}x${height}`;
@@ -1797,131 +2196,43 @@ var Datalyr = (function () {
1797
2196
  return null;
1798
2197
  }
1799
2198
  }
2199
+ // PRIVACY: Unused helper methods removed (coarsen functions, storage tests)
2200
+ // Minimal fingerprinting only
1800
2201
  /**
1801
- * Coarsen hardware concurrency for privacy
2202
+ * Generate fingerprint hash
1802
2203
  */
1803
- coarsenHardwareConcurrency() {
1804
- try {
1805
- const cores = navigator.hardwareConcurrency;
1806
- if (!cores)
1807
- return null;
1808
- return cores > 8 ? '8+' : cores;
1809
- }
1810
- catch (_a) {
1811
- return null;
1812
- }
1813
- }
1814
- /**
1815
- * Coarsen device memory for privacy
1816
- */
1817
- coarsenDeviceMemory() {
1818
- try {
1819
- const memory = navigator.deviceMemory;
1820
- if (!memory)
1821
- return null;
1822
- return memory > 4 ? '4+' : memory;
1823
- }
1824
- catch (_a) {
1825
- return null;
1826
- }
1827
- }
1828
- /**
1829
- * Coarsen pixel ratio for privacy
1830
- */
1831
- coarsenPixelRatio() {
1832
- try {
1833
- const ratio = window.devicePixelRatio;
1834
- if (!ratio)
1835
- return null;
1836
- // Round to common values
1837
- if (ratio <= 1)
1838
- return '1';
1839
- if (ratio <= 1.5)
1840
- return '1.5';
1841
- if (ratio <= 2)
1842
- return '2';
1843
- if (ratio <= 3)
1844
- return '3';
1845
- return '3+';
1846
- }
1847
- catch (_a) {
1848
- return null;
1849
- }
1850
- }
1851
- /**
1852
- * Coarsen timezone offset for privacy
1853
- */
1854
- coarsenTimezoneOffset() {
1855
- try {
1856
- const offset = new Date().getTimezoneOffset();
1857
- // Round to nearest 30 minutes
1858
- return Math.round(offset / 30) * 30;
1859
- }
1860
- catch (_a) {
1861
- return null;
1862
- }
1863
- }
1864
- /**
1865
- * Test if storage is available
1866
- */
1867
- testStorage(type) {
1868
- try {
1869
- const storage = window[type];
1870
- const testKey = '__dl_test__';
1871
- storage.setItem(testKey, '1');
1872
- storage.removeItem(testKey);
1873
- return true;
1874
- }
1875
- catch (_a) {
1876
- return false;
1877
- }
1878
- }
1879
- /**
1880
- * Test if IndexedDB is available
1881
- */
1882
- testIndexedDB() {
1883
- try {
1884
- return !!window.indexedDB;
1885
- }
1886
- catch (_a) {
1887
- return false;
1888
- }
1889
- }
1890
- /**
1891
- * Generate fingerprint hash
1892
- */
1893
- generateHash(data) {
1894
- return __awaiter(this, void 0, void 0, function* () {
1895
- try {
1896
- // Sort keys for consistent hashing
1897
- const sortedData = Object.keys(data)
1898
- .sort()
1899
- .reduce((obj, key) => {
1900
- obj[key] = data[key];
1901
- return obj;
1902
- }, {});
1903
- const str = JSON.stringify(sortedData);
1904
- // Use Web Crypto API if available
1905
- if (window.crypto && window.crypto.subtle) {
1906
- const encoder = new TextEncoder();
1907
- const data = encoder.encode(str);
1908
- const hashBuffer = yield crypto.subtle.digest('SHA-256', data);
1909
- const hashArray = Array.from(new Uint8Array(hashBuffer));
1910
- return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
1911
- }
1912
- // Fallback to simple hash
1913
- let hash = 0;
1914
- for (let i = 0; i < str.length; i++) {
1915
- const char = str.charCodeAt(i);
1916
- hash = ((hash << 5) - hash) + char;
1917
- hash = hash & hash; // Convert to 32bit integer
1918
- }
1919
- return Math.abs(hash).toString(16);
1920
- }
1921
- catch (_a) {
1922
- return '';
1923
- }
1924
- });
2204
+ generateHash(data) {
2205
+ return __awaiter(this, void 0, void 0, function* () {
2206
+ try {
2207
+ // Sort keys for consistent hashing
2208
+ const sortedData = Object.keys(data)
2209
+ .sort()
2210
+ .reduce((obj, key) => {
2211
+ obj[key] = data[key];
2212
+ return obj;
2213
+ }, {});
2214
+ const str = JSON.stringify(sortedData);
2215
+ // Use Web Crypto API if available
2216
+ if (window.crypto && window.crypto.subtle) {
2217
+ const encoder = new TextEncoder();
2218
+ const data = encoder.encode(str);
2219
+ const hashBuffer = yield crypto.subtle.digest('SHA-256', data);
2220
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
2221
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
2222
+ }
2223
+ // Fallback to simple hash
2224
+ let hash = 0;
2225
+ for (let i = 0; i < str.length; i++) {
2226
+ const char = str.charCodeAt(i);
2227
+ hash = ((hash << 5) - hash) + char;
2228
+ hash = hash & hash; // Convert to 32bit integer
2229
+ }
2230
+ return Math.abs(hash).toString(16);
2231
+ }
2232
+ catch (_a) {
2233
+ return '';
2234
+ }
2235
+ });
1925
2236
  }
1926
2237
  }
1927
2238
 
@@ -1936,6 +2247,9 @@ var Datalyr = (function () {
1936
2247
  this.sessionLoadedScripts = new Set();
1937
2248
  this.pixels = null;
1938
2249
  this.initialized = false;
2250
+ this.sandboxedIframes = []; // FIXED (ISSUE-02): Track iframes for cleanup
2251
+ this.iframeCleanupTimeouts = new Map(); // FIXED (ISSUE-02): Track cleanup timeouts
2252
+ this.messageHandler = null; // FIXED (ISSUE-02): Track message listener
1939
2253
  this.workspaceId = options.workspaceId;
1940
2254
  // Container scripts use the same endpoint as tracking (ingest)
1941
2255
  this.endpoint = options.endpoint || 'https://ingest.datalyr.com';
@@ -1943,6 +2257,19 @@ var Datalyr = (function () {
1943
2257
  // Load session scripts from storage
1944
2258
  const sessionScripts = storage.get('dl_session_scripts', []);
1945
2259
  this.sessionLoadedScripts = new Set(sessionScripts);
2260
+ // FIXED (ISSUE-02): Set up postMessage listener for iframe cleanup
2261
+ this.messageHandler = (event) => {
2262
+ if (event.data && event.data.type === 'datalyr_script_complete') {
2263
+ const scriptId = event.data.scriptId;
2264
+ this.log('Received script completion signal:', scriptId);
2265
+ // Find and clean up the corresponding iframe
2266
+ const iframe = this.sandboxedIframes.find(iframe => iframe.dataset.datalyrScript === scriptId);
2267
+ if (iframe) {
2268
+ this.cleanupIframe(iframe);
2269
+ }
2270
+ }
2271
+ };
2272
+ window.addEventListener('message', this.messageHandler);
1946
2273
  }
1947
2274
  /**
1948
2275
  * Initialize container and load scripts
@@ -2110,45 +2437,142 @@ var Datalyr = (function () {
2110
2437
  }
2111
2438
  }
2112
2439
  /**
2113
- * Load inline JavaScript
2440
+ * Load inline JavaScript in sandboxed iframe
2441
+ * SECURITY: User-provided scripts run in isolated context to prevent XSS
2442
+ * FIXED (ISSUE-02): Added cleanup mechanism to prevent memory leaks
2114
2443
  */
2115
2444
  loadInlineScript(script) {
2116
- // Basic XSS protection - ensure content doesn't contain obvious malicious patterns
2117
- if (this.containsMaliciousPatterns(script.content)) {
2118
- this.log('Blocked potentially malicious inline script:', script.id);
2119
- return;
2445
+ // SECURITY FIX: Run in sandboxed iframe instead of main page context
2446
+ // This prevents access to parent window, cookies, and localStorage
2447
+ const iframe = document.createElement('iframe');
2448
+ iframe.style.display = 'none';
2449
+ iframe.setAttribute('sandbox', 'allow-scripts'); // Minimal permissions
2450
+ iframe.dataset.datalyrScript = script.id;
2451
+ // FIXED (ISSUE-02): Track iframe for cleanup
2452
+ this.sandboxedIframes.push(iframe);
2453
+ // Create isolated script context with completion signal
2454
+ const iframeDoc = `
2455
+ <!DOCTYPE html>
2456
+ <html>
2457
+ <head>
2458
+ <meta charset="UTF-8">
2459
+ </head>
2460
+ <body>
2461
+ <script>
2462
+ // User-provided script runs here in isolation
2463
+ try {
2464
+ ${script.content}
2465
+ } catch (error) {
2466
+ console.error('[Datalyr Container] Script execution error:', error);
2467
+ }
2468
+
2469
+ // FIXED (ISSUE-02): Signal completion for cleanup
2470
+ // Scripts have 5 seconds to execute before iframe is removed
2471
+ setTimeout(function() {
2472
+ try {
2473
+ parent.postMessage({ type: 'datalyr_script_complete', scriptId: '${script.id}' }, '*');
2474
+ } catch (e) {
2475
+ // Ignore postMessage errors from sandbox
2476
+ }
2477
+ }, 5000);
2478
+ </script>
2479
+ </body>
2480
+ </html>
2481
+ `;
2482
+ document.body.appendChild(iframe);
2483
+ // Write content to iframe (safe because sandbox prevents parent access)
2484
+ if (iframe.contentDocument) {
2485
+ iframe.contentDocument.open();
2486
+ iframe.contentDocument.write(iframeDoc);
2487
+ iframe.contentDocument.close();
2488
+ }
2489
+ // FIXED (ISSUE-02): Remove iframe after execution (30 seconds max as fallback)
2490
+ // If script completes in 5s, postMessage will trigger early cleanup
2491
+ const timeoutId = window.setTimeout(() => {
2492
+ this.cleanupIframe(iframe);
2493
+ }, 30000);
2494
+ // Store timeout ID for cleanup
2495
+ this.iframeCleanupTimeouts.set(iframe, timeoutId);
2496
+ this.log('Loaded inline script in sandbox:', script.id);
2497
+ }
2498
+ /**
2499
+ * Clean up a sandboxed iframe
2500
+ * FIXED (ISSUE-02): Prevents memory leaks from iframe accumulation
2501
+ */
2502
+ cleanupIframe(iframe) {
2503
+ try {
2504
+ // FIXED (ISSUE-02): Clear pending timeout to prevent duplicate cleanup
2505
+ const timeoutId = this.iframeCleanupTimeouts.get(iframe);
2506
+ if (timeoutId) {
2507
+ clearTimeout(timeoutId);
2508
+ this.iframeCleanupTimeouts.delete(iframe);
2509
+ }
2510
+ // Remove from tracking array
2511
+ const index = this.sandboxedIframes.indexOf(iframe);
2512
+ if (index > -1) {
2513
+ this.sandboxedIframes.splice(index, 1);
2514
+ }
2515
+ // Remove from DOM
2516
+ if (iframe.parentNode) {
2517
+ iframe.parentNode.removeChild(iframe);
2518
+ this.log('Cleaned up sandboxed iframe:', iframe.dataset.datalyrScript);
2519
+ }
2520
+ }
2521
+ catch (error) {
2522
+ this.log('Error cleaning up iframe:', error);
2120
2523
  }
2121
- const scriptElement = document.createElement('script');
2122
- scriptElement.textContent = script.content;
2123
- scriptElement.dataset.datalyrScript = script.id;
2124
- scriptElement.setAttribute('data-nonce', this.generateNonce());
2125
- document.head.appendChild(scriptElement);
2126
2524
  }
2127
2525
  /**
2128
- * Load external JavaScript
2526
+ * Clean up all sandboxed iframes
2527
+ * FIXED (ISSUE-02): Called on SDK destroy to prevent memory leaks
2528
+ */
2529
+ cleanupAllIframes() {
2530
+ // Clean up all iframes
2531
+ const iframes = [...this.sandboxedIframes]; // Copy array since we're modifying it
2532
+ iframes.forEach(iframe => this.cleanupIframe(iframe));
2533
+ // FIXED (ISSUE-02): Remove message listener to prevent memory leak
2534
+ if (this.messageHandler) {
2535
+ window.removeEventListener('message', this.messageHandler);
2536
+ this.messageHandler = null;
2537
+ }
2538
+ // Clear any remaining timeouts
2539
+ this.iframeCleanupTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
2540
+ this.iframeCleanupTimeouts.clear();
2541
+ this.log(`Cleaned up ${iframes.length} sandboxed iframes`);
2542
+ }
2543
+ /**
2544
+ * Load external JavaScript with SRI validation
2545
+ * SECURITY (Phase 1.2): SRI is now REQUIRED for external scripts
2129
2546
  */
2130
2547
  loadExternalScript(script) {
2548
+ var _a;
2131
2549
  // Validate URL before loading
2132
2550
  if (!this.isValidScriptUrl(script.content)) {
2133
2551
  this.log('Blocked invalid script URL:', script.content);
2134
2552
  return;
2135
2553
  }
2554
+ // SECURITY FIX: Require SRI (Subresource Integrity) for external scripts
2555
+ if (!((_a = script.settings) === null || _a === void 0 ? void 0 : _a.integrity)) {
2556
+ console.error(`[Datalyr Container] SECURITY: External script "${script.id}" blocked - missing SRI hash.\n` +
2557
+ `All external scripts MUST include an integrity hash to prevent CDN compromise attacks.\n` +
2558
+ `Generate SRI hash at: https://www.srihash.org/\n` +
2559
+ `Example: { integrity: "sha384-..." }`);
2560
+ return;
2561
+ }
2136
2562
  const scriptElement = document.createElement('script');
2137
2563
  scriptElement.src = script.content;
2138
2564
  scriptElement.dataset.datalyrScript = script.id;
2139
2565
  // Apply settings
2140
2566
  if (script.settings) {
2567
+ scriptElement.integrity = script.settings.integrity; // REQUIRED
2568
+ scriptElement.crossOrigin = script.settings.crossorigin || 'anonymous'; // Required for SRI
2141
2569
  if (script.settings.async !== false)
2142
2570
  scriptElement.async = true;
2143
2571
  if (script.settings.defer)
2144
2572
  scriptElement.defer = true;
2145
- if (script.settings.integrity)
2146
- scriptElement.integrity = script.settings.integrity;
2147
- if (script.settings.crossorigin)
2148
- scriptElement.crossOrigin = script.settings.crossorigin;
2149
2573
  }
2150
2574
  else {
2151
- // Default to async for better performance
2575
+ // This should never happen now that integrity is required
2152
2576
  scriptElement.async = true;
2153
2577
  }
2154
2578
  document.head.appendChild(scriptElement);
@@ -2293,10 +2717,13 @@ var Datalyr = (function () {
2293
2717
  */
2294
2718
  trackToPixels(eventName, properties = {}) {
2295
2719
  var _a, _b, _c, _e, _f, _g;
2720
+ // Sanitize inputs to prevent XSS
2721
+ const sanitizedEventName = this.sanitizeEventName(eventName);
2722
+ const sanitizedProperties = this.sanitizeProperties(properties);
2296
2723
  // Track to Meta Pixel
2297
2724
  if (((_b = (_a = this.pixels) === null || _a === void 0 ? void 0 : _a.meta) === null || _b === void 0 ? void 0 : _b.enabled) && window.fbq) {
2298
2725
  try {
2299
- window.fbq('track', eventName, properties);
2726
+ window.fbq('track', sanitizedEventName, sanitizedProperties);
2300
2727
  }
2301
2728
  catch (error) {
2302
2729
  this.log('Error tracking Meta Pixel event:', error);
@@ -2305,7 +2732,7 @@ var Datalyr = (function () {
2305
2732
  // Track to Google Tag
2306
2733
  if (((_e = (_c = this.pixels) === null || _c === void 0 ? void 0 : _c.google) === null || _e === void 0 ? void 0 : _e.enabled) && window.gtag) {
2307
2734
  try {
2308
- window.gtag('event', eventName, properties);
2735
+ window.gtag('event', sanitizedEventName, sanitizedProperties);
2309
2736
  }
2310
2737
  catch (error) {
2311
2738
  this.log('Error tracking Google Tag event:', error);
@@ -2323,8 +2750,8 @@ var Datalyr = (function () {
2323
2750
  'Search': 'Search',
2324
2751
  'Lead': 'SubmitForm'
2325
2752
  };
2326
- const tiktokEvent = tiktokEventMap[eventName] || eventName;
2327
- window.ttq.track(tiktokEvent, properties);
2753
+ const tiktokEvent = tiktokEventMap[sanitizedEventName] || sanitizedEventName;
2754
+ window.ttq.track(tiktokEvent, sanitizedProperties);
2328
2755
  }
2329
2756
  catch (error) {
2330
2757
  this.log('Error tracking TikTok Pixel event:', error);
@@ -2347,49 +2774,25 @@ var Datalyr = (function () {
2347
2774
  return Array.from(this.loadedScripts);
2348
2775
  }
2349
2776
  /**
2350
- * Extract app endpoint from ingest endpoint
2777
+ * SECURITY MODEL (Phase 1.1 Fix):
2778
+ *
2779
+ * Inline scripts run in sandboxed iframes with 'allow-scripts' only.
2780
+ * This prevents:
2781
+ * - Access to parent window/document
2782
+ * - Access to cookies and localStorage
2783
+ * - Cross-origin requests
2784
+ * - Popup creation
2785
+ * - Form submission
2786
+ *
2787
+ * External scripts MUST have SRI (Subresource Integrity) hashes.
2788
+ * This prevents:
2789
+ * - CDN compromise attacks
2790
+ * - Man-in-the-middle script injection
2791
+ * - Unauthorized script modifications
2792
+ *
2793
+ * Previous regex-based validation was removed because it's trivially bypassable.
2794
+ * Sandboxing provides defense-in-depth regardless of script content.
2351
2795
  */
2352
- extractAppEndpoint(endpoint) {
2353
- if (!endpoint) {
2354
- return 'https://app.datalyr.com';
2355
- }
2356
- // If it's already an app endpoint, use it
2357
- if (endpoint.includes('app.datalyr.com')) {
2358
- return endpoint;
2359
- }
2360
- // Convert ingest endpoint to app endpoint
2361
- if (endpoint.includes('ingest.datalyr.com')) {
2362
- return 'https://app.datalyr.com';
2363
- }
2364
- // For local development
2365
- if (endpoint.includes('localhost') || endpoint.includes('127.0.0.1')) {
2366
- // Assume app is on port 3000 if ingest is on 3001
2367
- return endpoint.replace(':3001', ':3000').replace('/ingest', '');
2368
- }
2369
- // For custom endpoints, try to extract the base domain
2370
- try {
2371
- const url = new URL(endpoint);
2372
- return `${url.protocol}//${url.hostname}${url.port ? ':' + url.port : ''}`;
2373
- }
2374
- catch (_a) {
2375
- return 'https://app.datalyr.com';
2376
- }
2377
- }
2378
- /**
2379
- * Check for malicious patterns in inline scripts
2380
- */
2381
- containsMaliciousPatterns(content) {
2382
- // Basic patterns that might indicate malicious content
2383
- const dangerousPatterns = [
2384
- /<script[^>]*>/gi, // Script tags within content
2385
- /document\.cookie/gi, // Direct cookie access
2386
- /eval\s*\(/gi, // eval usage
2387
- /Function\s*\(/gi, // Function constructor
2388
- /innerHTML\s*=/gi, // Direct innerHTML assignment
2389
- /document\.write/gi, // document.write usage
2390
- ];
2391
- return dangerousPatterns.some(pattern => pattern.test(content));
2392
- }
2393
2796
  /**
2394
2797
  * Validate script URL
2395
2798
  */
@@ -2411,12 +2814,61 @@ var Datalyr = (function () {
2411
2814
  }
2412
2815
  }
2413
2816
  /**
2414
- * Generate a nonce for CSP
2817
+ * Sanitize event name - whitelist alphanumeric, underscore, dollar sign
2415
2818
  */
2416
- generateNonce() {
2417
- const array = new Uint8Array(16);
2418
- crypto.getRandomValues(array);
2419
- return btoa(String.fromCharCode(...array));
2819
+ sanitizeEventName(eventName) {
2820
+ if (typeof eventName !== 'string') {
2821
+ return 'unknown_event';
2822
+ }
2823
+ // Allow alphanumeric, underscore, dollar sign, and spaces
2824
+ return eventName.replace(/[^a-zA-Z0-9_$ ]/g, '').substring(0, 100);
2825
+ }
2826
+ /**
2827
+ * Recursively sanitize properties object
2828
+ */
2829
+ sanitizeProperties(properties) {
2830
+ if (properties === null || properties === undefined) {
2831
+ return {};
2832
+ }
2833
+ if (typeof properties !== 'object') {
2834
+ return this.sanitizeValue(properties);
2835
+ }
2836
+ if (Array.isArray(properties)) {
2837
+ return properties.map(item => this.sanitizeValue(item));
2838
+ }
2839
+ const sanitized = {};
2840
+ for (const [key, value] of Object.entries(properties)) {
2841
+ // Sanitize key
2842
+ const sanitizedKey = key.replace(/[^a-zA-Z0-9_]/g, '').substring(0, 100);
2843
+ if (sanitizedKey) {
2844
+ sanitized[sanitizedKey] = this.sanitizeValue(value);
2845
+ }
2846
+ }
2847
+ return sanitized;
2848
+ }
2849
+ /**
2850
+ * Sanitize individual value
2851
+ */
2852
+ sanitizeValue(value) {
2853
+ if (value === null || value === undefined) {
2854
+ return value;
2855
+ }
2856
+ if (typeof value === 'string') {
2857
+ // Remove potential XSS patterns
2858
+ return value
2859
+ .replace(/<script[^>]*>.*?<\/script>/gi, '')
2860
+ .replace(/<[^>]+>/g, '')
2861
+ .replace(/javascript:/gi, '')
2862
+ .replace(/on\w+\s*=/gi, '')
2863
+ .substring(0, 1000);
2864
+ }
2865
+ if (typeof value === 'number' || typeof value === 'boolean') {
2866
+ return value;
2867
+ }
2868
+ if (typeof value === 'object') {
2869
+ return this.sanitizeProperties(value);
2870
+ }
2871
+ return String(value).substring(0, 1000);
2420
2872
  }
2421
2873
  /**
2422
2874
  * Debug logging
@@ -2428,6 +2880,437 @@ var Datalyr = (function () {
2428
2880
  }
2429
2881
  }
2430
2882
 
2883
+ /**
2884
+ * Auto-Identify Module
2885
+ *
2886
+ * Automatically captures user identity (email) from:
2887
+ * 1. Fetch/XHR API requests (email in request/response)
2888
+ * 2. Form submissions (email inputs)
2889
+ * 3. Shopify-specific endpoints (/account.json)
2890
+ *
2891
+ * SECURITY:
2892
+ * - Email validation to prevent false positives
2893
+ * - Whitelist approach for trusted domains
2894
+ * - Rate limiting to prevent spam
2895
+ * - Privacy controls integration
2896
+ */
2897
+ class AutoIdentifyManager {
2898
+ constructor(config = {}) {
2899
+ this.formListeners = [];
2900
+ this.lastIdentifyTime = 0;
2901
+ this.RATE_LIMIT_MS = 5000; // Don't auto-identify more than once per 5 seconds
2902
+ this.config = {
2903
+ enabled: config.enabled !== false,
2904
+ captureFromForms: config.captureFromForms !== false,
2905
+ captureFromAPI: config.captureFromAPI !== false,
2906
+ captureFromShopify: config.captureFromShopify !== false,
2907
+ trustedDomains: config.trustedDomains || [],
2908
+ debug: config.debug || false
2909
+ };
2910
+ }
2911
+ /**
2912
+ * Initialize auto-identify system
2913
+ */
2914
+ initialize(identifyCallback) {
2915
+ if (!this.config.enabled) {
2916
+ this.log('Auto-identify disabled');
2917
+ return;
2918
+ }
2919
+ this.identifyCallback = identifyCallback;
2920
+ // Check if already identified
2921
+ const existingEmail = storage.get('dl_auto_identified_email');
2922
+ if (existingEmail) {
2923
+ this.log('User already auto-identified:', existingEmail);
2924
+ return;
2925
+ }
2926
+ // Setup monitoring
2927
+ if (this.config.captureFromForms) {
2928
+ this.setupFormMonitoring();
2929
+ }
2930
+ if (this.config.captureFromAPI) {
2931
+ this.setupFetchInterception();
2932
+ this.setupXHRInterception();
2933
+ }
2934
+ if (this.config.captureFromShopify) {
2935
+ this.setupShopifyMonitoring();
2936
+ }
2937
+ this.log('Auto-identify initialized');
2938
+ }
2939
+ /**
2940
+ * Setup form monitoring for email capture
2941
+ */
2942
+ setupFormMonitoring() {
2943
+ // Monitor existing forms
2944
+ this.scanForEmailForms();
2945
+ // Watch for new forms (SPAs)
2946
+ const observer = new MutationObserver(() => {
2947
+ this.scanForEmailForms();
2948
+ });
2949
+ observer.observe(document.body, {
2950
+ childList: true,
2951
+ subtree: true
2952
+ });
2953
+ this.log('Form monitoring active');
2954
+ }
2955
+ /**
2956
+ * Scan DOM for forms with email inputs
2957
+ */
2958
+ scanForEmailForms() {
2959
+ const forms = document.querySelectorAll('form');
2960
+ forms.forEach(form => {
2961
+ // Skip if already listening
2962
+ if (this.formListeners.some(l => l.element === form)) {
2963
+ return;
2964
+ }
2965
+ // Check if form has email input
2966
+ const emailInput = form.querySelector('input[type="email"], input[name*="email" i], input[id*="email" i]');
2967
+ if (emailInput) {
2968
+ const handler = (e) => this.handleFormSubmit(e, form);
2969
+ form.addEventListener('submit', handler);
2970
+ this.formListeners.push({ element: form, handler });
2971
+ this.log('Monitoring form:', form);
2972
+ }
2973
+ });
2974
+ }
2975
+ /**
2976
+ * Handle form submission
2977
+ */
2978
+ handleFormSubmit(_event, form) {
2979
+ try {
2980
+ // Find email input
2981
+ const emailInput = form.querySelector('input[type="email"], input[name*="email" i], input[id*="email" i]');
2982
+ if (!emailInput)
2983
+ return;
2984
+ const email = emailInput.value.trim();
2985
+ if (this.isValidEmail(email)) {
2986
+ this.log('Email captured from form:', email);
2987
+ this.triggerIdentify(email, 'form');
2988
+ }
2989
+ }
2990
+ catch (error) {
2991
+ this.log('Error handling form submit:', error);
2992
+ }
2993
+ }
2994
+ /**
2995
+ * Setup fetch interception for API email capture
2996
+ */
2997
+ setupFetchInterception() {
2998
+ if (typeof window.fetch !== 'function')
2999
+ return;
3000
+ this.originalFetch = window.fetch;
3001
+ const self = this;
3002
+ window.fetch = function (input, init) {
3003
+ return __awaiter(this, void 0, void 0, function* () {
3004
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
3005
+ // Check if trusted domain
3006
+ if (!self.isTrustedDomain(url)) {
3007
+ return self.originalFetch.call(window, input, init);
3008
+ }
3009
+ try {
3010
+ // Check request body for email
3011
+ if (init === null || init === void 0 ? void 0 : init.body) {
3012
+ self.extractEmailFromData(init.body, 'api-request');
3013
+ }
3014
+ // Make actual request
3015
+ const response = self.originalFetch.call(window, input, init);
3016
+ // Check response for email
3017
+ response.then((res) => __awaiter(this, void 0, void 0, function* () {
3018
+ var _a;
3019
+ if (res.ok && ((_a = res.headers.get('content-type')) === null || _a === void 0 ? void 0 : _a.includes('application/json'))) {
3020
+ try {
3021
+ const clone = res.clone();
3022
+ const data = yield clone.json();
3023
+ self.extractEmailFromData(data, 'api-response');
3024
+ }
3025
+ catch (error) {
3026
+ // Ignore JSON parse errors
3027
+ }
3028
+ }
3029
+ })).catch(() => {
3030
+ // Ignore response errors
3031
+ });
3032
+ return response;
3033
+ }
3034
+ catch (error) {
3035
+ self.log('Error intercepting fetch:', error);
3036
+ return self.originalFetch.call(window, input, init);
3037
+ }
3038
+ });
3039
+ };
3040
+ this.log('Fetch interception active');
3041
+ }
3042
+ /**
3043
+ * Setup XHR interception for API email capture
3044
+ */
3045
+ setupXHRInterception() {
3046
+ if (typeof XMLHttpRequest === 'undefined')
3047
+ return;
3048
+ const self = this;
3049
+ this.originalXHROpen = XMLHttpRequest.prototype.open;
3050
+ this.originalXHRSend = XMLHttpRequest.prototype.send;
3051
+ XMLHttpRequest.prototype.open = function (method, url, ...args) {
3052
+ this._datalyrUrl = typeof url === 'string' ? url : url.href;
3053
+ return self.originalXHROpen.apply(this, [method, url, ...args]);
3054
+ };
3055
+ XMLHttpRequest.prototype.send = function (body) {
3056
+ const url = this._datalyrUrl;
3057
+ if (url && self.isTrustedDomain(url)) {
3058
+ // Check request body
3059
+ if (body) {
3060
+ self.extractEmailFromData(body, 'api-request');
3061
+ }
3062
+ // Monitor response
3063
+ this.addEventListener('load', function () {
3064
+ if (this.status >= 200 && this.status < 300) {
3065
+ try {
3066
+ const contentType = this.getResponseHeader('content-type');
3067
+ if (contentType === null || contentType === void 0 ? void 0 : contentType.includes('application/json')) {
3068
+ const data = JSON.parse(this.responseText);
3069
+ self.extractEmailFromData(data, 'api-response');
3070
+ }
3071
+ }
3072
+ catch (error) {
3073
+ // Ignore parse errors
3074
+ }
3075
+ }
3076
+ });
3077
+ }
3078
+ return self.originalXHRSend.call(this, body);
3079
+ };
3080
+ this.log('XHR interception active');
3081
+ }
3082
+ /**
3083
+ * Setup Shopify-specific monitoring
3084
+ */
3085
+ setupShopifyMonitoring() {
3086
+ // Detect Shopify
3087
+ if (!this.isShopify()) {
3088
+ return;
3089
+ }
3090
+ this.log('Shopify detected, setting up monitoring');
3091
+ // Try to fetch customer data immediately
3092
+ this.checkShopifyCustomer();
3093
+ // Check periodically (in case customer logs in later)
3094
+ this.shopifyCheckInterval = setInterval(() => {
3095
+ this.checkShopifyCustomer();
3096
+ }, 10000); // Check every 10 seconds
3097
+ this.log('Shopify monitoring active');
3098
+ }
3099
+ /**
3100
+ * Check if running on Shopify
3101
+ */
3102
+ isShopify() {
3103
+ return !!(window.Shopify ||
3104
+ document.querySelector('meta[name="shopify-checkout-api-token"]') ||
3105
+ window.location.hostname.includes('.myshopify.com'));
3106
+ }
3107
+ /**
3108
+ * Check Shopify customer endpoint for email
3109
+ */
3110
+ checkShopifyCustomer() {
3111
+ return __awaiter(this, void 0, void 0, function* () {
3112
+ var _a;
3113
+ try {
3114
+ const response = yield fetch('/account.json', {
3115
+ credentials: 'same-origin'
3116
+ });
3117
+ if (response.ok) {
3118
+ const data = yield response.json();
3119
+ if ((_a = data === null || data === void 0 ? void 0 : data.customer) === null || _a === void 0 ? void 0 : _a.email) {
3120
+ this.log('Email captured from Shopify:', data.customer.email);
3121
+ this.triggerIdentify(data.customer.email, 'shopify');
3122
+ // Stop checking once we have email
3123
+ if (this.shopifyCheckInterval) {
3124
+ clearInterval(this.shopifyCheckInterval);
3125
+ this.shopifyCheckInterval = undefined;
3126
+ }
3127
+ }
3128
+ }
3129
+ }
3130
+ catch (error) {
3131
+ // User not logged in or endpoint unavailable
3132
+ this.log('Shopify customer check failed:', error);
3133
+ }
3134
+ });
3135
+ }
3136
+ /**
3137
+ * Extract email from data (object, string, FormData)
3138
+ */
3139
+ extractEmailFromData(data, source) {
3140
+ try {
3141
+ let emails = [];
3142
+ if (typeof data === 'string') {
3143
+ // Try to parse as JSON
3144
+ try {
3145
+ const parsed = JSON.parse(data);
3146
+ emails = this.findEmailsInObject(parsed);
3147
+ }
3148
+ catch (_a) {
3149
+ // Try to extract from string directly
3150
+ const emailPattern = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
3151
+ const matches = data.match(emailPattern);
3152
+ if (matches) {
3153
+ emails = matches;
3154
+ }
3155
+ }
3156
+ }
3157
+ else if (data instanceof FormData) {
3158
+ // Extract from FormData
3159
+ data.forEach((value) => {
3160
+ if (typeof value === 'string' && this.isValidEmail(value)) {
3161
+ emails.push(value);
3162
+ }
3163
+ });
3164
+ }
3165
+ else if (typeof data === 'object' && data !== null) {
3166
+ // Extract from object
3167
+ emails = this.findEmailsInObject(data);
3168
+ }
3169
+ // Trigger identify for first valid email
3170
+ if (emails.length > 0) {
3171
+ const email = emails[0];
3172
+ this.log(`Email found in ${source}:`, email);
3173
+ this.triggerIdentify(email, 'api');
3174
+ }
3175
+ }
3176
+ catch (error) {
3177
+ this.log('Error extracting email from data:', error);
3178
+ }
3179
+ }
3180
+ /**
3181
+ * Recursively find emails in object
3182
+ */
3183
+ findEmailsInObject(obj, depth = 0) {
3184
+ if (depth > 5)
3185
+ return []; // Prevent infinite recursion
3186
+ const emails = [];
3187
+ for (const key in obj) {
3188
+ if (!obj.hasOwnProperty(key))
3189
+ continue;
3190
+ const value = obj[key];
3191
+ // Check if key suggests email field
3192
+ if (/email/i.test(key) && typeof value === 'string' && this.isValidEmail(value)) {
3193
+ emails.push(value);
3194
+ }
3195
+ // Recursively check nested objects
3196
+ else if (typeof value === 'object' && value !== null) {
3197
+ emails.push(...this.findEmailsInObject(value, depth + 1));
3198
+ }
3199
+ }
3200
+ return emails;
3201
+ }
3202
+ /**
3203
+ * Check if domain is trusted for auto-identify
3204
+ */
3205
+ isTrustedDomain(url) {
3206
+ try {
3207
+ const urlObj = new URL(url, window.location.origin);
3208
+ // Always trust same origin
3209
+ if (urlObj.origin === window.location.origin) {
3210
+ return true;
3211
+ }
3212
+ // Check trusted domains list
3213
+ if (this.config.trustedDomains.length === 0) {
3214
+ // If no trusted domains specified, only trust same origin
3215
+ return false;
3216
+ }
3217
+ return this.config.trustedDomains.some(domain => {
3218
+ return urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`);
3219
+ });
3220
+ }
3221
+ catch (error) {
3222
+ return false;
3223
+ }
3224
+ }
3225
+ /**
3226
+ * Validate email format
3227
+ */
3228
+ isValidEmail(email) {
3229
+ if (!email || typeof email !== 'string')
3230
+ return false;
3231
+ // Basic email validation
3232
+ const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
3233
+ if (!emailPattern.test(email))
3234
+ return false;
3235
+ // Exclude common test/dummy emails
3236
+ const dummyPatterns = [
3237
+ /test@/i,
3238
+ /example@/i,
3239
+ /demo@/i,
3240
+ /fake@/i,
3241
+ /@test\./i,
3242
+ /@example\./i
3243
+ ];
3244
+ if (dummyPatterns.some(pattern => pattern.test(email))) {
3245
+ return false;
3246
+ }
3247
+ return true;
3248
+ }
3249
+ /**
3250
+ * Trigger identify with rate limiting
3251
+ */
3252
+ triggerIdentify(email, source) {
3253
+ // Rate limiting
3254
+ const now = Date.now();
3255
+ if (now - this.lastIdentifyTime < this.RATE_LIMIT_MS) {
3256
+ this.log('Rate limited, skipping identify');
3257
+ return;
3258
+ }
3259
+ // Check if already identified
3260
+ const existingEmail = storage.get('dl_auto_identified_email');
3261
+ if (existingEmail === email) {
3262
+ this.log('Already identified with this email');
3263
+ return;
3264
+ }
3265
+ // Store email to prevent duplicate identification
3266
+ storage.set('dl_auto_identified_email', email);
3267
+ this.lastIdentifyTime = now;
3268
+ // Trigger callback
3269
+ if (this.identifyCallback) {
3270
+ this.log(`Auto-identifying user: ${email} (source: ${source})`);
3271
+ this.identifyCallback(email, source);
3272
+ }
3273
+ }
3274
+ /**
3275
+ * Destroy and cleanup
3276
+ */
3277
+ destroy() {
3278
+ // Restore original fetch
3279
+ if (this.originalFetch) {
3280
+ window.fetch = this.originalFetch;
3281
+ this.originalFetch = undefined;
3282
+ }
3283
+ // Restore original XHR
3284
+ if (this.originalXHROpen) {
3285
+ XMLHttpRequest.prototype.open = this.originalXHROpen;
3286
+ this.originalXHROpen = undefined;
3287
+ }
3288
+ if (this.originalXHRSend) {
3289
+ XMLHttpRequest.prototype.send = this.originalXHRSend;
3290
+ this.originalXHRSend = undefined;
3291
+ }
3292
+ // Remove form listeners
3293
+ this.formListeners.forEach(({ element, handler }) => {
3294
+ element.removeEventListener('submit', handler);
3295
+ });
3296
+ this.formListeners = [];
3297
+ // Clear Shopify interval
3298
+ if (this.shopifyCheckInterval) {
3299
+ clearInterval(this.shopifyCheckInterval);
3300
+ this.shopifyCheckInterval = undefined;
3301
+ }
3302
+ this.log('Auto-identify destroyed');
3303
+ }
3304
+ /**
3305
+ * Debug logging
3306
+ */
3307
+ log(...args) {
3308
+ if (this.config.debug) {
3309
+ console.log('[Datalyr Auto-Identify]', ...args);
3310
+ }
3311
+ }
3312
+ }
3313
+
2431
3314
  /**
2432
3315
  * Datalyr Web SDK
2433
3316
  * Modern attribution tracking for web applications
@@ -2440,9 +3323,9 @@ var Datalyr = (function () {
2440
3323
  this.initialized = false;
2441
3324
  this.errors = [];
2442
3325
  this.MAX_ERRORS = 50;
2443
- this.heavyFingerprintCollected = false;
2444
- // Check for opt-out cookie on instantiation using default cookie instance
2445
- this.optedOut = cookies.get('__dl_opt_out') === 'true';
3326
+ // FIXED (ISSUE-01): Async initialization promise to prevent race conditions
3327
+ this.initializationPromise = null;
3328
+ // Opt-out check moved to init() after cookies configured (Issue #14)
2446
3329
  }
2447
3330
  /**
2448
3331
  * Initialize the SDK
@@ -2457,7 +3340,7 @@ var Datalyr = (function () {
2457
3340
  throw new Error('[Datalyr] workspaceId is required');
2458
3341
  }
2459
3342
  // Set default config values
2460
- this.config = Object.assign({ endpoint: 'https://ingest.datalyr.com', debug: false, batchSize: 10, flushInterval: 5000, flushAt: 10, criticalEvents: undefined, highPriorityEvents: undefined, sessionTimeout: 30 * 60 * 1000, trackSessions: true, attributionWindow: 30 * 24 * 60 * 60 * 1000, trackedParams: [], respectDoNotTrack: false, respectGlobalPrivacyControl: true, privacyMode: 'standard', cookieDomain: 'auto', cookieExpires: 365, secureCookie: 'auto', sameSite: 'Lax', cookiePrefix: '__dl_', enablePerformanceTracking: true, enableFingerprinting: true, maxRetries: 5, retryDelay: 1000, maxOfflineQueueSize: 100, trackSPA: true, trackPageViews: true, fallbackEndpoints: [], plugins: [] }, config);
3343
+ this.config = Object.assign({ endpoint: 'https://ingest.datalyr.com', debug: false, batchSize: 10, flushInterval: 5000, flushAt: 10, criticalEvents: undefined, highPriorityEvents: undefined, sessionTimeout: 60 * 60 * 1000, trackSessions: true, attributionWindow: 90 * 24 * 60 * 60 * 1000, trackedParams: [], respectDoNotTrack: false, respectGlobalPrivacyControl: true, privacyMode: 'standard', cookieDomain: 'auto', cookieExpires: 365, secureCookie: 'auto', sameSite: 'Lax', cookiePrefix: '__dl_', enablePerformanceTracking: true, enableFingerprinting: true, maxRetries: 5, retryDelay: 1000, maxOfflineQueueSize: 100, trackSPA: true, trackPageViews: true, fallbackEndpoints: [], plugins: [] }, config);
2461
3344
  // Initialize cookie storage with config
2462
3345
  this.cookies = new CookieStorage({
2463
3346
  domain: this.config.cookieDomain,
@@ -2465,6 +3348,10 @@ var Datalyr = (function () {
2465
3348
  sameSite: this.config.sameSite,
2466
3349
  secure: this.config.secureCookie
2467
3350
  });
3351
+ // Migrate legacy storage keys (fixes double-prefix issue)
3352
+ storage.migrateFromLegacyPrefix();
3353
+ // Check opt-out AFTER cookies configured (Issue #14)
3354
+ this.optedOut = this.cookies.get('__dl_opt_out') === 'true';
2468
3355
  // Initialize modules
2469
3356
  this.identity = new IdentityManager();
2470
3357
  this.session = new SessionManager(this.config.sessionTimeout);
@@ -2480,28 +3367,9 @@ var Datalyr = (function () {
2480
3367
  // Set session ID in identity manager
2481
3368
  const sessionId = this.session.getSessionId();
2482
3369
  this.identity.setSessionId(sessionId);
2483
- // Load stored user properties
2484
- this.userProperties = storage.get('dl_user_traits', {});
2485
- // Setup SPA tracking if enabled
2486
- if (this.config.trackSPA) {
2487
- this.setupSPATracking();
2488
- }
2489
- // Initialize container manager if enabled
2490
- if (this.config.enableContainer !== false) {
2491
- this.container = new ContainerManager({
2492
- workspaceId: this.config.workspaceId,
2493
- endpoint: this.config.endpoint,
2494
- debug: this.config.debug
2495
- });
2496
- // Initialize container asynchronously
2497
- this.container.init().catch(error => {
2498
- this.log('Container initialization failed:', error);
2499
- });
2500
- }
2501
- // Track initial page view if enabled
2502
- if (this.config.trackPageViews) {
2503
- this.page();
2504
- }
3370
+ // FIXED (ISSUE-01): Start async initialization immediately but don't block constructor
3371
+ // This allows encryption to initialize before any events are tracked
3372
+ this.initializeAsync();
2505
3373
  // Setup page unload handler
2506
3374
  this.setupUnloadHandler();
2507
3375
  // Initialize plugins
@@ -2512,6 +3380,8 @@ var Datalyr = (function () {
2512
3380
  this.log(`Plugin initialized: ${plugin.name}`);
2513
3381
  }
2514
3382
  catch (error) {
3383
+ // Issue #16: Always warn about plugin failures, even when debug is false
3384
+ console.warn(`[Datalyr] Plugin '${plugin.name}' failed to initialize:`, error);
2515
3385
  this.trackError(error, { plugin: plugin.name });
2516
3386
  }
2517
3387
  }
@@ -2519,20 +3389,103 @@ var Datalyr = (function () {
2519
3389
  this.initialized = true;
2520
3390
  this.log('SDK initialized');
2521
3391
  }
3392
+ /**
3393
+ * Complete async initialization (encryption, user properties, container, page view)
3394
+ *
3395
+ * FIXED (ISSUE-01): Separated async initialization to prevent race conditions
3396
+ * Encryption must complete before first events are tracked
3397
+ */
3398
+ initializeAsync() {
3399
+ return __awaiter(this, void 0, void 0, function* () {
3400
+ if (this.initializationPromise) {
3401
+ return this.initializationPromise;
3402
+ }
3403
+ this.initializationPromise = (() => __awaiter(this, void 0, void 0, function* () {
3404
+ try {
3405
+ // SEC-03 Fix: Initialize encryption for PII data
3406
+ const deviceId = this.identity.getAnonymousId();
3407
+ yield dataEncryption.initialize(this.config.workspaceId, deviceId);
3408
+ // Load encrypted user properties
3409
+ this.userProperties = yield storage.getEncrypted('dl_user_traits', {});
3410
+ this.log('Encryption initialized, user properties loaded');
3411
+ // Setup SPA tracking if enabled
3412
+ if (this.config.trackSPA) {
3413
+ this.setupSPATracking();
3414
+ }
3415
+ // Initialize container manager if enabled
3416
+ if (this.config.enableContainer !== false) {
3417
+ this.container = new ContainerManager({
3418
+ workspaceId: this.config.workspaceId,
3419
+ endpoint: this.config.endpoint,
3420
+ debug: this.config.debug
3421
+ });
3422
+ // Initialize container asynchronously
3423
+ yield this.container.init().catch(error => {
3424
+ this.log('Container initialization failed:', error);
3425
+ });
3426
+ }
3427
+ // Initialize auto-identify if explicitly enabled (opt-in)
3428
+ if (this.config.autoIdentify === true) {
3429
+ this.autoIdentify = new AutoIdentifyManager({
3430
+ enabled: true,
3431
+ captureFromForms: this.config.autoIdentifyForms,
3432
+ captureFromAPI: this.config.autoIdentifyAPI,
3433
+ captureFromShopify: this.config.autoIdentifyShopify,
3434
+ trustedDomains: this.config.autoIdentifyTrustedDomains,
3435
+ debug: this.config.debug
3436
+ });
3437
+ // Setup auto-identify callback
3438
+ this.autoIdentify.initialize((email, source) => {
3439
+ this.log(`Auto-identified user: ${email} from ${source}`);
3440
+ // Track auto-identify event
3441
+ this.track('$auto_identify', {
3442
+ email,
3443
+ source,
3444
+ timestamp: Date.now()
3445
+ });
3446
+ // Automatically call identify with email
3447
+ this.identify(email, { email });
3448
+ });
3449
+ }
3450
+ // Track initial page view if enabled (AFTER encryption ready)
3451
+ if (this.config.trackPageViews) {
3452
+ this.page();
3453
+ }
3454
+ this.log('Async initialization complete');
3455
+ }
3456
+ catch (error) {
3457
+ console.error('[Datalyr] Async initialization failed:', error);
3458
+ // Fallback: Load unencrypted and continue
3459
+ this.userProperties = storage.get('dl_user_traits', {});
3460
+ }
3461
+ }))();
3462
+ return this.initializationPromise;
3463
+ });
3464
+ }
3465
+ /**
3466
+ * Wait for async initialization to complete
3467
+ * FIXED (ISSUE-01): Public method to await full initialization
3468
+ */
3469
+ ready() {
3470
+ return __awaiter(this, void 0, void 0, function* () {
3471
+ if (!this.initialized) {
3472
+ throw new Error('[Datalyr] SDK not initialized. Call init() first.');
3473
+ }
3474
+ return this.initializationPromise || Promise.resolve();
3475
+ });
3476
+ }
2522
3477
  /**
2523
3478
  * Track an event
2524
3479
  */
2525
3480
  track(eventName, properties = {}) {
3481
+ if (!this.initialized) {
3482
+ console.warn('[Datalyr] SDK not initialized. Call init() first.');
3483
+ return;
3484
+ }
2526
3485
  if (!this.shouldTrack())
2527
3486
  return;
2528
3487
  try {
2529
- // Collect heavy fingerprint on first event (lazy loading)
2530
- if (!this.heavyFingerprintCollected && this.config.enableFingerprinting) {
2531
- this.heavyFingerprintCollected = true;
2532
- this.fingerprint.collectHeavyFingerprint().catch(err => {
2533
- this.log('Heavy fingerprint collection failed:', err);
2534
- });
2535
- }
3488
+ // PRIVACY: Heavy fingerprinting removed - using minimal fingerprinting only
2536
3489
  // Update session activity
2537
3490
  this.session.updateActivity(eventName);
2538
3491
  // Create event payload
@@ -2566,6 +3519,10 @@ var Datalyr = (function () {
2566
3519
  * Identify a user
2567
3520
  */
2568
3521
  identify(userId, traits = {}) {
3522
+ if (!this.initialized) {
3523
+ console.warn('[Datalyr] SDK not initialized. Call init() first.');
3524
+ return;
3525
+ }
2569
3526
  if (!this.shouldTrack())
2570
3527
  return;
2571
3528
  if (!userId) {
@@ -2573,11 +3530,23 @@ var Datalyr = (function () {
2573
3530
  return;
2574
3531
  }
2575
3532
  try {
3533
+ // FIXED (DATA-05): Rotate session ID on identify to prevent session fixation
3534
+ if (this.session) {
3535
+ this.session.rotateSessionId();
3536
+ }
2576
3537
  // Update identity
2577
3538
  const identityLink = this.identity.identify(userId, traits);
2578
- // Store user properties
3539
+ // SEC-03 Fix: Store user properties encrypted (contains PII)
3540
+ // FIXED (H2): Fail loudly instead of silent fallback to unencrypted
2579
3541
  this.userProperties = Object.assign(Object.assign({}, this.userProperties), traits);
2580
- storage.set('dl_user_traits', this.userProperties);
3542
+ storage.setEncrypted('dl_user_traits', this.userProperties).catch(error => {
3543
+ console.error('[Datalyr] Failed to encrypt user traits - NOT storing unencrypted PII:', error);
3544
+ console.error('[Datalyr] User traits will only persist in memory until page reload');
3545
+ // DO NOT fallback to unencrypted storage - this would expose PII
3546
+ // This is a breaking change from the previous silent fallback behavior
3547
+ // If encryption fails, user traits are only stored in memory (this.userProperties)
3548
+ // and will be lost on page reload - but PII is NOT exposed in localStorage
3549
+ });
2581
3550
  // Track $identify event
2582
3551
  this.track('$identify', Object.assign(Object.assign({}, identityLink), { traits }));
2583
3552
  // Call plugin handlers
@@ -2603,6 +3572,10 @@ var Datalyr = (function () {
2603
3572
  * Track a page view
2604
3573
  */
2605
3574
  page(properties = {}) {
3575
+ if (!this.initialized) {
3576
+ console.warn('[Datalyr] SDK not initialized. Call init() first.');
3577
+ return;
3578
+ }
2606
3579
  if (!this.shouldTrack())
2607
3580
  return;
2608
3581
  const pageData = Object.assign({ title: document.title, url: window.location.href, path: window.location.pathname, search: window.location.search, referrer: document.referrer }, properties);
@@ -2641,6 +3614,10 @@ var Datalyr = (function () {
2641
3614
  * Associate user with a group/account
2642
3615
  */
2643
3616
  group(groupId, traits = {}) {
3617
+ if (!this.initialized) {
3618
+ console.warn('[Datalyr] SDK not initialized. Call init() first.');
3619
+ return;
3620
+ }
2644
3621
  this.track('$group', {
2645
3622
  group_id: groupId,
2646
3623
  traits
@@ -2650,6 +3627,10 @@ var Datalyr = (function () {
2650
3627
  * Alias one ID to another
2651
3628
  */
2652
3629
  alias(userId, previousId) {
3630
+ if (!this.initialized) {
3631
+ console.warn('[Datalyr] SDK not initialized. Call init() first.');
3632
+ return;
3633
+ }
2653
3634
  const aliasData = this.identity.alias(userId, previousId);
2654
3635
  this.track('$alias', aliasData);
2655
3636
  }
@@ -2657,6 +3638,10 @@ var Datalyr = (function () {
2657
3638
  * Reset the current user
2658
3639
  */
2659
3640
  reset() {
3641
+ if (!this.initialized) {
3642
+ console.warn('[Datalyr] SDK not initialized. Call init() first.');
3643
+ return;
3644
+ }
2660
3645
  this.identity.reset();
2661
3646
  this.userProperties = {};
2662
3647
  storage.remove('dl_user_traits');
@@ -2725,8 +3710,12 @@ var Datalyr = (function () {
2725
3710
  * Opt out of tracking
2726
3711
  */
2727
3712
  optOut() {
3713
+ if (!this.initialized) {
3714
+ console.warn('[Datalyr] SDK not initialized. Call init() first.');
3715
+ return;
3716
+ }
2728
3717
  this.optedOut = true;
2729
- cookies.set('__dl_opt_out', 'true', this.config.cookieExpires);
3718
+ this.cookies.set('__dl_opt_out', 'true', this.config.cookieExpires);
2730
3719
  this.queue.clear();
2731
3720
  this.log('User opted out');
2732
3721
  }
@@ -2734,8 +3723,12 @@ var Datalyr = (function () {
2734
3723
  * Opt in to tracking
2735
3724
  */
2736
3725
  optIn() {
3726
+ if (!this.initialized) {
3727
+ console.warn('[Datalyr] SDK not initialized. Call init() first.');
3728
+ return;
3729
+ }
2737
3730
  this.optedOut = false;
2738
- cookies.set('__dl_opt_out', 'false', this.config.cookieExpires);
3731
+ this.cookies.set('__dl_opt_out', 'false', this.config.cookieExpires);
2739
3732
  this.log('User opted in');
2740
3733
  }
2741
3734
  /**
@@ -2796,8 +3789,7 @@ var Datalyr = (function () {
2796
3789
  if (this.config.enableFingerprinting) {
2797
3790
  const fingerprintData = this.fingerprint.collect();
2798
3791
  Object.assign(eventData, {
2799
- fingerprint: fingerprintData,
2800
- device_fingerprint: fingerprintData // Snake case alias
3792
+ device_fingerprint: fingerprintData // Use snake_case only (matches backend)
2801
3793
  });
2802
3794
  }
2803
3795
  // Add browser context
@@ -2811,7 +3803,7 @@ var Datalyr = (function () {
2811
3803
  viewport_width: window.innerWidth,
2812
3804
  viewport_height: window.innerHeight
2813
3805
  });
2814
- // Create payload with both camelCase and snake_case fields
3806
+ // Create payload using snake_case only (matches backend API and production script)
2815
3807
  const identityFields = this.identity.getIdentityFields();
2816
3808
  const eventId = generateUUID();
2817
3809
  // Ensure we have all required identity fields
@@ -2820,31 +3812,25 @@ var Datalyr = (function () {
2820
3812
  const visitorId = identityFields.visitor_id || anonymousId;
2821
3813
  const sessionId = identityFields.session_id;
2822
3814
  const payload = {
2823
- // Required fields
2824
- workspaceId: this.config.workspaceId,
2825
- workspace_id: this.config.workspaceId, // Snake case alias
2826
- eventId: eventId,
2827
- event_id: eventId, // Snake case alias (same ID)
2828
- eventName,
2829
- event_name: eventName, // Snake case alias
2830
- eventData,
2831
- event_data: eventData, // Snake case alias
3815
+ // Required fields (snake_case only - no duplication)
3816
+ workspace_id: this.config.workspaceId,
3817
+ event_id: eventId,
3818
+ event_name: eventName,
3819
+ event_data: eventData,
2832
3820
  source: 'web',
2833
3821
  timestamp: new Date().toISOString(),
2834
- // Identity fields (explicit to satisfy TypeScript)
3822
+ // Identity fields (snake_case only)
2835
3823
  distinct_id: distinctId,
2836
3824
  anonymous_id: anonymousId,
2837
3825
  visitor_id: visitorId,
2838
- visitorId: visitorId,
2839
3826
  user_id: identityFields.user_id,
2840
3827
  canonical_id: identityFields.canonical_id,
2841
- sessionId: sessionId,
2842
3828
  session_id: sessionId,
2843
3829
  // Resolution metadata
2844
3830
  resolution_method: 'browser_sdk',
2845
3831
  resolution_confidence: 1.0,
2846
3832
  // SDK metadata
2847
- sdk_version: '1.0.0',
3833
+ sdk_version: '1.1.1',
2848
3834
  sdk_name: 'datalyr-web-sdk'
2849
3835
  };
2850
3836
  return payload;
@@ -2869,36 +3855,48 @@ var Datalyr = (function () {
2869
3855
  }
2870
3856
  /**
2871
3857
  * Setup SPA tracking
3858
+ * Fixed Issue #15: Store original methods for cleanup
3859
+ * Fixed CRITICAL-03: Clear attribution cache on navigation to prevent stale data
2872
3860
  */
2873
3861
  setupSPATracking() {
2874
- // Store original methods
2875
- const originalPushState = history.pushState;
2876
- const originalReplaceState = history.replaceState;
3862
+ // Store original methods for cleanup
3863
+ this.originalPushState = history.pushState;
3864
+ this.originalReplaceState = history.replaceState;
2877
3865
  const self = this;
2878
3866
  // Override pushState
2879
3867
  history.pushState = function (...args) {
2880
- originalPushState.apply(history, args);
3868
+ self.originalPushState.apply(history, args);
2881
3869
  setTimeout(() => {
3870
+ // Clear attribution cache to capture fresh URL params
3871
+ self.attribution.clearCache();
2882
3872
  self.page();
2883
3873
  }, 0);
2884
3874
  };
2885
3875
  // Override replaceState
2886
3876
  history.replaceState = function (...args) {
2887
- originalReplaceState.apply(history, args);
3877
+ self.originalReplaceState.apply(history, args);
2888
3878
  setTimeout(() => {
3879
+ // Clear attribution cache to capture fresh URL params
3880
+ self.attribution.clearCache();
2889
3881
  self.page();
2890
3882
  }, 0);
2891
3883
  };
2892
3884
  // Listen for popstate
2893
- window.addEventListener('popstate', () => {
3885
+ this.popstateHandler = () => {
2894
3886
  setTimeout(() => {
2895
- this.page();
3887
+ // Clear attribution cache to capture fresh URL params
3888
+ self.attribution.clearCache();
3889
+ self.page();
2896
3890
  }, 0);
2897
- });
3891
+ };
3892
+ window.addEventListener('popstate', this.popstateHandler);
2898
3893
  // Listen for hashchange
2899
- window.addEventListener('hashchange', () => {
2900
- this.page();
2901
- });
3894
+ this.hashchangeHandler = () => {
3895
+ // Clear attribution cache to capture fresh URL params
3896
+ self.attribution.clearCache();
3897
+ self.page();
3898
+ };
3899
+ window.addEventListener('hashchange', this.hashchangeHandler);
2902
3900
  }
2903
3901
  /**
2904
3902
  * Setup page unload handler
@@ -3005,6 +4003,20 @@ var Datalyr = (function () {
3005
4003
  * Destroy the SDK instance and cleanup resources
3006
4004
  */
3007
4005
  destroy() {
4006
+ // Restore original history methods (Issue #15)
4007
+ if (this.originalPushState) {
4008
+ history.pushState = this.originalPushState;
4009
+ }
4010
+ if (this.originalReplaceState) {
4011
+ history.replaceState = this.originalReplaceState;
4012
+ }
4013
+ // Remove event listeners (Issue #15)
4014
+ if (this.popstateHandler) {
4015
+ window.removeEventListener('popstate', this.popstateHandler);
4016
+ }
4017
+ if (this.hashchangeHandler) {
4018
+ window.removeEventListener('hashchange', this.hashchangeHandler);
4019
+ }
3008
4020
  // Clean up queue
3009
4021
  if (this.queue) {
3010
4022
  this.queue.destroy();
@@ -3013,6 +4025,16 @@ var Datalyr = (function () {
3013
4025
  if (this.session) {
3014
4026
  this.session.destroy();
3015
4027
  }
4028
+ // FIXED (ISSUE-02): Clean up sandboxed iframes to prevent memory leaks
4029
+ if (this.container) {
4030
+ this.container.cleanupAllIframes();
4031
+ }
4032
+ // Clean up auto-identify
4033
+ if (this.autoIdentify) {
4034
+ this.autoIdentify.destroy();
4035
+ }
4036
+ // SEC-03 Fix: Clean up encryption keys
4037
+ dataEncryption.destroy();
3016
4038
  // Clear any remaining data
3017
4039
  this.superProperties = {};
3018
4040
  this.userProperties = {};