import { createClient } from "@supabase/supabase-js"; import type { H3Event } from "h3"; import { isAdminUser } from "../db/admin"; import { findUserDevice, registerDevice, touchDevice } from "../db/devices"; import { getProfile } from "../db/profile"; import { getPlanLimits } from "./plan-features"; const TOUCH_THROTTLE_MS = 60_000; // touch lastSeenAt höchstens 1×/min pro Device export interface RequireUserOptions { /** Bootstrap-Endpoints (z.B. /api/devices/register) brauchen kein Device-Binding */ skipDeviceCheck?: boolean; } export async function requireUser( event: H3Event, opts: RequireUserOptions = {}, ) { const authHeader = getHeader(event, "authorization"); let token = authHeader?.replace("Bearer ", ""); if (!token) { const query = getQuery(event); token = query.token as string; } if (!token) { throw createError({ statusCode: 401, message: "Nicht eingeloggt" }); } // ─── RebreakMagic-Session-Token (mgc_*) ────────────────────────────────── // Mac-App benutzt keinen Supabase-JWT, sondern einen mgc_*-Token den sie // beim Pairing erhalten hat. Diese Tokens sind nur für /api/magic/* gültig // und unsere requireUser-Funktion akzeptiert sie überall — Endpoint-Layer // ist verantwortlich Magic-Tokens nur dort zuzulassen wo sinnvoll. if (token.startsWith("mgc_")) { const db = usePrisma(); const session = await db.magicSession.findUnique({ where: { token }, select: { id: true, userId: true, revokedAt: true }, }); if (!session || session.revokedAt) { throw createError({ statusCode: 401, message: "Magic-Session ungültig" }); } // Touch lastUsedAt fire-and-forget db.magicSession .update({ where: { id: session.id }, data: { lastUsedAt: new Date() }, }) .catch(() => {}); // Synthetisches User-Objekt mit minimalen Feldern (auth.users-kompatibel). return { id: session.userId } as any; } const config = useRuntimeConfig(event); const supabaseCfg = (config as any).public?.supabase ?? (config as any).supabase; const client = createClient( supabaseCfg.url as string, supabaseCfg.key as string, { global: { headers: { Authorization: `Bearer ${token}` } } }, ); const { data: { user }, error, } = await client.auth.getUser(); if (error || !user) { throw createError({ statusCode: 401, message: "Nicht eingeloggt" }); } if (opts.skipDeviceCheck) return user; // Device-Binding: nur enforced wenn Client einen x-device-id Header schickt. // Web-Clients ohne Header laufen weiter wie bisher. const deviceId = getHeader(event, "x-device-id"); if (!deviceId) return user; // Frontend sendet name/model/osVersion als x-device-* Headers (URL-encoded) function readEncoded(name: string): string | null { const raw = getHeader(event, name); if (!raw) return null; try { return decodeURIComponent(raw); } catch { return raw; } } const deviceName = readEncoded("x-device-name"); const deviceModel = readEncoded("x-device-model"); const deviceOs = readEncoded("x-device-os"); const existing = await findUserDevice(user.id, deviceId); if (existing) { // Touch lastSeenAt + Backfill name/model/osVersion falls noch nicht gesetzt, // throttled auf 1×/min — fire-and-forget if (Date.now() - existing.lastSeenAt.getTime() > TOUCH_THROTTLE_MS) { touchDevice(user.id, deviceId, { name: deviceName, model: deviceModel, osVersion: deviceOs, }).catch(() => {}); } return user; } // Device unbekannt — Auto-Register (ohne Frontend-explicit-call) // Plan-Limit holen const profile = await getProfile(user.id); const limits = getPlanLimits(profile?.plan ?? "free"); const platform = getHeader(event, "x-platform") ?? "unknown"; try { await registerDevice({ userId: user.id, deviceId, platform, name: deviceName, model: deviceModel, osVersion: deviceOs, maxDevices: limits.maxAppDevices, }); return user; } catch (err: any) { if (err.code === "DEVICE_LIMIT_REACHED") { // Devices-Liste mitschicken damit das Frontend-Modal die Geräte // anzeigen + Freigeben-Buttons rendern kann (auch wenn der 403 // nicht vom register-Endpoint sondern vom auth-Middleware kommt). const { listUserDevices } = await import("../db/devices"); const devices = await listUserDevices(user.id); throw createError({ statusCode: 403, statusMessage: "device_limit_reached", data: { error: "device_limit_reached", max: limits.maxAppDevices, plan: profile?.plan ?? "free", devices, }, }); } throw err; } } /** * requireAdmin — wirft 401 wenn nicht eingeloggt, 403 wenn nicht in admin_users. * Wraps requireUser mit skipDeviceCheck=true (Admin-App hat kein Device-Binding). */ export async function requireAdmin(event: H3Event) { const user = await requireUser(event, { skipDeviceCheck: true }); const admin = await isAdminUser(user.id); if (!admin) { throw createError({ statusCode: 403, message: "Kein Admin-Zugang" }); } return user; }