Implement rate limiting and enhance authentication flow
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s

- Added rate limiting configuration for authentication endpoints, allowing customizable request limits via environment variables.
- Updated authentication flow to utilize HttpRequest for cookie management, ensuring secure handling of refresh tokens.
- Introduced a new endpoint to retrieve user subscription details.
- Enhanced the handling of Telegram bot token validation to prevent errors with empty tokens.
- Updated the application to serialize enums as strings for better documentation and compatibility with TypeScript.
- Improved test coverage for new features and adjustments in command handlers.
This commit is contained in:
Leonid Pershin
2026-07-02 12:40:23 +03:00
parent ed07221ca5
commit 8067be3c35
106 changed files with 8823 additions and 172 deletions
+93
View File
@@ -0,0 +1,93 @@
import type { ApiError } from './types'
let accessToken: string | null = null
let refreshInFlight: Promise<boolean> | null = null
let onUnauthorized: (() => void) | null = null
export function setAccessToken(token: string | null) {
accessToken = token
}
export function getAccessToken() {
return accessToken
}
/** Вызывается, когда refresh-токен недействителен — обычно очищает стор авторизации и шлёт на /login. */
export function setUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler
}
type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
body?: unknown
/** Не пытаться освежить токен на 401 (используется самим refresh-запросом, чтобы не зациклиться). */
skipRefresh?: boolean
}
async function refreshAccessToken(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = (async () => {
try {
const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
if (!response.ok) return false
const data = (await response.json()) as { accessToken: string }
setAccessToken(data.accessToken)
return true
} catch {
return false
} finally {
refreshInFlight = null
}
})()
}
return refreshInFlight
}
export class HttpError extends Error implements ApiError {
title: string
detail: string
status: number
constructor(problem: Partial<ApiError>, status: number) {
super(problem.detail ?? problem.title ?? `HTTP ${status}`)
this.title = problem.title ?? 'Error'
this.detail = problem.detail ?? this.message
this.status = status
}
}
async function parseError(response: Response): Promise<HttpError> {
try {
const problem = (await response.json()) as Partial<ApiError>
return new HttpError(problem, response.status)
} catch {
return new HttpError({ title: response.statusText }, response.status)
}
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {}
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
const response = await fetch(`/api${path}`, {
method: options.method ?? 'GET',
headers,
credentials: 'include',
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
})
if (response.status === 401 && !options.skipRefresh) {
const refreshed = await refreshAccessToken()
if (refreshed) return apiRequest<T>(path, { ...options, skipRefresh: true })
onUnauthorized?.()
throw await parseError(response)
}
if (!response.ok) throw await parseError(response)
if (response.status === 204) return undefined as T
const text = await response.text()
return (text ? JSON.parse(text) : undefined) as T
}