Files
linkforty/src/lib/fingerprint.ts
T

728 lines
22 KiB
TypeScript

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<readonly [string, number]> = [
['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<string, unknown>): 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<FingerprintMatch | null> {
// 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<void> {
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,
};
}