import { getProfile } from "../../db/profile"; import { findActiveDeviceLock, bindDeviceToUser, isLockingPlan, markDeviceLockNotified, } from "../../db/devices"; import { isLockNotifyRateLimited, sendDeviceLockEmail, } from "../../utils/device-lock-email"; /** * POST /api/devices/check-lock * * Authenticated Device-Account-Lock-Check für Native-Auth-Flow. * * Die native App nutzt supabase.auth.signInWithPassword direkt (BFF-Pattern * gilt für token-storage, nicht für den Auth-Call selbst). Nach dem Sign-In * ruft der Auth-Store diesen Endpoint mit dem frischen JWT auf. * * Warum separater Endpoint statt /api/auth/login: * - /api/auth/login ist für username+password (BFF-Signin mit internal-email-trick) * - Native-Auth geht direkt via Supabase-SDK → JWT vorhanden, kein Password-Re-Entry * - Dieser Endpoint prüft POST-AUTH ob das Device an einen anderen Account gebunden ist * * Auth: requireUser mit skipDeviceCheck=true — kein Chicken-Egg-Problem. * - skipDeviceCheck=true: verhindert dass requireUser selbst einen 403 wirft * wenn das Device noch nicht registriert ist (Lock-Check muss VOR Register laufen) * * Request Headers: * - Authorization: Bearer * - x-device-id: * * Response: * - 200: { ok: true } — kein Lock, Device ggf. an currentUser gebunden (Pro/Legend) * - 400: { error: "DEVICE_ID_REQUIRED" } — x-device-id Header fehlt * - 409: { error: "DEVICE_LOCKED", lockedUntil, releaseRequestable } * * Side-effects bei 200 (Pro/Legend only): * - bindDeviceToUser — idempotent, nur wenn noch nicht gebunden * * Side-effects bei 409: * - sendDeviceLockEmail — Rate-Limited auf 1 Mail / 6h via lockNotifiedAt * - markDeviceLockNotified — schreibt lockNotifiedAt in DB */ export default defineEventHandler(async (event) => { // skipDeviceCheck=true: requireUser darf nicht selbst Device-Logic auslösen — // dieser Endpoint IST der Device-Check. Ohne diesen Flag: 403-Loop möglich wenn // Device noch nicht in UserDevice-Table (Erstlogin vor register-Endpoint). const user = await requireUser(event, { skipDeviceCheck: true }); const deviceId = getHeader(event, "x-device-id"); if (!deviceId) { throw createError({ statusCode: 400, data: { error: "DEVICE_ID_REQUIRED" }, }); } // ─── Lock-Check ─────────────────────────────────────────────────────────── // findActiveDeviceLock matched: deviceId + NOT userId=currentUser. // Wenn kein Lock für einen anderen User → null → weiter. const lock = await findActiveDeviceLock(deviceId, user.id); if (lock) { // ─── Mail-Notification an Original-User (Rate-Limited 6h) ───────────── if (!isLockNotifyRateLimited(lock.lockNotifiedAt)) { const config = useRuntimeConfig(event); const supabaseCfg = (config as any).public?.supabase ?? (config as any).supabase; const resendApiKey = (config as any).resendApiKey as string | undefined; const supabaseServiceKey = (config as any).supabaseServiceKey as string | undefined; // Fire-and-forget — 409 wird sofort geworfen, Mail läuft async void (async () => { try { const ownerProfile = await getProfile(lock.userId); if (ownerProfile && resendApiKey && supabaseServiceKey) { const { createClient } = await import("@supabase/supabase-js"); const adminClient = createClient( supabaseCfg.url as string, supabaseServiceKey, ); const { data: adminUser } = await adminClient.auth.admin.getUserById(lock.userId); const ownerEmail = adminUser?.user?.email; if (ownerEmail) { await sendDeviceLockEmail({ recipientNickname: ownerProfile.nickname ?? ownerProfile.username ?? "Nutzer", recipientEmail: ownerEmail, deviceRowId: lock.id, deviceName: lock.name, lockNotifiedAt: lock.lockNotifiedAt, resendApiKey, }); await markDeviceLockNotified(lock.id); } } } catch (mailErr: any) { console.error("[check-lock] device-lock mail failed:", mailErr?.message ?? mailErr); } })(); } // lockedUntil: releaseRequestedAt + 24h, oder null wenn kein Release beantragt const lockedUntil = lock.releaseRequestedAt ? new Date(lock.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000).toISOString() : null; throw createError({ statusCode: 409, statusMessage: "DEVICE_LOCKED", data: { error: "DEVICE_LOCKED", lockedUntil, releaseRequestable: true, }, }); } // ─── Kein Lock — ggf. Device an currentUser binden ─────────────────────── // Pro/Legend-User: idempotentes Binding (boundToPlan=null → setzen). // Free-User: kein Binding (isLockingPlan-Guard in bindDeviceToUser). // Fire-and-forget — 200 soll nicht durch Binding-Fehler blockiert werden. const profile = await getProfile(user.id); const plan = profile?.plan ?? "free"; if (isLockingPlan(plan)) { void bindDeviceToUser(user.id, deviceId, plan).catch((err: any) => { console.error("[check-lock] device-bind failed:", err?.message ?? err); }); } return { ok: true }; });