feat(planner): вход в конструктор планнеров по подписанному токену

GET /api/planner/start проверяет сессию и доступ к курсу planner,
подписывает короткоживущий токен (HMAC-SHA256, 15 минут) и отправляет
в planner.second-brain.ru. Токен уходит во фрагменте, а не в строке
запроса: так он не попадает ни в логи, ни в Referer.

Алгоритм подписи — зеркало packages/web/src/auth.mjs в репозитории
планнера; совместимость проверена.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1UnMjwLLs22j7rymn9vUq
This commit is contained in:
2026-09-14 10:19:52 +05:00
co-authored by Claude Opus 5
parent 5ad0bd98f1
commit 273cb2d1b4
+60
View File
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { createHmac } from "crypto";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
/**
* Вход в конструктор планнеров (planner.second-brain.ru).
*
* Своей авторизации у конструктора нет: школа проверяет сессию и доступ,
* подписывает короткоживущий токен, конструктор его сверяет тем же секретом.
* Формат токена: base64url(payload).base64url(hmacSha256(payload)) —
* зеркало packages/web/src/auth.mjs в репозитории планнера. Менять оба конца.
*/
const PLANNER_URL = process.env.PLANNER_URL ?? "https://planner.second-brain.ru";
const COURSE_SLUG = "planner";
const TTL_SECONDS = 900;
const b64 = (input: Buffer | string) => Buffer.from(input).toString("base64url");
export async function GET(req: NextRequest) {
const secret = process.env.PLANNER_TOKEN_SECRET;
if (!secret) {
// Лучше честная ошибка, чем редирект в конструктор с заведомо негодным
// токеном: там человек увидит невнятное «подпись не сходится».
return NextResponse.json(
{ error: "PLANNER_TOKEN_SECRET не задан" },
{ status: 503 },
);
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.redirect(new URL("/login", req.url));
}
const enrolled = await prisma.courseEnrollment.findFirst({
where: { userId: session.user.id, course: { slug: COURSE_SLUG } },
select: { expiresAt: true },
});
if (!enrolled) {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
if (enrolled.expiresAt && enrolled.expiresAt < new Date()) {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
const payload = {
userId: session.user.id,
email: session.user.email,
exp: Math.floor(Date.now() / 1000) + TTL_SECONDS,
};
const body = b64(JSON.stringify(payload));
const signature = b64(createHmac("sha256", secret).update(body).digest());
// Токен во фрагменте, а не в строке запроса: фрагмент не попадает ни в
// логи сервера, ни в заголовок Referer при переходах со страницы.
return NextResponse.redirect(`${PLANNER_URL}/build.html#token=${body}.${signature}`);
}