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

828 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
### Формат ошибок
Все ошибки возвращаются в едином формате:
```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=<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:**
```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` — процент правильных ответов (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 |
```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 <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:**
```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 <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"]) |
```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 <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:**
```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 <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 | нет | Исправленное объяснение |
```json
{
"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](../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 день |