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