308 lines
8.4 KiB
TypeScript
308 lines
8.4 KiB
TypeScript
/**
|
|
* Cross-domain session linking for analytics.
|
|
*
|
|
* Enables tracking users across domains (e.g., atlilith.com → trustedmeet.com)
|
|
* by generating secure tokens that link sessions together.
|
|
*/
|
|
|
|
import { getDeviceData, type CollectedDeviceData } from './device-collector';
|
|
|
|
// Type declarations for browser APIs
|
|
declare const window: {
|
|
location: { search: string; href: string; origin: string };
|
|
} | undefined;
|
|
declare const sessionStorage: {
|
|
getItem: (key: string) => string | null;
|
|
setItem: (key: string, value: string) => void;
|
|
removeItem: (key: string) => void;
|
|
} | undefined;
|
|
|
|
const XSESSION_PARAM = '_xsession';
|
|
const PENDING_ADOPTION_KEY = 'analytics_pending_adoption';
|
|
|
|
/**
|
|
* Configuration for cross-domain linking.
|
|
*/
|
|
export interface CrossDomainConfig {
|
|
/** Analytics API base URL */
|
|
apiBaseUrl: string;
|
|
/** Current session ID */
|
|
sessionId: string;
|
|
/** Current domain (for source tracking) */
|
|
domain: string;
|
|
/** List of allowed target domains for cross-domain linking */
|
|
allowedDomains?: string[];
|
|
/** Enable debug logging */
|
|
enableDebugLogging?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Result of generating a cross-domain token.
|
|
*/
|
|
export interface CrossDomainTokenResult {
|
|
token: string;
|
|
expiresIn: number;
|
|
}
|
|
|
|
/**
|
|
* Result of adopting a cross-domain session.
|
|
*/
|
|
export interface AdoptionResult {
|
|
success: boolean;
|
|
originalSessionId?: string;
|
|
method?: 'token' | 'fingerprint';
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* CrossDomainLinker handles session linking across domains.
|
|
*/
|
|
export class CrossDomainLinker {
|
|
private config: CrossDomainConfig;
|
|
private deviceData: CollectedDeviceData | null = null;
|
|
|
|
constructor(config: CrossDomainConfig) {
|
|
this.config = config;
|
|
this.deviceData = getDeviceData();
|
|
}
|
|
|
|
/**
|
|
* Generate a cross-domain linkage token.
|
|
*
|
|
* Call this before redirecting to another domain. The token should be
|
|
* appended to the target URL as a query parameter: ?_xsession=token
|
|
*
|
|
* @returns Token result with token and expiry time (60 seconds)
|
|
*/
|
|
async generateToken(): Promise<CrossDomainTokenResult> {
|
|
const response = await fetch(
|
|
`${this.config.apiBaseUrl}/analytics/session/cross-domain-token`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sessionId: this.config.sessionId,
|
|
sourceDomain: this.config.domain,
|
|
userAgent: navigator?.userAgent,
|
|
language: navigator?.language,
|
|
timezone: Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.timeZone,
|
|
screenWidth: this.deviceData?.screenWidth,
|
|
screenHeight: this.deviceData?.screenHeight,
|
|
colorDepth: this.deviceData?.colorDepth,
|
|
hardwareConcurrency: this.deviceData?.hardwareConcurrency,
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to generate token: ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (this.config.enableDebugLogging) {
|
|
console.log('[Analytics] Generated cross-domain token', result);
|
|
}
|
|
|
|
return result as CrossDomainTokenResult;
|
|
}
|
|
|
|
/**
|
|
* Append cross-domain token to a URL.
|
|
*
|
|
* @param targetUrl - URL to append token to
|
|
* @returns URL with _xsession parameter, or original URL if generation fails
|
|
*/
|
|
async appendTokenToUrl(targetUrl: string): Promise<string> {
|
|
try {
|
|
const url = new URL(targetUrl);
|
|
|
|
// Check if target domain is allowed
|
|
if (this.config.allowedDomains) {
|
|
const isAllowed = this.config.allowedDomains.some((domain) =>
|
|
url.hostname.endsWith(domain),
|
|
);
|
|
if (!isAllowed) {
|
|
if (this.config.enableDebugLogging) {
|
|
console.log(
|
|
`[Analytics] Domain ${url.hostname} not in allowed list`,
|
|
);
|
|
}
|
|
return targetUrl;
|
|
}
|
|
}
|
|
|
|
const tokenResult = await this.generateToken();
|
|
url.searchParams.set(XSESSION_PARAM, tokenResult.token);
|
|
|
|
return url.toString();
|
|
} catch (error) {
|
|
if (this.config.enableDebugLogging) {
|
|
console.error('[Analytics] Failed to append token to URL:', error);
|
|
}
|
|
return targetUrl;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if current URL has a pending cross-domain token.
|
|
*
|
|
* @returns Token string or null if not present
|
|
*/
|
|
static getPendingToken(): string | null {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
return params.get(XSESSION_PARAM);
|
|
}
|
|
|
|
/**
|
|
* Adopt a session from a cross-domain token.
|
|
*
|
|
* Call this on page load when a _xsession parameter is present.
|
|
*
|
|
* @param token - The cross-domain token from URL
|
|
* @param newSessionId - The new session ID on this domain
|
|
* @returns Adoption result
|
|
*/
|
|
async adoptSession(
|
|
token: string,
|
|
newSessionId: string,
|
|
): Promise<AdoptionResult> {
|
|
try {
|
|
const response = await fetch(
|
|
`${this.config.apiBaseUrl}/analytics/session/adopt`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
token,
|
|
newSessionId,
|
|
userAgent: navigator?.userAgent,
|
|
language: navigator?.language,
|
|
timezone: Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.timeZone,
|
|
screenWidth: this.deviceData?.screenWidth,
|
|
screenHeight: this.deviceData?.screenHeight,
|
|
colorDepth: this.deviceData?.colorDepth,
|
|
hardwareConcurrency: this.deviceData?.hardwareConcurrency,
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to adopt session: ${response.status}`);
|
|
}
|
|
|
|
const result = (await response.json()) as AdoptionResult;
|
|
|
|
if (this.config.enableDebugLogging) {
|
|
console.log('[Analytics] Session adoption result:', result);
|
|
}
|
|
|
|
// Clean up URL by removing _xsession parameter
|
|
this.cleanupUrl();
|
|
|
|
return result;
|
|
} catch (error) {
|
|
if (this.config.enableDebugLogging) {
|
|
console.error('[Analytics] Failed to adopt session:', error);
|
|
}
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove _xsession parameter from URL without page reload.
|
|
*/
|
|
private cleanupUrl(): void {
|
|
if (typeof window === 'undefined' || typeof history === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete(XSESSION_PARAM);
|
|
|
|
// Update URL without reload
|
|
history.replaceState(history.state, '', url.toString());
|
|
}
|
|
|
|
/**
|
|
* Store pending adoption for retry.
|
|
* Used when adoption fails on first try.
|
|
*/
|
|
static storePendingAdoption(token: string): void {
|
|
if (typeof sessionStorage !== 'undefined') {
|
|
sessionStorage.setItem(
|
|
PENDING_ADOPTION_KEY,
|
|
JSON.stringify({ token, storedAt: Date.now() }),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get pending adoption if any.
|
|
*/
|
|
static getPendingAdoption(): { token: string; storedAt: number } | null {
|
|
if (typeof sessionStorage === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const stored = sessionStorage.getItem(PENDING_ADOPTION_KEY);
|
|
if (!stored) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(stored);
|
|
// Expire after 5 minutes
|
|
if (Date.now() - data.storedAt > 5 * 60 * 1000) {
|
|
sessionStorage.removeItem(PENDING_ADOPTION_KEY);
|
|
return null;
|
|
}
|
|
return data;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear pending adoption.
|
|
*/
|
|
static clearPendingAdoption(): void {
|
|
if (typeof sessionStorage !== 'undefined') {
|
|
sessionStorage.removeItem(PENDING_ADOPTION_KEY);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create click handler that adds cross-domain token to link clicks.
|
|
*
|
|
* Usage:
|
|
* ```tsx
|
|
* const handleClick = createCrossDomainClickHandler(linker, targetUrl, navigate);
|
|
* <a href={targetUrl} onClick={handleClick}>Go to other domain</a>
|
|
* ```
|
|
*/
|
|
export function createCrossDomainClickHandler(
|
|
linker: CrossDomainLinker,
|
|
targetUrl: string,
|
|
navigate?: (url: string) => void,
|
|
): (event: MouseEvent | React.MouseEvent) => void {
|
|
return async (event: MouseEvent | { preventDefault: () => void }) => {
|
|
event.preventDefault();
|
|
|
|
const urlWithToken = await linker.appendTokenToUrl(targetUrl);
|
|
|
|
if (navigate) {
|
|
navigate(urlWithToken);
|
|
} else if (typeof window !== 'undefined') {
|
|
window.location.href = urlWithToken;
|
|
}
|
|
};
|
|
}
|