fix(security): пер-юзер лимиты ИИ + SSE через одноразовый тикет (Спринт1 #5,#6)

#5 rate-limit (byUser) на дорогих LLM-эндпоинтах: /assistant/ask (20/мин),
   /assistant/flashcards (10/мин), /imggen (20/мин) — поверх cooldown/дневного
   лимита. Защита от «сжигания» бюджета провайдера одним аккаунтом.
#6 SSE больше не таскает JWT в URL: добавлен authed /notifications/stream-ticket
   (одноразовый тикет, TTL 30с), клиент берёт тикет заголовком и подключается
   с ?ticket=. ?token= оставлен как временный фоллбэк для старых клиентов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-12 22:00:23 +03:00
parent 5a57812dab
commit 646e93cf46
5 changed files with 74 additions and 22 deletions
+19 -7
View File
@@ -1386,14 +1386,28 @@ async function markAllNotifsRead() { return req('POST', '/notifications/read-al
/* ── SSE real-time notifications ─────────────────────────────────────────── */
/* ── Shared SSE singleton — all listeners share one EventSource ─────── */
let _sseShared = null;
let _sseConnecting = false;
let _sseRetryMs = 2000;
let _sseEverConnected = false; // tracks whether SSE has successfully opened before
const _sseListeners = new Set();
function _sseConnect() {
const token = getToken();
if (!token) return;
const url = `${API}/notifications/stream?token=${encodeURIComponent(token)}`;
function _sseRetry() {
const delay = Math.min(_sseRetryMs, 30000);
_sseRetryMs = Math.min(_sseRetryMs * 2, 30000);
setTimeout(_sseConnect, delay);
}
async function _sseConnect() {
if (_sseShared || _sseConnecting) return;
if (!getToken()) return;
// Берём одноразовый тикет authed-запросом (Bearer в заголовке) — токен не попадает в URL.
_sseConnecting = true;
let ticket = null;
try { const r = await req('GET', '/notifications/stream-ticket'); ticket = r && r.ticket; } catch (e) {}
_sseConnecting = false;
if (_sseShared) return; // подключились параллельно
if (!ticket) { _sseRetry(); return; } // не вышло — повтор по backoff
const url = `${API}/notifications/stream?ticket=${encodeURIComponent(ticket)}`;
const es = new EventSource(url);
_sseShared = es;
es.onopen = () => {
@@ -1412,9 +1426,7 @@ function _sseConnect() {
es.onerror = () => {
es.close();
_sseShared = null;
const delay = Math.min(_sseRetryMs, 30000);
_sseRetryMs = Math.min(_sseRetryMs * 2, 30000);
setTimeout(_sseConnect, delay);
_sseRetry();
};
}