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