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<void> {
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);
}
}