import crypto from 'crypto'; import { db } from './database.js'; /** * Device fingerprint data structure */ export interface FingerprintData { ipAddress: string; userAgent: string; timezone?: string; language?: string; screenWidth?: number; screenHeight?: number; platform?: string; platformVersion?: string; } /** * Fingerprint match result with confidence scoring */ export interface FingerprintMatch { clickId: string; linkId: string; confidenceScore: number; matchedFactors: string[]; clickedAt: Date; } type FingerprintFactorDebug = { ip: { matched: boolean; installAttributable: boolean; clickAttributable: boolean; installValue: string; clickValue: string; }; userAgent: { matched: boolean; installValue: string; clickValue: string; mobilePlatformBridge: boolean; }; platform: { matched: boolean; installValue: string; clickValue: string; }; timezone: { matched: boolean; installValue: string; clickValue: string; }; language: { matched: boolean; installValue: string; clickValue: string; }; screen: { matched: boolean; installValue: string; clickValue: string; }; }; /** * Scoring weights for probabilistic matching * Total should equal 100 for percentage-based confidence */ const FINGERPRINT_WEIGHTS = { IP_ADDRESS: 40, USER_AGENT: 30, TIMEZONE: 10, LANGUAGE: 10, SCREEN_RESOLUTION: 10, PLATFORM: 20, }; /** * Default attribution window in hours (7 days) */ export const DEFAULT_ATTRIBUTION_WINDOW_HOURS = 168; /** * Minimum confidence threshold for attribution (70%) */ export const CONFIDENCE_THRESHOLD = 70; const ATTRIBUTION_DEBUG_ENV = 'LINKFORTY_DEBUG_ATTRIBUTION'; /** * Generate a fingerprint hash from device data * Uses SHA-256 hash of concatenated device attributes */ export function generateFingerprintHash(data: FingerprintData): string { const components = [ data.ipAddress || '', data.userAgent || '', data.timezone || '', data.language || '', data.screenWidth?.toString() || '', data.screenHeight?.toString() || '', data.platform || '', data.platformVersion || '', ]; const concatenated = components.join('|'); return crypto.createHash('sha256').update(concatenated).digest('hex'); } /** * Normalize IP address for comparison * Handles IPv4 and IPv6, removes subnet variations */ function ipv4ToInt(ip: string): number | null { const parts = ip.split('.'); if (parts.length !== 4) return null; let n = 0; for (const p of parts) { const o = Number(p); if (!Number.isInteger(o) || o < 0 || o > 255) return null; n = (n << 8) | o; } return n >>> 0; } function inCidr4(ipInt: number, baseIp: string, prefix: number): boolean { const base = ipv4ToInt(baseIp); if (base === null) return false; const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; return (ipInt & mask) === (base & mask); } // Shared / non-routable IPv4 ranges that can't identify a single device: // carrier-grade NAT, RFC1918 private, loopback, link-local, etc. Two different // users routinely share these (mobile carrier NAT, office Wi-Fi, VPN egress). const NON_ATTRIBUTABLE_V4: ReadonlyArray = [ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], // CGNAT (RFC 6598) ['127.0.0.0', 8], // loopback ['169.254.0.0', 16], // link-local ['172.16.0.0', 12], // private ['192.0.0.0', 24], // IETF protocol assignments ['192.168.0.0', 16], // private ['198.18.0.0', 15], // benchmarking ]; /** * Whether an IP is specific enough to use as an attribution signal. Returns * false for shared/non-routable ranges (CGNAT, RFC1918, loopback, link-local, * IPv6 ULA/link-local) where the IP does NOT identify a single device, so it * must not contribute to a fingerprint match. Public IPs return true. */ export function isAttributableIp(ip: string): boolean { if (!ip) return false; let addr = ip.trim(); if (addr.startsWith('::ffff:')) addr = addr.slice(7); // IPv4-mapped IPv6 // IPv4 if (addr.includes('.') && !addr.includes(':')) { const n = ipv4ToInt(addr); if (n === null) return false; return !NON_ATTRIBUTABLE_V4.some(([base, prefix]) => inCidr4(n, base, prefix)); } // IPv6 if (addr.includes(':')) { const low = addr.toLowerCase(); if (low === '::' || low === '::1') return false; // unspecified / loopback if (low.startsWith('fc') || low.startsWith('fd')) return false; // fc00::/7 ULA if (low.startsWith('fe8') || low.startsWith('fe9') || low.startsWith('fea') || low.startsWith('feb')) { return false; // fe80::/10 link-local } return true; } // Not a recognizable IPv4 or IPv6 address. return false; } function normalizeIP(ip: string): string { if (!ip) return ''; // For IPv4, use first 3 octets (e.g., 192.168.1.x) if (ip.includes('.')) { const parts = ip.split('.'); return parts.slice(0, 3).join('.'); } // For IPv6, use first 4 groups (e.g., 2001:0db8:85a3:0000:xxxx) if (ip.includes(':')) { const parts = ip.split(':'); return parts.slice(0, 4).join(':'); } return ip; } /** * Normalize user agent for comparison * Extracts key identifiers and removes version numbers */ function normalizeUserAgent(ua: string): string { if (!ua) return ''; // Extract platform (iOS, Android, Windows, Mac, Linux) const platformMatch = ua.match(/(iPhone|iPad|Android|Windows|Macintosh|Linux)/i); const platform = platformMatch ? platformMatch[1] : ''; // Extract browser (Chrome, Safari, Firefox, Edge) const browserMatch = ua.match(/(Chrome|Safari|Firefox|Edge|Opera)/i); const browser = browserMatch ? browserMatch[1] : ''; return `${platform}|${browser}`.toLowerCase(); } function extractPlatformFromUserAgent(ua: string): string { if (!ua) return ''; const platformMatch = ua.match(/(iPhone|iPad|iOS|Android|Windows|Macintosh|Linux)/i); return platformMatch ? normalizePlatform(platformMatch[1]) : ''; } function normalizePlatform(platform: string | undefined): string { if (!platform) return ''; const normalized = platform.trim().toLowerCase(); if (!normalized) return ''; if (['iphone', 'ipad', 'ios'].includes(normalized)) return 'ios'; if (normalized === 'android') return 'android'; if (['mac', 'macos', 'macintosh'].includes(normalized)) return 'mac'; if (['win', 'windows'].includes(normalized)) return 'windows'; return normalized; } function resolvePlatformFingerprint(data: FingerprintData): string { return normalizePlatform(data.platform) || extractPlatformFromUserAgent(data.userAgent); } function isMobilePlatform(platform: string): boolean { return platform === 'ios' || platform === 'android'; } function attributionDebugEnabled(): boolean { const value = process.env[ATTRIBUTION_DEBUG_ENV]?.trim().toLowerCase(); return value === '1' || value === 'true' || value === 'yes'; } function logAttributionDebug(event: string, payload: Record): void { if (!attributionDebugEnabled()) return; console.info(`[linkforty][attribution] ${event} ${JSON.stringify(payload)}`); } function scoreFingerprintMatch( fingerprint1: FingerprintData, fingerprint2: FingerprintData ): { score: number; matchedFactors: string[]; debug: FingerprintFactorDebug } { let score = 0; const matchedFactors: string[] = []; const platform1 = resolvePlatformFingerprint(fingerprint1); const platform2 = resolvePlatformFingerprint(fingerprint2); const installIpAttributable = isAttributableIp(fingerprint1.ipAddress); const clickIpAttributable = isAttributableIp(fingerprint2.ipAddress); const ip1 = fingerprint1.ipAddress ? normalizeIP(fingerprint1.ipAddress) : ''; const ip2 = fingerprint2.ipAddress ? normalizeIP(fingerprint2.ipAddress) : ''; const ua1 = fingerprint1.userAgent ? normalizeUserAgent(fingerprint1.userAgent) : ''; const ua2 = fingerprint2.userAgent ? normalizeUserAgent(fingerprint2.userAgent) : ''; const mobilePlatformBridge = platform1 !== '' && platform1 === platform2 && isMobilePlatform(platform1); const language1 = fingerprint1.language?.substring(0, 2).toLowerCase() || ''; const language2 = fingerprint2.language?.substring(0, 2).toLowerCase() || ''; const screen1 = fingerprint1.screenWidth && fingerprint1.screenHeight ? `${fingerprint1.screenWidth}x${fingerprint1.screenHeight}` : ''; const screen2 = fingerprint2.screenWidth && fingerprint2.screenHeight ? `${fingerprint2.screenWidth}x${fingerprint2.screenHeight}` : ''; const debug: FingerprintFactorDebug = { ip: { matched: false, installAttributable: installIpAttributable, clickAttributable: clickIpAttributable, installValue: ip1, clickValue: ip2, }, userAgent: { matched: false, installValue: ua1, clickValue: ua2, mobilePlatformBridge, }, platform: { matched: false, installValue: platform1, clickValue: platform2, }, timezone: { matched: false, installValue: fingerprint1.timezone || '', clickValue: fingerprint2.timezone || '', }, language: { matched: false, installValue: language1, clickValue: language2, }, screen: { matched: false, installValue: screen1, clickValue: screen2, }, }; if ( fingerprint1.ipAddress && fingerprint2.ipAddress && installIpAttributable && clickIpAttributable && ip1 === ip2 ) { score += FINGERPRINT_WEIGHTS.IP_ADDRESS; matchedFactors.push('ip'); debug.ip.matched = true; } if (fingerprint1.userAgent && fingerprint2.userAgent && (ua1 === ua2 || mobilePlatformBridge)) { score += FINGERPRINT_WEIGHTS.USER_AGENT; matchedFactors.push('user_agent'); debug.userAgent.matched = true; } if (mobilePlatformBridge) { score += FINGERPRINT_WEIGHTS.PLATFORM; matchedFactors.push('platform'); debug.platform.matched = true; } if (fingerprint1.timezone && fingerprint2.timezone && fingerprint1.timezone === fingerprint2.timezone) { score += FINGERPRINT_WEIGHTS.TIMEZONE; matchedFactors.push('timezone'); debug.timezone.matched = true; } if (language1 !== '' && language1 === language2) { score += FINGERPRINT_WEIGHTS.LANGUAGE; matchedFactors.push('language'); debug.language.matched = true; } if ( fingerprint1.screenWidth && fingerprint1.screenHeight && fingerprint2.screenWidth && fingerprint2.screenHeight && fingerprint1.screenWidth === fingerprint2.screenWidth && fingerprint1.screenHeight === fingerprint2.screenHeight ) { score += FINGERPRINT_WEIGHTS.SCREEN_RESOLUTION; matchedFactors.push('screen'); debug.screen.matched = true; } return { score: Math.min(score, 100), matchedFactors, debug }; } /** * Calculate confidence score by comparing two fingerprints * Returns a score from 0-100 based on matched components */ export function calculateConfidenceScore( fingerprint1: FingerprintData, fingerprint2: FingerprintData ): { score: number; matchedFactors: string[] } { const { score, matchedFactors } = scoreFingerprintMatch(fingerprint1, fingerprint2); return { score, matchedFactors }; } /** * Match an install event to potential click events via probabilistic fingerprinting * Returns the best match above confidence threshold within attribution window * * Note: Uses link-specific attribution windows - each link can have its own window */ export async function matchInstallToClick( installFingerprint: FingerprintData, attributionWindowHours: number = DEFAULT_ATTRIBUTION_WINDOW_HOURS ): Promise { // Query recent click events within maximum possible attribution window (90 days) // We'll validate against each link's specific window during matching const maxWindowHours = 2160; // 90 days const cutoffTime = new Date(Date.now() - maxWindowHours * 60 * 60 * 1000); const clicksResult = await db.query( `SELECT ce.id as click_id, ce.link_id, ce.clicked_at, l.attribution_window_hours, df.ip_address, df.user_agent, df.timezone, df.language, df.screen_width, df.screen_height, df.platform, df.platform_version FROM click_events ce INNER JOIN device_fingerprints df ON df.click_id = ce.id INNER JOIN links l ON ce.link_id = l.id WHERE ce.clicked_at >= $1 ORDER BY ce.clicked_at DESC LIMIT 1000`, [cutoffTime] ); if (clicksResult.rows.length === 0) { return null; } const installTime = new Date(); // Calculate confidence score for each potential match let bestMatch: FingerprintMatch | null = null; let highestScore = 0; for (const row of clicksResult.rows) { // Check if click is within the link's specific attribution window const linkWindowHours = row.attribution_window_hours || DEFAULT_ATTRIBUTION_WINDOW_HOURS; const clickTime = new Date(row.clicked_at); const timeDiffHours = (installTime.getTime() - clickTime.getTime()) / (1000 * 60 * 60); if (timeDiffHours > linkWindowHours) { // Click is too old for this link's attribution window, skip it continue; } const clickFingerprint: FingerprintData = { ipAddress: row.ip_address, userAgent: row.user_agent, timezone: row.timezone, language: row.language, screenWidth: row.screen_width, screenHeight: row.screen_height, platform: row.platform, platformVersion: row.platform_version, }; const { score, matchedFactors, debug } = scoreFingerprintMatch( installFingerprint, clickFingerprint ); logAttributionDebug('candidate_scored', { clickId: row.click_id, linkId: row.link_id, score, threshold: CONFIDENCE_THRESHOLD, matchedFactors, timeDiffHours, attributionWindowHours: linkWindowHours, installFingerprint: { ipAddress: installFingerprint.ipAddress, platform: installFingerprint.platform || null, timezone: installFingerprint.timezone || null, language: installFingerprint.language || null, screenWidth: installFingerprint.screenWidth || null, screenHeight: installFingerprint.screenHeight || null, }, clickFingerprint: { ipAddress: clickFingerprint.ipAddress, platform: clickFingerprint.platform || null, timezone: clickFingerprint.timezone || null, language: clickFingerprint.language || null, screenWidth: clickFingerprint.screenWidth || null, screenHeight: clickFingerprint.screenHeight || null, }, factors: debug, }); // Track the best match if (score > highestScore && score >= CONFIDENCE_THRESHOLD) { highestScore = score; bestMatch = { clickId: row.click_id, linkId: row.link_id, confidenceScore: score, matchedFactors, clickedAt: new Date(row.clicked_at), }; } } logAttributionDebug('install_match_result', { matched: bestMatch !== null, highestScore, threshold: CONFIDENCE_THRESHOLD, installFingerprint: { ipAddress: installFingerprint.ipAddress, platform: installFingerprint.platform || null, timezone: installFingerprint.timezone || null, language: installFingerprint.language || null, screenWidth: installFingerprint.screenWidth || null, screenHeight: installFingerprint.screenHeight || null, }, bestMatch: bestMatch ? { clickId: bestMatch.clickId, linkId: bestMatch.linkId, confidenceScore: bestMatch.confidenceScore, matchedFactors: bestMatch.matchedFactors, clickedAt: bestMatch.clickedAt.toISOString(), } : null, }); return bestMatch; } /** * Store device fingerprint for a click event */ export async function storeFingerprintForClick( clickId: string, fingerprintData: FingerprintData ): Promise { const fingerprintHash = generateFingerprintHash(fingerprintData); await db.query( `INSERT INTO device_fingerprints ( click_id, fingerprint_hash, ip_address, user_agent, timezone, language, screen_width, screen_height, platform, platform_version ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ clickId, fingerprintHash, fingerprintData.ipAddress, fingerprintData.userAgent, fingerprintData.timezone || null, fingerprintData.language || null, fingerprintData.screenWidth || null, fingerprintData.screenHeight || null, fingerprintData.platform || null, fingerprintData.platformVersion || null, ] ); } /** * Record an install event and attempt to match it to a click */ export async function recordInstallEvent( fingerprintData: FingerprintData, deviceId?: string, attributionWindowHours: number = DEFAULT_ATTRIBUTION_WINDOW_HOURS, sdk?: { name?: string | null; version?: string | null } ): Promise<{ installId: string; match: FingerprintMatch | null; deepLinkData: any; }> { const fingerprintHash = generateFingerprintHash(fingerprintData); // Attempt to match install to a click const match = await matchInstallToClick(fingerprintData, attributionWindowHours); // Attribution metadata for measurement (SIT-296): install attribution is // fingerprint-based, so the method is 'fingerprint' on a match and 'none' // (organic) otherwise; matched_factors records which signals matched. const attributionMethod = match ? 'fingerprint' : 'none'; const matchedFactors = match?.matchedFactors ?? null; // Insert install event const installResult = await db.query( `INSERT INTO install_events ( link_id, click_id, fingerprint_hash, confidence_score, installed_at, first_open_at, attribution_window_hours, ip_address, user_agent, timezone, language, screen_width, screen_height, platform, platform_version, device_id, deep_link_data, sdk_name, sdk_version, attribution_method, matched_factors ) VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id, deep_link_data`, [ match?.linkId || null, match?.clickId || null, fingerprintHash, match?.confidenceScore || null, attributionWindowHours, fingerprintData.ipAddress, fingerprintData.userAgent, fingerprintData.timezone || null, fingerprintData.language || null, fingerprintData.screenWidth || null, fingerprintData.screenHeight || null, fingerprintData.platform || null, fingerprintData.platformVersion || null, deviceId || null, match ? JSON.stringify({}) : JSON.stringify({}), // Will be populated from link data sdk?.name || null, sdk?.version || null, attributionMethod, matchedFactors, ] ); const installId = installResult.rows[0].id; let deepLinkData = {}; // If we have a match, retrieve the deep link data from the original link if (match) { const linkResult = await db.query( `SELECT short_code, original_url, ios_app_store_url, android_app_store_url, web_fallback_url, utm_parameters, targeting_rules, deep_link_parameters FROM links WHERE id = $1`, [match.linkId] ); if (linkResult.rows.length > 0) { const link = linkResult.rows[0]; deepLinkData = { shortCode: link.short_code, originalUrl: link.original_url, iosUrl: link.ios_app_store_url, androidUrl: link.android_app_store_url, webFallbackUrl: link.web_fallback_url, utmParameters: link.utm_parameters, targetingRules: link.targeting_rules, deepLinkParameters: link.deep_link_parameters, clickedAt: match.clickedAt, confidenceScore: match.confidenceScore, matchedFactors: match.matchedFactors, }; // Update the install event with deep link data await db.query( `UPDATE install_events SET deep_link_data = $1, deep_link_retrieved = true WHERE id = $2`, [JSON.stringify(deepLinkData), installId] ); // Trigger webhooks for install_event (only if attributed) try { // Get the link's user_id for webhook lookup const linkUserResult = await db.query( 'SELECT user_id FROM links WHERE id = $1', [match.linkId] ); if (linkUserResult.rows.length > 0) { const userId = linkUserResult.rows[0].user_id; const webhooksResult = await db.query( 'SELECT * FROM webhooks WHERE user_id = $1 AND is_active = true', [userId] ); if (webhooksResult.rows.length > 0) { const { triggerWebhooks } = await import('./webhook.js'); const installEventData = { id: installId, linkId: match.linkId, fingerprintHash, confidenceScore: match.confidenceScore, installedAt: new Date().toISOString(), deepLinkData, ipAddress: fingerprintData.ipAddress, userAgent: fingerprintData.userAgent, platform: fingerprintData.platform, }; // Trigger webhooks without delivery logging (basic version) // For delivery logging, use @linkforty/cloud premium features await triggerWebhooks( webhooksResult.rows, 'install_event', installId, installEventData ); } } } catch (webhookError) { console.error(`Error triggering install webhooks: ${webhookError}`); } } } return { installId, match, deepLinkData, }; }