@clianta/sdk 1.6.7 → 1.7.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
- * Clianta SDK v1.6.7
2
+ * Clianta SDK v1.7.1
3
3
  * (c) 2026 Clianta
4
4
  * Released under the MIT License.
5
5
  */
@@ -11,7 +11,7 @@ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentS
11
11
  * @see SDK_VERSION in core/config.ts
12
12
  */
13
13
  /** SDK Version */
14
- const SDK_VERSION = '1.6.7';
14
+ const SDK_VERSION = '1.7.0';
15
15
  /** Default API endpoint — reads from env or falls back to localhost */
16
16
  const getDefaultApiEndpoint = () => {
17
17
  // Next.js (process.env)
@@ -37,6 +37,15 @@ const getDefaultApiEndpoint = () => {
37
37
  if (typeof process !== 'undefined' && process.env?.CLIANTA_API_ENDPOINT) {
38
38
  return process.env.CLIANTA_API_ENDPOINT;
39
39
  }
40
+ // No env var found — warn if we're not on localhost (likely a production misconfiguration)
41
+ const isLocalhost = typeof window !== 'undefined' &&
42
+ (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
43
+ if (!isLocalhost && typeof console !== 'undefined') {
44
+ console.warn('[Clianta] No API endpoint configured. ' +
45
+ 'Set NEXT_PUBLIC_CLIANTA_API_ENDPOINT (Next.js), VITE_CLIANTA_API_ENDPOINT (Vite), ' +
46
+ 'or pass apiEndpoint directly to clianta(). ' +
47
+ 'Falling back to localhost — tracking will not work in production.');
48
+ }
40
49
  return 'http://localhost:5000';
41
50
  };
42
51
  /** Core plugins enabled by default — all auto-track with zero config */
@@ -182,7 +191,9 @@ const logger = createLogger(false);
182
191
  */
183
192
  const DEFAULT_TIMEOUT = 10000; // 10 seconds
184
193
  const DEFAULT_MAX_RETRIES = 3;
185
- const DEFAULT_RETRY_DELAY = 1000; // 1 second
194
+ const DEFAULT_RETRY_DELAY = 1000; // 1 second base — doubles each attempt (exponential backoff)
195
+ /** fetch keepalive hard limit in browsers (64KB) */
196
+ const KEEPALIVE_SIZE_LIMIT = 60000; // leave 4KB margin
186
197
  /**
187
198
  * Transport class for sending data to the backend
188
199
  */
@@ -201,6 +212,11 @@ class Transport {
201
212
  async sendEvents(events) {
202
213
  const url = `${this.config.apiEndpoint}/api/public/track/event`;
203
214
  const payload = JSON.stringify({ events });
215
+ // keepalive has a 64KB hard limit — fall back to beacon if too large
216
+ if (payload.length > KEEPALIVE_SIZE_LIMIT) {
217
+ const sent = this.sendBeacon(events);
218
+ return sent ? { success: true } : this.send(url, payload, 1, false);
219
+ }
204
220
  return this.send(url, payload);
205
221
  }
206
222
  /**
@@ -265,6 +281,15 @@ class Transport {
265
281
  return false;
266
282
  }
267
283
  }
284
+ /**
285
+ * Send an arbitrary POST request through the transport (with timeout + retry).
286
+ * Used for one-off calls like alias() that don't fit the event-batch or identify shapes.
287
+ */
288
+ async sendPost(path, body) {
289
+ const url = `${this.config.apiEndpoint}${path}`;
290
+ const payload = JSON.stringify(body);
291
+ return this.send(url, payload);
292
+ }
268
293
  /**
269
294
  * Fetch data from the tracking API (GET request)
270
295
  * Used for read-back APIs (visitor profile, activity, etc.)
@@ -299,38 +324,44 @@ class Transport {
299
324
  }
300
325
  }
301
326
  /**
302
- * Internal send with retry logic
327
+ * Internal send with exponential backoff retry logic
303
328
  */
304
- async send(url, payload, attempt = 1) {
329
+ async send(url, payload, attempt = 1, useKeepalive = true) {
330
+ // Don't bother sending when offline — caller should re-queue
331
+ if (typeof navigator !== 'undefined' && !navigator.onLine) {
332
+ logger.warn('Device offline, skipping send');
333
+ return { success: false, error: new Error('offline') };
334
+ }
305
335
  try {
306
336
  const response = await this.fetchWithTimeout(url, {
307
337
  method: 'POST',
308
- headers: {
309
- 'Content-Type': 'application/json',
310
- },
338
+ headers: { 'Content-Type': 'application/json' },
311
339
  body: payload,
312
- keepalive: true,
340
+ keepalive: useKeepalive,
313
341
  });
314
342
  if (response.ok) {
315
343
  logger.debug('Request successful:', url);
316
344
  return { success: true, status: response.status };
317
345
  }
318
- // Server error - may retry
346
+ // Server error retry with exponential backoff
319
347
  if (response.status >= 500 && attempt < this.config.maxRetries) {
320
- logger.warn(`Server error (${response.status}), retrying...`);
321
- await this.delay(this.config.retryDelay * attempt);
322
- return this.send(url, payload, attempt + 1);
348
+ const backoff = this.config.retryDelay * Math.pow(2, attempt - 1);
349
+ logger.warn(`Server error (${response.status}), retrying in ${backoff}ms...`);
350
+ await this.delay(backoff);
351
+ return this.send(url, payload, attempt + 1, useKeepalive);
323
352
  }
324
- // Client error - don't retry
353
+ // 4xx don't retry (bad payload, auth failure, etc.)
325
354
  logger.error(`Request failed with status ${response.status}`);
326
355
  return { success: false, status: response.status };
327
356
  }
328
357
  catch (error) {
329
- // Network error - retry if possible
330
- if (attempt < this.config.maxRetries) {
331
- logger.warn(`Network error, retrying (${attempt}/${this.config.maxRetries})...`);
332
- await this.delay(this.config.retryDelay * attempt);
333
- return this.send(url, payload, attempt + 1);
358
+ // Network error retry with exponential backoff if still online
359
+ const isOnline = typeof navigator === 'undefined' || navigator.onLine;
360
+ if (isOnline && attempt < this.config.maxRetries) {
361
+ const backoff = this.config.retryDelay * Math.pow(2, attempt - 1);
362
+ logger.warn(`Network error, retrying in ${backoff}ms (${attempt}/${this.config.maxRetries})...`);
363
+ await this.delay(backoff);
364
+ return this.send(url, payload, attempt + 1, useKeepalive);
334
365
  }
335
366
  logger.error('Request failed after retries:', error);
336
367
  return { success: false, error: error };
@@ -679,12 +710,17 @@ class EventQueue {
679
710
  this.queue = [];
680
711
  this.flushTimer = null;
681
712
  this.isFlushing = false;
713
+ this.isOnline = true;
682
714
  /** Rate limiting: timestamps of recent events */
683
715
  this.eventTimestamps = [];
684
716
  /** Unload handler references for cleanup */
685
717
  this.boundBeforeUnload = null;
686
718
  this.boundVisibilityChange = null;
687
719
  this.boundPageHide = null;
720
+ this.boundOnline = null;
721
+ this.boundOffline = null;
722
+ /** Guards against double-flush on unload (beforeunload + pagehide + visibilitychange all fire) */
723
+ this.unloadFlushed = false;
688
724
  this.transport = transport;
689
725
  this.config = {
690
726
  batchSize: config.batchSize ?? 10,
@@ -693,6 +729,7 @@ class EventQueue {
693
729
  storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
694
730
  };
695
731
  this.persistMode = config.persistMode || 'session';
732
+ this.isOnline = typeof navigator === 'undefined' || navigator.onLine;
696
733
  // Restore persisted queue
697
734
  this.restoreQueue();
698
735
  // Start auto-flush timer
@@ -741,7 +778,7 @@ class EventQueue {
741
778
  * Flush the queue (send all events)
742
779
  */
743
780
  async flush() {
744
- if (this.isFlushing || this.queue.length === 0) {
781
+ if (this.isFlushing || this.queue.length === 0 || !this.isOnline) {
745
782
  return;
746
783
  }
747
784
  this.isFlushing = true;
@@ -772,11 +809,14 @@ class EventQueue {
772
809
  }
773
810
  }
774
811
  /**
775
- * Flush synchronously using sendBeacon (for page unload)
812
+ * Flush synchronously using sendBeacon (for page unload).
813
+ * Guarded: no-ops after the first call per navigation to prevent
814
+ * triple-flush from beforeunload + visibilitychange + pagehide.
776
815
  */
777
816
  flushSync() {
778
- if (this.queue.length === 0)
817
+ if (this.unloadFlushed || this.queue.length === 0)
779
818
  return;
819
+ this.unloadFlushed = true;
780
820
  const events = this.queue.splice(0, this.queue.length);
781
821
  logger.debug(`Sync flushing ${events.length} events via beacon`);
782
822
  const success = this.transport.sendBeacon(events);
@@ -814,17 +854,17 @@ class EventQueue {
814
854
  clearInterval(this.flushTimer);
815
855
  this.flushTimer = null;
816
856
  }
817
- // Remove unload handlers
818
857
  if (typeof window !== 'undefined') {
819
- if (this.boundBeforeUnload) {
858
+ if (this.boundBeforeUnload)
820
859
  window.removeEventListener('beforeunload', this.boundBeforeUnload);
821
- }
822
- if (this.boundVisibilityChange) {
860
+ if (this.boundVisibilityChange)
823
861
  window.removeEventListener('visibilitychange', this.boundVisibilityChange);
824
- }
825
- if (this.boundPageHide) {
862
+ if (this.boundPageHide)
826
863
  window.removeEventListener('pagehide', this.boundPageHide);
827
- }
864
+ if (this.boundOnline)
865
+ window.removeEventListener('online', this.boundOnline);
866
+ if (this.boundOffline)
867
+ window.removeEventListener('offline', this.boundOffline);
828
868
  }
829
869
  }
830
870
  /**
@@ -839,24 +879,38 @@ class EventQueue {
839
879
  }, this.config.flushInterval);
840
880
  }
841
881
  /**
842
- * Setup page unload handlers
882
+ * Setup page unload handlers and online/offline listeners
843
883
  */
844
884
  setupUnloadHandlers() {
845
885
  if (typeof window === 'undefined')
846
886
  return;
847
- // Flush on page unload
887
+ // All three unload events share the same guarded flushSync()
848
888
  this.boundBeforeUnload = () => this.flushSync();
849
889
  window.addEventListener('beforeunload', this.boundBeforeUnload);
850
- // Flush when page becomes hidden
851
890
  this.boundVisibilityChange = () => {
852
891
  if (document.visibilityState === 'hidden') {
853
892
  this.flushSync();
854
893
  }
894
+ else {
895
+ // Page became visible again (e.g. tab switch back) — reset guard
896
+ this.unloadFlushed = false;
897
+ }
855
898
  };
856
899
  window.addEventListener('visibilitychange', this.boundVisibilityChange);
857
- // Flush on page hide (iOS Safari)
858
900
  this.boundPageHide = () => this.flushSync();
859
901
  window.addEventListener('pagehide', this.boundPageHide);
902
+ // Pause queue when offline, resume + flush when back online
903
+ this.boundOnline = () => {
904
+ logger.info('Connection restored — flushing queued events');
905
+ this.isOnline = true;
906
+ this.flush();
907
+ };
908
+ this.boundOffline = () => {
909
+ logger.warn('Connection lost — pausing event queue');
910
+ this.isOnline = false;
911
+ };
912
+ window.addEventListener('online', this.boundOnline);
913
+ window.addEventListener('offline', this.boundOffline);
860
914
  }
861
915
  /**
862
916
  * Persist queue to storage based on persistMode
@@ -939,6 +993,8 @@ class BasePlugin {
939
993
  * Clianta SDK - Page View Plugin
940
994
  * @see SDK_VERSION in core/config.ts
941
995
  */
996
+ /** Sentinel flag to prevent double-wrapping history methods across multiple SDK instances */
997
+ const WRAPPED_FLAG = '__clianta_pv_wrapped__';
942
998
  /**
943
999
  * Page View Plugin - Tracks page views
944
1000
  */
@@ -948,50 +1004,64 @@ class PageViewPlugin extends BasePlugin {
948
1004
  this.name = 'pageView';
949
1005
  this.originalPushState = null;
950
1006
  this.originalReplaceState = null;
1007
+ this.navHandler = null;
951
1008
  this.popstateHandler = null;
952
1009
  }
953
1010
  init(tracker) {
954
1011
  super.init(tracker);
955
1012
  // Track initial page view
956
1013
  this.trackPageView();
957
- // Track SPA navigation (History API)
958
- if (typeof window !== 'undefined') {
959
- // Store originals for cleanup
1014
+ if (typeof window === 'undefined')
1015
+ return;
1016
+ // Only wrap history methods once — guard against multiple SDK instances (e.g. microfrontends)
1017
+ // wrapping them repeatedly, which would cause duplicate navigation events and broken cleanup.
1018
+ if (!history.pushState[WRAPPED_FLAG]) {
960
1019
  this.originalPushState = history.pushState;
961
1020
  this.originalReplaceState = history.replaceState;
962
- // Intercept pushState and replaceState
963
- const self = this;
1021
+ const originalPush = this.originalPushState;
1022
+ const originalReplace = this.originalReplaceState;
964
1023
  history.pushState = function (...args) {
965
- self.originalPushState.apply(history, args);
966
- self.trackPageView();
967
- // Notify other plugins (e.g. ScrollPlugin) about navigation
1024
+ originalPush.apply(history, args);
1025
+ // Dispatch event so all listening instances track the navigation
968
1026
  window.dispatchEvent(new Event('clianta:navigation'));
969
1027
  };
1028
+ history.pushState[WRAPPED_FLAG] = true;
970
1029
  history.replaceState = function (...args) {
971
- self.originalReplaceState.apply(history, args);
972
- self.trackPageView();
1030
+ originalReplace.apply(history, args);
973
1031
  window.dispatchEvent(new Event('clianta:navigation'));
974
1032
  };
975
- // Handle back/forward navigation
976
- this.popstateHandler = () => this.trackPageView();
977
- window.addEventListener('popstate', this.popstateHandler);
1033
+ history.replaceState[WRAPPED_FLAG] = true;
978
1034
  }
1035
+ // Each instance listens to the shared navigation event rather than embedding
1036
+ // tracking directly in the pushState wrapper — decouples tracking from wrapping.
1037
+ this.navHandler = () => this.trackPageView();
1038
+ window.addEventListener('clianta:navigation', this.navHandler);
1039
+ // Handle back/forward navigation
1040
+ this.popstateHandler = () => this.trackPageView();
1041
+ window.addEventListener('popstate', this.popstateHandler);
979
1042
  }
980
1043
  destroy() {
981
- // Restore original history methods
1044
+ if (typeof window !== 'undefined') {
1045
+ if (this.navHandler) {
1046
+ window.removeEventListener('clianta:navigation', this.navHandler);
1047
+ this.navHandler = null;
1048
+ }
1049
+ if (this.popstateHandler) {
1050
+ window.removeEventListener('popstate', this.popstateHandler);
1051
+ this.popstateHandler = null;
1052
+ }
1053
+ }
1054
+ // Restore original history methods only if this instance was the one that wrapped them
982
1055
  if (this.originalPushState) {
983
1056
  history.pushState = this.originalPushState;
1057
+ delete history.pushState[WRAPPED_FLAG];
984
1058
  this.originalPushState = null;
985
1059
  }
986
1060
  if (this.originalReplaceState) {
987
1061
  history.replaceState = this.originalReplaceState;
1062
+ delete history.replaceState[WRAPPED_FLAG];
988
1063
  this.originalReplaceState = null;
989
1064
  }
990
- // Remove popstate listener
991
- if (this.popstateHandler && typeof window !== 'undefined') {
992
- window.removeEventListener('popstate', this.popstateHandler);
993
- this.popstateHandler = null;
994
- }
995
1065
  super.destroy();
996
1066
  }
997
1067
  trackPageView() {
@@ -1120,6 +1190,7 @@ class FormsPlugin extends BasePlugin {
1120
1190
  this.trackedForms = new WeakSet();
1121
1191
  this.formInteractions = new Set();
1122
1192
  this.observer = null;
1193
+ this.observerTimer = null;
1123
1194
  this.listeners = [];
1124
1195
  }
1125
1196
  init(tracker) {
@@ -1128,13 +1199,21 @@ class FormsPlugin extends BasePlugin {
1128
1199
  return;
1129
1200
  // Track existing forms
1130
1201
  this.trackAllForms();
1131
- // Watch for dynamically added forms
1202
+ // Watch for dynamically added forms — debounced to avoid O(DOM) cost on every mutation
1132
1203
  if (typeof MutationObserver !== 'undefined') {
1133
- this.observer = new MutationObserver(() => this.trackAllForms());
1204
+ this.observer = new MutationObserver(() => {
1205
+ if (this.observerTimer)
1206
+ clearTimeout(this.observerTimer);
1207
+ this.observerTimer = setTimeout(() => this.trackAllForms(), 100);
1208
+ });
1134
1209
  this.observer.observe(document.body, { childList: true, subtree: true });
1135
1210
  }
1136
1211
  }
1137
1212
  destroy() {
1213
+ if (this.observerTimer) {
1214
+ clearTimeout(this.observerTimer);
1215
+ this.observerTimer = null;
1216
+ }
1138
1217
  if (this.observer) {
1139
1218
  this.observer.disconnect();
1140
1219
  this.observer = null;
@@ -1256,8 +1335,13 @@ class ClicksPlugin extends BasePlugin {
1256
1335
  super.destroy();
1257
1336
  }
1258
1337
  handleClick(e) {
1259
- const target = e.target;
1260
- if (!target || !isTrackableClickElement(target))
1338
+ // Walk up the DOM to find the nearest trackable ancestor.
1339
+ // Without this, clicks on <span> or <img> inside a <button> are silently dropped.
1340
+ let target = e.target;
1341
+ while (target && !isTrackableClickElement(target)) {
1342
+ target = target.parentElement;
1343
+ }
1344
+ if (!target)
1261
1345
  return;
1262
1346
  const buttonText = getElementText(target, 100);
1263
1347
  const elementInfo = getElementInfo(target);
@@ -1290,6 +1374,8 @@ class EngagementPlugin extends BasePlugin {
1290
1374
  this.engagementStartTime = 0;
1291
1375
  this.isEngaged = false;
1292
1376
  this.engagementTimeout = null;
1377
+ /** Guard: beforeunload + visibilitychange:hidden both fire on tab close — only report once */
1378
+ this.unloadReported = false;
1293
1379
  this.boundMarkEngaged = null;
1294
1380
  this.boundTrackTimeOnPage = null;
1295
1381
  this.boundVisibilityHandler = null;
@@ -1311,8 +1397,9 @@ class EngagementPlugin extends BasePlugin {
1311
1397
  this.trackTimeOnPage();
1312
1398
  }
1313
1399
  else {
1314
- // Reset engagement timer when page becomes visible again
1400
+ // Page is visible again reset both the time counter and the unload guard
1315
1401
  this.engagementStartTime = Date.now();
1402
+ this.unloadReported = false;
1316
1403
  }
1317
1404
  };
1318
1405
  ['mousemove', 'keydown', 'touchstart', 'scroll'].forEach((event) => {
@@ -1358,6 +1445,7 @@ class EngagementPlugin extends BasePlugin {
1358
1445
  this.pageLoadTime = Date.now();
1359
1446
  this.engagementStartTime = Date.now();
1360
1447
  this.isEngaged = false;
1448
+ this.unloadReported = false;
1361
1449
  if (this.engagementTimeout) {
1362
1450
  clearTimeout(this.engagementTimeout);
1363
1451
  this.engagementTimeout = null;
@@ -1379,6 +1467,10 @@ class EngagementPlugin extends BasePlugin {
1379
1467
  }, 30000); // 30 seconds of inactivity
1380
1468
  }
1381
1469
  trackTimeOnPage() {
1470
+ // Guard: beforeunload and visibilitychange:hidden both fire on tab close — only report once
1471
+ if (this.unloadReported)
1472
+ return;
1473
+ this.unloadReported = true;
1382
1474
  const timeSpent = Math.floor((Date.now() - this.engagementStartTime) / 1000);
1383
1475
  if (timeSpent > 0) {
1384
1476
  this.track('time_on_page', 'Time Spent', {
@@ -1537,12 +1629,16 @@ class ExitIntentPlugin extends BasePlugin {
1537
1629
  /**
1538
1630
  * Error Tracking Plugin - Tracks JavaScript errors
1539
1631
  */
1632
+ /** Max unique errors to track per page (prevents queue flooding from error loops) */
1633
+ const MAX_UNIQUE_ERRORS = 20;
1540
1634
  class ErrorsPlugin extends BasePlugin {
1541
1635
  constructor() {
1542
1636
  super(...arguments);
1543
1637
  this.name = 'errors';
1544
1638
  this.boundErrorHandler = null;
1545
1639
  this.boundRejectionHandler = null;
1640
+ /** Seen error fingerprints — deduplicates repeated identical errors */
1641
+ this.seenErrors = new Set();
1546
1642
  }
1547
1643
  init(tracker) {
1548
1644
  super.init(tracker);
@@ -1565,6 +1661,9 @@ class ErrorsPlugin extends BasePlugin {
1565
1661
  super.destroy();
1566
1662
  }
1567
1663
  handleError(e) {
1664
+ const fingerprint = `${e.message}:${e.filename}:${e.lineno}`;
1665
+ if (!this.dedup(fingerprint))
1666
+ return;
1568
1667
  this.track('error', 'JavaScript Error', {
1569
1668
  message: e.message,
1570
1669
  filename: e.filename,
@@ -1574,9 +1673,22 @@ class ErrorsPlugin extends BasePlugin {
1574
1673
  });
1575
1674
  }
1576
1675
  handleRejection(e) {
1577
- this.track('error', 'Unhandled Promise Rejection', {
1578
- reason: String(e.reason).substring(0, 200),
1579
- });
1676
+ const reason = String(e.reason).substring(0, 200);
1677
+ if (!this.dedup(reason))
1678
+ return;
1679
+ this.track('error', 'Unhandled Promise Rejection', { reason });
1680
+ }
1681
+ /**
1682
+ * Returns true if this error fingerprint is new (should be tracked).
1683
+ * Caps at MAX_UNIQUE_ERRORS to prevent queue flooding from error loops.
1684
+ */
1685
+ dedup(fingerprint) {
1686
+ if (this.seenErrors.has(fingerprint))
1687
+ return false;
1688
+ if (this.seenErrors.size >= MAX_UNIQUE_ERRORS)
1689
+ return false;
1690
+ this.seenErrors.add(fingerprint);
1691
+ return true;
1580
1692
  }
1581
1693
  }
1582
1694
 
@@ -2244,113 +2356,173 @@ class PopupFormsPlugin extends BasePlugin {
2244
2356
  }
2245
2357
 
2246
2358
  /**
2247
- * Clianta SDK - Auto-Identify Plugin
2248
- * Automatically detects logged-in users by checking JWT tokens in
2249
- * cookies, localStorage, and sessionStorage. Works with any auth provider:
2250
- * Clerk, Firebase, Auth0, Supabase, NextAuth, Passport, custom JWT, etc.
2359
+ * Clianta SDK - Auto-Identify Plugin (Production)
2360
+ *
2361
+ * Automatically detects logged-in users across ANY auth system:
2362
+ * - Window globals: Clerk, Firebase, Auth0, Supabase, __clianta_user
2363
+ * - JWT tokens in cookies (decoded client-side)
2364
+ * - JSON/JWT in localStorage & sessionStorage (guarded recursive deep-scan)
2365
+ * - Real-time storage change detection via `storage` event
2366
+ * - NextAuth session probing (only when NextAuth signals detected)
2367
+ *
2368
+ * Production safeguards:
2369
+ * - No monkey-patching of window.fetch or XMLHttpRequest
2370
+ * - Size-limited storage scanning (skips values > 50KB)
2371
+ * - Depth & key-count limited recursion (max 4 levels, 20 keys/level)
2372
+ * - Proper email regex validation
2373
+ * - Exponential backoff polling (2s → 5s → 10s → 30s)
2374
+ * - Zero console errors from probing
2251
2375
  *
2252
- * How it works:
2253
- * 1. On init + periodically, scans for JWT tokens
2254
- * 2. Decodes the JWT payload (base64, no secret needed)
2255
- * 3. Extracts email/name from standard JWT claims
2256
- * 4. Calls tracker.identify() automatically
2376
+ * Works universally: Next.js, Vite, CRA, Nuxt, SvelteKit, Remix,
2377
+ * Astro, plain HTML, Zustand, Redux, Pinia, MobX, or any custom auth.
2257
2378
  *
2258
2379
  * @see SDK_VERSION in core/config.ts
2259
2380
  */
2260
- /** Known auth cookie patterns and their JWT locations */
2381
+ // ────────────────────────────────────────────────
2382
+ // Constants
2383
+ // ────────────────────────────────────────────────
2384
+ /** Max recursion depth for JSON scanning */
2385
+ const MAX_SCAN_DEPTH = 4;
2386
+ /** Max object keys to inspect per recursion level */
2387
+ const MAX_KEYS_PER_LEVEL = 20;
2388
+ /** Max storage value size to parse (bytes) — skip large blobs */
2389
+ const MAX_STORAGE_VALUE_SIZE = 50000;
2390
+ /** Proper email regex — must have user@domain.tld (2+ char TLD) */
2391
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
2392
+ /** Known auth cookie name patterns */
2261
2393
  const AUTH_COOKIE_PATTERNS = [
2262
- // Clerk
2263
- '__session',
2264
- '__clerk_db_jwt',
2265
- // NextAuth
2266
- 'next-auth.session-token',
2267
- '__Secure-next-auth.session-token',
2268
- // Supabase
2394
+ // Provider-specific
2395
+ '__session', '__clerk_db_jwt',
2396
+ 'next-auth.session-token', '__Secure-next-auth.session-token',
2269
2397
  'sb-access-token',
2270
- // Auth0
2271
2398
  'auth0.is.authenticated',
2272
- // Firebase — uses localStorage, handled separately
2273
- // Generic patterns
2274
- 'token',
2275
- 'jwt',
2276
- 'access_token',
2277
- 'session_token',
2278
- 'auth_token',
2279
- 'id_token',
2399
+ // Keycloak
2400
+ 'KEYCLOAK_SESSION', 'KEYCLOAK_IDENTITY', 'KC_RESTART',
2401
+ // Generic
2402
+ 'token', 'jwt', 'access_token', 'session_token', 'auth_token', 'id_token',
2280
2403
  ];
2281
- /** localStorage/sessionStorage key patterns for auth tokens */
2404
+ /** localStorage/sessionStorage key patterns */
2282
2405
  const STORAGE_KEY_PATTERNS = [
2283
- // Supabase
2284
- 'sb-',
2285
- 'supabase.auth.',
2286
- // Firebase
2287
- 'firebase:authUser:',
2288
- // Auth0
2289
- 'auth0spajs',
2290
- '@@auth0spajs@@',
2406
+ // Provider-specific
2407
+ 'sb-', 'supabase.auth.', 'firebase:authUser:', 'auth0spajs', '@@auth0spajs@@',
2408
+ // Microsoft MSAL
2409
+ 'msal.', 'msal.account',
2410
+ // AWS Cognito / Amplify
2411
+ 'CognitoIdentityServiceProvider', 'amplify-signin-with-hostedUI',
2412
+ // Keycloak
2413
+ 'kc-callback-',
2414
+ // State managers
2415
+ 'persist:', '-storage',
2291
2416
  // Generic
2292
- 'token',
2293
- 'jwt',
2294
- 'auth',
2295
- 'user',
2296
- 'session',
2417
+ 'token', 'jwt', 'auth', 'user', 'session', 'credential', 'account',
2418
+ ];
2419
+ /** JWT/user object fields containing email */
2420
+ const EMAIL_CLAIMS = ['email', 'sub', 'preferred_username', 'user_email', 'mail', 'emailAddress', 'e_mail'];
2421
+ /** Full name fields */
2422
+ const NAME_CLAIMS = ['name', 'full_name', 'display_name', 'displayName'];
2423
+ /** First name fields */
2424
+ const FIRST_NAME_CLAIMS = ['given_name', 'first_name', 'firstName', 'fname'];
2425
+ /** Last name fields */
2426
+ const LAST_NAME_CLAIMS = ['family_name', 'last_name', 'lastName', 'lname'];
2427
+ /** Polling schedule: exponential backoff (ms) */
2428
+ const POLL_SCHEDULE = [
2429
+ 2000, // 2s — first check (auth providers need time to init)
2430
+ 5000, // 5s — second check
2431
+ 10000, // 10s
2432
+ 10000, // 10s
2433
+ 30000, // 30s — slower from here
2434
+ 30000, // 30s
2435
+ 30000, // 30s
2436
+ 60000, // 1m
2437
+ 60000, // 1m
2438
+ 60000, // 1m — stop after ~4 min total
2297
2439
  ];
2298
- /** Standard JWT claim fields for email */
2299
- const EMAIL_CLAIMS = ['email', 'sub', 'preferred_username', 'user_email', 'mail'];
2300
- const NAME_CLAIMS = ['name', 'full_name', 'display_name', 'given_name'];
2301
- const FIRST_NAME_CLAIMS = ['given_name', 'first_name', 'firstName'];
2302
- const LAST_NAME_CLAIMS = ['family_name', 'last_name', 'lastName'];
2303
2440
  class AutoIdentifyPlugin extends BasePlugin {
2304
2441
  constructor() {
2305
2442
  super(...arguments);
2306
2443
  this.name = 'autoIdentify';
2307
- this.checkInterval = null;
2444
+ this.pollTimeouts = [];
2308
2445
  this.identifiedEmail = null;
2309
- this.checkCount = 0;
2310
- this.MAX_CHECKS = 30; // Stop checking after ~5 minutes
2311
- this.CHECK_INTERVAL_MS = 10000; // Check every 10 seconds
2446
+ this.storageHandler = null;
2447
+ this.sessionProbed = false;
2312
2448
  }
2313
2449
  init(tracker) {
2314
2450
  super.init(tracker);
2315
2451
  if (typeof window === 'undefined')
2316
2452
  return;
2317
- // First check after 2 seconds (give auth providers time to init)
2318
- setTimeout(() => {
2319
- try {
2320
- this.checkForAuthUser();
2321
- }
2322
- catch { /* silently fail */ }
2323
- }, 2000);
2324
- // Then check periodically
2325
- this.checkInterval = setInterval(() => {
2326
- this.checkCount++;
2327
- if (this.checkCount >= this.MAX_CHECKS) {
2328
- if (this.checkInterval) {
2329
- clearInterval(this.checkInterval);
2330
- this.checkInterval = null;
2331
- }
2332
- return;
2333
- }
2334
- try {
2335
- this.checkForAuthUser();
2336
- }
2337
- catch { /* silently fail */ }
2338
- }, this.CHECK_INTERVAL_MS);
2453
+ // Schedule poll checks with exponential backoff
2454
+ this.schedulePollChecks();
2455
+ // Listen for storage changes (real-time detection of login/logout)
2456
+ this.listenForStorageChanges();
2339
2457
  }
2340
2458
  destroy() {
2341
- if (this.checkInterval) {
2342
- clearInterval(this.checkInterval);
2343
- this.checkInterval = null;
2459
+ // Clear all scheduled polls
2460
+ for (const t of this.pollTimeouts)
2461
+ clearTimeout(t);
2462
+ this.pollTimeouts = [];
2463
+ // Remove storage listener
2464
+ if (this.storageHandler && typeof window !== 'undefined') {
2465
+ window.removeEventListener('storage', this.storageHandler);
2466
+ this.storageHandler = null;
2344
2467
  }
2345
2468
  super.destroy();
2346
2469
  }
2470
+ // ════════════════════════════════════════════════
2471
+ // SCHEDULING
2472
+ // ════════════════════════════════════════════════
2473
+ /**
2474
+ * Schedule poll checks with exponential backoff.
2475
+ * Much lighter than setInterval — each check is self-contained.
2476
+ */
2477
+ schedulePollChecks() {
2478
+ let cumulativeDelay = 0;
2479
+ for (let i = 0; i < POLL_SCHEDULE.length; i++) {
2480
+ cumulativeDelay += POLL_SCHEDULE[i];
2481
+ const timeout = setTimeout(() => {
2482
+ if (this.identifiedEmail)
2483
+ return; // Already identified, skip
2484
+ try {
2485
+ this.checkForAuthUser();
2486
+ }
2487
+ catch { /* silently fail */ }
2488
+ // On the 4th check (~27s), probe NextAuth if signals detected
2489
+ if (i === 3 && !this.sessionProbed) {
2490
+ this.sessionProbed = true;
2491
+ this.guardedSessionProbe();
2492
+ }
2493
+ }, cumulativeDelay);
2494
+ this.pollTimeouts.push(timeout);
2495
+ }
2496
+ }
2347
2497
  /**
2348
- * Main checkscan all sources for auth tokens
2498
+ * Listen for `storage` events fired when another tab or the app
2499
+ * modifies localStorage. Enables real-time detection after login.
2349
2500
  */
2501
+ listenForStorageChanges() {
2502
+ this.storageHandler = (event) => {
2503
+ if (this.identifiedEmail)
2504
+ return; // Already identified
2505
+ if (!event.key || !event.newValue)
2506
+ return;
2507
+ const keyLower = event.key.toLowerCase();
2508
+ const isAuthKey = STORAGE_KEY_PATTERNS.some(p => keyLower.includes(p.toLowerCase()));
2509
+ if (!isAuthKey)
2510
+ return;
2511
+ // Auth-related storage changed — run a check
2512
+ try {
2513
+ this.checkForAuthUser();
2514
+ }
2515
+ catch { /* silently fail */ }
2516
+ };
2517
+ window.addEventListener('storage', this.storageHandler);
2518
+ }
2519
+ // ════════════════════════════════════════════════
2520
+ // MAIN CHECK — scan all sources (priority order)
2521
+ // ════════════════════════════════════════════════
2350
2522
  checkForAuthUser() {
2351
2523
  if (!this.tracker || this.identifiedEmail)
2352
2524
  return;
2353
- // 0. Check well-known auth provider globals (most reliable)
2525
+ // 0. Check well-known auth provider globals (most reliable, zero overhead)
2354
2526
  try {
2355
2527
  const providerUser = this.checkAuthProviders();
2356
2528
  if (providerUser) {
@@ -2359,8 +2531,8 @@ class AutoIdentifyPlugin extends BasePlugin {
2359
2531
  }
2360
2532
  }
2361
2533
  catch { /* provider check failed */ }
2534
+ // 1. Check cookies for JWTs
2362
2535
  try {
2363
- // 1. Check cookies for JWTs
2364
2536
  const cookieUser = this.checkCookies();
2365
2537
  if (cookieUser) {
2366
2538
  this.identifyUser(cookieUser);
@@ -2368,8 +2540,8 @@ class AutoIdentifyPlugin extends BasePlugin {
2368
2540
  }
2369
2541
  }
2370
2542
  catch { /* cookie access blocked */ }
2543
+ // 2. Check localStorage (guarded deep scan)
2371
2544
  try {
2372
- // 2. Check localStorage
2373
2545
  if (typeof localStorage !== 'undefined') {
2374
2546
  const localUser = this.checkStorage(localStorage);
2375
2547
  if (localUser) {
@@ -2379,8 +2551,8 @@ class AutoIdentifyPlugin extends BasePlugin {
2379
2551
  }
2380
2552
  }
2381
2553
  catch { /* localStorage access blocked */ }
2554
+ // 3. Check sessionStorage (guarded deep scan)
2382
2555
  try {
2383
- // 3. Check sessionStorage
2384
2556
  if (typeof sessionStorage !== 'undefined') {
2385
2557
  const sessionUser = this.checkStorage(sessionStorage);
2386
2558
  if (sessionUser) {
@@ -2391,20 +2563,18 @@ class AutoIdentifyPlugin extends BasePlugin {
2391
2563
  }
2392
2564
  catch { /* sessionStorage access blocked */ }
2393
2565
  }
2394
- /**
2395
- * Check well-known auth provider globals on window
2396
- * These are the most reliable — they expose user data directly
2397
- */
2566
+ // ════════════════════════════════════════════════
2567
+ // AUTH PROVIDER GLOBALS
2568
+ // ════════════════════════════════════════════════
2398
2569
  checkAuthProviders() {
2399
2570
  const win = window;
2400
2571
  // ─── Clerk ───
2401
- // Clerk exposes window.Clerk after initialization
2402
2572
  try {
2403
2573
  const clerkUser = win.Clerk?.user;
2404
2574
  if (clerkUser) {
2405
2575
  const email = clerkUser.primaryEmailAddress?.emailAddress
2406
2576
  || clerkUser.emailAddresses?.[0]?.emailAddress;
2407
- if (email) {
2577
+ if (email && this.isValidEmail(email)) {
2408
2578
  return {
2409
2579
  email,
2410
2580
  firstName: clerkUser.firstName || undefined,
@@ -2418,7 +2588,7 @@ class AutoIdentifyPlugin extends BasePlugin {
2418
2588
  try {
2419
2589
  const fbAuth = win.firebase?.auth?.();
2420
2590
  const fbUser = fbAuth?.currentUser;
2421
- if (fbUser?.email) {
2591
+ if (fbUser?.email && this.isValidEmail(fbUser.email)) {
2422
2592
  const parts = (fbUser.displayName || '').split(' ');
2423
2593
  return {
2424
2594
  email: fbUser.email,
@@ -2432,10 +2602,9 @@ class AutoIdentifyPlugin extends BasePlugin {
2432
2602
  try {
2433
2603
  const sbClient = win.__SUPABASE_CLIENT__ || win.supabase;
2434
2604
  if (sbClient?.auth) {
2435
- // Supabase v2 stores session
2436
2605
  const session = sbClient.auth.session?.() || sbClient.auth.getSession?.();
2437
2606
  const user = session?.data?.session?.user || session?.user;
2438
- if (user?.email) {
2607
+ if (user?.email && this.isValidEmail(user.email)) {
2439
2608
  const meta = user.user_metadata || {};
2440
2609
  return {
2441
2610
  email: user.email,
@@ -2451,7 +2620,7 @@ class AutoIdentifyPlugin extends BasePlugin {
2451
2620
  const auth0 = win.__auth0Client || win.auth0Client;
2452
2621
  if (auth0?.isAuthenticated?.()) {
2453
2622
  const user = auth0.getUser?.();
2454
- if (user?.email) {
2623
+ if (user?.email && this.isValidEmail(user.email)) {
2455
2624
  return {
2456
2625
  email: user.email,
2457
2626
  firstName: user.given_name || user.name?.split(' ')[0] || undefined,
@@ -2461,11 +2630,88 @@ class AutoIdentifyPlugin extends BasePlugin {
2461
2630
  }
2462
2631
  }
2463
2632
  catch { /* Auth0 not available */ }
2633
+ // ─── Google Identity Services (Google OAuth / Sign In With Google) ───
2634
+ // GIS stores the credential JWT from the callback; also check gapi
2635
+ try {
2636
+ const gisCredential = win.__google_credential_response?.credential;
2637
+ if (gisCredential && typeof gisCredential === 'string') {
2638
+ const user = this.extractUserFromToken(gisCredential);
2639
+ if (user)
2640
+ return user;
2641
+ }
2642
+ // Legacy gapi.auth2
2643
+ const gapiUser = win.gapi?.auth2?.getAuthInstance?.()?.currentUser?.get?.();
2644
+ const profile = gapiUser?.getBasicProfile?.();
2645
+ if (profile) {
2646
+ const email = profile.getEmail?.();
2647
+ if (email && this.isValidEmail(email)) {
2648
+ return {
2649
+ email,
2650
+ firstName: profile.getGivenName?.() || undefined,
2651
+ lastName: profile.getFamilyName?.() || undefined,
2652
+ };
2653
+ }
2654
+ }
2655
+ }
2656
+ catch { /* Google auth not available */ }
2657
+ // ─── Microsoft MSAL (Microsoft OAuth / Azure AD) ───
2658
+ // MSAL stores account info in window.msalInstance or PublicClientApplication
2659
+ try {
2660
+ const msalInstance = win.msalInstance || win.__msalInstance;
2661
+ if (msalInstance) {
2662
+ const accounts = msalInstance.getAllAccounts?.() || [];
2663
+ const account = accounts[0];
2664
+ if (account?.username && this.isValidEmail(account.username)) {
2665
+ const nameParts = (account.name || '').split(' ');
2666
+ return {
2667
+ email: account.username,
2668
+ firstName: nameParts[0] || undefined,
2669
+ lastName: nameParts.slice(1).join(' ') || undefined,
2670
+ };
2671
+ }
2672
+ }
2673
+ }
2674
+ catch { /* MSAL not available */ }
2675
+ // ─── AWS Cognito / Amplify ───
2676
+ try {
2677
+ // Amplify v6+
2678
+ const amplifyUser = win.aws_amplify_currentUser || win.__amplify_user;
2679
+ if (amplifyUser?.signInDetails?.loginId && this.isValidEmail(amplifyUser.signInDetails.loginId)) {
2680
+ return {
2681
+ email: amplifyUser.signInDetails.loginId,
2682
+ firstName: amplifyUser.attributes?.given_name || undefined,
2683
+ lastName: amplifyUser.attributes?.family_name || undefined,
2684
+ };
2685
+ }
2686
+ // Check Cognito localStorage keys directly
2687
+ if (typeof localStorage !== 'undefined') {
2688
+ const cognitoUser = this.checkCognitoStorage();
2689
+ if (cognitoUser)
2690
+ return cognitoUser;
2691
+ }
2692
+ }
2693
+ catch { /* Cognito/Amplify not available */ }
2694
+ // ─── Keycloak ───
2695
+ try {
2696
+ const keycloak = win.keycloak || win.Keycloak;
2697
+ if (keycloak?.authenticated && keycloak.tokenParsed) {
2698
+ const claims = keycloak.tokenParsed;
2699
+ const email = claims.email || claims.preferred_username;
2700
+ if (email && this.isValidEmail(email)) {
2701
+ return {
2702
+ email,
2703
+ firstName: claims.given_name || undefined,
2704
+ lastName: claims.family_name || undefined,
2705
+ };
2706
+ }
2707
+ }
2708
+ }
2709
+ catch { /* Keycloak not available */ }
2464
2710
  // ─── Global clianta identify hook ───
2465
2711
  // Any auth system can set: window.__clianta_user = { email, firstName, lastName }
2466
2712
  try {
2467
2713
  const manualUser = win.__clianta_user;
2468
- if (manualUser?.email && typeof manualUser.email === 'string' && manualUser.email.includes('@')) {
2714
+ if (manualUser?.email && typeof manualUser.email === 'string' && this.isValidEmail(manualUser.email)) {
2469
2715
  return {
2470
2716
  email: manualUser.email,
2471
2717
  firstName: manualUser.firstName || undefined,
@@ -2476,9 +2722,9 @@ class AutoIdentifyPlugin extends BasePlugin {
2476
2722
  catch { /* manual user not set */ }
2477
2723
  return null;
2478
2724
  }
2479
- /**
2480
- * Identify the user and stop checking
2481
- */
2725
+ // ════════════════════════════════════════════════
2726
+ // IDENTIFY USER
2727
+ // ════════════════════════════════════════════════
2482
2728
  identifyUser(user) {
2483
2729
  if (!this.tracker || this.identifiedEmail === user.email)
2484
2730
  return;
@@ -2487,15 +2733,14 @@ class AutoIdentifyPlugin extends BasePlugin {
2487
2733
  firstName: user.firstName,
2488
2734
  lastName: user.lastName,
2489
2735
  });
2490
- // Stop interval — we found the user
2491
- if (this.checkInterval) {
2492
- clearInterval(this.checkInterval);
2493
- this.checkInterval = null;
2494
- }
2495
- }
2496
- /**
2497
- * Scan cookies for JWT tokens
2498
- */
2736
+ // Cancel all remaining polls — we found the user
2737
+ for (const t of this.pollTimeouts)
2738
+ clearTimeout(t);
2739
+ this.pollTimeouts = [];
2740
+ }
2741
+ // ════════════════════════════════════════════════
2742
+ // COOKIE SCANNING
2743
+ // ════════════════════════════════════════════════
2499
2744
  checkCookies() {
2500
2745
  if (typeof document === 'undefined')
2501
2746
  return null;
@@ -2505,7 +2750,6 @@ class AutoIdentifyPlugin extends BasePlugin {
2505
2750
  const [name, ...valueParts] = cookie.split('=');
2506
2751
  const value = valueParts.join('=');
2507
2752
  const cookieName = name.trim().toLowerCase();
2508
- // Check if this cookie matches known auth patterns
2509
2753
  const isAuthCookie = AUTH_COOKIE_PATTERNS.some(pattern => cookieName.includes(pattern.toLowerCase()));
2510
2754
  if (isAuthCookie && value) {
2511
2755
  const user = this.extractUserFromToken(decodeURIComponent(value));
@@ -2515,13 +2759,13 @@ class AutoIdentifyPlugin extends BasePlugin {
2515
2759
  }
2516
2760
  }
2517
2761
  catch {
2518
- // Cookie access may fail in some environments
2762
+ // Cookie access may fail (cross-origin iframe, etc.)
2519
2763
  }
2520
2764
  return null;
2521
2765
  }
2522
- /**
2523
- * Scan localStorage or sessionStorage for auth tokens
2524
- */
2766
+ // ════════════════════════════════════════════════
2767
+ // STORAGE SCANNING (GUARDED DEEP RECURSIVE)
2768
+ // ════════════════════════════════════════════════
2525
2769
  checkStorage(storage) {
2526
2770
  try {
2527
2771
  for (let i = 0; i < storage.length; i++) {
@@ -2534,16 +2778,19 @@ class AutoIdentifyPlugin extends BasePlugin {
2534
2778
  const value = storage.getItem(key);
2535
2779
  if (!value)
2536
2780
  continue;
2781
+ // Size guard — skip values larger than 50KB
2782
+ if (value.length > MAX_STORAGE_VALUE_SIZE)
2783
+ continue;
2537
2784
  // Try as direct JWT
2538
2785
  const user = this.extractUserFromToken(value);
2539
2786
  if (user)
2540
2787
  return user;
2541
- // Try as JSON containing a token
2788
+ // Try as JSON guarded deep recursive scan
2542
2789
  try {
2543
2790
  const json = JSON.parse(value);
2544
- const user = this.extractUserFromJson(json);
2545
- if (user)
2546
- return user;
2791
+ const jsonUser = this.deepScanForUser(json, 0);
2792
+ if (jsonUser)
2793
+ return jsonUser;
2547
2794
  }
2548
2795
  catch {
2549
2796
  // Not JSON, skip
@@ -2556,11 +2803,63 @@ class AutoIdentifyPlugin extends BasePlugin {
2556
2803
  }
2557
2804
  return null;
2558
2805
  }
2806
+ // ════════════════════════════════════════════════
2807
+ // DEEP RECURSIVE SCANNING (guarded)
2808
+ // ════════════════════════════════════════════════
2809
+ /**
2810
+ * Recursively scan a JSON object for user data.
2811
+ * Guards: max depth (4), max keys per level (20), no array traversal.
2812
+ *
2813
+ * Handles ANY nesting pattern:
2814
+ * - Zustand persist: { state: { user: { email } } }
2815
+ * - Redux persist: { auth: { user: { email } } }
2816
+ * - Pinia: { auth: { userData: { email } } }
2817
+ * - NextAuth: { user: { email }, expires: ... }
2818
+ * - Direct: { email, name }
2819
+ */
2820
+ deepScanForUser(data, depth) {
2821
+ if (depth > MAX_SCAN_DEPTH || !data || typeof data !== 'object' || Array.isArray(data)) {
2822
+ return null;
2823
+ }
2824
+ const obj = data;
2825
+ const keys = Object.keys(obj);
2826
+ // 1. Try direct extraction at this level
2827
+ const user = this.extractUserFromClaims(obj);
2828
+ if (user)
2829
+ return user;
2830
+ // Guard: limit keys scanned per level
2831
+ const keysToScan = keys.slice(0, MAX_KEYS_PER_LEVEL);
2832
+ // 2. Check for JWT strings at this level
2833
+ for (const key of keysToScan) {
2834
+ const val = obj[key];
2835
+ if (typeof val === 'string' && val.length > 30 && val.length < 4000) {
2836
+ // Only check strings that could plausibly be JWTs (30-4000 chars)
2837
+ const dotCount = (val.match(/\./g) || []).length;
2838
+ if (dotCount === 2) {
2839
+ const tokenUser = this.extractUserFromToken(val);
2840
+ if (tokenUser)
2841
+ return tokenUser;
2842
+ }
2843
+ }
2844
+ }
2845
+ // 3. Recurse into nested objects
2846
+ for (const key of keysToScan) {
2847
+ const val = obj[key];
2848
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
2849
+ const nestedUser = this.deepScanForUser(val, depth + 1);
2850
+ if (nestedUser)
2851
+ return nestedUser;
2852
+ }
2853
+ }
2854
+ return null;
2855
+ }
2856
+ // ════════════════════════════════════════════════
2857
+ // TOKEN & CLAIMS EXTRACTION
2858
+ // ════════════════════════════════════════════════
2559
2859
  /**
2560
- * Try to extract user info from a JWT token string
2860
+ * Try to extract user info from a JWT token string (header.payload.signature)
2561
2861
  */
2562
2862
  extractUserFromToken(token) {
2563
- // JWT format: header.payload.signature
2564
2863
  const parts = token.split('.');
2565
2864
  if (parts.length !== 3)
2566
2865
  return null;
@@ -2573,48 +2872,29 @@ class AutoIdentifyPlugin extends BasePlugin {
2573
2872
  }
2574
2873
  }
2575
2874
  /**
2576
- * Extract user info from a JSON object (e.g., Firebase auth user stored in localStorage)
2577
- */
2578
- extractUserFromJson(data) {
2579
- if (!data || typeof data !== 'object')
2580
- return null;
2581
- // Direct user object
2582
- const user = this.extractUserFromClaims(data);
2583
- if (user)
2584
- return user;
2585
- // Nested: { user: { email } } or { data: { user: { email } } }
2586
- for (const key of ['user', 'data', 'session', 'currentUser', 'authUser', 'access_token', 'token']) {
2587
- if (data[key]) {
2588
- if (typeof data[key] === 'string') {
2589
- // Might be a JWT inside JSON
2590
- const tokenUser = this.extractUserFromToken(data[key]);
2591
- if (tokenUser)
2592
- return tokenUser;
2593
- }
2594
- else if (typeof data[key] === 'object') {
2595
- const nestedUser = this.extractUserFromClaims(data[key]);
2596
- if (nestedUser)
2597
- return nestedUser;
2598
- }
2599
- }
2600
- }
2601
- return null;
2602
- }
2603
- /**
2604
- * Extract user from JWT claims or user object
2875
+ * Extract user from JWT claims or user-like object.
2876
+ * Uses proper email regex validation.
2605
2877
  */
2606
2878
  extractUserFromClaims(claims) {
2607
2879
  if (!claims || typeof claims !== 'object')
2608
2880
  return null;
2609
- // Find email
2881
+ // Find email — check standard claim fields
2610
2882
  let email = null;
2611
2883
  for (const claim of EMAIL_CLAIMS) {
2612
2884
  const value = claims[claim];
2613
- if (value && typeof value === 'string' && value.includes('@') && value.includes('.')) {
2885
+ if (value && typeof value === 'string' && this.isValidEmail(value)) {
2614
2886
  email = value;
2615
2887
  break;
2616
2888
  }
2617
2889
  }
2890
+ // Check nested email objects (Clerk pattern)
2891
+ if (!email) {
2892
+ const nestedEmail = claims.primaryEmailAddress?.emailAddress
2893
+ || claims.emailAddresses?.[0]?.emailAddress;
2894
+ if (nestedEmail && typeof nestedEmail === 'string' && this.isValidEmail(nestedEmail)) {
2895
+ email = nestedEmail;
2896
+ }
2897
+ }
2618
2898
  if (!email)
2619
2899
  return null;
2620
2900
  // Find name
@@ -2632,7 +2912,7 @@ class AutoIdentifyPlugin extends BasePlugin {
2632
2912
  break;
2633
2913
  }
2634
2914
  }
2635
- // If no first/last name, try full name
2915
+ // Try full name if no first/last found
2636
2916
  if (!firstName) {
2637
2917
  for (const claim of NAME_CLAIMS) {
2638
2918
  if (claims[claim] && typeof claims[claim] === 'string') {
@@ -2645,6 +2925,117 @@ class AutoIdentifyPlugin extends BasePlugin {
2645
2925
  }
2646
2926
  return { email, firstName, lastName };
2647
2927
  }
2928
+ // ════════════════════════════════════════════════
2929
+ // GUARDED SESSION PROBING (NextAuth only)
2930
+ // ════════════════════════════════════════════════
2931
+ /**
2932
+ * Probe NextAuth session endpoint ONLY if NextAuth signals are present.
2933
+ * Signals: `next-auth.session-token` cookie, `__NEXTAUTH` or `__NEXT_DATA__` globals.
2934
+ * This prevents unnecessary 404 errors on non-NextAuth sites.
2935
+ */
2936
+ async guardedSessionProbe() {
2937
+ if (this.identifiedEmail)
2938
+ return;
2939
+ // Check for NextAuth signals before probing
2940
+ const hasNextAuthCookie = typeof document !== 'undefined' &&
2941
+ (document.cookie.includes('next-auth.session-token') ||
2942
+ document.cookie.includes('__Secure-next-auth.session-token'));
2943
+ const hasNextAuthGlobal = typeof window !== 'undefined' &&
2944
+ (window.__NEXTAUTH != null || window.__NEXT_DATA__ != null);
2945
+ if (!hasNextAuthCookie && !hasNextAuthGlobal)
2946
+ return;
2947
+ // NextAuth detected — safe to probe /api/auth/session
2948
+ try {
2949
+ const response = await fetch('/api/auth/session', {
2950
+ method: 'GET',
2951
+ credentials: 'include',
2952
+ headers: { 'Accept': 'application/json' },
2953
+ });
2954
+ if (response.ok) {
2955
+ const body = await response.json();
2956
+ // NextAuth returns { user: { name, email, image }, expires }
2957
+ if (body && typeof body === 'object' && Object.keys(body).length > 0) {
2958
+ const user = this.deepScanForUser(body, 0);
2959
+ if (user) {
2960
+ this.identifyUser(user);
2961
+ }
2962
+ }
2963
+ }
2964
+ }
2965
+ catch {
2966
+ // Endpoint failed — silently ignore
2967
+ }
2968
+ }
2969
+ // ════════════════════════════════════════════════
2970
+ // AWS COGNITO STORAGE SCANNING
2971
+ // ════════════════════════════════════════════════
2972
+ /**
2973
+ * Scan localStorage for AWS Cognito / Amplify user data.
2974
+ * Cognito stores tokens under keys like:
2975
+ * CognitoIdentityServiceProvider.<clientId>.<username>.idToken
2976
+ * CognitoIdentityServiceProvider.<clientId>.<username>.userData
2977
+ */
2978
+ checkCognitoStorage() {
2979
+ try {
2980
+ for (let i = 0; i < localStorage.length; i++) {
2981
+ const key = localStorage.key(i);
2982
+ if (!key)
2983
+ continue;
2984
+ // Look for Cognito ID tokens (contain email in JWT claims)
2985
+ if (key.startsWith('CognitoIdentityServiceProvider.') && key.endsWith('.idToken')) {
2986
+ const value = localStorage.getItem(key);
2987
+ if (value && value.length < MAX_STORAGE_VALUE_SIZE) {
2988
+ const user = this.extractUserFromToken(value);
2989
+ if (user)
2990
+ return user;
2991
+ }
2992
+ }
2993
+ // Look for Cognito userData (JSON with email attribute)
2994
+ if (key.startsWith('CognitoIdentityServiceProvider.') && key.endsWith('.userData')) {
2995
+ const value = localStorage.getItem(key);
2996
+ if (value && value.length < MAX_STORAGE_VALUE_SIZE) {
2997
+ try {
2998
+ const data = JSON.parse(value);
2999
+ // Cognito userData format: { UserAttributes: [{ Name: 'email', Value: '...' }] }
3000
+ const attrs = data.UserAttributes || data.attributes || [];
3001
+ const emailAttr = attrs.find?.((a) => a.Name === 'email' || a.name === 'email');
3002
+ if (emailAttr?.Value && this.isValidEmail(emailAttr.Value)) {
3003
+ const nameAttr = attrs.find?.((a) => a.Name === 'name' || a.name === 'name');
3004
+ const givenNameAttr = attrs.find?.((a) => a.Name === 'given_name' || a.name === 'given_name');
3005
+ const familyNameAttr = attrs.find?.((a) => a.Name === 'family_name' || a.name === 'family_name');
3006
+ let firstName = givenNameAttr?.Value;
3007
+ let lastName = familyNameAttr?.Value;
3008
+ if (!firstName && nameAttr?.Value) {
3009
+ const parts = nameAttr.Value.split(' ');
3010
+ firstName = parts[0];
3011
+ lastName = lastName || parts.slice(1).join(' ') || undefined;
3012
+ }
3013
+ return {
3014
+ email: emailAttr.Value,
3015
+ firstName: firstName || undefined,
3016
+ lastName: lastName || undefined,
3017
+ };
3018
+ }
3019
+ }
3020
+ catch { /* invalid JSON */ }
3021
+ }
3022
+ }
3023
+ }
3024
+ }
3025
+ catch { /* storage access failed */ }
3026
+ return null;
3027
+ }
3028
+ // ════════════════════════════════════════════════
3029
+ // UTILITIES
3030
+ // ════════════════════════════════════════════════
3031
+ /**
3032
+ * Validate email with proper regex.
3033
+ * Rejects: user@v2.0, config@internal, tokens with @ signs.
3034
+ * Accepts: user@domain.com, user@sub.domain.co.uk
3035
+ */
3036
+ isValidEmail(value) {
3037
+ return EMAIL_REGEX.test(value);
3038
+ }
2648
3039
  }
2649
3040
 
2650
3041
  /**
@@ -3236,28 +3627,17 @@ class Tracker {
3236
3627
  }
3237
3628
  const prevId = previousId || this.visitorId;
3238
3629
  logger.info('Aliasing visitor:', { from: prevId, to: newId });
3239
- try {
3240
- const url = `${this.config.apiEndpoint}/api/public/track/alias`;
3241
- const response = await fetch(url, {
3242
- method: 'POST',
3243
- headers: { 'Content-Type': 'application/json' },
3244
- body: JSON.stringify({
3245
- workspaceId: this.workspaceId,
3246
- previousId: prevId,
3247
- newId,
3248
- }),
3249
- });
3250
- if (response.ok) {
3251
- logger.info('Alias successful');
3252
- return true;
3253
- }
3254
- logger.error('Alias failed:', response.status);
3255
- return false;
3256
- }
3257
- catch (error) {
3258
- logger.error('Alias request failed:', error);
3259
- return false;
3630
+ const result = await this.transport.sendPost('/api/public/track/alias', {
3631
+ workspaceId: this.workspaceId,
3632
+ previousId: prevId,
3633
+ newId,
3634
+ });
3635
+ if (result.success) {
3636
+ logger.info('Alias successful');
3637
+ return true;
3260
3638
  }
3639
+ logger.error('Alias failed:', result.error ?? result.status);
3640
+ return false;
3261
3641
  }
3262
3642
  /**
3263
3643
  * Track a screen view (for mobile-first PWAs and SPAs).
@@ -3620,13 +4000,24 @@ let globalInstance = null;
3620
4000
  * });
3621
4001
  */
3622
4002
  function clianta(workspaceId, config) {
3623
- // Return existing instance if same workspace
4003
+ // Return existing instance if same workspace and no config change
3624
4004
  if (globalInstance && globalInstance.getWorkspaceId() === workspaceId) {
4005
+ if (config && Object.keys(config).length > 0) {
4006
+ // Config was passed to an already-initialized instance — warn the developer
4007
+ // because the new config is ignored. They must call destroy() first to reconfigure.
4008
+ if (typeof console !== 'undefined') {
4009
+ console.warn('[Clianta] clianta() called with config on an already-initialized instance ' +
4010
+ 'for workspace "' + workspaceId + '". The new config was ignored. ' +
4011
+ 'Call tracker.destroy() first if you need to reconfigure.');
4012
+ }
4013
+ }
3625
4014
  return globalInstance;
3626
4015
  }
3627
- // Destroy existing instance if workspace changed
4016
+ // Destroy existing instance if workspace changed (fire-and-forget flush, then destroy)
3628
4017
  if (globalInstance) {
3629
- globalInstance.destroy();
4018
+ // Kick off async flush+destroy without blocking the new instance creation.
4019
+ // Using void to make the intentional fire-and-forget explicit.
4020
+ void globalInstance.destroy();
3630
4021
  }
3631
4022
  // Create new instance
3632
4023
  globalInstance = new Tracker(workspaceId, config);
@@ -3654,8 +4045,21 @@ if (typeof window !== 'undefined') {
3654
4045
  const projectId = script.getAttribute('data-project-id');
3655
4046
  if (!projectId)
3656
4047
  return;
3657
- const debug = script.hasAttribute('data-debug');
3658
- const instance = clianta(projectId, { debug });
4048
+ const initConfig = {
4049
+ debug: script.hasAttribute('data-debug'),
4050
+ };
4051
+ // Support additional config via script tag attributes:
4052
+ // data-api-endpoint="https://api.yourhost.com"
4053
+ // data-cookieless (boolean flag)
4054
+ // data-use-cookies (boolean flag)
4055
+ const apiEndpoint = script.getAttribute('data-api-endpoint');
4056
+ if (apiEndpoint)
4057
+ initConfig.apiEndpoint = apiEndpoint;
4058
+ if (script.hasAttribute('data-cookieless'))
4059
+ initConfig.cookielessMode = true;
4060
+ if (script.hasAttribute('data-use-cookies'))
4061
+ initConfig.useCookies = true;
4062
+ const instance = clianta(projectId, initConfig);
3659
4063
  // Expose the auto-initialized instance globally
3660
4064
  window.__clianta = instance;
3661
4065
  };