platform-codebase/@packages/@infrastructure/analytics-client/src/cross-domain.ts

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;
}
};
}