openapi: 3.1.1
info:
  title: HTTPS API сервера Scacelith
  version: "0.9.0"
  summary: HTTPS API любого сервера Scacelith — как официального, так и серверов сообщества.
  description: |-
    Этот HTTPS API предоставляет каждый сервер Scacelith — и официальный `caissa.scacelith.com`, и любой сервер сообщества. Игра использует его для всего, что происходит вне самой партии: регистрации и входа (включая двухфакторную аутентификацию и вход через Google), страницы аккаунта, истории партий, скачивания PGN и анимированных GIF партий, активных сеансов, скачивания данных, удаления аккаунта и жалоб.

    Игра в реальном времени (поиск соперника, вызовы, ходы, часы) идёт через WebSocket того же сервера (`wss://<host>/ws`), который открывается с токеном сеанса этого API; она описана в `PROTOCOL.md`, а не здесь.

    Значения по умолчанию, приведённые ниже, — это значения сервера с нетронутой конфигурацией; сервер сообщества может их изменить (все параметры перечислены в `CONFIG.md`).

    ## Базовый URL, порт и транспорт

    - Официальный сервер: `https://caissa.scacelith.com/api/v1`, TCP-порт 443.
    - Сервер сообщества: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. По умолчанию `API_PORT` равен 443; за NAT или прокси порт, которым пользуются игроки, — `PUBLIC_API_PORT`.
    - WebSocket игры по умолчанию работает на том же порту (`WS_PORT` переносит его на другой; где он находится, клиент узнаёт из `GET /info`).
    - HTTP/1.1 поверх TLS. При `TLS_MODE=proxy` TLS терминирует обратный прокси перед сервером. `TLS_MODE=off` (обычный HTTP) предназначен только для локальной разработки.
    - HTML-страницы, которые открываются по ссылкам из писем (`/verify-email`, `/reset-password`, `/confirm-email-change`), находятся в корне сервера, вне `/api/v1`. Эндпоинты проверки состояния отвечают и в корне, и под `/api/v1`. У входа через Google нет страницы на сервере: Google возвращает браузер прямо в игру, на `127.0.0.1`.
    - Завершающая косая черта игнорируется (`/api/v1/info/` — то же, что `/api/v1/info`), а параметры пути декодируются из URL-кодировки (на параметр с недопустимой URL-кодировкой сервер отвечает 400 `invalid_request`).

    ## Запросы

    - **Тела в JSON.** Запросы `POST`, `PUT` и `DELETE` передают объект JSON с `Content-Type: application/json`. `charset`, отличный от UTF-8, отклоняется, как и любой другой тип содержимого (415 `unsupported_media_type`). Пустое тело считается равным `{}` — именно его принимают эндпоинты без параметров (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Размер и время.** Размер тела ограничен `HTTP_BODY_LIMIT` байтами (по умолчанию 16 384; у `POST /gif` собственный лимит — 135 168 байт): 413 `payload_too_large`. Тело должно прийти в течение 10 секунд: 408 `request_timeout`. После любой из этих ошибок сервер закрывает соединение.
    - **Строгие схемы.** Поле, неизвестное эндпоинту, отклоняется, поле, не помеченное как необязательное, обязательно, а типы и длины проверяются. При любом из этих нарушений ответ — 400 `invalid_request` с `field`, где указано поле (для вложенного — через точку, например `pow.nonce`). Строки не могут содержать управляющие символы. Длина считается в кодовых единицах UTF-16, поэтому символ вне базовой многоязычной плоскости (например, эмодзи) считается дважды. На тело, которое не является JSON, ответ — 400 `invalid_json`. `POST /gif` и `POST /reports` проверяют свои тела сами (см. описание каждого эндпоинта).
    - **Строки запроса.** Учитывается первое вхождение параметра, неизвестные параметры игнорируются, а `+` декодируется в пробел: эндпоинты, принимающие категорию контроля времени, понимают и `3%2B2`, и `3+2`.
    - **Методы.** `HEAD` — это `GET` без тела. `OPTIONS` для существующего пути отвечает 204 с заголовком `Allow`. Любой другой метод, которого у пути нет, получает ответ 405 `method_not_allowed` с `Allow`.
    - **Цель запроса.** Она не может быть длиннее 4096 символов (414 `uri_too_long`). Запрос, который вообще не удаётся разобрать или у которого заголовочная часть слишком велика либо приходит слишком медленно, получает пустой ответ 400, 431 или 408, и соединение закрывается.
    - **Без CORS.** API обслуживает игру, а не веб-страницы. Заголовки `Access-Control-*` никогда не отправляются, поэтому веб-страница не может прочитать ответ; а поскольку принимаются только тела в JSON, для межсайтовой записи понадобился бы предварительный запрос (preflight), и он не проходит.

    ## Ответы и ошибки

    - Ответы — JSON в UTF-8, кроме скачивания PGN (`application/x-chess-pgn`), анимированных GIF (`image/gif`) и HTML-страниц. Время указывается в миллисекундах с 1970-01-01 UTC. Идентификаторы — целые числа. Идентификаторы партий содержат до 16 цифр, но остаются меньше 2^53, поэтому число JSON (double) хранит их точно.
    - Каждый ответ API содержит `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` и `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (у HTML-страниц своя политика). При `TLS_MODE=native` он также содержит `Strict-Transport-Security: max-age=31536000`.
    - Ответ должен быть прочитан в течение 60 секунд с момента, когда сервер его подготовил; это важно только для больших ответов (GIF, экспорт данных, длинный PGN). Время, которое сервер тратит на подготовку ответа, не учитывается ни здесь, ни в тех 30 секундах без единого входящего или исходящего байта, после которых соединение закрывается.

    У всех ошибок один формат (схема `Error`):

    ```json
    { "error": "snake_case_code", "message": "An English sentence.", "retryAfter": 30 }
    ```

    `message` предназначено для журналов и служит запасным вариантом; что показать пользователю, клиент выбирает по `error`. `retryAfter` (в секундах) присутствует только в отказах, которые снимаются со временем, и тогда ответ содержит также заголовок `Retry-After` с тем же значением; единственное исключение — 503 `busy` при чтении истории, партий, игроков и таблицы лидеров: там есть только поле. Некоторые ошибки добавляют поля: `field` (недопустимые входные данные), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` и `column` (`invalid_pgn`).

    Ошибки, которые может вернуть любой эндпоинт:

    | Статус | `error` | Когда |
    |---|---|---|
    | 400 | `invalid_request` | Тело нарушает схему эндпоинта (где именно — указывает `field`), цель запроса или `Content-Length` имеют неверный формат, параметр пути не является допустимой URL-кодировкой, или тело оборвано. |
    | 400 | `invalid_json` | Тело не является JSON. |
    | 401 | `unauthorized` | Нет заголовка `Authorization` на эндпоинте, которому нужен сеанс. |
    | 401 | `invalid_token` | Токен имеет неверный формат, истёк, отозван или принадлежит удалённому аккаунту; в том числе на эндпоинтах, где сеанс необязателен. |
    | 404 | `not_found` | Такого эндпоинта нет. Некоторые эндпоинты тоже её используют: нет такой партии, игрока или сеанса. |
    | 405 | `method_not_allowed` | Путь существует для других методов (см. `Allow`). |
    | 408 | `request_timeout` | Тело не пришло в течение 10 секунд. |
    | 413 | `payload_too_large` | Тело превышает `HTTP_BODY_LIMIT` (135 168 байт для `POST /gif`). |
    | 414 | `uri_too_long` | Цель запроса длиннее 4096 символов. |
    | 415 | `unsupported_media_type` | Тело не в формате `application/json`, или его кодировка не UTF-8. |
    | 429 | `rate_limited` | Сработал лимит частоты запросов (см. ниже): `retryAfter` и заголовок `Retry-After`. |
    | 500 | `internal_error` | Непредвиденный сбой. Сервер записывает его в журнал. |
    | 503 | `timeout` | Сервер не ответил в течение 30 секунд (60 секунд для экспорта, 45 секунд для GIF при настройках по умолчанию). |
    | 503 | `server_busy` | Поиск сеанса или изменение аккаунта натолкнулись на заблокированную базу данных (`retryAfter` 1), или очередь хеширования паролей заполнена (см. ниже). |

    Эндпоинты чтения (история партий, партии, игроки, таблица лидеров) и экспорт отвечают 503 `busy` с `retryAfter: 1`, если база данных так и осталась заблокированной.

    Эндпоинты, которые проверяют или хешируют пароль, могут также вернуть одну из следующих ошибок, обе со случайным `retryAfter` от 5 до 15 секунд:

    - 503 `server_busy`: очередь хеширования паролей на сервере (`PASSWORD_HASH_QUEUE_MAX`) заполнена, или истекло время ожидания.
    - 429 `rate_limited`: очередь заполнена наполовину, а у этого клиента (адрес IPv4 или сеть IPv6 /48) уже ждут `PASSWORD_HASH_WAITERS_PER_SOURCE` хешей. Этот 429 возвращает токены лимитов, которые взял запрос.

    При этом ничего не изменено и неудачная попытка не засчитана (кроме `POST /auth/sso/google/link`, где попытка по билету и неудача аккаунта учитываются ещё до хеширования), а ссылка для сброса пароля остаётся действительной.

    HTML-страницы сообщают о своих ошибках в виде HTML-страниц с теми же кодами статуса.

    ## Аутентификация

    Эндпоинты, которым нужен сеанс, принимают токен Bearer в заголовке `Authorization` (схема `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Получение токена.** Токен (`sct_` и 43 символа base64url) выдают `POST /auth/login` (а затем `POST /auth/login/mfa`, если включена двухфакторная аутентификация), `POST /auth/sso/google/finish` и `POST /auth/sso/google/link` (вход через Google), а также `POST /auth/sso/complete`. Каждый из них отвечает `{ token, expiresAt, user }`. Сервер хранит только SHA-256 токена.
    - **Обязательный сеанс.** При отсутствии заголовка ответ — 401 `unauthorized` с `WWW-Authenticate: Bearer realm="scacelith"`. При недействительном токене ответ — 401 `invalid_token` с `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Получив `invalid_token`, клиент должен забыть токен и войти снова.
    - **Необязательный сеанс.** Для `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` и `GET /players/{username}/games` сеанс необязателен. Без заголовка они возвращают общедоступное представление; если заголовок отправлен, его токен должен быть действительным.
    - **Срок действия.** Сеанс завершается при наступлении первого из двух сроков: через `SESSION_MAX_DAYS` (90) дней после входа (это `expiresAt`) или после `SESSION_IDLE_DAYS` (30) дней без использования. Каждое использование отодвигает срок бездействия; сервер записывает новое значение не чаще чем раз в 5 минут. У аккаунта может быть не более `MAX_SESSIONS_PER_USER` (10) сеансов: новый вход отзывает самые старые сверх этого числа.
    - **Отзыв.** Выход (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) отзывает сеансы; то же делают смена пароля (остальные сеансы), сброс пароля и удаление аккаунта (все сеансы), а также администратор. Отзыв через API действует сразу, и WebSocket, открытый с отозванным сеансом, закрывается. Сервер кеширует результаты поиска сеансов на 30 секунд, поэтому отзыв административной командой, которая работает в отдельном процессе, вступает в силу в течение 30 секунд.
    - **Область действия.** Токен принадлежит одному серверу и открывает также его WebSocket. Никогда не отправляйте его другому серверу.

    ## Лимиты частоты запросов и другие ограничения

    Лимиты действуют в одной из двух областей. **Клиент:** адрес IPv4 или сеть IPv6 /64 (в режиме прокси адрес берётся из `X-Forwarded-For`, присланного адресом из `TRUSTED_PROXIES`); некоторые лимиты, помимо каждой сети /64, считают ещё и каждую сеть IPv6 /48 целиком. **Игрок:** аккаунт, в который выполнен вход, с какого бы адреса ни шли запросы; лимит, считаемый по игроку на эндпоинте с необязательным сеансом, для запроса без токена считается по клиенту.

    Каждый лимит — это корзина токенов (token bucket) вместимостью `limit` запросов, которая непрерывно пополняется со скоростью `limit / window`; `retryAfter` — время до появления следующего токена. Лимиты с пометкой *общий* дополнительно считаются в скользящем окне той же длины, чтобы ни одно окно не вмещало заметно больше `limit` запросов. Каждый лимит считается для всего сервера. Когда один из лимитов эндпоинта отклоняет запрос, токены, взятые для этого запроса остальными его лимитами, возвращаются. Отказ — это ответ 429 `rate_limited` с `retryAfter` и `Retry-After`.

    - **Поадресный уровень.** Каждый запрос (любой путь и метод, включая эндпоинты проверки состояния и переход на WebSocket) сначала берёт один токен из бюджета своего клиента: `HTTP_RATE_PER_IP` (600) в минуту с запасом на всплеск в полминуты и `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) на сеть IPv6 /48. Кроме того, у клиента может выполняться не более `IP_MAX_INFLIGHT` (32 x `WORKERS`) запросов одновременно (сверх этого — `retryAfter` 1). Клиент, который продолжает слать запросы после отказов, блокируется: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) отказов за одну минуту блокируют его на 1 минуту, а каждая новая блокировка в течение 6 часов — на 4, 16 и 60 минут. Отказ по лимитам `auth`, `auth_*` и `reauth` засчитывается за 5; отказы по лимитам, которые считаются по игроку, не засчитываются никогда. Адреса из `ABUSE_EXEMPT` минуют этот уровень, но не бюджет аккаунта и не лимиты эндпоинтов.
    - **Бюджет аккаунта.** Каждый запрос с действительным токеном сеанса засчитывается также его аккаунту: `USER_RATE_PER_MIN` (120) в минуту на все эндпоинты вместе, с любого адреса, с запасом на всплеск в полминуты.
    - **Лимиты эндпоинтов:**

    | Лимит | По умолчанию | Считается по | Эндпоинты |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 мин, общий | клиенту, а также `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) на IPv6 /48 | `POST /auth/register`, `/auth/login`, `/auth/login/mfa`, `/auth/verify-email/resend`, `/auth/password/forgot`, `/auth/password/reset`, `/auth/sso/google/link`, `/auth/sso/complete`; `POST /verify-email`, `/reset-password`, `/confirm-email-change` |
    | `auth_register` | `AUTH_REGISTER_PER_HOUR` (10) / час, общий | клиенту, втрое больше на IPv6 /48 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / час, общий | клиенту, втрое больше на IPv6 /48 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / час, общий | клиенту, втрое больше на IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 часа, общий | клиенту, втрое больше на IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / час, общий | клиенту, втрое больше на IPv6 /48 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | значения `auth`, собственная корзина, общий | клиенту и IPv6 /48 | изменения аккаунта, требующие пароль (см. «Повторная аутентификация»), и `POST /account/export` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 мин, общий | игроку | те же эндпоинты, что и у `reauth` |
    | `account` | 60 / мин | игроку | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / мин | игроку | `GET /account/games` |
    | `account_export` | 5 / час, общий | игроку | `POST /account/export` (проверяется до `reauth`; засчитывается каждая попытка) |
    | `sessions` | 60 / мин | игроку | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / мин | игроку (клиенту, если токена нет) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (одна корзина на все четыре) |
    | `gif` | 30 / мин | игроку | `GET /games/{id}/gif`, `POST /gif` (одна корзина на оба) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / мин и `GIF_USER_RENDERS_PER_HOUR` (30) / час, общие | игроку | те же два, только когда GIF нужно создать (а не взять из кеша) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / мин и `GIF_IP_RENDERS_PER_HOUR` (120) / час, общие | клиенту (все его аккаунты вместе), втрое больше на IPv6 /48 | те же, с тем же условием |
    | `reports` | 30 / час | игроку | `POST /reports` |
    | `sso_start` | 30 / 10 мин, общий | клиенту, а также 90 на IPv6 /48 | `POST /auth/sso/google/start` |
    | `sso_finish` | 30 / мин | клиенту | `POST /auth/sso/google/finish` |
    | `page` | 60 / мин | клиенту | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    У `GET /info` и `GET /leaderboard` нет собственных лимитов: действует только поадресный уровень. Эндпоинт с несколькими лимитами проверяет их в порядке, указанном в его описании.

    Сервер обрабатывает запрос в таком порядке: поадресный уровень, затем эндпоинты проверки состояния; сопоставление с эндпоинтом; аутентификация; бюджет аккаунта, если запрос несёт сеанс; лимиты эндпоинта; тело; сам эндпоинт (эндпоинты GIF расходуют свои лимиты на рендеринг, только когда им действительно нужно создать GIF). Поэтому запрос, отклонённый из-за токена, не тратит ни одного токена эндпоинта, а запрос с недопустимым телом их тратит.

    Другие ограничения, о которых сообщают сами эндпоинты:

    - **Неудачные входы под одним логином** (имя пользователя или адрес эл. почты): начиная с `AUTH_FAILURES_PER_ACCOUNT` (5) неудач каждая попытка должна ждать вдвое дольше предыдущей (2 с, 4 с и так далее, до 15 минут): 429 `too_many_attempts` с `retryAfter`. Счётчик сбрасывается после часа без неудач.
    - **Неудачные вторые факторы при входе:** то же правило, начиная с 5-го неверного кода аккаунта. Один шаг входа принимает не более 5 неверных кодов.
    - **Коды второго фактора одного аккаунта:** не более `AUTH_MFA_PER_ACCOUNT` (10) кодов (из приложения-аутентификатора или кодов восстановления, верных или неверных) за 15 минут, с любого адреса, при входе и при повторной аутентификации; сверх этого — 429 `too_many_attempts` ещё до проверки кода, так что код восстановления не расходуется.
    - **Неудачные повторные аутентификации аккаунта** (неверный пароль или код): то же правило (429 `too_many_attempts`), общее для всех эндпоинтов с повторной аутентификацией.
    - **Письма:** не более одного письма с подтверждением или сбросом пароля на адрес раз в 5 минут (ответ при этом не меняется); не более одного уведомления «кто-то пытался использовать ваш адрес» на адрес в час.
    - **Жалобы:** `REPORTS_PER_DAY` (5) от одного игрока за 24 часа: 429 `report_limit`.

    ## Доказательство работы

    `POST /auth/register` всегда требует доказательства работы (proof of work), если `POW_REGISTER_BITS` больше 0 (по умолчанию 18; `GET /info` сообщает это значение как `pow.register`). `POST /auth/login` и `POST /auth/sso/google/link` (у них один вид задачи) требуют его только в течение 5 минут после того, как сервер заметит волну неудачных входов (`POW_LOGIN_TRIGGER_PER_MIN`, затем `POW_LOGIN_BITS`); клиент узнаёт об этом из ответа.

    1. На запрос без доказательства (или с отклонённым доказательством) приходит ответ 428 `pow_required` с `reason` и задачей `pow` `{ challenge, bits, expiresAt }`.
    2. Найдите nonce — десятичную строку не длиннее 20 цифр, такую, что `SHA-256(challenge + ":" + nonce)` начинается с `bits` нулевых битов (начиная со старшего бита первого байта). Для 18 битов требуется в среднем около 260 000 хешей.
    3. Отправьте тот же запрос ещё раз с `"pow": { "challenge": "...", "nonce": "123456" }` в теле.

    Задача действительна 2 минуты и только один раз, для одного эндпоинта и одной сети клиента (адрес IPv4 или IPv6 /64). Она подписана, поэтому сервер ничего о ней не хранит, пока она не вернётся. `reason` сообщает, почему доказательство отклонено: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` или `replayed`.

    ## Повторная аутентификация

    Изменения аккаунта требуют снова ввести пароль, а при включённой двухфакторной аутентификации — ещё и второй фактор:

    - только пароль: `POST /account/password` и `POST /account/mfa/totp/setup`;
    - пароль и код из приложения-аутентификатора (код восстановления не принимается): `POST /account/mfa/recovery-codes`;
    - пароль и код из приложения-аутентификатора или код восстановления: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` и `POST /account/delete`.

    В телах запросов `code` содержит 6-значный код из приложения-аутентификатора, а `recoveryCode` — код восстановления (`xxxx-xxxx-xx`; регистр, пробелы и дефисы не важны). Там, где коды восстановления принимаются, код восстановления можно передать и в `code`. Каждый код срабатывает один раз: использованный код из приложения-аутентификатора отклоняется до следующего 30-секундного шага, а код восстановления после использования пропадает.

    | Статус | `error` | Когда |
    |---|---|---|
    | 403 | `invalid_password` | Неверный пароль. |
    | 403 | `mfa_code_required` | Двухфакторная аутентификация включена, а ни `code`, ни `recoveryCode` не отправлены. |
    | 403 | `invalid_code` | Неверный или уже использованный код. |
    | 400 | `password_not_set` | У аккаунта, который входит только через Google, ещё нет пароля (его задают через «Забыли пароль?»). |
    | 429 | `too_many_attempts` | Слишком много неудач или слишком много опробованных кодов для этого аккаунта. |
    | 503 / 429 | `server_busy` / `rate_limited` | Очередь хеширования паролей занята. |

    Неверные пароли и коды учитываются в счётчике неудач аккаунта и записываются как события безопасности.

    ## Порт метрик

    Помимо порта API, сервер отвечает по обычному HTTP на порту метрик (`METRICS_PORT`, 9464, привязан к `METRICS_BIND`, по умолчанию 127.0.0.1; не открывайте его наружу). Он не входит в этот API: `GET /healthz` отвечает `ok`; `GET /readyz` отвечает `ready` после завершения запуска (каждый шард воспроизвёл свой журнал, и слушающие сокеты привязаны) и до начала остановки, а в остальное время — 503 `not ready`; `GET /metrics` отдаёт метрики Prometheus и, если задан `METRICS_TOKEN`, требует `Authorization: Bearer <token>` именно с этим токеном.
  contact:
    name: Scacelith
    url: https://github.com/DarkCenobyte/scacelith-chess-server
  license:
    name: GPL-3.0-or-later
    identifier: GPL-3.0-or-later
externalDocs:
  description: Исходный код сервера Scacelith и его справочная документация (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Официальный сервер.
  - url: https://{host}:{port}/api/v1
    description: Любой сервер Scacelith, например сервер сообщества.
    variables:
      host:
        default: caissa.scacelith.com
        description: Публичное имя хоста сервера (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: Публичный порт API (`PUBLIC_API_PORT`, а если он не задан — `API_PORT`).
tags:
  - name: server-info
    x-displayName: Сведения о сервере
    description: Что клиенту нужно знать до входа или подключения.
  - name: health
    x-displayName: Проверка состояния
    description: |-
      Работоспособность и готовность сервера — для мониторинга. Эти эндпоинты отвечают на порту API до аутентификации и до любых лимитов эндпоинтов, но, как и любой запрос, берут токен поадресного уровня; хост мониторинга можно указать в `ABUSE_EXEMPT`. Для каждого эндпоинта работают оба пути: в корне сервера и под `/api/v1`.

      У порта метрик есть собственные эндпоинты проверки состояния, они описаны во введении.
  - name: auth
    x-displayName: Регистрация и вход
    description: |-
      Создание аккаунта, вход с паролем (и вторым фактором), письма с подтверждением адреса и для сброса пароля.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` и `POST /auth/sso/complete` возвращают сеанс `{ token, expiresAt, user }` (или — первые из них — второй шаг).
  - name: google-sign-in
    x-displayName: Вход через Google
    description: |-
      Доступен, если `GET /info` сообщает `sso.google: true`; иначе каждый эндпоинт ниже отвечает 404 `sso_disabled`. Игра выполняет вход через системный браузер по схеме для установленных приложений из RFC 8252 (клиент типа «Desktop app»): Google возвращает браузер на слушающий сокет игры на `127.0.0.1`, а не на этот сервер, и игра никогда не видит учётных данных Google.

      1. Клиент начинает слушать `127.0.0.1:0` (порт выбирает система) и создаёт пару PKCE: `codeVerifier` длиной от 43 до 128 символов `[A-Za-z0-9._~-]` и `codeChallenge = BASE64URL(SHA-256(codeVerifier))` — 43 символа без дополнения.
      2. `POST /auth/sso/google/start` с challenge PKCE и портом возвращает URL Google и `state` попытки. Клиент проверяет URL (см. ниже) и открывает его в браузере.
      3. Google направляет браузер на `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Клиент принимает только `state` из ответа, полученного при запуске попытки, и ничего не отправляет после перенаправления с `error=`.
      4. `POST /auth/sso/google/finish` с идентификатором попытки, `codeVerifier`, `state` и `code` (и `iss`, если Google его прислал).
      5. Ответ — сеанс, шаг двухфакторной аутентификации (продолжение — `POST /auth/login/mfa`), `needsUsername` для нового игрока (продолжение — `POST /auth/sso/complete`) или `needsPassword`, если этот адрес использует аккаунт с паролем (продолжение — `POST /auth/sso/google/link`).

      **Тег источника.** URI перенаправления содержит тег сервера, который добавил игрок, чтобы игра отклоняла URL, полученный другим сервером для своих игроков. Источник (origin) — это имя хоста в нижнем регистре (литерал IPv6 — в квадратных скобках), `:` и десятичный порт API, который пишется всегда, в том числе 443: сервер берёт `SERVER_PUBLIC_HOST` и свой публичный порт API, а игра — адрес, к которому она подключена. Тег — первые 22 символа base64url (без дополнения) от SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + источник). URI перенаправления — `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: всегда литерал IPv4 и порт слушающего сокета игры (от 1024 до 65535). Сервер никогда не берёт у клиента URI, хост или путь.

      | Источник | Тег |
      |---|---|
      | `play.scacelith.example:443` | `IhcScoV7eDOzTEcSnqPUPt` |
      | `localhost:8443` | `TFGx7zQ_8QlGZW5zpqznCr` |
      | `[::1]:8443` | `XToJm0DG5PjciEVmZa9Cho` |
      | `127.0.0.1:50443` | `3r653wM5ZjYsHcAJljmCwY` |

      Поэтому вход через Google работает только у игроков, которые добавили сервер именно под именем `SERVER_PUBLIC_HOST` и с его публичным портом API.

      **Что игра проверяет, прежде чем открыть браузер.** `authUrl` начинается ровно с `https://accounts.google.com/o/oauth2/v2/auth?`, состоит из печатных символов ASCII и короче 4096 символов, а в его строке запроса ровно один `response_type=code`, ровно один `redirect_uri`, равный URI, который игра вычисляет по своему порту и своему тегу источника, ровно один `state`, равный `state` из ответа, `code_challenge_method=S256` и `code_challenge` из 43 символов. Иначе игра останавливает слушающий сокет и ничего не открывает. Запросы `finish` и `link` она отправляет только тому серверу, который ответил на `start`.

      **В какой аккаунт ведёт вход через Google:** в аккаунт, уже привязанный к этому аккаунту Google; иначе — в активный аккаунт с адресом, который подтвердил Google (если у аккаунта есть пароль, `finish` отвечает `needsPassword`, и привязка сохраняется, только когда пройдены его пароль, а затем, если он включён, второй фактор; если пароля нет — 409 `sso_account_exists`); иначе — в новый аккаунт (`needsUsername`). Аккаунт Google никогда не привязывается к существующему аккаунту только по совпадению адреса. На адрес аккаунта приходит письмо, когда вход через Google создаёт аккаунт и когда он добавляется к существующему.
  - name: sessions
    x-displayName: Сеансы
    description: Активные сеансы аккаунта и выход.
  - name: account
    x-displayName: Аккаунт
    description: Данные аккаунта, его настройки и пароль.
  - name: two-step-verification
    x-displayName: Двухфакторная аутентификация
    description: 'Приложения-аутентификаторы (TOTP, RFC 6238): SHA-1, 6 цифр, шаг 30 секунд и допуск в один шаг в обе стороны, а также одноразовые коды восстановления.'
  - name: email-change
    x-displayName: Смена адреса эл. почты
    description: Смена адреса аккаунта с подтверждением по ссылке, отправленной на новый адрес.
  - name: data-export
    x-displayName: Экспорт данных
    description: Всё, что сервер хранит об аккаунте, в одном файле JSON.
  - name: account-deletion
    x-displayName: Удаление аккаунта
    description: Окончательное удаление аккаунта.
  - name: game-history
    x-displayName: История партий
    description: Собственные партии игрока, выполнившего вход, с фильтрами и постраничным выводом.
  - name: games
    x-displayName: Партии и PGN
    description: |-
      Записи партий с ходами и показаниями часов, а также их файлы PGN.

      **Коды партий.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (хранятся только завершённые партии). `result`: `1-0`, `0-1`, `1/2-1/2` или `*` (партия отменена). `reason` — с названием (`termination`) и словами, которыми заканчивается текст ходов в файле PGN; коды 7 и 21 означают ничью (у игрока, у которого истекло время или который покинул партию, соперник не мог поставить мат), а кодами 4 и 13 сервер партии никогда не завершает (они входят в общий список причин):

      | Код | `termination` | Слова в PGN (с переводом) |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (мат) |
      | 2 | `Resignation` | Resignation (сдача) |
      | 3 | `Timeout` | Loss on time (поражение по времени) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (второй невозможный ход, поражение) |
      | 5 | `Stalemate` | Stalemate (пат) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (мёртвая позиция, недостаточно материала) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (время истекло, но соперник не может поставить мат) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (пятикратное повторение) |
      | 9 | `SeventyFiveMoves` | 75-move rule (правило 75 ходов) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (троекратное повторение по требованию) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (правило 50 ходов по требованию) |
      | 12 | `Agreement` | Draw by agreement (ничья по соглашению) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (второй невозможный ход, но соперник не может поставить мат) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (партия покинута: слишком долгое отключение) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (партия покинута, но соперник не может поставить мат) |
      | 22 | `Aborted` | Game aborted (партия отменена) |
      | 23 | `NoShow` | Aborted: first move not played in time (отменена: первый ход не сделан вовремя) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (техническое поражение за нарушение честной игры) |
      | 25 | `ServerAborted` | Aborted by the server (отменена сервером) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (отменена: оба игрока отключились) |
  - name: gifs
    x-displayName: Анимированные GIF
    description: |-
      Партия в виде анимированного GIF — чтобы сохранить или поделиться: доска сверху, по одному кадру на каждую позицию от начальной до финальной, имена и рейтинги игроков над доской, последний ход под ней, а на последнем кадре — результат и то, чем закончилась партия.

      **Затраты, кеш и квоты.** GIF создаётся в отдельном потоке рендеринга, никогда не в потоках, где идут партии, и с самым низким приоритетом CPU: `GIF_THREADS` потоков (по умолчанию `WORKERS`), которые запускаются с первым GIF и останавливаются после минуты без GIF. До `GIF_QUEUE_MAX` (4 x `WORKERS`) GIF ждут свободного потока, каждый не дольше `GIF_QUEUE_TIMEOUT_MS` (10 с); рендеринг может длиться до `GIF_RENDER_TIMEOUT_MS` (30 с). Созданные GIF сервер хранит в кеше размером `GIF_CACHE_MB` МБ (32 x `WORKERS`), первыми вытесняя те, что дольше всего не использовались; GIF из кеша или GIF, который уже создаётся для другого запроса, рендеринга не требует. Ключ кеша учитывает всё, что меняет изображение, включая имена.

      Каждый запрос учитывается в `gif` (30 в минуту на игрока для обоих эндпоинтов). GIF, который нужно создать, учитывается также в лимитах рендеринга: на игрока — `GIF_USER_RENDERS_PER_MIN` (4) в минуту и `GIF_USER_RENDERS_PER_HOUR` (30) в час; на клиента (все его аккаунты вместе) — `GIF_IP_RENDERS_PER_MIN` (12) в минуту и `GIF_IP_RENDERS_PER_HOUR` (120) в час, и втрое больше на IPv6 /48. Клиенту следует хранить скачанный файл, а не запрашивать его снова, и выжидать `retryAfter` после 429 или 503.

      Размеры: `small` (поля по 32 пикс.: 284 x 350 пикселей), `medium` (48 пикс.: 424 x 515), `large` (72 пикс.: 628 x 762); без координат — 268 x 342, 400 x 503 и 600 x 748. Начальная позиция показывается не меньше 1 с (или дольше, если задержка между ходами больше), финальная — 3 с, и GIF зацикливается; после первого кадра сохраняется только изменившаяся часть изображения. Партия из 40 ходов занимает около 135, 205 и 325 КиБ (малый, средний и большой размер), партия из 150 ходов — 0,5; 0,8 и 1,2 МиБ.
  - name: players
    x-displayName: Игроки
    description: Публичные профили и недавние партии. Только общедоступные данные — никогда адрес эл. почты, сеанс, санкция или уровень доверия античита. У удалённого аккаунта профиля нет.
  - name: leaderboard
    x-displayName: Таблица лидеров
    description: Лучшие игроки в каждой официальной категории.
  - name: reports
    x-displayName: Жалобы
    description: Жалоба модераторам на соперника по недавней партии.
  - name: pages
    x-displayName: HTML-страницы
    description: |-
      Страницы для браузера, которые открываются по ссылкам из писем. Их ссылки ведут на `https://<SERVER_PUBLIC_HOST>` (с `:<PUBLIC_API_PORT>`, если это не 443), вне `/api/v1`.

      - Страницы не выполняют JavaScript и не загружают внешних ресурсов. Они отдаются с `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - `GET` только показывает кнопку или форму, чтобы почтовый сканер, открывший ссылку, не израсходовал её. Изменение происходит по `POST`: форма отправляется как `application/x-www-form-urlencoded` (тело в JSON тоже принимается), а токен ссылки передаётся в скрытом поле. Поле, указанное дважды, отклоняется.
      - Ошибки (лимиты частоты запросов, недопустимые поля) — тоже HTML-страницы, с заголовком "Request refused" (запрос отклонён) или, начиная с 500, "Server error" (ошибка сервера). Только сбои, обнаруженные до сопоставления со страницей (слишком длинная или искажённая цель запроса, поадресный уровень), возвращаются в JSON.
x-tagGroups:
  - name: server
    x-displayName: Сервер
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Аккаунты
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Партии
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Игроки и жалобы
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Страницы для браузера
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Получить имя сервера, версии, порты и правила регистрации
      description: |-
        Всё, что нужно клиенту до входа или подключения: имя и идентификатор сервера, версии протокола WebSocket и расположение WebSocket, правила регистрации, официальные контроли времени и ограничения, которые клиент может проверить до отправки формы.

        **Лимиты:** только поадресный уровень.
      security: []
      responses:
        '200':
          description: Описание сервера.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServerInfo'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: поадресный уровень.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Создать аккаунт
      description: |-
        Создаёт аккаунт: после подтверждения адреса эл. почты по отправленной на него ссылке или сразу, если подтверждение по почте не требуется.

        **С подтверждением по почте** (`REQUIRE_EMAIL_VERIFICATION`, по умолчанию): 202 `verification_sent`. Аккаунта пока нет: заявка на регистрацию ждёт 24 часа и всё это время удерживает за собой имя пользователя. На адрес отправляется ссылка, действительная эти 24 часа, — не чаще одной на адрес раз в 5 минут (`POST /auth/verify-email/resend` отправляет новую); аккаунт создаётся, а его адрес считается подтверждённым, когда ссылкой воспользуются (кнопка на странице `/verify-email`), и после этого игрок может войти. До тех пор вход с этим именем пользователя получает ответ `invalid_credentials`, как для неизвестного аккаунта, и публичного профиля не существует. Новая заявка с тем же адресом заменяет ожидающую. Ответ такой же, если адрес уже использует другой аккаунт: ссылка тогда не отправляется, вместо неё владелец получает уведомление (не чаще одного в час), а имя пользователя удерживается точно так же, чтобы ничто не выдавало, есть ли у адреса аккаунт. Заявка, ссылкой которой не воспользовались, удаляется через 24 часа, и её имя пользователя снова становится свободным.

        **Без подтверждения по почте** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, аккаунт создаётся сразу, и в него можно войти.

        **Правила.** `username`: от `USERNAME_MIN` до `USERNAME_MAX` символов (от 3 до 20) — буквы, цифры, `_` и `-`, первой должна идти буква или цифра (`GET /info` сообщает эти значения в `limits`); зарезервированные имена (`admin`, `moderator`, `deleted`...) и некоторые префиксы отклоняются; имя уникально без учёта регистра. `email`: пробелы по краям обрезаются, хранится в нижнем регистре, только ASCII, домен с точкой. `password`: не менее `PASSWORD_MIN_LENGTH` (10) символов и не более 256 байт в UTF-8; не должен содержать имя пользователя или локальную часть адреса эл. почты и не должен быть распространённым паролем.

        **Ошибки** проверяются в таком порядке: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; ошибки очереди хеширования паролей; 409 `email_taken` (только без подтверждения по почте).

        **Лимиты:** `auth`, затем `auth_register` (10 регистраций в час на клиента). **Доказательство работы:** всегда, если `POW_REGISTER_BITS` больше 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Первая попытка, без доказательства работы
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Повторная отправка с доказательством работы
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: Аккаунт создан, в него можно войти (на этом сервере нет подтверждения по почте).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: Заявка на регистрацию ждёт перехода по ссылке подтверждения (или у адреса уже есть аккаунт — ответ этого не сообщает).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationSentStatus'
              example:
                status: verification_sent
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request` (с `field`), `invalid_json`, `invalid_username`, `invalid_email` или `weak_password` с `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` или `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: регистрация на этом сервере закрыта (`GET /info` сообщает `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: это имя пользователя уже есть у аккаунта, или его удерживает ожидающая заявка с другим адресом. `email_taken`: адрес использует другой аккаунт (только без подтверждения по почте).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '428':
          $ref: '#/components/responses/PowRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `auth` или `auth_register` либо очередь хеширования паролей.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (очередь хеширования паролей, или база данных так и осталась заблокированной), `timeout`.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Войти с паролем
      description: |-
        Выполняет вход по имени пользователя или адресу эл. почты и паролю. Возможны два ответа, оба со статусом 200: сеанс или, если включена двухфакторная аутентификация, второй шаг, который нужно завершить в течение 5 минут через `POST /auth/login/mfa`.

        Неизвестный аккаунт, неверный пароль и аккаунт без пароля получают одинаковый ответ через одинаковое время: 401 `invalid_credentials`. Проверки аккаунта (`banned`, `email_unverified`) выполняются только после верного пароля. Начиная с `AUTH_FAILURES_PER_ACCOUNT` (5) неудач для одного логина каждой попытке приходится выжидать (429 `too_many_attempts`).

        **Лимит:** `auth`. **Доказательство работы:** только во время волны неудачных входов (428 `pow_required`).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
            example:
              login: alice
              password: correct horse battery
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: Сеанс или второй шаг входа с двухфакторной аутентификацией.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Вход выполнен
                  value:
                    token: sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
                    expiresAt: 1798658839708
                    user:
                      id: 1
                      username: alice
                      email: alice@example.org
                      emailVerified: true
                      mfaEnabled: false
                      googleLinked: false
                      hasPassword: true
                      acceptChallenges: all
                      createdAt: 1790882839743
                      lastLoginAt: 1790882839708
                      pendingEmail: null
                mfaRequired:
                  summary: Включена двухфакторная аутентификация
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: неизвестный аккаунт, неверный пароль или аккаунт без пароля.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Только после верного пароля: `banned` с `until` (мс от начала эпохи, `null` при бессрочной блокировке) или `email_unverified` (старый аккаунт, адрес которого ещё не подтверждён).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '428':
          $ref: '#/components/responses/PowRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (задержка после неудач для этого логина) или `rate_limited` (лимит `auth`, очередь хеширования паролей).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (очередь хеширования паролей, или база данных так и осталась заблокированной), `timeout`.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Завершить вход вторым фактором
      description: |-
        Второй шаг входа с двухфакторной аутентификацией — после того как `POST /auth/login` или вход через Google (`POST /auth/sso/google/finish` или `POST /auth/sso/google/link`) ответил `mfaRequired`. Отправьте `code` (6-значный код из приложения-аутентификатора или код восстановления) или `recoveryCode`. Использованный здесь код восстановления пропадает.

        Один шаг принимает не более 5 неверных кодов; шаг также завершается при сбросе или смене пароля. Если перед этим был шаг с паролем при привязке Google, привязка сохраняется, только когда код здесь пройдёт проверку.

        **Лимиты:** `auth` и не более `AUTH_MFA_PER_ACCOUNT` (10) кодов за 15 минут на аккаунт, с любого адреса; сверх этого код не проверяется, так что код восстановления не расходуется.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Код из приложения-аутентификатора
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Код восстановления
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Вход выполнен.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: не отправлены ни `code`, ни `recoveryCode`, или тело нарушает схему; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: шаг истёк, уже использован или завершён после 5 неверных кодов, либо после первого шага пароль был сброшен или изменён (войдите снова). `invalid_code`: неверный или уже использованный код.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (с `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: только после шага с паролем при привязке Google — за это время аккаунт Google был привязан к другому аккаунту.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: только после шага с паролем при привязке Google — за это время аккаунт изменился; начните заново из игры.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: задержка после неудач для аккаунта, или его `AUTH_MFA_PER_ACCOUNT` кодов за последние 15 минут исчерпаны. `rate_limited`: лимит `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` с `retryAfter: 1`: база данных так и осталась заблокированной (если это шаг привязки Google, ничего не привязано: начните заново из игры). `timeout`.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Завершить текущий сеанс
      description: |-
        Отзывает сеанс использованного токена. WebSocket, открытый с этим сеансом, закрывается. Без тела (или `{}`).

        **Лимит:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Выход выполнен.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoggedOutStatus'
              example:
                status: logged_out
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: тело, отличное от `{}`; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `sessions` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (база данных так и осталась заблокированной), `timeout`.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Завершить все сеансы
      description: |-
        Отзывает все сеансы аккаунта, включая текущий. Открытые с ними WebSocket закрываются. Без тела (или `{}`).

        **Лимит:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Все сеансы завершены.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoggedOutStatus'
              example:
                status: logged_out
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: тело, отличное от `{}`; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `sessions` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (база данных так и осталась заблокированной), `timeout`.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Отправить ссылку подтверждения ещё раз
      description: |-
        Повторно отправляет ссылку для подтверждения адреса эл. почты. Ответ — 202 `accepted` при любом адресе, чтобы никогда не выдавать, использует ли его аккаунт или заявка на регистрацию.

        Запрос срабатывает не чаще раза в 5 минут для одного адреса. Ожидающая заявка с этим адресом снова получает свои 24 часа, независимо от того, использует ли адрес другой аккаунт, чтобы в обоих случаях её имя пользователя удерживалось одинаково долго. Ссылка отправляется только для этой заявки, если у адреса нет аккаунта (новая ссылка, действительная 24 часа, заменяет предыдущую), или для активного неподтверждённого аккаунта с этим адресом.

        **Лимиты:** `auth`, затем `auth_mail` (10 в час на клиента).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Принято (при любом адресе).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedStatus'
              example:
                status: accepted
        '400':
          $ref: '#/components/responses/BadRequest'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `auth` или `auth_mail` (при любом адресе).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` с `retryAfter: 1`: база данных так и осталась заблокированной при поиске аккаунта (продление ожидающей заявки выполняется по мере возможности и никогда не приводит к ошибке запроса). `timeout`.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Отправить ссылку для сброса пароля
      description: |-
        Отправляет ссылку для сброса пароля, действительную один час, которая открывает страницу `/reset-password`. Ответ — 202 `accepted` при любом адресе. Ссылка отправляется только активному аккаунту, не чаще раза в 5 минут на адрес. Аккаунт, который входит только через Google, так задаёт свой первый пароль.

        У восстановления пароля самые строгие лимиты в API, и все они считаются для всего сервера: 3 запроса в час (`AUTH_FORGOT_PER_HOUR`) и 10 за 24 часа (`AUTH_FORGOT_PER_DAY`) на клиента (адрес IPv4 или сеть IPv6 /64), втрое больше на IPv6 /48 — сверх лимита `auth` (20 за 10 минут) и правила «одно письмо на адрес раз в 5 минут». Ни 202, ни 429 не выдают, использует ли адрес какой-либо аккаунт. У установки нового пароля свой лимит (`auth_reset`).

        **Лимиты:** `auth`, `auth_forgot`, затем `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Принято (при любом адресе).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedStatus'
              example:
                status: accepted
        '400':
          $ref: '#/components/responses/BadRequest'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `auth`, `auth_forgot` или `auth_forgot_day` (при любом адресе).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` с `retryAfter: 1`: база данных так и осталась заблокированной. `timeout`.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Задать новый пароль по токену из ссылки для сброса
      description: |-
        Задаёт новый пароль по токену из ссылки для сброса (страница `/reset-password` делает то же самое). Новый пароль подчиняется правилам регистрации.

        Сброс отзывает все сеансы и отменяет ожидающую смену адреса эл. почты; остальные ссылки для сброса пароля этого аккаунта перестают работать; адрес считается подтверждённым (ссылка это доказала); владелец получает письмо. Двухфакторная аутентификация не затрагивается. После ошибки очереди хеширования паролей или ответа 503 ссылка остаётся действительной.

        **Лимиты:** `auth`, затем `auth_reset` (10 в час на клиента, 30 на IPv6 /48, общий со страницей: каждая попытка хеширует пароль).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Пароль изменён, все сеансы завершены.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: password_reset
              example:
                status: password_reset
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_token`: ссылка недействительна, уже использована или истекла либо была отправлена на адрес, которого у аккаунта больше нет. `weak_password` (с `reason`). `invalid_request`, `invalid_json`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `auth` или `auth_reset` либо очередь хеширования паролей.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: очередь хеширования паролей, или база данных так и осталась заблокированной (`retryAfter: 1`, ничего не изменено). `timeout`.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Начать вход через Google
      description: |-
        Начинает попытку входа через Google для заданного challenge PKCE и порта слушающего сокета игры на `127.0.0.1`. Ответ содержит URL Google, который нужно открыть в системном браузере, и `state` попытки; попытка действительна 10 минут.

        `authUrl` содержит `client_id`, `redirect_uri` (собранный из `redirectPort` и тега источника сервера), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 от собственного code verifier сервера для Google) с `code_challenge_method=S256` и `prompt=select_account`. Игра проверяет его, прежде чем открыть (см. описание тега).

        **Лимит:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: Попытка.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoStartAnswer'
              example:
                attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
                authUrl: https://accounts.google.com/o/oauth2/v2/auth?client_id=1234567890-abc.apps.googleusercontent.com&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2Foauth2%2Fgoogle%2FIhcScoV7eDOzTEcSnqPUPt&response_type=code&scope=openid%20email%20profile&state=yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ&nonce=gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU&code_challenge=ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc&code_challenge_method=S256&prompt=select_account
                state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
                expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: вход через Google на этом сервере недоступен.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (база данных так и осталась заблокированной), `timeout`.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Передать ответ Google серверу
      description: |-
        Передаёт серверу `code` и `state`, которые Google прислал на слушающий сокет игры, вместе с идентификатором попытки и code verifier PKCE. Идентификатор попытки бесполезен без code verifier, а код бесполезен без собственного code verifier PKCE сервера и секрета клиента.

        Сервер проверяет попытку (неизвестная, использованная или истёкшая — 410), затем code verifier (при неверном попытка остаётся пригодной), затем однократно использует попытку, проверяет `state` и `iss`, обменивает код у Google и проверяет ID-токен. Ответ (200) — один из следующих:

        - `{ token, expiresAt, user }`: вход в привязанный аккаунт выполнен;
        - `{ mfaRequired, mfaToken, expiresIn }`: продолжение — `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: новый аккаунт; продолжение — `POST /auth/sso/complete` в течение 10 минут. `suggestedUsername` берётся из имени в Google или из адреса и равно `""`, если ничего не подходит;
        - `{ needsPassword, linkTicket, username, expiresIn }`: у аккаунта с этим адресом есть пароль; продолжение — `POST /auth/sso/google/link`. Пока ничего не привязано.

        **Лимит:** `sso_finish`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoFinishRequest'
            example:
              attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
              codeVerifier: Sb5SaoX0ByHGjJ0XQkEqaaPaJYx2BId4ncTT8tLM6W8
              state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
              code: 4/0AVMBsJhR2x7cKq9vT1pLmN3oW8yZ5aB6dE7fG8hJ
              iss: https://accounts.google.com
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: Сеанс, второй шаг или следующий шаг первого входа через Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Вход в привязанный аккаунт выполнен
                  value:
                    token: sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
                    expiresAt: 1798658839708
                    user:
                      id: 1
                      username: alice
                      email: alice@example.org
                      emailVerified: true
                      mfaEnabled: false
                      googleLinked: true
                      hasPassword: false
                      acceptChallenges: all
                      createdAt: 1790882839743
                      lastLoginAt: 1790882839708
                      pendingEmail: null
                mfaRequired:
                  summary: Включена двухфакторная аутентификация
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Новый игрок
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Адрес использует аккаунт с паролем
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: code verifier не соответствует challenge попытки (попытка остаётся пригодной). `sso_email_unverified`: Google не подтвердил адрес. `registration_closed`. `account_disabled`. Только для привязанного аккаунта: `banned` (с `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: вход через Google на этом сервере недоступен.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: адрес использует активный аккаунт без пароля (его имя не сообщается).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: неизвестная, использованная или истёкшая попытка.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (база данных так и осталась заблокированной), `timeout`.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Привязать Google к существующему аккаунту с помощью его пароля
      description: |-
        После того как `finish` ответил `needsPassword`: привязывает Google к существующему аккаунту с помощью пароля этого аккаунта, введённого в игре. Ответ (200) — сеанс (привязка сохранена, и игрок вошёл) или, если включена двухфакторная аутентификация, `{ mfaRequired, mfaToken, expiresIn }`: продолжение — `POST /auth/login/mfa`, и привязка сохраняется, только когда там пройдёт код.

        При неверном пароле билет остаётся пригодным — всего на 5 попыток. Попытка расходуется непосредственно перед проверкой пароля: 429 `too_many_attempts` или 428 `pow_required` не расходуют ни одной, а отказ очереди хеширования паролей (503 `server_busy`, 429 `rate_limited`) уже израсходовал одну и засчитывается как неудача аккаунта. Задержка после неудач использует тот же счётчик, что и `POST /auth/login`.

        **Лимит:** `auth` (с учётом по IPv6 /48). **Доказательство работы:** как для `POST /auth/login`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoLinkRequest'
            example:
              linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
              password: correct horse battery
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: Сеанс или второй шаг входа.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: неверный пароль; билет остаётся пригодным — всего на 5 попыток.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (с `until`), только после верного пароля.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: вход через Google на этом сервере недоступен.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: за это время аккаунт Google был привязан к другому аккаунту.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: неизвестный, использованный или истёкший билет, 5-й неверный пароль, аккаунт, у которого после `finish` изменились статус или адрес, или аккаунт, у которого статус, адрес, пароль или двухфакторная аутентификация изменились во время сохранения привязки. Начните заново из игры.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '428':
          $ref: '#/components/responses/PowRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (задержка после неудач для аккаунта) или `rate_limited` (лимит `auth`, очередь хеширования паролей).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: очередь хеширования паролей, или база данных так и осталась заблокированной (`retryAfter: 1`, ничего не привязано). `timeout`.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Создать аккаунт при первом входе через Google
      description: |-
        После того как `finish` ответил `needsUsername`: создаёт аккаунт с выбранным именем пользователя (действуют правила регистрации) и выполняет в него вход. У аккаунта нет пароля: `hasPassword` равно `false`, а задать пароль можно через «Забыли пароль?» (`POST /auth/password/forgot`).

        **Лимит:** `auth` (с учётом по IPv6 /48).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoCompleteRequest'
            example:
              ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
              username: alice
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: Аккаунт создан, вход выполнен.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`, `invalid_request` или `invalid_json`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: вход через Google на этом сервере недоступен.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (это имя пользователя уже есть у аккаунта, или его удерживает ожидающая заявка с другим адресом), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: неизвестный, использованный или истёкший билет.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (база данных так и осталась заблокированной), `timeout`.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Список активных сеансов
      description: |-
        Активные сеансы аккаунта (устройства, на которых выполнен вход), начиная с последнего использованного. `current` отмечает сеанс, от которого пришёл запрос. `lastSeenAt` обновляется не чаще раза в 5 минут; `expiresAt` — абсолютный срок окончания, а лимит бездействия может завершить сеанс раньше.

        **Лимит:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Активные сеансы.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionList'
              example:
                sessions:
                  - id: 3
                    createdAt: 1790882839708
                    lastSeenAt: 1790882839708
                    expiresAt: 1798658839708
                    clientLabel: Laptop
                    current: false
                  - id: 1
                    createdAt: 1790882839708
                    lastSeenAt: 1790882839708
                    expiresAt: 1798658839708
                    clientLabel: Scacelith 1.4 (Windows)
                    current: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `sessions` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (база данных так и осталась заблокированной), `timeout`.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Завершить сеанс на одном устройстве
      description: |-
        Завершает один сеанс аккаунта; можно завершить и текущий. WebSocket, открытый с этим сеансом, закрывается. Без тела (или `{}`).

        **Лимит:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Сеанс завершён.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: revoked
              example:
                status: revoked
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: тело, отличное от `{}`, или `id` с недопустимой URL-кодировкой; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: у этого аккаунта нет активного сеанса с таким идентификатором.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `sessions` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (база данных так и осталась заблокированной), `timeout`.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Получить аккаунт, его рейтинги и действующие санкции
      description: |-
        Аккаунт таким, каким его видит сам игрок: данные аккаунта (они же — `user` в каждом ответе на вход), по одной записи рейтинга на каждую категорию, в которой игрок играл рейтинговые партии, действующие санкции и блокировка. Уровень доверия античита никогда не показывается.

        **Лимит:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Аккаунт.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountMe'
              example:
                user:
                  id: 1
                  username: alice
                  email: alice@example.org
                  emailVerified: true
                  mfaEnabled: true
                  googleLinked: false
                  hasPassword: true
                  acceptChallenges: all
                  createdAt: 1790882871478
                  lastLoginAt: 1790882902200
                  pendingEmail: alice.new@example.org
                ratings:
                  - category: '3+2'
                    rating: 1510
                    games: 2
                    wins: 1
                    draws: 1
                    losses: 0
                    peak: 1510
                    provisional: true
                sanctions: []
                ban: null
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`unauthorized` или `invalid_token` (в том числе если аккаунт удалён).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `account` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (база данных так и осталась заблокированной), `timeout`.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Принимать или отклонять прямые вызовы
      description: |-
        Определяет, могут ли другие игроки вызывать этого игрока по имени. При `none` прямые вызовы отклоняются: вызывающему сообщается, что игрок недоступен. Повторная аутентификация не требуется.

        **Лимит:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Настройки, действующие теперь.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - preferences
                properties:
                  preferences:
                    $ref: '#/components/schemas/Preferences'
              example:
                preferences:
                  acceptChallenges: none
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `account` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (база данных так и осталась заблокированной), `timeout`.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Сменить пароль
      description: |-
        Меняет пароль. Нужен текущий пароль, но не второй фактор — даже при включённой двухфакторной аутентификации. Новый пароль подчиняется правилам регистрации (они проверяются после текущего пароля).

        Смена отзывает все остальные сеансы (текущий остаётся активным), отменяет ожидающую смену адреса эл. почты, делает недействительными ссылки для сброса пароля этого аккаунта и отправляет владельцу письмо.

        **Повторная аутентификация:** только пароль. **Лимиты:** `reauth`, затем `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordChangeRequest'
            example:
              currentPassword: correct horse battery
              newPassword: a much better passphrase
      responses:
        '200':
          description: Пароль изменён.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: password_changed
              example:
                status: password_changed
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set` (аккаунт, который входит только через Google), `weak_password` (с `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: неверный текущий пароль или сброс либо смена пароля, произошедшие, пока запрос проверялся.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (неудачные повторные аутентификации аккаунта) или `rate_limited` (лимит `reauth` или `reauth_user`, бюджет аккаунта, очередь хеширования паролей).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (очередь хеширования паролей, или база данных так и осталась заблокированной), `timeout`.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Начать включение двухфакторной аутентификации
      description: |-
        Сохраняет новый ожидающий секрет аутентификатора, который заменяет любой прежний ожидающий, и возвращает его для приложения-аутентификатора (в виде текста и в виде URI `otpauth://`, который показывают как QR-код). Двухфакторная аутентификация пока не включена: её включает `POST /account/mfa/totp/enable` с кодом, сгенерированным по этому секрету.

        **Повторная аутентификация:** только пароль. **Лимиты:** `reauth`, затем `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Ожидающий секрет.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TotpSetup'
              example:
                secret: OCKJVMPMMKMPLBSIYLN6QQRMKIPC2VLH
                uri: otpauth://totp/Scacelith:alice?secret=OCKJVMPMMKMPLBSIYLN6QQRMKIPC2VLH&issuer=Scacelith&algorithm=SHA1&digits=6&period=30
                algorithm: SHA1
                digits: 6
                period: 30
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set` (аккаунт, который входит только через Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled` (проверяется до пароля).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (неудачные повторные аутентификации аккаунта) или `rate_limited` (лимит `reauth` или `reauth_user`, бюджет аккаунта, очередь хеширования паролей).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (очередь хеширования паролей, или база данных так и осталась заблокированной), `timeout`.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Завершить включение двухфакторной аутентификации и получить коды восстановления
      description: |-
        Включает двухфакторную аутентификацию по коду, сгенерированному по ожидающему секрету (ровно 6 цифр). Пароль здесь не запрашивается — он был введён на этапе настройки. Ответ содержит 10 кодов восстановления, которые показываются только этот единственный раз.

        **Лимиты:** `reauth`, затем `reauth_user`; неверный код засчитывается в неудачные повторные аутентификации аккаунта.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Двухфакторная аутентификация включена.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - recoveryCodes
                properties:
                  status:
                    const: mfa_enabled
                  recoveryCodes:
                    $ref: '#/components/schemas/RecoveryCodeList'
              example:
                status: mfa_enabled
                recoveryCodes:
                  - j7v5-3ezx-zn
                  - 4kqm-8w2p-hd
                  - x0ra-c6tn-5g
                  - mb3s-9yzj-k1
                  - 2hvd-pq7e-w8
                  - c5ng-0tka-xr
                  - zz4y-b1me-7q
                  - 8pjh-6dsw-3n
                  - t9xc-2gfk-0v
                  - q6wa-ner5-jy
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: `code` не состоит ровно из 6 цифр, или тело нарушает схему; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: неверный код (проверьте время на устройстве).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: нет ожидающего секрета (сначала вызовите `POST /account/mfa/totp/setup`).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (неудачные повторные аутентификации аккаунта) или `rate_limited` (лимит `reauth` или `reauth_user`, бюджет аккаунта).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (база данных так и осталась заблокированной), `timeout`.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Отключить двухфакторную аутентификацию
      description: |-
        Отключает двухфакторную аутентификацию. Секрет и коды восстановления удаляются, владелец получает письмо.

        **Повторная аутентификация:** пароль и код из приложения-аутентификатора или код восстановления (обязателен `code` или `recoveryCode`). **Лимиты:** `reauth`, затем `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Двухфакторная аутентификация отключена.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: mfa_disabled
              example:
                status: mfa_disabled
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set` (аккаунт, который входит только через Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (нет ни `code`, ни `recoveryCode`; проверяется до пароля), `invalid_password`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_not_enabled`.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (неудачные повторные аутентификации или слишком много опробованных кодов для этого аккаунта) или `rate_limited` (лимит `reauth` или `reauth_user`, бюджет аккаунта, очередь хеширования паролей).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (очередь хеширования паролей, или база данных так и осталась заблокированной), `timeout`.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Заменить коды восстановления
      description: |-
        Заменяет коды восстановления 10 новыми; старые перестают работать.

        **Повторная аутентификация:** пароль и код из приложения-аутентификатора в `code` (код восстановления отклоняется с 403 `invalid_code`). **Лимиты:** `reauth`, затем `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecoveryCodesRequest'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Новые коды восстановления, которые показываются только этот единственный раз.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - recoveryCodes
                properties:
                  recoveryCodes:
                    $ref: '#/components/schemas/RecoveryCodeList'
              example:
                recoveryCodes:
                  - j7v5-3ezx-zn
                  - 4kqm-8w2p-hd
                  - x0ra-c6tn-5g
                  - mb3s-9yzj-k1
                  - 2hvd-pq7e-w8
                  - c5ng-0tka-xr
                  - zz4y-b1me-7q
                  - 8pjh-6dsw-3n
                  - t9xc-2gfk-0v
                  - q6wa-ner5-jy
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set` (аккаунт, который входит только через Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (в том числе для кода восстановления).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_not_enabled`.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (неудачные повторные аутентификации или слишком много опробованных кодов для этого аккаунта) или `rate_limited` (лимит `reauth` или `reauth_user`, бюджет аккаунта, очередь хеширования паролей).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (очередь хеширования паролей, или база данных так и осталась заблокированной), `timeout`.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Сменить адрес эл. почты
      description: |-
        Меняет адрес эл. почты аккаунта. У `newEmail` обрезаются пробелы по краям, он приводится к нижнему регистру и затем проверяется так же, как адрес при регистрации.

        **С подтверждением по почте** (по умолчанию): 202 `verification_sent`. На новый адрес отправляется ссылка, действительная 24 часа; она открывает `/confirm-email-change`, и адрес меняется, только когда игрок нажмёт кнопку на этой странице. До тех пор `GET /account/me` показывает новый адрес в `pendingEmail`. Новый запрос заменяет ожидающий; смена или сброс пароля отменяют его. На конкретный новый адрес уходит не больше одной ссылки раз в 5 минут, кто бы её ни запрашивал (запрос на уже ожидающую смену сохраняет отправленную ранее ссылку, и она остаётся действительной); ответ при этом тот же. На текущий адрес приходит уведомление о том, что запрошена смена на замаскированный адрес (`a***@example.org`). Ответ и `pendingEmail` такие же, если новый адрес уже использует другой аккаунт: ссылка тогда не отправляется, так что смена никогда не завершится, а владелец того адреса вместо этого получает уведомление (не чаще одного в час).

        Когда ссылка подтверждена, адрес меняется и считается подтверждённым, сеансы на устройствах сохраняются, отправленные ранее ссылки (подтверждение, сброс пароля, другие смены адреса) перестают работать, а на прежний адрес приходит уведомление, в котором новый адрес замаскирован.

        **Без подтверждения по почте** (`REQUIRE_EMAIL_VERIFICATION=false`): адрес меняется сразу (200 `email_changed`), и на прежний адрес приходит уведомление. Если адрес использует другой аккаунт, ответ — 409 `email_taken`, а владелец того адреса получает уведомление.

        **Повторная аутентификация:** пароль, а при двухфакторной аутентификации — ещё код из приложения-аутентификатора или код восстановления. `invalid_email` и `same_email` проверяются до пароля, поэтому неудачами не считаются. **Лимиты:** `reauth`, затем `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailChangeRequest'
            example:
              newEmail: alice.new@example.org
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Адрес сменён сразу (на этом сервере нет подтверждения по почте).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - email
                properties:
                  status:
                    const: email_changed
                  email:
                    type: string
                    format: email
                    description: Новый адрес в том виде, в каком он сохранён.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: На новый адрес отправлена ссылка для подтверждения (или адрес принадлежит другому аккаунту — ответ этого не сообщает).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationSentStatus'
              example:
                status: verification_sent
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_email`, `same_email` (текущий адрес аккаунта), `password_not_set` (аккаунт, который входит только через Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (в том числе если смена или сброс пароля опередили запрос: ссылка не отправляется), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: только без подтверждения по почте.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (неудачные повторные аутентификации или слишком много опробованных кодов для этого аккаунта) или `rate_limited` (лимит `reauth` или `reauth_user`, бюджет аккаунта, очередь хеширования паролей).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: очередь хеширования паролей, или база данных так и осталась заблокированной (`retryAfter: 1`: ничего не изменено, и тот же запрос можно отправить снова). `timeout`.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Скачать данные аккаунта
      description: |-
        Всё, что сервер хранит об аккаунте, в виде одного файла JSON для сохранения (`format` `scacelith-account-export`, `version` 1). Экспорт записывает событие безопасности (`account_exported`). Массив `notes` в документе простым английским языком объясняет игроку, что в файл не вошло.

        **В экспорт никогда не попадают:** хеш пароля, секрет двухфакторной аутентификации и коды восстановления; любые токены сеансов и ссылок или их хеши; данные античита (уровень доверия и балл, аномалии, анализ партий, вес жалобы); жалобы других игроков на этого игрока; личности модераторов; личные данные других игроков (соперники указаны под публичным именем и с рейтингом, ничто не выдаёт, получал ли другой игрок санкции, и не включается ни один IP-адрес, который может принадлежать другому человеку).

        **Повторная аутентификация:** пароль, а при двухфакторной аутентификации — ещё код из приложения-аутентификатора или код восстановления. **Лимиты:** `account_export` (5 в час на игрока, для всего сервера; засчитывается каждая попытка, включая неудачные; проверяется первым), затем `reauth` и `reauth_user`. **Ограничение по времени:** 60 секунд.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Документ экспорта в виде вложения.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Символы имени пользователя, кроме букв, цифр, `_`, `.` и `-`, заменяются на `_`.'
              schema:
                type: string
                pattern: '^attachment; filename="scacelith-account-[A-Za-z0-9_.-]+\.json"$'
              example: attachment; filename="scacelith-account-alice.json"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountExport'
              example:
                format: scacelith-account-export
                version: 1
                exportedAt: 1790882839708
                server:
                  name: Scacelith
                  host: caissa.scacelith.com
                notes:
                  - This file holds the data Scacelith keeps about your account. Times are milliseconds since 1970-01-01 UTC.
                account:
                  id: 1
                  username: alice
                  email: alice@example.org
                  emailVerified: true
                  pendingEmail: null
                  mfaEnabled: false
                  googleLinked: false
                  googleEmail: null
                  hasPassword: true
                  acceptChallenges: all
                  createdAt: 1790882839743
                  lastLoginAt: 1790882839708
                ratings:
                  - category: '3+2'
                    rating: 1510
                    games: 2
                    wins: 1
                    draws: 1
                    losses: 0
                    peak: 1510
                    provisional: true
                    rated: true
                    countedGames: 2
                    updatedAt: 1790882839809
                ratingRefunds:
                  - day: 1790812800000
                    category: '3+2'
                    points: 9
                sessions:
                  - id: 1
                    createdAt: 1790882839708
                    lastSeenAt: 1790882839708
                    expiresAt: 1798658839708
                    revokedAt: null
                    clientLabel: Scacelith 1.4 (Windows)
                    ip: 203.0.113.7
                securityEvents:
                  - kind: login
                    at: 1790882839708
                    ip: 203.0.113.7
                    detail:
                      method: password
                sanctions: []
                conduct:
                  - kind: abort
                    at: 1790800000000
                reportsFiled:
                  - gameId: 4100000000001
                    reported: bob
                    category: other
                    comment: rude
                    createdAt: 1790882840000
                    status: open
                games:
                  total: 1
                  list:
                    - id: 4100000000001
                      category: '3+2'
                      rated: true
                      timeControl: '180+2'
                      white:
                        name: alice
                        rating: 1500
                        ratingAfter: 1510
                        ratingDiff: 10
                      black:
                        name: bob
                        rating: 1520
                        ratingAfter: 1510
                        ratingDiff: -10
                      color: white
                      status: 1
                      reason: 2
                      result: 1-0
                      termination: Resignation
                      plies: 41
                      startedAt: 1790620205000
                      endedAt: 1790620611000
                      baseMs: 180000
                      incMs: 2000
                      outcome: win
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set` (аккаунт, который входит только через Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required` или `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (неудачные повторные аутентификации или слишком много опробованных кодов для этого аккаунта) или `rate_limited` (лимит `account_export`, `reauth` или `reauth_user`, бюджет аккаунта, очередь хеширования паролей).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (база данных так и осталась заблокированной; с заголовком `Retry-After: 1`), `server_busy` (очередь хеширования паролей), `timeout` (60 секунд).'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Удалить аккаунт
      description: |-
        Удаляет аккаунт; это действие нельзя отменить.

        - Все сеансы сразу отзываются: с этого момента токен получает 401 `invalid_token`.
        - Имя пользователя заменяется на `deleted#<id>` — в аккаунте и во всех записях партий.
        - Стираются: адрес эл. почты, хеш пароля, секрет двухфакторной аутентификации и коды восстановления, сеансы и токены ссылок, привязка к Google, запись античита об уровне доверия и IP-адреса, сохранённые вместе с событиями безопасности.
        - Рейтинги и партии сохраняются. Партии остаются доступными под анонимным именем, а `GET /players/{username}` для прежнего имени отвечает 404.

        **Повторная аутентификация:** пароль, а при двухфакторной аутентификации — ещё код из приложения-аутентификатора или код восстановления. **Лимиты:** `reauth`, затем `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Аккаунт удалён.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: deleted
              example:
                status: deleted
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set` (аккаунт, который входит только через Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required` или `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (неудачные повторные аутентификации или слишком много опробованных кодов для этого аккаунта) или `rate_limited` (лимит `reauth` или `reauth_user`, бюджет аккаунта, очередь хеширования паролей).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (очередь хеширования паролей, или база данных так и осталась заблокированной), `timeout`.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Список партий игрока с фильтрами и постраничным выводом
      description: |-
        Партии игрока, выполнившего вход, начиная с самых новых, с фильтрами и постраничным выводом, а также число партий, подходящих под фильтр. Все параметры запроса необязательны, а пустое значение равносильно отсутствию параметра.

        Постраничный вывод: передайте `next` предыдущей страницы в `before`; на последней странице `next` равно `null`. `total` — число партий, подходящих под фильтр, на всех страницах.

        **Лимит:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: Идентификатор официальной категории (`3+2` или `3%2B2`) или `custom` — для всех партий с другим контролем времени.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Только рейтинговые (`true`) или товарищеские (`false`) партии.
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Только выигранные, проигранные или сыгранные вничью партии — с точки зрения игрока. Отменённые партии видны только без этого фильтра.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Одна страница истории.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HistoryPage'
              example:
                games:
                  - id: 4100000000001
                    category: '3+2'
                    rated: true
                    timeControl: '180+2'
                    white:
                      name: alice
                      rating: 1500
                      ratingAfter: 1510
                      ratingDiff: 10
                    black:
                      name: bob
                      rating: 1520
                      ratingAfter: 1510
                      ratingDiff: -10
                    color: white
                    status: 1
                    reason: 2
                    result: 1-0
                    termination: Resignation
                    plies: 41
                    startedAt: 1790620205000
                    endedAt: 1790620611000
                    baseMs: 180000
                    incMs: 2000
                    outcome: win
                next: null
                total: 1
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_cursor` (`before` не является идентификатором партии), `invalid_limit` или `invalid_filter` (`category`, `rated` или `result`) — каждая с `field`, где указан параметр.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `account_games` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (база данных так и осталась заблокированной; `retryAfter: 1` в теле, без заголовка `Retry-After`), `server_busy` (при поиске сеанса база данных оказалась заблокированной), `timeout`.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Получить запись партии с ходами и показаниями часов
      description: |-
        Одна запись партии с ходами и показаниями часов. Без токена или с токеном игрока, который не участвовал в партии, возвращается общедоступный ответ. Если владелец токена играл в этой партии, в ответ добавляются `you` и `reportable`.

        **Лимит:** `public_read` (по игроку с токеном, по клиенту без него).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Запись партии.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GameRecord'
              example:
                id: 4100000000001
                category: '3+2'
                rated: true
                timeControl: '180+2'
                white:
                  name: alice
                  rating: 1500
                  ratingAfter: 1510
                  ratingDiff: 10
                black:
                  name: bob
                  rating: 1520
                  ratingAfter: 1510
                  ratingDiff: -10
                status: 1
                reason: 2
                result: 1-0
                termination: Resignation
                plies: 2
                startedAt: 1790620205000
                endedAt: 1790620611000
                baseMs: 180000
                incMs: 2000
                statusName: WhiteWins
                rematchOf: null
                moves:
                  - uci: e2e4
                    spentMs: 0
                    clockMs: 180000
                  - uci: e7e5
                    spentMs: 1700
                    clockMs: 180300
                pgn:
                  Event: Scacelith rated 3+2
                  Site: caissa.scacelith.com
                  Date: '2026.09.28'
                  Round: '-'
                  White: alice
                  Black: bob
                  Result: 1-0
                  WhiteElo: 1500
                  BlackElo: 1520
                  TimeControl: '180+2'
                  Termination: Resignation
                  PlyCount: 2
                you: white
                reportable: true
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_game_id`: не положительное целое число из не более чем 16 цифр (меньше 2^53); `invalid_request`: недопустимая URL-кодировка.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: токен отправлен, но недействителен.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: такой партии нет.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `public_read` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (база данных так и осталась заблокированной; `retryAfter: 1` в теле, без заголовка `Retry-After`), `server_busy` (при поиске сеанса база данных оказалась заблокированной), `timeout`.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Скачать партию в виде файла PGN
      description: |-
        Та же партия в виде файла PGN: одна партия с окончаниями строк `\n` и текстом ходов в строках короче 80 символов. Ответ одинаков с токеном и без него.

        Теги в таком порядке: `Event` (`<SERVER_NAME> rated <category>` или `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (дата начала по UTC), `Round`, `White`, `Black`, `Result` (`*` для отменённой партии), `UTCDate` и `UTCTime` (начало), `WhiteElo` и `BlackElo` (рейтинги на начало партии или `-`), `WhiteRatingDiff` и `BlackRatingDiff` (изменения, например `+10` и `-10`, — в каждой рейтинговой партии, `+0`, если по правилам рейтинга он остался прежним; у товарищеской партии, партии с нестандартным контролем времени или отменённой партии их нет), `TimeControl` (в секундах), `Termination` (стандартное значение PGN: `normal`; `time forfeit` — падение флажка, в том числе когда партия заканчивается ничьей; `abandoned`; `rules infraction` — второй невозможный ход или техническое поражение за нарушение честной игры; `unterminated` — отменённая партия), `PlyCount`, `ScacelithGameId` (десятичный идентификатор).

        У каждого хода есть `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: показание часов сходившего игрока после хода и время, затраченное на ход, в десятых долях секунды (с отбрасыванием остатка). Значение опускается, если в записи его нет. После последнего хода словами указывается причина окончания партии, затем результат.

        **Лимит:** `public_read` (по игроку с токеном, по клиенту без него).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Файл PGN.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: 'Значение заголовка: `attachment; filename="scacelith-<id>.pgn"`.'
              schema:
                type: string
                pattern: '^attachment; filename="scacelith-[1-9][0-9]{0,15}\.pgn"$'
              example: attachment; filename="scacelith-4100000000001.pgn"
          content:
            application/x-chess-pgn:
              schema:
                type: string
                description: 'Текст PGN в UTF-8 (`Content-Type: application/x-chess-pgn; charset=utf-8`).'
              example: |
                [Event "Scacelith rated 3+2"]
                [Site "caissa.scacelith.com"]
                [Date "2026.09.28"]
                [Round "-"]
                [White "alice"]
                [Black "bob"]
                [Result "1-0"]
                [UTCDate "2026.09.28"]
                [UTCTime "18:30:05"]
                [WhiteElo "1500"]
                [BlackElo "1520"]
                [WhiteRatingDiff "+10"]
                [BlackRatingDiff "-10"]
                [TimeControl "180+2"]
                [Termination "normal"]
                [PlyCount "2"]
                [ScacelithGameId "4100000000001"]

                1. e4 {[%clk 0:03:00.0] [%emt 0:00:00.0]} 1... e5 {[%clk 0:03:00.3]
                [%emt 0:00:01.7]} {Resignation} 1-0
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_game_id`: не положительное целое число из не более чем 16 цифр (меньше 2^53); `invalid_request`: недопустимая URL-кодировка.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: токен отправлен, но недействителен.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: такой партии нет.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `public_read` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: в частности, если сохранённые ходы не удаётся воспроизвести до сохранённого окончания партии (сервер записывает это в журнал).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (база данных так и осталась заблокированной; `retryAfter: 1` в теле, без заголовка `Retry-After`), `server_busy` (при поиске сеанса база данных оказалась заблокированной), `timeout`.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Скачать партию этого сервера в виде анимированного GIF
      description: |-
        Партия в виде анимированного GIF. Имена и рейтинги берутся из записи партии (рейтинги на начало партии, удалённый аккаунт — как `deleted#<id>`), как и результат и способ окончания партии (`Resignation`, `Loss on time`...).

        Проверки выполняются в таком порядке: `gif_disabled`, параметры изображения (`size`, `orientation`, `delay`, `coords`), идентификатор партии, сама партия, её длина.

        **Требуется сеанс:** квоты считаются по аккаунту. **Лимиты:** `gif` — на каждый запрос; `gif_user_min`, `gif_user_hour`, `gif_ip_min` и `gif_ip_hour` — только когда GIF нужно создать (см. описание тега). **Ограничение по времени:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 секунд (по умолчанию 45 секунд).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: 'Размер изображения: `small` (поля по 32 пикс.), `medium` (48 пикс.) или `large` (72 пикс.).'
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Сторона, которая находится внизу доски.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Миллисекунды на ход (от 1 до 6 десятичных цифр).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Рисовать ли вокруг доски буквы вертикалей и цифры горизонталей (`1`) или нет (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` с `field` (`size`, `orientation`, `delay` или `coords`): недопустимое значение. `invalid_game_id`. `invalid_request`: недопустимая URL-кодировка.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: такой партии нет. `gif_disabled`: на сервере отключены GIF (`GIF_ENABLED=false`; токен `gif` возвращается).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `gif`, один из лимитов рендеринга или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: не удалось создать GIF (причину сервер записывает в журнал). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` с `retryAfter` (от 3 до 10 секунд) и `Retry-After`: очередь рендеринга заполнена, или GIF прождал свободного потока `GIF_QUEUE_TIMEOUT_MS` (10 секунд); все токены лимитов, взятые запросом, возвращаются. `busy`: база данных так и осталась заблокированной (`retryAfter: 1` только в теле). `timeout`.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Создать анимированный GIF любой партии, присланной в формате PGN
      description: |-
        То же изображение, что и у `GET /games/{id}/gif`, для любой партии, присланной текстом PGN: партии, сохранённой игрой, экспорта с другого сайта, партии, набранной вручную. Используется только первая партия из текста.

        - Парсер PGN принимает то же, что и собственный парсер игры: любой PGN, который пишет этот сервер, и обычные экспорты других сайтов (комментарии, варианты, NAG и пометки часов пропускаются; номера ходов и SAN читаются нестрого; тег `FEN` задаёт начальную позицию, если только `SetUp` не равен `"0"`).
        - Имена и рейтинги берутся из тегов `White`, `Black`, `WhiteElo` и `BlackElo`. Буквы с диакритикой теряют диакритические знаки, прочие символы вне печатного ASCII заменяются на `?`, а длинные имена обрезаются (до 48 символов). Результат берётся из тега `Result`, а при его отсутствии — из конца текста ходов. Тег `Termination` показывается, если он не равен `normal`: в этом случае обо всём говорит сама финальная позиция (мат, пат).
        - Этот эндпоинт проверяет тело сам: на неизвестное поле, а также если `pgn` отсутствует или не является строкой, ответ — 400 `invalid_request` с `field`; на неверный параметр — 400 `invalid_option` (`delayMs` должен быть числом JSON, `coords` — логическим значением; `null` отклоняется).

        **Требуется сеанс:** квоты считаются по аккаунту. **Лимиты:** как у `GET /games/{id}/gif`. **Ограничение размера тела:** 135 168 байт независимо от `HTTP_BODY_LIMIT` (PGN в виде строки JSON, с учётом экранирования). **Ограничение по времени:** по умолчанию 45 секунд.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Короткая партия, набранная вручную, без координат
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Файл PGN со всеми параметрами
                value:
                  pgn: |
                    [White "alice"]
                    [Black "bob"]
                    [WhiteElo "1500"]
                    [BlackElo "1520"]
                    [Result "1-0"]

                    1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0
                  size: small
                  orientation: black
                  delayMs: 800
                  coords: true
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request` с `field` (неизвестное поле или `pgn` отсутствует либо не является строкой; без `field`, если тело не является объектом); `invalid_json`; `invalid_option` с `field` (`size`, `orientation`, `delayMs` или `coords`); `invalid_pgn` с `line` и `column` (начиная с 1, столбцы в символах) и `message` парсера: невозможный или неоднозначный ход, испорченный тег, неизвестная разновидность шахмат, более 65 536 байт.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: на сервере отключены GIF (`GIF_ENABLED=false`; токен `gif` возвращается).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: тело больше 135 168 байт.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `gif`, один из лимитов рендеринга или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: не удалось создать GIF (причину сервер записывает в журнал). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` с `retryAfter` (от 3 до 10 секунд) и `Retry-After`: очередь рендеринга заполнена, или GIF слишком долго ждал свободного потока; все токены лимитов, взятые запросом, возвращаются. `timeout`.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Получить публичный профиль игрока
      description: |-
        Публичный профиль игрока: рейтинги в официальных категориях (в порядке, заданном сервером) и количество партий. `games.total` учитывает все сохранённые партии, включая товарищеские и отменённые; `games.rated`, `wins`, `draws` и `losses` суммируются по записям рейтинга, то есть учитывают только рейтинговые партии. Ответ одинаков с токеном и без него.

        **Лимит:** `public_read` (по игроку с токеном, по клиенту без него).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Профиль.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerProfile'
              example:
                username: alice
                createdAt: 1790882839743
                ratings:
                  - category: '3+2'
                    rating: 1510
                    provisional: true
                    games: 2
                    wins: 1
                    draws: 1
                    losses: 0
                    peak: 1510
                games:
                  total: 3
                  rated: 2
                  wins: 1
                  draws: 1
                  losses: 0
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`: не 2–24 символа из `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: токен отправлен, но недействителен.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: такого игрока нет или аккаунт удалён.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `public_read` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (база данных так и осталась заблокированной; `retryAfter: 1` в теле, без заголовка `Retry-After`), `server_busy` (при поиске сеанса база данных оказалась заблокированной), `timeout`.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Список недавних партий игрока
      description: |-
        Недавние партии игрока, начиная с самых новых, с постраничным выводом, как в истории, но без фильтров и общего числа. `color` — цвет этого игрока. `next` — идентификатор последней партии всякий раз, когда страница заполнена целиком, поэтому следующая страница может оказаться пустой. Сначала выполняется поиск игрока: для неизвестного игрока ответ — 404, что бы ни было в строке запроса.

        **Лимит:** `public_read` (по игроку с токеном, по клиенту без него).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Одна страница партий игрока.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerGamesPage'
              example:
                username: alice
                games:
                  - id: 4100000000001
                    category: '3+2'
                    rated: true
                    timeControl: '180+2'
                    white:
                      name: alice
                      rating: 1500
                      ratingAfter: 1510
                      ratingDiff: 10
                    black:
                      name: bob
                      rating: 1520
                      ratingAfter: 1510
                      ratingDiff: -10
                    color: white
                    status: 1
                    reason: 2
                    result: 1-0
                    termination: Resignation
                    plies: 41
                    startedAt: 1790620205000
                    endedAt: 1790620611000
                next: 4100000000001
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`, `invalid_cursor` (`before` не является идентификатором партии), `invalid_limit` (последние две — без `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: токен отправлен, но недействителен.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: такого игрока нет или аккаунт удалён.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: лимит `public_read` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (база данных так и осталась заблокированной; `retryAfter: 1` в теле, без заголовка `Retry-After`), `server_busy` (при поиске сеанса база данных оказалась заблокированной), `timeout`.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Получить лучших игроков категории
      description: |-
        100 лучших записей рейтинга официальной категории, в которых учтено не менее `minGames` (`PROVISIONAL_GAMES`) партий, без удалённых аккаунтов и игроков, уличённых в нечестной игре. Сервер пересчитывает каждую таблицу не чаще раза в 10 секунд; когда это произошло, сообщает `updatedAt`.

        **Лимиты:** только поадресный уровень.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Идентификатор официальной категории (`3+2` или `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Сколько игроков вернуть, от 1 до 100. Большее число длиной до 3 цифр считается равным 100; пустое значение равносильно отсутствию параметра.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: Таблица лидеров.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Leaderboard'
              example:
                category: '3+2'
                minGames: 30
                updatedAt: 1790882839828
                players:
                  - rank: 1
                    username: bob
                    rating: 1874
                    games: 212
                    wins: 120
                    draws: 30
                    losses: 62
                    peak: 1901
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_category` (отсутствует или не является официальной категорией), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: поадресный уровень.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (база данных так и осталась заблокированной; `retryAfter: 1` в теле, без заголовка `Retry-After`), `timeout`.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Пожаловаться на соперника по недавней партии
      description: |-
        Жалоба на соперника по одной из собственных партий игрока, закончившейся за последние 7 дней. Сама по себе жалоба никогда не меняет ни рейтинг, ни санкции, ни уровень доверия. Она повышает приоритет проверки, который видят модераторы, и, кроме категории `abuse`, запрашивает анализ партии движком.

        Повторная жалоба на того же соперника по той же партии получает тот же ответ и ничего не меняет; ответ никогда ничего не сообщает об аккаунте, на который подана жалоба. `GET /games/{id}` заранее сообщает участникам партии, будет ли принята жалоба (`reportable`).

        Этот эндпоинт проверяет тело сам, в порядке `gameId`, `reported`, `category`, `comment`: при ошибке ответ — 400 `invalid_request` без `field`. Неизвестные поля игнорируются.

        **Лимиты:** `reports` (по игроку), затем `REPORTS_PER_DAY` (5) жалоб от игрока за 24 часа (429 `report_limit`).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportRequest'
            example:
              gameId: 4100000000001
              reported: bob
              category: cheating
              comment: engine-like play
      responses:
        '202':
          description: Жалоба принята (или уже была подана).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: received
              example:
                status: received
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request` (без `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: это не соперник автора жалобы по партии, закончившейся за последние 7 дней.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`report_limit` (`retryAfter: 3600`, с заголовком `Retry-After`): за последние 24 часа уже подано `REPORTS_PER_DAY` жалоб. `rate_limited`: лимит `reports` или бюджет аккаунта.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (при поиске сеанса база данных оказалась заблокированной), `timeout`.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Официальный сервер (страницы находятся в корне, вне `/api/v1`).
      - url: https://{host}:{port}
        description: Любой сервер Scacelith (страницы находятся в корне, вне `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Публичное имя хоста сервера (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Публичный порт API (`PUBLIC_API_PORT`, а если он не задан — `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Показать страницу подтверждения адреса эл. почты
      description: |-
        Страница ссылки для подтверждения адреса эл. почты (при регистрации или из повторно отправленного письма с подтверждением). Она только показывает кнопку "Confirm my e-mail address" (подтвердить мой адрес эл. почты), чтобы почтовый сканер, открывший ссылку, не израсходовал её; кнопка отправляет форму на `POST /verify-email`.

        **Лимит:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Страница с кнопкой подтверждения.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ссылка недействительна или истекла (HTML-страница).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Лимит `page` (HTML-страница) или поадресный уровень (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Подтвердить адрес эл. почты
      description: |-
        Форма страницы подтверждения. Подтверждает адрес; для новой заявки на регистрацию заодно создаёт аккаунт, после чего игрок может войти.

        **Лимит:** `auth`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
          application/json:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Адрес подтверждён (а для новой заявки на регистрацию создан аккаунт).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ссылка недействительна, уже использована или истекла, либо форма некорректна (HTML-страница).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Ссылка новой заявки на регистрацию, имя пользователя или адрес которой за это время занял другой аккаунт; аккаунт не создаётся (HTML-страница).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Лимит `auth` (HTML-страница) или поадресный уровень (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'База данных так и осталась заблокированной (`Retry-After: 1`): ничего не изменено, и ссылка по-прежнему работает. Также истечение времени обработчика.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Официальный сервер (страницы находятся в корне, вне `/api/v1`).
      - url: https://{host}:{port}
        description: Любой сервер Scacelith (страницы находятся в корне, вне `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Публичное имя хоста сервера (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Публичный порт API (`PUBLIC_API_PORT`, а если он не задан — `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Показать форму сброса пароля
      description: |-
        Страница ссылки для сброса пароля: форма нового пароля (пароль вводится дважды). Форма отправляется на `POST /reset-password`.

        **Лимит:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Форма нового пароля.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ссылка недействительна (в том числе если она была отправлена на адрес, которого у аккаунта больше нет).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Лимит `page` (HTML-страница) или поадресный уровень (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Задать новый пароль из формы сброса
      description: |-
        Форма страницы сброса; делает то же, что и `POST /auth/password/reset`: сеансы на всех устройствах завершаются, ожидающая смена адреса эл. почты отменяется, остальные ссылки для сброса перестают работать, адрес считается подтверждённым, а владелец получает письмо.

        Сначала проверяется ссылка, затем совпадение двух паролей, затем правила для пароля. Если сервер занят, форма возвращается с `Retry-After`, а ссылка остаётся действительной.

        **Лимиты:** `auth`, затем `auth_reset` (общий с `POST /auth/password/reset`).
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/ResetPasswordForm'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
              confirmPassword: a much better passphrase
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordForm'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
              confirmPassword: a much better passphrase
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Пароль изменён, сеансы на всех устройствах завершены.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Снова форма с ошибкой (пароли не совпадают, слабый пароль), ссылка недействительна или форма некорректна (HTML-страница).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Лимит `auth` или `auth_reset` (HTML-страница), снова форма с `Retry-After`, если у этого клиента слишком много паролей ждут хеширования (ссылка остаётся действительной), или поадресный уровень (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: Снова форма с `Retry-After`, если сервер занят (очередь хеширования паролей или база данных так и осталась заблокированной); ссылка остаётся действительной. Также истечение времени обработчика.
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Официальный сервер (страницы находятся в корне, вне `/api/v1`).
      - url: https://{host}:{port}
        description: Любой сервер Scacelith (страницы находятся в корне, вне `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Публичное имя хоста сервера (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Публичный порт API (`PUBLIC_API_PORT`, а если он не задан — `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Показать страницу подтверждения смены адреса эл. почты
      description: |-
        Страница ссылки для смены адреса эл. почты: показывает новый адрес и имя аккаунта с кнопкой "Use this e-mail address" (использовать этот адрес эл. почты), которая отправляет форму на `POST /confirm-email-change`.

        **Лимит:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Страница с новым адресом и кнопкой подтверждения.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ссылка недействительна или истекла (в том числе если адрес аккаунта изменился после запроса).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Лимит `page` (HTML-страница) или поадресный уровень (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Подтвердить новый адрес эл. почты
      description: |-
        Форма страницы смены адреса эл. почты. Адрес меняется и считается подтверждённым; сеансы на устройствах сохраняются; отправленные ранее ссылки (подтверждение, сброс пароля, другие смены адреса) перестают работать; на прежний адрес приходит уведомление, в котором новый адрес замаскирован.

        **Лимит:** `auth`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: lZ50AvPxUwbGqQCwWPIO28ONray7SzW0Cl_sLImwF3Q
          application/json:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: lZ50AvPxUwbGqQCwWPIO28ONray7SzW0Cl_sLImwF3Q
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Адрес изменён.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ссылка недействительна, уже использована или истекла, либо форма некорректна (HTML-страница).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: За это время адрес занял другой аккаунт (HTML-страница).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Лимит `auth` (HTML-страница) или поадресный уровень (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'База данных так и осталась заблокированной (`Retry-After: 1`): ничего не изменено, и ссылка по-прежнему работает. Также истечение времени обработчика.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Официальный сервер, в корне.
      - url: https://caissa.scacelith.com/api/v1
        description: Официальный сервер, под `/api/v1`.
      - url: https://{host}:{port}
        description: Любой сервер Scacelith, в корне.
        variables:
          host:
            default: caissa.scacelith.com
            description: Публичное имя хоста сервера (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Публичный порт API (`PUBLIC_API_PORT`, а если он не задан — `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Любой сервер Scacelith, под `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Публичное имя хоста сервера (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Публичный порт API (`PUBLIC_API_PORT`, а если он не задан — `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Проверить, что процесс работает
      description: |-
        Отвечает 200 `ok`, пока процесс работает. `HEAD` тоже поддерживается. Любой другой метод (включая `OPTIONS`) получает ответ 405 `method_not_allowed` с `Allow: GET, HEAD`.

        **Лимиты:** только поадресный уровень.
      security: []
      responses:
        '200':
          description: Процесс работает.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ok
              example:
                status: ok
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: поадресный уровень.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Официальный сервер, в корне.
      - url: https://caissa.scacelith.com/api/v1
        description: Официальный сервер, под `/api/v1`.
      - url: https://{host}:{port}
        description: Любой сервер Scacelith, в корне.
        variables:
          host:
            default: caissa.scacelith.com
            description: Публичное имя хоста сервера (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Публичный порт API (`PUBLIC_API_PORT`, а если он не задан — `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Любой сервер Scacelith, под `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Публичное имя хоста сервера (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Публичный порт API (`PUBLIC_API_PORT`, а если он не задан — `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Проверить, что сервер принимает игроков
      description: |-
        Отвечает 200 `ready`, когда сервер принимает игроков, и 503 `not_ready`, пока он запускается или останавливается. `HEAD` тоже поддерживается. Любой другой метод (включая `OPTIONS`) получает ответ 405 `method_not_allowed` с `Allow: GET, HEAD`.

        **Лимиты:** только поадресный уровень.
      security: []
      responses:
        '200':
          description: Сервер принимает игроков.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: поадресный уровень.'
        '503':
          description: Сервер запускается или останавливается.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: not_ready
              example:
                status: not_ready
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sct_[A-Za-z0-9_-]{43}
      description: |-
        Токен сеанса (`sct_` и 43 символа base64url) из ответа на вход, в заголовке `Authorization`: `Authorization: Bearer <token>`. Префикс — ровно `Bearer` (с учётом регистра) и один пробел; на значение без него или на токен, который не состоит из 1–512 печатных символов ASCII, ответ — 401 `invalid_token`, как для любого другого недействительного токена. Пустой заголовок равносилен его отсутствию.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: Идентификатор партии — положительное десятичное целое число не длиннее 16 цифр без ведущих нулей, меньше 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: Идентификатор сеанса в том виде, в каком его возвращает `GET /auth/sessions`, без ведущих нулей (`03` — это не сеанс 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Имя пользователя игрока, без учёта регистра. Сервер принимает любое имя из 2–24 символов `[A-Za-z0-9_.-]` (самое широкое правило, которое он когда-либо допускал).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Идентификатор партии; выводятся только более старые партии. Передайте `next` предыдущей страницы. Пустое значение равносильно отсутствию параметра.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Размер страницы, от 1 до 50. Большее число длиной до 3 цифр считается равным 50; пустое значение равносильно отсутствию параметра.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Токен ссылки из письма (43 символа base64url). Если его нет или он неверен, показывается страница "link invalid or expired" (ссылка недействительна или истекла).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Каждый ответ сервера запрещает кеширование.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Сколько секунд подождать перед повторной попыткой; то же значение, что и `retryAfter` в теле.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Вызов аутентификации по схеме Bearer, с `error="invalid_token"` при недействительном токене.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: После этого ответа сервер закрывает соединение.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (с `field`, если ошибка в поле тела) или `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Поле нарушает схему
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Тело не является JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Слабый пароль
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: Нечитаемый PGN
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (нет заголовка `Authorization`) или `invalid_token` (токен имеет неверный формат, истёк, отозван или принадлежит удалённому аккаунту).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Нет сеанса
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Недействительный токен
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: Запрос отклонён.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Неверный пароль при повторной аутентификации
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Заблокированный аккаунт
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found` или функция, отключённая на этом сервере.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: not_found
            message: No such game.
    RequestTimeout:
      description: '`request_timeout`: тело не пришло в течение 10 секунд. Сервер закрывает соединение.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: request_timeout
            message: The request body took too long.
    Conflict:
      description: Запрос противоречит состоянию сервера.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: username_taken
            message: This username is already taken.
    Gone:
      description: '`sso_expired`: попытка или билет входа через Google неизвестны, уже использованы или истекли; начните заново из игры.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: sso_expired
            message: This sign-in has expired; start again from Scacelith.
    PayloadTooLarge:
      description: '`payload_too_large`: тело превышает `HTTP_BODY_LIMIT` (по умолчанию 16 384 байта). Сервер закрывает соединение.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: payload_too_large
            message: The body must not exceed 16384 bytes.
    UriTooLong:
      description: '`uri_too_long`: цель запроса длиннее 4096 символов.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: uri_too_long
            message: The URL is too long.
    UnsupportedMediaType:
      description: '`unsupported_media_type`: тело не в формате `application/json`, или его кодировка не UTF-8.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: unsupported_media_type
            message: Content-Type must be application/json.
    UnprocessableContent:
      description: '`game_too_long`: в партии больше `GIF_MAX_PLIES` (600) полуходов.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: game_too_long
            message: The game is too long for a GIF (742 plies, at most 600).
    PowRequired:
      description: '`pow_required`: решите задачу `pow` и отправьте тот же запрос снова с `pow: { challenge, nonce }`. `reason` сообщает причину: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` или `replayed`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: pow_required
            message: Proof of work required.
            reason: required
            pow:
              challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
              bits: 18
              expiresAt: 1790882991200
    TooManyRequests:
      description: '`rate_limited` (лимит частоты запросов, бюджет аккаунта или очередь хеширования паролей) или `too_many_attempts` (ограничение по аккаунту), с `retryAfter` и заголовком `Retry-After`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rateLimited:
              summary: Лимит частоты запросов
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Слишком много неудач для этого аккаунта
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: непредвиденный сбой. Сервер записывает его в журнал.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: internal_error
            message: Internal server error.
    BadGateway:
      description: '`sso_failed`: неверный `state` или `iss`, либо Google отклонил код или прислал ID-токен, который не проходит проверку. Текст от Google в ответ никогда не включается.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: sso_failed
            message: Google sign-in could not be completed.
    ServiceUnavailable:
      description: '`server_busy`, `busy` или `timeout`: повторите попытку через `retryAfter`, если он указан.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Сервер занят
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: База данных так и осталась заблокированной (чтение)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Сервер отвечал слишком долго
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: Анимированный GIF в виде вложения.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` для партии этого сервера, `attachment; filename="scacelith-game.gif"` для PGN, присланного через `POST /gif`.'
          schema:
            type: string
            pattern: '^attachment; filename="scacelith-([1-9][0-9]{0,15}|game)\.gif"$'
          example: attachment; filename="scacelith-4100000000001.gif"
        Content-Length:
          description: Размер файла в байтах.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: HTML-страница.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: HTML-страница с объяснением отказа.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Форма не пришла в течение 10 секунд (HTML-страница). Сервер закрывает соединение.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: HTML-страница с объяснением конфликта.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Форма превышает `HTTP_BODY_LIMIT` (HTML-страница). Сервер закрывает соединение.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Тело не является ни формой (`application/x-www-form-urlencoded`), ни JSON, или его кодировка не UTF-8 (HTML-страница).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Лимит частоты запросов (HTML-страница), с `Retry-After`.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlInternalError:
      description: Непредвиденный сбой (HTML-страница с заголовком "Server error" (ошибка сервера)). Сервер записывает его в журнал.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Сервер занят (база данных так и осталась заблокированной, с `Retry-After`) или отвечал слишком долго (HTML-страница с заголовком "Server error" (ошибка сервера)).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
  schemas:
    Error:
      type: object
      description: Единый формат всех ответов JSON API с ошибкой.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: Код ошибки в `snake_case`. По нему клиент выбирает, что показать.
          examples:
            - rate_limited
        message:
          type: string
          description: Предложение на английском языке — для журналов и как запасной вариант.
        retryAfter:
          type: integer
          minimum: 1
          description: Сколько секунд подождать перед повторной попыткой; есть только в отказах, которые снимаются со временем. Тогда ответ содержит также заголовок `Retry-After` с тем же значением — кроме 503 `busy` при чтении истории, партий, игроков и таблицы лидеров.
        field:
          type: string
          description: Поле или параметр запроса, в котором ошибка (`invalid_request`, `invalid_option`, а также `invalid_cursor`, `invalid_limit` и `invalid_filter` у `GET /account/games`). Вложенное поле указывается через точку (`pow.nonce`).
        reason:
          type: string
          description: 'Причина: правило, которое нарушает `weak_password`, или почему доказательство работы потребовалось либо было отклонено (`pow_required`).'
          enum:
            - too_short
            - too_long
            - contains_username
            - contains_email
            - too_common
            - required
            - malformed
            - signature
            - endpoint
            - network
            - expired
            - bits
            - work
            - replayed
        pow:
          $ref: '#/components/schemas/PowChallenge'
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: 'Только для `banned`: окончание блокировки (мс от начала эпохи), `null` при бессрочной блокировке.'
        line:
          type: integer
          minimum: 1
          description: 'Только для `invalid_pgn`: строка с ошибкой, начиная с 1.'
        column:
          type: integer
          minimum: 1
          description: 'Только для `invalid_pgn`: столбец с ошибкой, начиная с 1, в символах.'
    PowChallenge:
      type: object
      description: Задача доказательства работы (`pow` в ответе 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: Подписанная задача, которую нужно вернуть как есть.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Число ведущих нулевых битов, которое должно быть у `SHA-256(challenge + ":" + nonce)`.
        expiresAt:
          type: integer
          format: int64
          description: Когда истекает задача (мс от начала эпохи), — через 2 минуты после выдачи.
    PowAnswer:
      type: object
      description: Решение задачи доказательства работы.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: '`challenge` из ответа 428 в том виде, в каком он был получен.'
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Десятичная строка не длиннее 20 цифр, такая, что `SHA-256(challenge + ":" + nonce)` начинается с `bits` нулевых битов.
    ServerInfo:
      type: object
      description: Всё, что нужно клиенту до входа или подключения.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Имя сервера (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: UUID, который база данных получает при первом запуске. Он не меняется при перезапусках. Равен `null`, если база данных не может его выдать.
        motd:
          type: string
          description: Сообщение дня (`SERVER_MOTD`), может быть пустым.
        protocol:
          type: object
          description: Протокол WebSocket (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: Самая старая версия протокола, которую поддерживает сервер.
            max:
              type: integer
              minimum: 1
              description: Самая новая версия протокола, которую поддерживает сервер.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: Отпечаток схемы протокола (первые 4 байта SHA-256 её канонической формы как беззнаковое целое); справочное значение.
            subprotocol:
              type: string
              description: Токен подпротокола WebSocket (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: Порт WebSocket, которым пользуются игроки (`PUBLIC_WS_PORT`, иначе `WS_PORT`, иначе `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Путь WebSocket.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Можно ли создавать новые аккаунты.
        emailVerification:
          type: boolean
          description: Подтверждают ли новые аккаунты свой адрес (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
          properties:
            google:
              type: boolean
              description: Доступен ли вход через Google.
        mfa:
          type: boolean
          const: true
          description: Двухфакторная аутентификация доступна всегда.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Сколько битов доказательства работы требует регистрация (0 — не требует).
        categories:
          type: array
          description: Официальные (рейтинговые) контроли времени (`RATED_CATEGORIES`) в порядке, заданном сервером. Любой другой контроль времени — `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Правила, которые клиент может проверить до отправки формы.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: Минимальная длина имени пользователя (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: Максимальная длина имени пользователя (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: Регулярное выражение, которому должно соответствовать новое имя пользователя.
            passwordMinLength:
              type: integer
              description: Минимальная длина пароля в символах (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: Максимальная длина пароля в байтах UTF-8.
            customTimeControls:
              type: boolean
              description: Можно ли использовать нестандартные контроли времени в вызовах и частных партиях.
            reportsPerDay:
              type: integer
              description: Сколько жалоб игрок может подать за 24 часа (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: Максимальный размер сообщения WebSocket, которое может отправить клиент, в байтах.
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 97842216
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: Официальный контроль времени.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 'Идентификатор категории: минуты и секунды добавления (`3+2`).'
        baseSec:
          type: number
          minimum: 0
          description: Основное время в секундах.
        incSec:
          type: number
          minimum: 0
          description: Добавление за ход в секундах.
    AccountView:
      type: object
      description: Аккаунт таким, каким его видит сам игрок (`user` в ответах на вход и в `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: Идентификатор аккаунта.
        username:
          type: string
          description: Имя пользователя.
        email:
          type: string
          format: email
          description: Адрес эл. почты.
        emailVerified:
          type: boolean
          description: Подтверждён ли адрес.
        mfaEnabled:
          type: boolean
          description: Включена ли двухфакторная аутентификация.
        googleLinked:
          type: boolean
          description: Привязан ли аккаунт Google.
        hasPassword:
          type: boolean
          description: '`false` для аккаунта, созданного через Google, у которого ещё не задан пароль.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Принимаются ли прямые вызовы по имени (см. `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Когда создан аккаунт (мс от начала эпохи).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Последний вход (мс от начала эпохи) или `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Новый адрес при смене адреса эл. почты, ожидающей перехода по ссылке, или `null`.
    SessionAnswer:
      type: object
      description: Новый сеанс.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Токен сеанса — для заголовка `Authorization` и для WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: Абсолютный срок окончания сеанса (мс от начала эпохи); лимит бездействия может завершить его раньше.
        user:
          $ref: '#/components/schemas/AccountView'
      example:
        token: sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
        expiresAt: 1798658839708
        user:
          id: 1
          username: alice
          email: alice@example.org
          emailVerified: true
          mfaEnabled: true
          googleLinked: false
          hasPassword: true
          acceptChallenges: all
          createdAt: 1790882839743
          lastLoginAt: 1790882839708
          pendingEmail: null
    MfaChallenge:
      type: object
      description: Второй шаг входа с двухфакторной аутентификацией; продолжение — `POST /auth/login/mfa`.
      required:
        - mfaRequired
        - mfaToken
        - expiresIn
      properties:
        mfaRequired:
          type: boolean
          const: true
        mfaToken:
          type: string
          pattern: '^mfa_[A-Za-z0-9_-]{43}$'
          description: Токен шага для `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Сколько секунд осталось на завершение шага.
    LoginAnswer:
      description: Сеанс или второй шаг входа с двухфакторной аутентификацией.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Первый вход через Google; продолжение — `POST /auth/sso/complete`.
      required:
        - needsUsername
        - ssoTicket
        - suggestedUsername
      properties:
        needsUsername:
          type: boolean
          const: true
        ssoTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Билет для `POST /auth/sso/complete`, действителен 10 минут.
        suggestedUsername:
          type: string
          description: Имя пользователя, составленное из имени в Google или из адреса, либо `""`, если ничего не подходит.
    SsoNeedsPassword:
      type: object
      description: Адрес использует аккаунт с паролем; продолжение — `POST /auth/sso/google/link`. Пока ничего не привязано.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Билет для `POST /auth/sso/google/link`.
        username:
          type: string
          description: Имя пользователя этого аккаунта (его видит только тот, кто подтвердил владение адресом в Google).
        expiresIn:
          type: integer
          const: 600
          description: Сколько секунд билет остаётся действительным.
    SsoFinishAnswer:
      description: Ответ `POST /auth/sso/google/finish`.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
        - $ref: '#/components/schemas/SsoNeedsUsername'
        - $ref: '#/components/schemas/SsoNeedsPassword'
    SsoStartAnswer:
      type: object
      description: Попытка входа через Google.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Попытка — для `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: URL Google, который нужно открыть в системном браузере после проверки.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state`, который Google должен вернуть.'
        expiresIn:
          type: integer
          const: 600
          description: Сколько секунд попытка остаётся действительной.
    VerificationSentStatus:
      type: object
      required:
        - status
      properties:
        status:
          const: verification_sent
    AcceptedStatus:
      type: object
      required:
        - status
      properties:
        status:
          const: accepted
    LoggedOutStatus:
      type: object
      required:
        - status
      properties:
        status:
          const: logged_out
    RegisterRequest:
      type: object
      additionalProperties: false
      required:
        - username
        - email
        - password
      properties:
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Имя пользователя; действуют правила сервера (см. описание).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Адрес эл. почты. Пробелы по краям обрезаются, адрес хранится в нижнем регистре.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Пароль; действуют правила для паролей.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Имя пользователя или адрес эл. почты (любой текст с `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Необязательно. Показывается в списке активных сеансов (например, `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: '`mfaToken` из ответа на вход (или из ответа при входе через Google).'
        code:
          type: string
          maxLength: 32
          description: Необязательно. 6-значный код из приложения-аутентификатора или код восстановления.
        recoveryCode:
          type: string
          maxLength: 32
          description: Необязательно. Код восстановления. Нужен один из `code` и `recoveryCode`.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Адрес эл. почты.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Параметр `token` из ссылки для сброса.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Новый пароль; действуют правила для паролей, принятые при регистрации.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Challenge PKCE (S256) от `codeVerifier` клиента (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: Порт слушающего сокета клиента на `127.0.0.1`.
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: '`attemptId` из ответа `POST /auth/sso/google/start`.'
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: Code verifier PKCE; его SHA-256 должен совпадать с challenge, переданным в `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state` в том виде, в каком его вернул Google.'
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: '`code` в том виде, в каком его вернул Google (печатные символы ASCII без пробелов).'
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Необязательно. `iss` в том виде, в каком его вернул Google, если он его вернул.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`linkTicket` из ответа `finish`, действителен 10 минут.'
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Пароль аккаунта.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    SsoCompleteRequest:
      type: object
      additionalProperties: false
      required:
        - ssoTicket
        - username
      properties:
        ssoTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`ssoTicket` из ответа `finish`, действителен 10 минут.'
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Имя пользователя; действуют правила регистрации.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Активные сеансы, начиная с последнего использованного.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Активный сеанс (устройство, на котором выполнен вход).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: Идентификатор сеанса для `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: Время входа (мс от начала эпохи).
        lastSeenAt:
          type: integer
          format: int64
          description: Последнее использование (мс от начала эпохи); обновляется не чаще раза в 5 минут.
        expiresAt:
          type: integer
          format: int64
          description: Абсолютный срок окончания (мс от начала эпохи); лимит бездействия может завершить сеанс раньше.
        clientLabel:
          type:
            - string
            - 'null'
          description: '`clientLabel`, переданный при входе, или `null`, если его не было.'
        current:
          type: boolean
          description: Является ли этот сеанс тем, от которого пришёл запрос.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: По одной записи на каждую категорию, в которой игрок играл рейтинговые партии.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Действующие санкции.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }`, пока действует блокировка, иначе `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Запись рейтинга по одной категории.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: Идентификатор категории.
        rating:
          type: integer
          description: Рейтинг.
        games:
          type: integer
          minimum: 0
          description: Партии, сыгранные в категории (учтённые в рейтинге или нет).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Наивысший достигнутый рейтинг.
        provisional:
          type: boolean
          description: '`true`, пока рейтинг ещё не установлен или в нём учтено меньше `PROVISIONAL_GAMES` партий (игра показывает такой рейтинг как `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Вид санкции.
        reason:
          type:
            - string
            - 'null'
          description: Указанная причина, если она есть.
        startsAt:
          type: integer
          format: int64
          description: Начало (мс от начала эпохи).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Окончание (мс от начала эпохи), `null` для бессрочной санкции.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Окончание блокировки (мс от начала эпохи), `null` для бессрочной.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` — принимать прямые вызовы по имени, `none` — отклонять их.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Текущий пароль.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Новый пароль; действуют правила для паролей, принятые при регистрации.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Пароль аккаунта.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Код, сгенерированный по ожидающему секрету, ровно 6 цифр.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Пароль аккаунта.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Необязательно; нужен при двухфакторной аутентификации (или `recoveryCode`). Код из приложения-аутентификатора или код восстановления.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Необязательно. Код восстановления (`xxxx-xxxx-xx`; регистр, пробелы и дефисы не важны).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Пароль аккаунта.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Код из приложения-аутентификатора (код восстановления не принимается).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Новый адрес; пробелы по краям обрезаются, адрес приводится к нижнему регистру и затем проверяется так же, как при регистрации.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Пароль аккаунта.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Необязательно; нужен при двухфакторной аутентификации (или `recoveryCode`). Код из приложения-аутентификатора или код восстановления.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Необязательно. Код восстановления.
    TotpSetup:
      type: object
      description: Ожидающий секрет для приложения-аутентификатора.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Секрет в base32 для ввода в приложение.
        uri:
          type: string
          format: uri
          description: URI `otpauth://totp/...`, который показывают как QR-код.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Длительность шага в секундах.
    RecoveryCodeList:
      type: array
      description: 10 кодов восстановления, которые показываются только этот единственный раз. Каждый срабатывает один раз.
      minItems: 10
      maxItems: 10
      items:
        type: string
        pattern: '^[0-9a-hjkmnp-tv-z]{4}-[0-9a-hjkmnp-tv-z]{4}-[0-9a-hjkmnp-tv-z]{2}$'
    PlayerSide:
      type: object
      description: Одна из сторон партии.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Имя в записи партии (`deleted#<id>` для удалённого аккаунта).
        rating:
          type:
            - integer
            - 'null'
          description: Рейтинг на начало партии, `null`, если он неизвестен.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Рейтинг после партии. У рейтинговой партии он есть всегда; `null` только для партии, которая не учитывается в рейтингах (товарищеская, с нестандартным контролем времени, отменённая).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: Изменение рейтинга; `0`, если по правилам рейтинга он остался прежним (правило FIDE о нулевом результате, соперник без установленного рейтинга, первые партии игрока без установленного рейтинга); `null` — в тех же случаях, что и у `ratingAfter`.
    GameSummary:
      type: object
      description: Краткие сведения о сохранённой партии.
      required:
        - id
        - category
        - rated
        - timeControl
        - white
        - black
        - status
        - reason
        - result
        - termination
        - plies
        - startedAt
        - endedAt
      properties:
        id:
          type: integer
          format: int64
          minimum: 1
          maximum: 9007199254740991
          description: Идентификатор партии.
        category:
          type: string
          description: Идентификатор официальной категории (`3+2`) или `custom`.
        rated:
          type: boolean
          description: Учитывается ли партия в рейтингах.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Контроль времени в секундах (`180+2`).
        white:
          $ref: '#/components/schemas/PlayerSide'
        black:
          $ref: '#/components/schemas/PlayerSide'
        status:
          type: integer
          enum:
            - 1
            - 2
            - 3
            - 4
          description: '1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (см. коды партий).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Код причины окончания партии (см. коды партий).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Результат; `*` для отменённой партии.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Число полуходов.
        startedAt:
          type: integer
          format: int64
          description: Начало (мс от начала эпохи).
        endedAt:
          type: integer
          format: int64
          description: Окончание (мс от начала эпохи).
    Termination:
      type: string
      description: Название причины окончания партии (см. коды партий); `Unknown` для кода, неизвестного протоколу.
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: Краткие сведения о партии с точки зрения одного игрока.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Цвет игрока.
    HistoryGameSummary:
      description: Партия из истории игрока, выполнившего вход.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Основное время в миллисекундах.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Добавление в миллисекундах.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: Исход партии для этого игрока.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Партии страницы, начиная с самых новых.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Идентификатор, который нужно передать в `before` для следующей страницы, или `null` на последней странице.
        total:
          type: integer
          minimum: 0
          description: Число партий, подходящих под фильтр, на всех страницах.
    MoveRecord:
      type: object
      description: Один полуход партии.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: Ход в нотации UCI; при превращении пешки добавляется `n`, `b`, `r` или `q`.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Время, затраченное на ход (мс), `null`, если в записи его нет.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Показание часов сходившего игрока после хода (мс), `null`, если в записи его нет.
    PgnTagsView:
      type: object
      description: Основные теги PGN для отображения. `Termination` здесь — название причины окончания партии; в файле PGN используются стандартные значения PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` или `<SERVER_NAME> casual <category>`.'
        Site:
          type: string
          description: '`SERVER_PUBLIC_HOST`.'
        Date:
          type: string
          pattern: '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$'
          description: Дата начала по UTC.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: Рейтинг на начало партии или `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Рейтинг на начало партии или `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        TimeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
        Termination:
          $ref: '#/components/schemas/Termination'
        PlyCount:
          type: integer
          minimum: 0
    GameRecord:
      description: Запись партии с ходами и показаниями часов. Содержит поля кратких сведений о партии, кроме `color` и `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Основное время в миллисекундах.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Добавление в миллисекундах.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: Название `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: Идентификатор партии, реваншем которой является эта, или `null`.
            moves:
              type: array
              description: По одной записи на каждый полуход.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: 'Только если владелец токена играл в этой партии: его цвет.'
            reportable:
              type: boolean
              description: 'Только если владелец токена играл в этой партии: `true`, если `POST /reports` сейчас принял бы жалобу на соперника по этой партии (партия закончилась не более 7 дней назад, суточная квота не исчерпана, и на соперника ещё не подана жалоба по этой партии).'
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Имя пользователя в том написании, которое выбрал игрок.
        createdAt:
          type: integer
          format: int64
          description: Когда создан аккаунт (мс от начала эпохи).
        ratings:
          type: array
          description: Только официальные категории, в порядке, заданном сервером.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Все сохранённые партии, включая товарищеские и отменённые.
            rated:
              type: integer
              minimum: 0
              description: Рейтинговые партии, просуммированные по записям рейтинга.
            wins:
              type: integer
              minimum: 0
            draws:
              type: integer
              minimum: 0
            losses:
              type: integer
              minimum: 0
    PlayerGamesPage:
      type: object
      required:
        - username
        - games
        - next
      properties:
        username:
          type: string
          description: Имя пользователя в том написании, которое выбрал игрок.
        games:
          type: array
          description: Партии страницы, начиная с самых новых.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Идентификатор последней партии, если страница заполнена целиком (тогда следующая страница может оказаться пустой), иначе `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: Идентификатор категории.
        minGames:
          type: integer
          minimum: 0
          description: Сколько учтённых партий нужно записи, чтобы попасть в таблицу (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Когда была рассчитана таблица (мс от начала эпохи).
        players:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LeaderboardEntry'
    LeaderboardEntry:
      type: object
      required:
        - rank
        - username
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
      properties:
        rank:
          type: integer
          minimum: 1
        username:
          type: string
        rating:
          type: integer
        games:
          type: integer
          minimum: 0
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
    GifRequest:
      type: object
      additionalProperties: false
      required:
        - pgn
      properties:
        pgn:
          type: string
          description: Текст PGN, не более 65 536 байт в UTF-8. Используется только первая партия.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Необязательно. Размер изображения.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Необязательно. Сторона, которая находится внизу доски.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Необязательно. Миллисекунды на ход — число JSON с целым значением.
        coords:
          type: boolean
          default: true
          description: Необязательно. Рисовать ли вокруг доски буквы вертикалей и цифры горизонталей.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: Партия — целое число или строка из 1–16 цифр.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: Имя пользователя соперника — как в записи партии или текущее, без учёта регистра. Не может быть пустым.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: Предмет жалобы.
        comment:
          type:
            - string
            - 'null'
          description: Необязательно. Не более 500 символов после удаления управляющих символов (кроме табуляции и перевода строки) и обрезки пробелов по краям.
    AccountExport:
      type: object
      description: Всё, что сервер хранит об аккаунте (время — в мс от начала эпохи).
      required:
        - format
        - version
        - exportedAt
        - server
        - notes
        - account
        - ratings
        - ratingRefunds
        - sessions
        - securityEvents
        - sanctions
        - conduct
        - reportsFiled
        - games
      properties:
        format:
          type: string
          const: scacelith-account-export
        version:
          type: integer
          const: 1
        exportedAt:
          type: integer
          format: int64
          description: Когда был сделан экспорт.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Что входит в файл и что в него не входит — простым английским языком для игрока.
          items:
            type: string
        account:
          description: Данные аккаунта из `GET /account/me` с добавлением `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: Адрес привязанного аккаунта Google или `null`.
        ratings:
          type: array
          description: Полные записи рейтинга.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Пункты рейтинга, возвращённые после того, как соперник был уличён в нечестной игре, суммированные по дням (UTC) и категориям, начиная с самых новых. Ни партии, ни нарушители не называются.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Все сохранённые сеансы, начиная с самых новых, без каких-либо токенов. Очистка по сроку хранения удаляет истёкший сеанс, а завершённый — через сутки после выхода.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: События безопасности, начиная с самых новых; хранятся `RETENTION_SECURITY_DAYS` дней.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Все санкции, включая снятые. Имя модератора никогда не указывается.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: События поведения, записанные для покинутых и отменённых партий игрока, а также партий, где он не сделал первый ход вовремя; хранятся 30 дней.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Жалобы, поданные игроком.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Число партий.
            list:
              type: array
              description: Все партии, начиная с самых новых, в виде кратких сведений, как в `GET /account/games`. Ходы доступны через `GET /games/{id}`, а PGN — через `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Полная запись рейтинга.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Вышел ли игрок из фазы, когда рейтинг ещё не установлен.
            countedGames:
              type: integer
              minimum: 0
              description: Партии, учтённые в рейтинге.
            updatedAt:
              type: integer
              format: int64
              description: Когда запись менялась в последний раз.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: 00:00 UTC этого дня (мс от начала эпохи).
        category:
          type: string
        points:
          type: integer
          description: Пункты, возвращённые в этот день в этой категории.
    ExportSession:
      type: object
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - revokedAt
        - clientLabel
        - ip
      properties:
        id:
          type: integer
          format: int64
        createdAt:
          type: integer
          format: int64
        lastSeenAt:
          type: integer
          format: int64
        expiresAt:
          type: integer
          format: int64
        revokedAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Когда сеанс был завершён, или `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: Адрес, с которого выполнен вход; стирается через `RETENTION_IP_DAYS` дней.
    SecurityEvent:
      type: object
      description: |-
        Событие безопасности. `ip` указывается только для действий, совершённых в аккаунте после входа, с его паролем (и вторым фактором) или по ссылке, отправленной на его адрес: `register`, `email_verified`, `login`, `sso_login`, `sso_linked`, `sso_account_created`, `recovery_code_used`, `password_reset`, `password_changed`, `reauth_failed`, `mfa_setup_started`, `mfa_enabled`, `mfa_disabled`, `recovery_codes_regenerated`, `session_revoked`, `sessions_revoked_all`, `email_change_requested`, `email_changed`, `email_change_refused` и `account_exported`; у всех остальных видов `ip: null`, а `ip` стирается через `RETENTION_IP_DAYS` дней.

        `detail` сохраняет только эти поля: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` и `method` (`password` или `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` и `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. Событие `moderator_action` сохраняет только `{ action }` — для действий `ban`, `unban`, `reset_mfa`, `verify_email` и `revoke_sessions` (остальные действия модераторов не включаются). У всех остальных видов `detail: null`. События `rating_refund` не включаются: их пункты находятся в `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Вид события (`login`, `password_changed`...).
        at:
          type: integer
          format: int64
          description: Когда оно произошло.
        ip:
          type:
            - string
            - 'null'
        detail:
          type:
            - object
            - 'null'
    ExportSanction:
      type: object
      required:
        - id
        - kind
        - reason
        - source
        - gameId
        - startsAt
        - endsAt
        - createdAt
        - liftedAt
      properties:
        id:
          type: integer
          format: int64
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
        reason:
          type:
            - string
            - 'null'
        source:
          type: string
          enum:
            - auto
            - moderator
        gameId:
          type:
            - integer
            - 'null'
          format: int64
        startsAt:
          type: integer
          format: int64
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
        createdAt:
          type: integer
          format: int64
        liftedAt:
          type:
            - integer
            - 'null'
          format: int64
    ConductEvent:
      type: object
      required:
        - kind
        - at
      properties:
        kind:
          type: string
          enum:
            - abandon
            - abort
            - noshow
        at:
          type: integer
          format: int64
    FiledReport:
      type: object
      required:
        - gameId
        - reported
        - category
        - comment
        - createdAt
        - status
      properties:
        gameId:
          type:
            - integer
            - 'null'
          format: int64
        reported:
          type: string
          description: Текущее публичное имя игрока, на которого подана жалоба.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Открыта ли ещё жалоба (получил ли санкции игрок, на которого она подана, не сообщается).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Токен ссылки из письма (скрытое поле формы на странице).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Токен ссылки для сброса (скрытое поле формы).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Новый пароль; действуют правила для паролей, принятые при регистрации.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Новый пароль ещё раз; он должен совпадать.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: HTML-страница в UTF-8 (`text/html; charset=utf-8`), без JavaScript и внешних ресурсов.
