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