import { getHeader } from "h3"; import { requireUser } from "../../utils/auth"; import { appendProtectionEventDeduped, type ProtectionSource, } from "../../db/protectionStateLog"; import { upsertDeviceProtectionState, type ProtectionType, } from "../../db/device-protection"; const VALID_SOURCES: ProtectionSource[] = ["vpn", "mdm", "client"]; function sourceToProtectionType(source: ProtectionSource): ProtectionType { if (source === "mdm") return "nefilter"; return "vpn"; } async function resolveUserIdFromDeviceId( deviceId: string, ): Promise { const db = usePrisma(); const row = await db.userDevice.findUnique({ where: { deviceId }, select: { userId: true }, }); return row?.userId ?? null; } /** * POST /api/protection/event * * Body: { * active: boolean, * source: 'vpn' | 'mdm' | 'client', * deviceId?: string // optional, falls bekannt (z.B. native App) * } * * Auth: Bearer (native app) OR x-extension-secret: (iOS * Network-Extension when the container app is not running). * * Called from the native app (useProtectionState / lib/protection) when the * combined protection state transitions on↔off. The client deduplicates * locally (only fires on real transitions); the server deduplicates again * against the last DB row for the user. * * Side-effect: if deviceId is provided (body or x-device-id header), the * per-device protection state (device_protection_states) is also updated so * that MDM / Magic-App views see the current nefilter/vpn status. * * Returns { success: true, written: true } if a new row was written, * { success: true, written: false } if deduplicated (state unchanged). */ export default defineEventHandler(async (event) => { const body = await readBody(event); if (typeof body?.active !== "boolean") { throw createError({ statusCode: 400, message: "active (boolean) required" }); } const source: ProtectionSource = VALID_SOURCES.includes(body.source) ? (body.source as ProtectionSource) : "client"; let userId: string; // ─── Extension auth path (iOS Network Extension) ──────────────────────────── const extensionSecret = getHeader(event, "x-extension-secret"); const config = useRuntimeConfig(event); const expectedSecret = (config as any).extensionSecret as string | undefined; if (extensionSecret && extensionSecret === expectedSecret && expectedSecret) { const deviceId = body?.deviceId; if (!deviceId || typeof deviceId !== "string") { throw createError({ statusCode: 400, message: "deviceId required for extension auth", }); } const resolved = await resolveUserIdFromDeviceId(deviceId); if (!resolved) { throw createError({ statusCode: 404, message: "device not found", }); } userId = resolved; } else { // ─── Standard auth path (container app with JWT) ────────────────────────── const user = await requireUser(event); userId = user.id; } const row = await appendProtectionEventDeduped(userId, body.active, source); const deviceId = body?.deviceId ?? getHeader(event, "x-device-id") ?? null; if (deviceId && typeof deviceId === "string") { try { await upsertDeviceProtectionState( userId, deviceId, "ios", sourceToProtectionType(source), body.active, new Date(), `protection event via ${source}`, "native-app", ); } catch (err: any) { console.error( "[protection/event] device_protection_states upsert failed:", err?.message ?? err, ); } } return { success: true, written: row !== null }; });