- Auth: register без токенов до верификации (userId, message, verificationCode)
- Auth: login — 429 RATE_LIMIT_EXCEEDED при lockout, user с avatarUrl
- Auth: verify-email — { userId, code }, без Bearer
- Auth: reset-password — поле newPassword
- Profile: stats — byStack, totalTestsTaken, totalQuestions, correctAnswers, accuracy
- Tests: POST /tests возвращает полный список questions
- Tests: answer — полный snapshot отвеченного вопроса
- Tests: history — offset-пагинация (limit/offset), формат { tests, total }
- Admin: GET /admin/questions/pending, POST approve/reject, PATCH для редактирования
- DB: email_verification_codes, password_reset_tokens; обновлена question_cache_meta
- Security: CORS_ORIGINS из env, CSP/COEP отключены
- LLM: LLM_FALLBACK_MODEL, LLM_RETRY_DELAY_MS
- Onboarding: правило .env.example, JWT_SECRET >= 32 символов
22 KiB
API контракты
Общие соглашения
- Базовый URL:
/api/v1 - Формат: JSON (
Content-Type: application/json) - Аутентификация: Bearer token в заголовке
Authorization: Bearer <accessToken> - Refresh token: httpOnly secure cookie
refreshToken(устанавливается сервером при login/refresh) - Даты: ISO 8601, UTC (
2026-03-03T12:00:00.000Z) - ID: UUID v7
Формат ошибок
Все ошибки возвращаются в едином формате:
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description"
}
}
Общие коды ошибок
| HTTP | Код | Описание |
|---|---|---|
| 400 | BAD_REQUEST |
Некорректный запрос |
| 401 | UNAUTHORIZED |
Не авторизован или токен истёк |
| 403 | FORBIDDEN |
Нет прав |
| 404 | NOT_FOUND |
Ресурс не найден |
| 409 | CONFLICT |
Конфликт (дубликат) |
| 422 | VALIDATION_ERROR |
Невалидные данные |
| 429 | RATE_LIMIT_EXCEEDED |
Превышен лимит запросов |
| 500 | INTERNAL_ERROR |
Внутренняя ошибка сервера |
Пагинация
Для списков тестов и админ-очередей используется offset-based пагинация: limit, offset.
Auth
POST /auth/register
Регистрация нового пользователя. Отправляет письмо с кодом подтверждения email. Токены не выдаются до подтверждения email.
Авторизация: не требуется
Request:
| Поле | Тип | Обязательное | Валидация |
|---|---|---|---|
| string | да | format: email, max 255 | |
| password | string | да | min 8, max 128 |
| nickname | string | да | min 2, max 30, alphanumeric + underscore |
{
"email": "user@example.com",
"password": "securePass123",
"nickname": "john_doe"
}
Response 201:
{
"userId": "0192a8b0-1234-7000-8000-000000000001",
"message": "Verification code sent to your email",
"verificationCode": "123456"
}
verificationCode — для dev/тестов; в production не отдаётся.
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 409 | EMAIL_TAKEN |
Email уже зарегистрирован |
| 409 | NICKNAME_TAKEN |
Никнейм уже занят |
| 422 | VALIDATION_ERROR |
Невалидные данные |
| 429 | RATE_LIMIT_EXCEEDED |
Более 3 регистраций с IP за час |
POST /auth/login
Аутентификация по email и паролю.
Авторизация: не требуется
Request:
| Поле | Тип | Обязательное |
|---|---|---|
| string | да | |
| password | string | да |
{
"email": "user@example.com",
"password": "securePass123"
}
Response 200:
{
"user": {
"id": "0192a8b0-1234-7000-8000-000000000001",
"email": "user@example.com",
"nickname": "john_doe",
"avatarUrl": null,
"role": "free",
"emailVerified": true
},
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
}
Set-Cookie: refreshToken=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=604800
Создаётся запись в sessions с device info и IP.
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 401 | INVALID_CREDENTIALS |
Неверный email или пароль |
| 429 | RATE_LIMIT_EXCEEDED |
Прогрессивный lockout (brute force) или превышен лимит попыток входа |
POST /auth/logout
Завершение текущей сессии. Удаляет запись из sessions, очищает refresh cookie.
Авторизация: Bearer token
Request: пустое тело
Response 200:
{
"message": "Logged out successfully"
}
Set-Cookie: refreshToken=; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=0
POST /auth/refresh
Обновление access token по refresh token из cookie. Выполняет ротацию refresh token.
Авторизация: не требуется (refresh token в cookie)
Request: пустое тело (refresh token читается из cookie)
Response 200:
{
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
}
Set-Cookie: новый refreshToken (ротация).
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 401 | INVALID_REFRESH_TOKEN |
Токен невалидный или истёк |
| 401 | TOKEN_REUSE_DETECTED |
Повторное использование старого токена — все сессии пользователя инвалидируются |
POST /auth/verify-email
Подтверждение email по коду из письма.
Авторизация: не требуется (Bearer не нужен)
Request:
| Поле | Тип | Обязательное |
|---|---|---|
| userId | uuid | да |
| code | string | да |
{
"userId": "0192a8b0-1234-7000-8000-000000000001",
"code": "123456"
}
Response 200:
{
"message": "Email verified successfully"
}
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 400 | INVALID_CODE |
Неверный или истёкший код |
| 409 | ALREADY_VERIFIED |
Email уже подтверждён |
POST /auth/forgot-password
Запрос на сброс пароля. Отправляет письмо с кодом/ссылкой.
Авторизация: не требуется
Request:
| Поле | Тип | Обязательное |
|---|---|---|
| string | да |
{
"email": "user@example.com"
}
Response 200:
Всегда возвращает успех (даже если email не найден — чтобы не раскрывать наличие аккаунта):
{
"message": "If this email is registered, a reset link has been sent"
}
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 429 | RATE_LIMIT_EXCEEDED |
Более 3 запросов с IP за час |
POST /auth/reset-password
Сброс пароля по токену из письма.
Авторизация: не требуется
Request:
| Поле | Тип | Обязательное |
|---|---|---|
| token | string | да |
| newPassword | string | да |
{
"token": "reset-token-from-email",
"newPassword": "newSecurePass456"
}
Response 200:
{
"message": "Password reset successfully"
}
Все сессии пользователя инвалидируются после сброса пароля.
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 400 | INVALID_RESET_TOKEN |
Токен невалидный или истёк |
| 422 | VALIDATION_ERROR |
Пароль не соответствует требованиям |
Profile
GET /profile
Текущий профиль авторизованного пользователя.
Авторизация: Bearer token
Response 200:
{
"id": "0192a8b0-1234-7000-8000-000000000001",
"email": "user@example.com",
"nickname": "john_doe",
"avatarUrl": null,
"country": "Russia",
"city": null,
"selfLevel": null,
"isPublic": true,
"role": "free",
"emailVerified": true,
"plan": "free",
"createdAt": "2026-03-03T12:00:00.000Z"
}
Поле plan берётся из subscriptions через subscription middleware.
PATCH /profile
Обновление профиля.
Авторизация: Bearer token
Request (все поля опциональны):
| Поле | Тип | Валидация |
|---|---|---|
| nickname | string | min 2, max 30 |
| country | string | max 100 |
| city | string | max 100 |
| selfLevel | string | enum: jun / mid / sen |
| isPublic | boolean |
{
"nickname": "jane_doe",
"country": "Russia",
"selfLevel": "jun"
}
Response 200:
Полный объект профиля (как в GET /profile) с обновлёнными полями.
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 409 | NICKNAME_TAKEN |
Никнейм уже занят |
| 422 | VALIDATION_ERROR |
Невалидные данные |
GET /profile/:username
Публичный профиль пользователя.
Авторизация: не требуется
Response 200:
{
"nickname": "john_doe",
"avatarUrl": null,
"country": "Russia",
"selfLevel": "jun",
"createdAt": "2026-03-03T12:00:00.000Z",
"stats": {
"byStack": {
"html": { "totalTestsTaken": 5, "totalQuestions": 50, "correctAnswers": 42, "accuracy": 84 },
"css": { "totalTestsTaken": 3, "totalQuestions": 30, "correctAnswers": 24, "accuracy": 80 }
},
"totalTestsTaken": 8,
"totalQuestions": 80,
"correctAnswers": 66,
"accuracy": 82.5
}
}
Структура stats:
byStack— объект: ключ — стек (html, css и т.д.), значение — статистика по стеку (totalTestsTaken,totalQuestions,correctAnswers,accuracy)totalTestsTaken— общее количество пройденных тестовtotalQuestions— общее количество отвеченных вопросовcorrectAnswers— количество правильных ответовaccuracy— процент правильных ответов (0–100)
Не возвращает email, role, план — только публичная информация.
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 404 | USER_NOT_FOUND |
Пользователь не найден или профиль скрыт (isPublic = false) |
Tests
POST /tests
Создание нового теста. Генерирует вопросы через LLM или берёт из банка.
Авторизация: Bearer token
Request:
| Поле | Тип | Обязательное | Валидация |
|---|---|---|---|
| stack | string | да | enum: html / css (MVP 0) |
| level | string | да | enum: basic / beginner (MVP 0) |
| questionCount | integer | да | enum: 10 / 20 |
{
"stack": "html",
"level": "basic",
"questionCount": 10
}
Response 201:
{
"id": "0192a8b0-5678-7000-8000-000000000002",
"stack": "html",
"level": "basic",
"questionCount": 10,
"status": "in_progress",
"startedAt": "2026-03-03T12:05:00.000Z",
"timeLimitSeconds": null,
"questions": [
{
"id": "0192a8b0-9abc-7000-8000-000000000003",
"orderNumber": 1,
"type": "single_choice",
"questionText": "What does the <meta charset=\"UTF-8\"> tag do?",
"options": [
{ "key": "A", "text": "Sets the character encoding" },
{ "key": "B", "text": "Sets the page title" },
{ "key": "C", "text": "Links a stylesheet" },
{ "key": "D", "text": "Defines a script" }
]
},
{
"id": "0192a8b0-9abc-7000-8000-000000000004",
"orderNumber": 2,
"type": "single_choice",
"questionText": "Which HTML element is used for the largest heading?",
"options": [
{ "key": "A", "text": "<h6>" },
{ "key": "B", "text": "<h1>" },
{ "key": "C", "text": "<head>" },
{ "key": "D", "text": "<header>" }
]
}
]
}
При создании теста возвращается полный список всех вопросов в questions. Вопросы копируются в test_questions (снепшот).
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 403 | DAILY_LIMIT_REACHED |
Free: 5 тестов в день |
| 403 | EMAIL_NOT_VERIFIED |
Email не подтверждён |
| 422 | VALIDATION_ERROR |
Невалидный стек/уровень |
| 503 | QUESTIONS_UNAVAILABLE |
LLM и банк вопросов недоступны |
GET /tests/:id
Получение текущего состояния теста (для восстановления после перезагрузки страницы).
Авторизация: Bearer token (только свой тест)
Response 200:
{
"id": "0192a8b0-5678-7000-8000-000000000002",
"stack": "html",
"level": "basic",
"questionCount": 10,
"status": "in_progress",
"currentQuestion": 3,
"answeredCount": 2,
"startedAt": "2026-03-03T12:05:00.000Z",
"timeLimitSeconds": null,
"question": {
"id": "0192a8b0-9abc-7000-8000-000000000005",
"orderNumber": 3,
"type": "true_false",
"questionText": "The <div> element is an inline element.",
"options": [
{ "key": "A", "text": "True" },
{ "key": "B", "text": "False" }
]
}
}
Не возвращает правильные ответы и объяснения — только текущий вопрос.
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 403 | FORBIDDEN |
Тест принадлежит другому пользователю |
| 404 | NOT_FOUND |
Тест не найден |
POST /tests/:id/answer
Ответ на текущий вопрос.
Авторизация: Bearer token (только свой тест)
Request:
| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
| questionId | uuid | да | ID вопроса из question / questions |
| answer | string / string[] | да | Ключ ответа ("A") или массив (["A", "C"]) |
{
"questionId": "0192a8b0-9abc-7000-8000-000000000003",
"answer": "A"
}
Response 200:
Возвращает полный snapshot отвеченного вопроса (формат из реализации). Структура может отличаться от минимальной «answered + progress + nextQuestion» — контракт определяется backend. Может включать детали ответа, правильный ответ, объяснение, прогресс и/или следующий вопрос.
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 400 | QUESTION_ALREADY_ANSWERED |
Вопрос уже отвечен |
| 400 | WRONG_QUESTION |
questionId не соответствует текущему вопросу |
| 400 | TEST_ALREADY_FINISHED |
Тест завершён |
| 422 | VALIDATION_ERROR |
Невалидный ответ |
POST /tests/:id/finish
Завершение теста. Подсчитывает результат, обновляет user_stats.
Авторизация: Bearer token (только свой тест)
Request: пустое тело
Response 200:
{
"id": "0192a8b0-5678-7000-8000-000000000002",
"status": "completed",
"score": 8,
"totalQuestions": 10,
"percentage": 80,
"timeSpentSeconds": 342,
"finishedAt": "2026-03-03T12:10:42.000Z"
}
score — количество правильных ответов (integer). percentage приходит с backend (или считается на фронте).
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 400 | TEST_ALREADY_FINISHED |
Тест уже завершён |
| 400 | NO_ANSWERS |
Ни один вопрос не отвечен |
GET /tests/:id/results
Детальные результаты теста с разбором каждого вопроса.
Авторизация: Bearer token (только свой тест)
Response 200:
{
"id": "0192a8b0-5678-7000-8000-000000000002",
"stack": "html",
"level": "basic",
"score": 8,
"totalQuestions": 10,
"percentage": 80,
"timeSpentSeconds": 342,
"startedAt": "2026-03-03T12:05:00.000Z",
"finishedAt": "2026-03-03T12:10:42.000Z",
"questions": [
{
"orderNumber": 1,
"type": "single_choice",
"questionText": "What does the <meta charset=\"UTF-8\"> tag do?",
"options": [
{ "key": "A", "text": "Sets the character encoding" },
{ "key": "B", "text": "Sets the page title" },
{ "key": "C", "text": "Links a stylesheet" },
{ "key": "D", "text": "Defines a script" }
],
"userAnswer": "A",
"correctAnswer": "A",
"isCorrect": true,
"explanation": "The <meta charset=\"UTF-8\"> tag specifies the character encoding for the HTML document."
},
{
"orderNumber": 2,
"type": "single_choice",
"questionText": "Which HTML element is used for the largest heading?",
"options": [
{ "key": "A", "text": "<h6>" },
{ "key": "B", "text": "<h1>" },
{ "key": "C", "text": "<head>" },
{ "key": "D", "text": "<header>" }
],
"userAnswer": "C",
"correctAnswer": "B",
"isCorrect": false,
"explanation": "<h1> defines the largest heading. <head> is a container for metadata, not a heading element."
}
]
}
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 400 | TEST_NOT_FINISHED |
Тест ещё не завершён |
| 404 | NOT_FOUND |
Тест не найден |
GET /tests/history
История тестов пользователя. Offset-based пагинация, сортировка по дате (новые первые).
Авторизация: Bearer token
Query параметры:
| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| limit | integer | нет | default 10, max 50 |
| offset | integer | нет | Смещение (default 0) |
| stack | string | нет | Фильтр по стеку |
| status | string | нет | Фильтр: completed / abandoned |
Response 200:
{
"tests": [
{
"id": "0192a8b0-5678-7000-8000-000000000002",
"stack": "html",
"level": "basic",
"questionCount": 10,
"score": 8,
"percentage": 80,
"status": "completed",
"startedAt": "2026-03-03T12:05:00.000Z",
"finishedAt": "2026-03-03T12:10:42.000Z"
}
],
"total": 42
}
Admin
GET /admin/questions/pending
Список вопросов на модерацию (статус pending). Offset-based пагинация.
Авторизация: Bearer token (role: admin)
Query параметры:
| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| limit | integer | нет | default 20, max 50 |
| offset | integer | нет | Смещение (default 0) |
| stack | string | нет | Фильтр по стеку |
Response 200:
{
"questions": [
{
"id": "0192a8b0-def0-7000-8000-000000000010",
"stack": "html",
"level": "basic",
"type": "single_choice",
"questionText": "What does the <meta charset=\"UTF-8\"> tag do?",
"options": [
{ "key": "A", "text": "Sets the character encoding" },
{ "key": "B", "text": "Sets the page title" },
{ "key": "C", "text": "Links a stylesheet" },
{ "key": "D", "text": "Defines a script" }
],
"correctAnswer": "A",
"explanation": "The meta charset tag specifies the character encoding.",
"source": "llm_generated",
"status": "pending",
"usageCount": 0,
"createdAt": "2026-03-03T11:00:00.000Z",
"reportsCount": 0
}
],
"total": 15
}
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 403 | FORBIDDEN |
Пользователь не admin |
POST /admin/questions/:id/approve
Одобрение вопроса (смена статуса на approved).
Авторизация: Bearer token (role: admin)
Request: пустое тело
Response 200: полный объект вопроса с обновлённым статусом.
Действие записывается в audit_logs.
POST /admin/questions/:id/reject
Отклонение вопроса (смена статуса на rejected).
Авторизация: Bearer token (role: admin)
Request: пустое тело
Response 200: полный объект вопроса с обновлённым статусом.
Действие записывается в audit_logs.
PATCH /admin/questions/:id
Редактирование контента вопроса без смены статуса (текст, варианты, правильный ответ, объяснение).
Авторизация: Bearer token (role: admin)
Request:
| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
| questionText | string | нет | Отредактированный текст |
| options | array | нет | Отредактированные варианты |
| correctAnswer | string | нет | Исправленный ответ |
| explanation | string | нет | Исправленное объяснение |
{
"explanation": "The <meta charset> tag specifies the character encoding for the HTML document, typically UTF-8."
}
Response 200: полный объект вопроса с обновлёнными полями.
Ошибки:
| HTTP | Код | Когда |
|---|---|---|
| 403 | FORBIDDEN |
Пользователь не admin |
| 404 | NOT_FOUND |
Вопрос не найден |
| 422 | VALIDATION_ERROR |
Невалидные данные |
Rate limits по endpoint
Подробности в security.md. Краткая сводка:
| Группа | Лимит | Окно |
|---|---|---|
/auth/login |
Прогрессивный lockout | 15 мин / 1 час / 24 часа |
/auth/register |
3 | 1 час |
/auth/forgot-password |
3 | 1 час |
| Общий (авторизованный) | 100 | 1 мин |
| Общий (гость) | 30 | 1 мин |
| Тесты (Free) | 5 тестов | 1 день |