export type AltSignalMatch = { reason: 'ip' | 'invite_cluster'; matchedUserId: string | null; }; export type AltDetectionInput = { userId: string; accountAgeDays: number; inviteCode: string | null; ipHash: string | null; /** Prior verification records in the same guild (excluding current user). */ priorRecords: Array<{ userId: string; inviteCode: string | null; ipHash: string | null; createdAt: Date; flaggedAsAlt: boolean; }>; altIpWindowDays: number; altInviteWindowHours: number; altMaxAccountsPerInvite: number; /** Only flag invite clusters when the current account is this young or younger. */ youngAccountMaxDays?: number; }; /** * Detects likely alt accounts from Discord + captcha signals. * Does not use browser fingerprinting beyond salted IP / UA hashes from the captcha page. */ export function detectAltAccount(input: AltDetectionInput): AltSignalMatch | null { const youngMax = input.youngAccountMaxDays ?? 7; const now = Date.now(); if (input.ipHash) { const ipWindowMs = input.altIpWindowDays * 24 * 60 * 60 * 1000; const ipMatch = input.priorRecords.find( (record) => record.ipHash === input.ipHash && now - record.createdAt.getTime() <= ipWindowMs ); if (ipMatch) { return { reason: 'ip', matchedUserId: ipMatch.userId }; } } if (input.inviteCode && input.accountAgeDays <= youngMax) { const inviteWindowMs = input.altInviteWindowHours * 60 * 60 * 1000; const recentSameInvite = input.priorRecords.filter( (record) => record.inviteCode === input.inviteCode && now - record.createdAt.getTime() <= inviteWindowMs ); if (recentSameInvite.length >= input.altMaxAccountsPerInvite) { return { reason: 'invite_cluster', matchedUserId: recentSameInvite[0]?.userId ?? null }; } } return null; }