@datalyr/web 1.1.0 → 1.2.0

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