# 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` | Внутренняя ошибка сервера | ### Пагинация (cursor-based) Для списков используется cursor-based пагинация на основе UUID v7 (сортируемый по времени). Запрос: ```text GET /tests/history?limit=10&cursor=0192a8b0-... ``` | Параметр | Тип | Обязательный | Описание | | ---------- | ----- | -------------- | ---------- | | limit | integer | нет | Количество записей (default 10, max 50) | | cursor | uuid | нет | ID последнего элемента предыдущей страницы | Ответ всегда содержит: ```json { "data": [...], "pagination": { "nextCursor": "0192a8b0-...", "hasMore": true } } ``` `nextCursor = null` и `hasMore = false` если это последняя страница. --- ## Auth ### POST /auth/register Регистрация нового пользователя. Отправляет письмо с кодом подтверждения 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 { "user": { "id": "0192a8b0-1234-7000-8000-000000000001", "email": "user@example.com", "nickname": "john_doe", "role": "free", "emailVerified": false, "createdAt": "2026-03-03T12:00:00.000Z" }, "accessToken": "eyJhbGciOiJIUzI1NiIs..." } ``` Set-Cookie: `refreshToken=; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=604800` **Ошибки:** | HTTP | Код | Когда | | ------ | ----- | ------- | | 409 | `EMAIL_TAKEN` | Email уже зарегистрирован | | 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", "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 или пароль | | 403 | `ACCOUNT_LOCKED` | Прогрессивный lockout (brute force) | | 429 | `RATE_LIMIT_EXCEEDED` | Превышен лимит попыток входа | --- ### 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 token **Request:** | Поле | Тип | Обязательное | | ------ | ----- | -------------- | | code | string | да | ```json { "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 | да | | password | string | да | ```json { "token": "reset-token-from-email", "password": "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": { "testsCompleted": 42, "averageScore": 78 } } ``` Не возвращает 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", "currentQuestion": 1, "startedAt": "2026-03-03T12:05:00.000Z", "timeLimitSeconds": null, "question": { "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" } ] } } ``` При создании теста вопросы копируются в `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` | | answer | string / string[] | да | Ключ ответа ("A") или массив (["A", "C"]) | ```json { "questionId": "0192a8b0-9abc-7000-8000-000000000003", "answer": "A" } ``` **Response 200:** ```json { "answered": { "questionId": "0192a8b0-9abc-7000-8000-000000000003", "isCorrect": true }, "progress": { "answeredCount": 1, "totalCount": 10 }, "nextQuestion": { "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": "
" } ] } } ``` `nextQuestion = null` если это был последний вопрос (клиент должен вызвать `/finish`). **Ошибки:** | 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" } ``` **Ошибки:** | 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 История тестов пользователя. Cursor-based пагинация, сортировка по дате (новые первые). **Авторизация:** Bearer token **Query параметры:** | Параметр | Тип | Обязательный | Описание | | ---------- | ----- | -------------- | --------- | | limit | integer | нет | default 10, max 50 | | cursor | uuid | нет | ID последнего теста предыдущей страницы | | stack | string | нет | Фильтр по стеку | | status | string | нет | Фильтр: completed / abandoned | **Response 200:** ```json { "data": [ { "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" } ], "pagination": { "nextCursor": "0192a8b0-5678-7000-8000-000000000002", "hasMore": false } } ``` --- ## Admin ### GET /admin/questions/queue QA очередь вопросов для модерации. Cursor-based пагинация. **Авторизация:** Bearer token (role: admin) **Query параметры:** | Параметр | Тип | Обязательный | Описание | | ---------- | ----- | -------------- | ---------- | | limit | integer | нет | default 20, max 50 | | cursor | uuid | нет | | | status | string | нет | pending / approved / rejected (default: pending) | | stack | string | нет | Фильтр по стеку | **Response 200:** ```json { "data": [ { "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 } ], "pagination": { "nextCursor": "0192a8b0-def0-7000-8000-000000000010", "hasMore": true } } ``` **Ошибки:** | HTTP | Код | Когда | | ------ | ----- | ------- | | 403 | `FORBIDDEN` | Пользователь не admin | --- ### PATCH /admin/questions/:id Одобрение, отклонение или редактирование вопроса. **Авторизация:** Bearer token (role: admin) **Request:** | Поле | Тип | Обязательное | Описание | | ------ | ----- | -------------- | ---------- | | status | string | нет | approved / rejected | | questionText | string | нет | Отредактированный текст | | options | array | нет | Отредактированные варианты | | correctAnswer | string | нет | Исправленный ответ | | explanation | string | нет | Исправленное объяснение | ```json { "status": "approved" } ``` Или с редактированием: ```json { "status": "approved", "explanation": "The tag specifies the character encoding for the HTML document, typically UTF-8." } ``` **Response 200:** Полный объект вопроса (как в GET queue) с обновлёнными полями. Действие записывается в `audit_logs`. **Ошибки:** | 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 день |