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