/** * Проверка «у этой почты есть действующий доступ к курсу» для внутренних * вызовов (бот выдачи SB Access в payment-router, 20261013): старый покупатель * Second Brain Vault оставляет почту, бот получает ключ входа, только если * доступ к курсу действительно есть. * * Ответ — только да/нет: ни имени, ни дат наружу не отдаём. */ import { timingSafeEqual } from "crypto"; export type AccessCheckInput = { email: string; courseSlug: string }; export function parseAccessCheck(body: unknown): AccessCheckInput | null { if (!body || typeof body !== "object") return null; const b = body as Record; const email = typeof b.email === "string" ? b.email.trim().toLowerCase() : ""; const courseSlug = typeof b.course_slug === "string" ? b.course_slug.trim() : ""; if (!email || email.length > 254 || !email.includes("@") || !courseSlug || courseSlug.length > 100) { return null; } return { email, courseSlug }; } /** * Условие Prisma: не истёкшая запись о доступе к курсу у незаблокированного * пользователя с ЭТОЙ почтой. * * Почту сравниваем точно, без `mode: "insensitive"`: Prisma превращает такое * сравнение в ILIKE, и `%`/`_` в адресе становятся шаблоном — по адресу-маске * метод отвечал бы «доступ есть» за чужую почту (проверка безопасности * 20261013). Все почты в базе хранятся в нижнем регистре (проверено на проде: * 0 из 1426 с заглавными), вход нормализуется parseAccessCheck. */ export function accessWhere(input: AccessCheckInput, now: Date) { return { user: { email: input.email, NOT: { banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] }, }, course: { slug: input.courseSlug }, OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], }; } /** Секрет сравниваем за постоянное время: обычное === выдаёт длину совпавшего префикса по времени. */ export function isAuthorized(header: string | null, secret: string | undefined): boolean { if (!secret || !header) return false; const a = Buffer.from(header); const b = Buffer.from(secret); return a.length === b.length && timingSafeEqual(a, b); }