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