@clianta/sdk 1.6.7 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/angular.cjs.js +635 -231
- package/dist/angular.cjs.js.map +1 -1
- package/dist/angular.d.ts +1 -1
- package/dist/angular.esm.js +635 -231
- package/dist/angular.esm.js.map +1 -1
- package/dist/clianta.cjs.js +635 -231
- package/dist/clianta.cjs.js.map +1 -1
- package/dist/clianta.esm.js +635 -231
- package/dist/clianta.esm.js.map +1 -1
- package/dist/clianta.umd.js +635 -231
- package/dist/clianta.umd.js.map +1 -1
- package/dist/clianta.umd.min.js +2 -2
- package/dist/clianta.umd.min.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/react.cjs.js +642 -238
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +2 -1
- package/dist/react.esm.js +643 -239
- package/dist/react.esm.js.map +1 -1
- package/dist/svelte.cjs.js +635 -231
- package/dist/svelte.cjs.js.map +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/svelte.esm.js +635 -231
- package/dist/svelte.esm.js.map +1 -1
- package/dist/vue.cjs.js +635 -231
- package/dist/vue.cjs.js.map +1 -1
- package/dist/vue.d.ts +1 -1
- package/dist/vue.esm.js +635 -231
- package/dist/vue.esm.js.map +1 -1
- package/package.json +1 -1
package/dist/clianta.esm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Clianta SDK v1.
|
|
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.
|
|
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:
|
|
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
|
|
343
|
+
// Server error — retry with exponential backoff
|
|
316
344
|
if (response.status >= 500 && attempt < this.config.maxRetries) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
//
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
-
|
|
960
|
-
const
|
|
1018
|
+
const originalPush = this.originalPushState;
|
|
1019
|
+
const originalReplace = this.originalReplaceState;
|
|
961
1020
|
history.pushState = function (...args) {
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
969
|
-
self.trackPageView();
|
|
1027
|
+
originalReplace.apply(history, args);
|
|
970
1028
|
window.dispatchEvent(new Event('clianta:navigation'));
|
|
971
1029
|
};
|
|
972
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
1257
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1575
|
-
|
|
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
|
-
*
|
|
2246
|
-
*
|
|
2247
|
-
* Clerk, Firebase, Auth0, Supabase,
|
|
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)
|
|
2248
2364
|
*
|
|
2249
|
-
*
|
|
2250
|
-
*
|
|
2251
|
-
*
|
|
2252
|
-
*
|
|
2253
|
-
*
|
|
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
|
|
2372
|
+
*
|
|
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
|
-
|
|
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
|
-
//
|
|
2260
|
-
'__session',
|
|
2261
|
-
'
|
|
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
|
-
//
|
|
2270
|
-
|
|
2271
|
-
|
|
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
|
|
2401
|
+
/** localStorage/sessionStorage key patterns */
|
|
2279
2402
|
const STORAGE_KEY_PATTERNS = [
|
|
2280
|
-
//
|
|
2281
|
-
'sb-',
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
'
|
|
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
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
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.
|
|
2441
|
+
this.pollTimeouts = [];
|
|
2305
2442
|
this.identifiedEmail = null;
|
|
2306
|
-
this.
|
|
2307
|
-
this.
|
|
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
|
-
//
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
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
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
2393
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
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
|
|
2759
|
+
// Cookie access may fail (cross-origin iframe, etc.)
|
|
2516
2760
|
}
|
|
2517
2761
|
return null;
|
|
2518
2762
|
}
|
|
2519
|
-
|
|
2520
|
-
|
|
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
|
|
2785
|
+
// Try as JSON — guarded deep recursive scan
|
|
2539
2786
|
try {
|
|
2540
2787
|
const json = JSON.parse(value);
|
|
2541
|
-
const
|
|
2542
|
-
if (
|
|
2543
|
-
return
|
|
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
|
|
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' &&
|
|
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
|
-
//
|
|
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
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
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
|
-
|
|
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
|
|
3655
|
-
|
|
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
|
};
|