From 83f34ea6798a75c5f6c37fd77478d2a6ba726c13 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 13 Oct 2026 15:03:24 +0500 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BD=D1=83=D1=82=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20/api/inte?= =?UTF-8?q?rnal/access-check:=20=D0=B5=D1=81=D1=82=D1=8C=20=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=83=20=D0=BF=D0=BE=D1=87=D1=82=D1=8B=20=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=83=D0=BF=20=D0=BA=20=D0=BA=D1=83=D1=80=D1=81=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для бота выдачи SB Access (payment-router): старый покупатель Second Brain Vault оставляет почту на /tg/link, ключ входа в бота выдаётся, только если доступ к курсу действительно есть. Тот же общий секрет, что у grant; ответ только да/нет, почта без учёта регистра, истёкший доступ не считается. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/api/internal/access-check/route.ts | 30 +++++++++++++++++++ src/lib/__tests__/access-check.test.ts | 30 +++++++++++++++++++ src/lib/access-check.ts | 34 ++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 src/app/api/internal/access-check/route.ts create mode 100644 src/lib/__tests__/access-check.test.ts create mode 100644 src/lib/access-check.ts diff --git a/src/app/api/internal/access-check/route.ts b/src/app/api/internal/access-check/route.ts new file mode 100644 index 0000000..d6e51b9 --- /dev/null +++ b/src/app/api/internal/access-check/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { accessWhere, isAuthorized, parseAccessCheck } from "@/lib/access-check"; + +/** + * Internal: есть ли у почты действующий доступ к курсу. + * Auth: тот же общий секрет, что у /api/internal/grant (`x-internal-secret`). + * + * Body: { email, course_slug } → { ok: true, has_access: boolean } + */ +export async function POST(request: NextRequest) { + if (!isAuthorized(request.headers.get("x-internal-secret"), process.env.INTERNAL_GRANT_SECRET)) { + return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); + } + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ ok: false, error: "bad_json" }, { status: 400 }); + } + const input = parseAccessCheck(body); + if (!input) { + return NextResponse.json({ ok: false, error: "missing_fields", need: ["email", "course_slug"] }, { status: 400 }); + } + const found = await prisma.courseEnrollment.findFirst({ + where: accessWhere(input, new Date()), + select: { userId: true }, + }); + return NextResponse.json({ ok: true, has_access: Boolean(found) }); +} diff --git a/src/lib/__tests__/access-check.test.ts b/src/lib/__tests__/access-check.test.ts new file mode 100644 index 0000000..8e601f9 --- /dev/null +++ b/src/lib/__tests__/access-check.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { accessWhere, isAuthorized, parseAccessCheck } from "../access-check"; + +describe("access-check", () => { + it("нормализует почту и требует оба поля", () => { + expect(parseAccessCheck({ email: " A@Example.COM ", course_slug: "second-brain-vault" })) + .toEqual({ email: "a@example.com", courseSlug: "second-brain-vault" }); + expect(parseAccessCheck({ email: "a@example.com" })).toBeNull(); + expect(parseAccessCheck({ course_slug: "x" })).toBeNull(); + expect(parseAccessCheck({ email: "без-собаки", course_slug: "x" })).toBeNull(); + expect(parseAccessCheck(null)).toBeNull(); + expect(parseAccessCheck({ email: "a".repeat(250) + "@b.ru", course_slug: "x" })).toBeNull(); + }); + + it("условие: почта без учёта регистра, курс по slug, доступ не истёк", () => { + const now = new Date("2026-10-13T00:00:00Z"); + const w = accessWhere({ email: "a@example.com", courseSlug: "second-brain-vault" }, now); + expect(w.user.email).toEqual({ equals: "a@example.com", mode: "insensitive" }); + expect(w.course).toEqual({ slug: "second-brain-vault" }); + expect(w.OR).toEqual([{ expiresAt: null }, { expiresAt: { gt: now } }]); + }); + + it("без секрета или с чужим — отказ", () => { + expect(isAuthorized("s", "s")).toBe(true); + expect(isAuthorized("s", undefined)).toBe(false); + expect(isAuthorized("s", "")).toBe(false); + expect(isAuthorized(null, "s")).toBe(false); + expect(isAuthorized("x", "s")).toBe(false); + }); +}); diff --git a/src/lib/access-check.ts b/src/lib/access-check.ts new file mode 100644 index 0000000..3e12b97 --- /dev/null +++ b/src/lib/access-check.ts @@ -0,0 +1,34 @@ +/** + * Проверка «у этой почты есть действующий доступ к курсу» для внутренних + * вызовов (бот выдачи SB Access в payment-router, 20261013): старый покупатель + * Second Brain Vault оставляет почту, бот получает ключ входа, только если + * доступ к курсу действительно есть. + * + * Ответ — только да/нет: ни имени, ни дат наружу не отдаём. + */ + +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: запись о доступе к курсу у пользователя с этой почтой, не истёкшая. */ +export function accessWhere(input: AccessCheckInput, now: Date) { + return { + user: { email: { equals: input.email, mode: "insensitive" as const } }, + course: { slug: input.courseSlug }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }; +} + +export function isAuthorized(header: string | null, secret: string | undefined): boolean { + return Boolean(secret) && header === secret; +}