openapi: 3.1.1
info:
  title: HTTPS API сервера Scacelith
  version: "0.9.0"
  summary: HTTPS API кожного сервера Scacelith — як офіційного, так і серверів спільноти.
  description: |-
    Кожен сервер Scacelith — і офіційний `caissa.scacelith.com`, і будь-який сервер спільноти — надає цей HTTPS API. Гра використовує його для всього, що відбувається поза самою партією: реєстрації та входу (зокрема з двофакторною автентифікацією та через 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, міжсайтовий запис потребував би попереднього запиту CORS, а такий запит не проходить.

    ## Відповіді та помилки

    - Відповіді — це JSON в UTF-8, окрім завантаження PGN (`application/x-chess-pgn`), анімованих GIF (`image/gif`) та HTML-сторінок. Час подано в мілісекундах від 1970-01-01 UTC. Ідентифікатори — цілі числа. Ідентифікатори партій мають до 16 цифр, але залишаються меншими за 2^53, тож число JSON (число з рухомою комою подвійної точності) зберігає їх точно.
    - Кожна відповідь 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`); деякі ліміти також рахують кожну мережу IPv6 /48 загалом, на додачу до кожної з її мереж /64. **Гравець:** обліковий запис, у який виконано вхід, незалежно від адреси; ліміт, що рахується на гравця, на ендпоінті з необов’язковим сеансом для запиту без токена рахується на клієнта.

    Кожен ліміт — це відро токенів, яке вміщує `limit` запитів і безперервно поповнюється зі швидкістю `limit / window`; `retryAfter` — час до появи наступного токена. Ліміти з позначкою *спільний* також рахуються в ковзному вікні тієї самої тривалості, щоб жодне вікно не вміщувало помітно більше ніж `limit` запитів. Кожен ліміт рахується для всього сервера. Коли один із лімітів ендпоінта відхиляє запит, токени, які інші його ліміти забрали для цього запиту, повертаються. Відмова — це 429 `rate_limited` з `retryAfter` і `Retry-After`.

    - **Рівень обмежень за адресою.** Кожен запит (будь-який шлях і метод, зокрема ендпоінти перевірки стану й запити на перехід до WebSocket) спершу забирає один токен із бюджету свого клієнта: `HTTP_RATE_PER_IP` (600) за хвилину із запасом на пів хвилини та `HTTP_RATE_PER_PREFIX` (4 × `HTTP_RATE_PER_IP`) для мережі IPv6 /48. Клієнт також може мати щонайбільше `IP_MAX_INFLIGHT` (32 × `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 × `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` завжди вимагає доказу роботи, коли `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. Знайдіть одноразове число: десятковий рядок не довше 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 (клієнт Google типу «застосунок для комп’ютера»): 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` із викликом 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-адресу, яку інший сервер отримав для своїх гравців. Походження — це хост у нижньому регістрі (літерал 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` і 43-символьний `code_challenge`. Інакше гра зупиняє свого слухача й нічого не відкриває. Запити `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 створюється в окремому потоці рендерингу, ніколи не в потоках, які обслуговують партії, і з найнижчим пріоритетом процесора: `GIF_THREADS` потоків (типово `WORKERS`), які запускаються з першим GIF і зупиняються після хвилини без жодного. До `GIF_QUEUE_MAX` (4 × `WORKERS`) GIF чекають на потік, кожен щонайбільше `GIF_QUEUE_TIMEOUT_MS` (10 с); рендеринг може тривати `GIF_RENDER_TIMEOUT_MS` (30 с). Сервер зберігає створені GIF у кеші розміром `GIF_CACHE_MB` МБ (32 × `WORKERS`), першими витісняються ті, що найдовше не використовувалися; 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 × 350 пікселів), `medium` (48 пкс: 424 × 515), `large` (72 пкс: 628 × 762); без координат — 268 × 342, 400 × 503 і 600 × 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` (мс Unix-часу, `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 для виклику 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 власного верифікатора сервера для Google) з `code_challenge_method=S256` і `prompt=select_account`. Гра перевіряє його, перш ніж відкрити (див. опис групи «Вхід через Google»).

        **Ліміт:** `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 надіслав слухачеві гри, разом з ідентифікатором спроби та верифікатором PKCE. Ідентифікатор спроби марний без верифікатора, а код марний без власного верифікатора PKCE сервера та його секрету клієнта.

        Сервер перевіряє спробу (невідома, використана або прострочена: 410), потім верифікатор (неправильний верифікатор залишає спробу придатною до використання), потім одноразово використовує спробу, перевіряє `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`: верифікатор не відповідає виклику PKCE спроби (спроба залишається придатною). `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»). **Обмеження часу:** `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` від читача PGN: неможливий або неоднозначний хід, пошкоджений тег, невідомий різновид шахів, понад 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`): `REPORTS_PER_DAY` скарг за останні 24 години. `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`: кінець блокування (мс Unix-часу), `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: 'Коли спливає термін дії завдання (мс Unix-часу): через 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: Коли створено обліковий запис (мс Unix-часу).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Останній вхід (мс Unix-часу) або `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: Абсолютний кінець сеансу (мс Unix-часу); межа бездіяльності може завершити його раніше.
        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: Виклик 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: Верифікатор PKCE; його SHA-256 має відповідати виклику, переданому в `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: Час входу (мс Unix-часу).
        lastSeenAt:
          type: integer
          format: int64
          description: Останнє використання (мс Unix-часу); оновлюється не частіше ніж раз на 5 хвилин.
        expiresAt:
          type: integer
          format: int64
          description: Абсолютний кінець (мс Unix-часу); межа бездіяльності може завершити сеанс раніше.
        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: Початок (мс Unix-часу).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Кінець (мс Unix-часу); `null`, якщо санкція безстрокова.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Кінець блокування (мс Unix-часу); `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: Початок (мс Unix-часу).
        endedAt:
          type: integer
          format: int64
          description: Кінець (мс Unix-часу).
    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: Коли створено обліковий запис (мс Unix-часу).
        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: Коли таблицю було обчислено (мс Unix-часу).
        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: Усе, що сервер зберігає про обліковий запис (час — у мс Unix-часу).
      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 цього дня (мс Unix-часу).
        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 і зовнішніх ресурсів.
