/** * Apple-Style Device-Approval (iCloud Sign-In Pattern). * * Wenn ein neues Gerät registriert wird und das Device-Limit erreicht ist, * kann der User auf einem bereits angemeldeten Gerät den Login bestätigen * (Variante 2 — Code wird auf BEIDEN Geräten gezeigt für visuellen Vergleich). * * Fallback: Wenn kein anderes Device aktiv → Email mit One-Click-Approval-Link. * * TTL: 10 Minuten. Codes sind 6-stellig numerisch. */ import crypto from "node:crypto"; import { usePrisma } from "../utils/prisma"; const APPROVAL_TTL_MS = 10 * 60 * 1000; // 10 min /** Devices die innerhalb dieser Zeit gesehen wurden gelten als "online genug für Push". */ const ACTIVE_DEVICE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7d export interface DeviceApprovalRecord { id: string; userId: string; newDeviceId: string; newPlatform: string; newModel: string | null; newName: string | null; newOsVersion: string | null; code: string; status: string; approvedByDeviceRowId: string | null; approvedAt: Date | null; rejectedAt: Date | null; evictedDeviceRowId: string | null; emailSentAt: Date | null; createdAt: Date; expiresAt: Date; } const APPROVAL_SELECT = { id: true, userId: true, newDeviceId: true, newPlatform: true, newModel: true, newName: true, newOsVersion: true, code: true, status: true, approvedByDeviceRowId: true, approvedAt: true, rejectedAt: true, evictedDeviceRowId: true, emailSentAt: true, createdAt: true, expiresAt: true, } as const; /** Generiert einen 6-stelligen numerischen Code (000000-999999). */ function generateCode(): string { // 0..999999 cryptographically random const n = crypto.randomInt(0, 1_000_000); return n.toString().padStart(6, "0"); } function generateEmailToken(): string { return crypto.randomBytes(32).toString("hex"); } /** Hat der User mind. 1 anderes Device das innerhalb des Active-Windows gesehen wurde? */ export async function hasOtherActiveDevices( userId: string, excludeDeviceId: string, ): Promise { const db = usePrisma(); const cutoff = new Date(Date.now() - ACTIVE_DEVICE_WINDOW_MS); const count = await db.userDevice.count({ where: { userId, deviceId: { not: excludeDeviceId }, lastSeenAt: { gte: cutoff }, }, }); return count > 0; } /** * Erstellt einen neuen Approval-Request. Cancelt alle anderen pending Requests * für dasselbe newDeviceId (verhindert Spam wenn User mehrfach klickt). */ export async function createApprovalRequest(opts: { userId: string; newDeviceId: string; newPlatform: string; newModel?: string | null; newName?: string | null; newOsVersion?: string | null; }): Promise { const db = usePrisma(); // Expire alte pending Requests für dieses Device (Cleanup). await db.deviceApprovalRequest.updateMany({ where: { userId: opts.userId, newDeviceId: opts.newDeviceId, status: "pending", }, data: { status: "expired" }, }); const code = generateCode(); const expiresAt = new Date(Date.now() + APPROVAL_TTL_MS); return db.deviceApprovalRequest.create({ data: { userId: opts.userId, newDeviceId: opts.newDeviceId, newPlatform: opts.newPlatform, newModel: opts.newModel ?? null, newName: opts.newName ?? null, newOsVersion: opts.newOsVersion ?? null, code, expiresAt, }, select: APPROVAL_SELECT, }); } /** Holt einen Approval-Request mit Ownership-Check. */ export async function getApprovalRequest( id: string, userId: string, ): Promise { const db = usePrisma(); const row = await db.deviceApprovalRequest.findFirst({ where: { id, userId }, select: APPROVAL_SELECT, }); if (!row) return null; return maybeExpire(row); } /** Holt einen Approval-Request per Email-Token (für Email-Link-Approval). */ export async function getApprovalByEmailToken( token: string, ): Promise { const db = usePrisma(); const row = await db.deviceApprovalRequest.findUnique({ where: { emailToken: token }, select: APPROVAL_SELECT, }); if (!row) return null; return maybeExpire(row); } /** Listet pending Requests für einen User (für Banner auf existierenden Devices). */ export async function listPendingApprovals( userId: string, ): Promise { const db = usePrisma(); const rows = await db.deviceApprovalRequest.findMany({ where: { userId, status: "pending", expiresAt: { gt: new Date() }, }, orderBy: { createdAt: "desc" }, select: APPROVAL_SELECT, }); return rows; } /** * Approve: marks request as approved + (atomically) registers the new device, * optionally evicting an existing device if the user is at the limit. * * Returns the approved record. Caller (endpoint) is responsible for the * actual UserDevice creation since registerDevice() handles merge-heuristic * + slot-check anyway — but we DO evict here if evictDeviceRowId is given * so registerDevice doesn't fail with DEVICE_LIMIT_REACHED again. */ export async function approveRequest(opts: { approvalId: string; userId: string; approvedByDeviceRowId: string | null; // null = via email-link evictDeviceRowId: string | null; }): Promise { const db = usePrisma(); const existing = await getApprovalRequest(opts.approvalId, opts.userId); if (!existing) return null; if (existing.status !== "pending") return existing; // Eviction transactional with status update. return db.$transaction(async (tx) => { if (opts.evictDeviceRowId) { // Make sure the device-to-evict belongs to the user (defense in depth). await tx.userDevice.deleteMany({ where: { id: opts.evictDeviceRowId, userId: opts.userId }, }); } return tx.deviceApprovalRequest.update({ where: { id: opts.approvalId }, data: { status: "approved", approvedAt: new Date(), approvedByDeviceRowId: opts.approvedByDeviceRowId, evictedDeviceRowId: opts.evictDeviceRowId, }, select: APPROVAL_SELECT, }); }); } export async function rejectRequest( approvalId: string, userId: string, ): Promise { const db = usePrisma(); const existing = await getApprovalRequest(approvalId, userId); if (!existing) return null; if (existing.status !== "pending") return existing; return db.deviceApprovalRequest.update({ where: { id: approvalId }, data: { status: "rejected", rejectedAt: new Date() }, select: APPROVAL_SELECT, }); } /** * Markiert dass eine Email mit Approval-Link verschickt wurde. Generiert * (idempotent) einen emailToken den der Link enthält. * Rate-Limit: 1 Email pro Approval-Request. */ export async function markEmailSent( approvalId: string, userId: string, ): Promise { const db = usePrisma(); const existing = await getApprovalRequest(approvalId, userId); if (!existing) return null; if (existing.status !== "pending") return existing; if (existing.emailSentAt) return existing; // already sent const token = existing.emailToken ?? generateEmailToken(); return db.deviceApprovalRequest.update({ where: { id: approvalId }, data: { emailSentAt: new Date(), emailToken: token }, select: APPROVAL_SELECT, }); } /** Wenn expiresAt überschritten und status noch pending → setze auf expired. */ async function maybeExpire( row: DeviceApprovalRecord, ): Promise { if (row.status !== "pending") return row; if (row.expiresAt.getTime() > Date.now()) return row; const db = usePrisma(); const updated = await db.deviceApprovalRequest.update({ where: { id: row.id }, data: { status: "expired" }, select: APPROVAL_SELECT, }); return updated; }