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