chahinebrini 1215356990 feat(backend): add POST /api/devices/check-lock for native auth flow
Native app uses supabase.auth.signInWithPassword directly, bypassing
/api/auth/login. This authenticated endpoint runs the same device-lock
check post-auth: 409 DEVICE_LOCKED if bound to another user, 200+bind
if Pro/Legend user, no-op bind for Free users. CORS headers extended
to include x-device-name/model/os. 34 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:38:59 +02:00

137 lines
5.5 KiB
TypeScript

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 <access_token>
* - x-device-id: <persistent device UUID>
*
* 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 };
});