import { env } from "cloudflare:workers"; import { first } from "../db"; import { sha256Hex } from "../crypto"; import { emailTemplates, queueEmail } from "../email"; import { captureServerEvent } from "../analytics"; const DEVICE_TTL_SECONDS = 90 * 24 * 60 * 60; const MAX_AGENT_LENGTH = 120; function describeAgent(userAgent: string): string { const browser = /(Firefox|Edg|Chrome|Safari)\/[\d.]+/.exec(userAgent)?.[1]?.replace("Edg", "Edge"); const os = /(Windows|Mac OS X|Android|iPhone|iPad|Linux)/.exec(userAgent)?.[1]?.replace("Mac OS X", "macOS"); if (browser || os) return [browser ?? "a browser", os ? `on ${os}` : ""].filter(Boolean).join(" "); return userAgent ? userAgent.slice(0, MAX_AGENT_LENGTH) : "an unknown client"; } /** * Remembers the (IP, browser family and OS) pair per user, so browser updates don't count as new; a sign-in from an unseen pair emails a security notice, * except for the very first sign-in on the account. Failures never block sign-in. */ export async function noteSignIn(userId: string, ip: string, userAgent: string): Promise { captureServerEvent("user_signed_in", userId); try { const prefix = `signin-device:${userId}:`; const key = `${prefix}${await sha256Hex(`${ip}\n${describeAgent(userAgent)}`)}`; if (await env.KV.get(key)) return; const known = await env.KV.list({ prefix, limit: 1 }); await env.KV.put(key, "1", { expirationTtl: DEVICE_TTL_SECONDS }); if (known.keys.length === 0) return; const user = await first<{ email: string; email_verified_at: number | null; name: string }>( "SELECT u.email, u.email_verified_at, a.name FROM users u JOIN accounts a ON a.id = u.account_id WHERE u.account_id = ?", userId, ); if (!user?.email_verified_at) return; const event = `there was a new sign-in to your account from ${describeAgent(userAgent)} (IP ${ip})`; await queueEmail(user.email, emailTemplates.securityNotice(user.name, event)); } catch (error) { console.error("Failed to record sign-in", error); } }