# API контракты ## Общие соглашения - Базовый URL: `/api/v1` - Формат: JSON (`Content-Type: application/json`) - Аутентификация: Bearer token в заголовке `Authorization: Bearer ` - Refresh token: httpOnly secure cookie `refreshToken` (устанавливается сервером при login/refresh) - Даты: ISO 8601, UTC (`2026-03-03T12:00:00.000Z`) - ID: UUID v7 ### Формат ошибок Все ошибки возвращаются в едином формате: ```json { "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 | ```json { "email": "user@example.com", "password": "securePass123", "nickname": "john_doe" } ``` **Response 201:** ```json { "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 | да | ```json { "email": "user@example.com", "password": "securePass123" } ``` **Response 200:** ```json { "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=; 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:** ```json { "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:** ```json { "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 | да | ```json { "userId": "0192a8b0-1234-7000-8000-000000000001", "code": "123456" } ``` **Response 200:** ```json { "message": "Email verified successfully" } ``` **Ошибки:** | HTTP | Код | Когда | | ------ | ----- | ------- | | 400 | `INVALID_CODE` | Неверный или истёкший код | | 409 | `ALREADY_VERIFIED` | Email уже подтверждён | --- ### POST /auth/forgot-password Запрос на сброс пароля. Отправляет письмо с кодом/ссылкой. **Авторизация:** не требуется **Request:** | Поле | Тип | Обязательное | | ------ | ----- | -------------- | | email | string | да | ```json { "email": "user@example.com" } ``` **Response 200:** Всегда возвращает успех (даже если email не найден — чтобы не раскрывать наличие аккаунта): ```json { "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 | да | ```json { "token": "reset-token-from-email", "newPassword": "newSecurePass456" } ``` **Response 200:** ```json { "message": "Password reset successfully" } ``` Все сессии пользователя инвалидируются после сброса пароля. **Ошибки:** | HTTP | Код | Когда | | ------ | ----- | ------- | | 400 | `INVALID_RESET_TOKEN` | Токен невалидный или истёк | | 422 | `VALIDATION_ERROR` | Пароль не соответствует требованиям | --- ## Profile ### GET /profile Текущий профиль авторизованного пользователя. **Авторизация:** Bearer token **Response 200:** ```json { "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 | | ```json { "nickname": "jane_doe", "country": "Russia", "selfLevel": "jun" } ``` **Response 200:** Полный объект профиля (как в GET /profile) с обновлёнными полями. **Ошибки:** | HTTP | Код | Когда | | ------ | ----- | ------- | | 409 | `NICKNAME_TAKEN` | Никнейм уже занят | | 422 | `VALIDATION_ERROR` | Невалидные данные | --- ### GET /profile/:username Публичный профиль пользователя. **Авторизация:** не требуется **Response 200:** ```json { "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 | ```json { "stack": "html", "level": "basic", "questionCount": 10 } ``` **Response 201:** ```json { "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 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": "
" }, { "key": "B", "text": "

" }, { "key": "C", "text": "" }, { "key": "D", "text": "
" } ] } ] } ``` При создании теста возвращается **полный список всех вопросов** в `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:** ```json { "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
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"]) | ```json { "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:** ```json { "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:** ```json { "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 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 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": "
" }, { "key": "B", "text": "

" }, { "key": "C", "text": "" }, { "key": "D", "text": "
" } ], "userAnswer": "C", "correctAnswer": "B", "isCorrect": false, "explanation": "

defines the largest heading. 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:** ```json { "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:** ```json { "questions": [ { "id": "0192a8b0-def0-7000-8000-000000000010", "stack": "html", "level": "basic", "type": "single_choice", "questionText": "What does the 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 | нет | Исправленное объяснение | ```json { "explanation": "The 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](../principles/security.md). Краткая сводка: | Группа | Лимит | Окно | | -------- | ------- | ------ | | `/auth/login` | Прогрессивный lockout | 15 мин / 1 час / 24 часа | | `/auth/register` | 3 | 1 час | | `/auth/forgot-password` | 3 | 1 час | | Общий (авторизованный) | 100 | 1 мин | | Общий (гость) | 30 | 1 мин | | Тесты (Free) | 5 тестов | 1 день |