Files
samreshu_docs/api/contracts.md
Anton 99cd8ae727 docs: add full project documentation
- Architecture: overview, 7 ADR, tech stack
- Principles: code-style, git-workflow, security
- API contracts: auth, profile, tests, admin endpoints
- Database schema: tables, relationships, indexes
- LLM strategy: prompts, fallback, validation, Qwen 2.5 14B
- Onboarding: setup, Docker, .env template
- Progress: roadmap, changelog
- Agents: context, backend instructions

Made-with: Cursor
2026-03-04 12:07:17 +03:00

22 KiB
Raw 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 Внутренняя ошибка сервера

Пагинация (cursor-based)

Для списков используется cursor-based пагинация на основе UUID v7 (сортируемый по времени).

Запрос:

GET /tests/history?limit=10&cursor=0192a8b0-...
Параметр Тип Обязательный Описание
limit integer нет Количество записей (default 10, max 50)
cursor uuid нет ID последнего элемента предыдущей страницы

Ответ всегда содержит:

{
  "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
{
  "email": "user@example.com",
  "password": "securePass123",
  "nickname": "john_doe"
}

Response 201:

{
  "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=<token>; 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 да
{
  "email": "user@example.com",
  "password": "securePass123"
}

Response 200:

{
  "user": {
    "id": "0192a8b0-1234-7000-8000-000000000001",
    "email": "user@example.com",
    "nickname": "john_doe",
    "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 или пароль
403 ACCOUNT_LOCKED Прогрессивный lockout (brute force)
429 RATE_LIMIT_EXCEEDED Превышен лимит попыток входа

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 token

Request:

Поле Тип Обязательное
code string да
{
  "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 да
password string да
{
  "token": "reset-token-from-email",
  "password": "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": {
    "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
{
  "stack": "html",
  "level": "basic",
  "questionCount": 10
}

Response 201:

{
  "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 <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" }
    ]
  }
}

При создании теста вопросы копируются в 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
answer string / string[] да Ключ ответа ("A") или массив (["A", "C"])
{
  "questionId": "0192a8b0-9abc-7000-8000-000000000003",
  "answer": "A"
}

Response 200:

{
  "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": "<h6>" },
      { "key": "B", "text": "<h1>" },
      { "key": "C", "text": "<head>" },
      { "key": "D", "text": "<header>" }
    ]
  }
}

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:

{
  "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:

{
  "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

История тестов пользователя. Cursor-based пагинация, сортировка по дате (новые первые).

Авторизация: Bearer token

Query параметры:

Параметр Тип Обязательный Описание
limit integer нет default 10, max 50
cursor uuid нет ID последнего теста предыдущей страницы
stack string нет Фильтр по стеку
status string нет Фильтр: completed / abandoned

Response 200:

{
  "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:

{
  "data": [
    {
      "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
    }
  ],
  "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 нет Исправленное объяснение
{
  "status": "approved"
}

Или с редактированием:

{
  "status": "approved",
  "explanation": "The <meta charset> 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. Краткая сводка:

Группа Лимит Окно
/auth/login Прогрессивный lockout 15 мин / 1 час / 24 часа
/auth/register 3 1 час
/auth/forgot-password 3 1 час
Общий (авторизованный) 100 1 мин
Общий (гость) 30 1 мин
Тесты (Free) 5 тестов 1 день