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