@clianta/sdk 1.6.7 → 1.7.1

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