Files
samreshu_docs/api/contracts.md
Anton 2f45a0b851 docs: приведение документации в соответствие с backend
- 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 символов
2026-03-06 13:52:24 +03:00

22 KiB
Raw Permalink Blame History

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:

Поле Тип Обязательное Валидация
email 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:

Поле Тип Обязательное
email 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:

Поле Тип Обязательное
email 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 — процент правильных ответов (0100)

Не возвращает 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 день