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