openapi: 3.1.1
info:
  title: API HTTPS serwera Scacelith
  version: "1.1.0"
  summary: API HTTPS każdego serwera Scacelith, zarówno oficjalnego, jak i serwerów społecznościowych.
  description: |-
    Każdy serwer Scacelith, zarówno oficjalny `caissa.scacelith.com`, jak i każdy serwer społecznościowy, odpowiada na to API HTTPS. Gra używa go do wszystkiego, co nie jest samą partią: rejestracji i logowania (w tym weryfikacji dwuetapowej i logowania przez Google), strony konta, historii partii, pobierania plików PGN i animowanych GIF-ów partii, zalogowanych urządzeń, pobierania danych, usuwania konta i zgłoszeń.

    Gra na żywo (dobieranie przeciwników, wyzwania, ruchy, zegary) odbywa się przez WebSocket tego samego serwera (`wss://<host>/ws`), otwierany z tokenem sesji tego API; opisuje ją `PROTOCOL.md`, nie ten dokument.

    Podane niżej wartości domyślne dotyczą serwera z nienaruszoną konfiguracją; serwer społecznościowy może je zmienić (`CONFIG.md` wymienia każde ustawienie).

    ## Bazowy URL, port i transport

    - Serwer oficjalny: `https://caissa.scacelith.com/api/v1`, port TCP 443.
    - Serwer społecznościowy: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` wynosi domyślnie 443; za NAT-em lub proxy port, którego używają gracze, to `PUBLIC_API_PORT`.
    - WebSocket gry działa domyślnie na tym samym porcie (`WS_PORT` go przenosi; `GET /info` mówi klientowi, gdzie się znajduje).
    - HTTP/1.1 przez TLS. Przy `TLS_MODE=proxy` połączenie TLS kończy się na odwrotnym proxy (reverse proxy) przed serwerem. `TLS_MODE=off` (zwykły HTTP) służy wyłącznie do programowania lokalnego.
    - Strony HTML otwierane z linków w e-mailach (`/verify-email`, `/reset-password`, `/confirm-email-change`) znajdują się w katalogu głównym serwera, poza `/api/v1`. Endpointy kontroli stanu odpowiadają zarówno w katalogu głównym, jak i pod `/api/v1`. Logowanie przez Google nie ma strony na serwerze: Google odsyła przeglądarkę z powrotem do samej gry, na `127.0.0.1`, albo na stronę witryny, która je rozpoczęła (`GOOGLE_WEB_REDIRECT_URI`).
    - Końcowy ukośnik jest ignorowany (`/api/v1/info/` to `/api/v1/info`), a parametry ścieżki są dekodowane z kodowania URL (parametr, który nie jest poprawnym kodowaniem URL, dostaje odpowiedź 400 `invalid_request`).

    ## Żądania

    - **Treść JSON.** Żądania `POST`, `PUT` i `DELETE` mają w treści obiekt JSON z `Content-Type: application/json`. `charset` inny niż UTF-8 jest odrzucany, podobnie jak każdy inny typ zawartości (415 `unsupported_media_type`). Pusta treść liczy się jako `{}`, czyli to, co przyjmują endpointy bez parametrów (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Rozmiar i czas.** Treść jest ograniczona do `HTTP_BODY_LIMIT` bajtów (domyślnie 16 384; `POST /gif` ma własny limit 135 168 bajtów): 413 `payload_too_large`. Musi dotrzeć w ciągu 10 sekund: 408 `request_timeout`. Po każdym z tych błędów serwer zamyka połączenie.
    - **Ścisłe schematy.** Pole, którego endpoint nie zna, jest odrzucane, pole nieoznaczone jako opcjonalne jest wymagane, a typy i długości są sprawdzane. Każdy z tych błędów daje odpowiedź 400 `invalid_request`, a `field` wskazuje pole (z kropkami dla pola zagnieżdżonego, np. `pow.nonce`). Ciągi znaków nie mogą zawierać znaków sterujących. Długości liczy się w jednostkach kodowych UTF-16, więc znak spoza podstawowej płaszczyzny wielojęzycznej (Basic Multilingual Plane), np. emoji, liczy się podwójnie. Treść, która nie jest JSON-em, dostaje odpowiedź 400 `invalid_json`. `POST /gif` i `POST /reports` same sprawdzają swoją treść (zob. opis każdego z nich).
    - **Parametry zapytania (query string).** Liczy się pierwsze wystąpienie parametru, nieznane parametry są ignorowane, a `+` jest dekodowany jako spacja: endpointy, które przyjmują kategorię tempa gry, akceptują zarówno `3%2B2`, jak i `3+2`.
    - **Metody.** `HEAD` to `GET` bez treści. `OPTIONS` na istniejącej ścieżce dostaje odpowiedź 204 z nagłówkiem `Allow` (i nagłówkami żądania wstępnego CORS, opisanymi niżej). Każda inna metoda, której ścieżka nie ma, dostaje odpowiedź 405 `method_not_allowed` z `Allow`.
    - **Cel żądania (request target).** Nie może przekraczać 4096 znaków (414 `uri_too_long`). Żądanie, którego w ogóle nie da się sparsować albo którego nagłówki są za duże lub docierają zbyt wolno, dostaje pustą odpowiedź 400, 431 lub 408, a połączenie zostaje zamknięte.
    - **CORS: lista dozwolonych źródeł, domyślnie wyłączony.** API obsługuje grę, która nie wysyła `Origin`. Gdy `CORS_ORIGINS` jest puste (domyślnie), żaden nagłówek `Access-Control-*` nie jest nigdy wysyłany, więc żadna strona WWW nie może odczytać odpowiedzi. `CORS_ORIGINS` wymienia źródła WWW (origins), których strony mogą wywoływać API z przeglądarki, każde dokładnie tak, jak przeglądarki wysyłają je w `Origin`: `https://host` lub `https://host:port`, małymi literami, bez ścieżki, końcowego ukośnika i domyślnego portu; `http://` tylko dla `localhost`, `127.0.0.1` i `[::1]` (programowanie lokalne); bez symboli wieloznacznych. Serwer oficjalny ustawia `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com` dla swojej witryny. Wtedy:
      - Każda odpowiedź dla ścieżki pod `/api/v1` z wyjątkiem endpointów kontroli stanu, zarówno sukces, jak i błąd (łącznie z 429 warstwy limitów na adres), zawiera `Vary: Origin`. Gdy `Origin` żądania jest bajt w bajt jednym z wymienionych źródeł, odpowiedź zawiera także `Access-Control-Allow-Origin: <that origin>` oraz, z wyjątkiem odpowiedzi na żądanie wstępne, `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, aby strona mogła odczytać kody błędów, `Retry-After` i nazwy pobieranych plików PGN i GIF.
      - Żądanie wstępne (`OPTIONS` z `Access-Control-Request-Method`) z wymienionego źródła na istniejącej ścieżce dostaje odpowiedź 204, bez treści, z `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (metody z `Allow`), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600` i `Vary: Origin`. Nie wymaga tokenu i nie liczy się do żadnego budżetu konta ani limitu endpointu; jak każde żądanie zabiera żeton warstwy limitów na adres. Przeglądarka używa ponownie wyniku żądania wstępnego przez 10 minut, ale tylko dla dokładnie tego samego URL-a, łącznie z parametrami zapytania. Żądanie bez `Authorization` i bez treści JSON, takie jak `GET /info` czy `GET /leaderboard`, nie wymaga żądania wstępnego.
      - Każda inna odpowiedź na żądanie wstępne oznacza nieudane żądanie wstępne, nawet z nagłówkami CORS: 404 dla ścieżki, która nie istnieje, albo 429 warstwy limitów na adres. Przeglądarka nie wysyła wtedy żądania, a strona widzi błąd sieci, bez odpowiedzi i jej `Retry-After`.
      - `Access-Control-Allow-Credentials` nie jest nigdy wysyłany, podobnie jak `Access-Control-Allow-Origin: *`: strona wysyła swój token sesji w `Authorization: Bearer`, tak jak gra, a API nie ustawia żadnego pliku cookie. Strony HTML, endpointy kontroli stanu i puste odpowiedzi na nieczytelne żądania nie mają żadnego nagłówka CORS. WebSocket nie używa CORS: `WS_ALLOWED_ORIGINS` wymienia strony, które mogą go otwierać.
      - Strona ze źródła, którego nie ma na liście, nic nie odczytuje, a jej żądania zapisu między witrynami (cross-site) nigdy nie zostają wysłane: treść JSON i `Authorization` wymagają żądania wstępnego, którego odpowiedź nie ma `Access-Control-Allow-Origin` dla tego źródła. `Cross-Origin-Resource-Policy: same-origin` pozostaje w każdej odpowiedzi: dotyczy tylko żądań `no-cors` (inna witryna osadza odpowiedź), nigdy żądań `cors` strony z listy.

    ## Odpowiedzi i błędy

    - Odpowiedzi to JSON w UTF-8, z wyjątkiem pobierania PGN (`application/x-chess-pgn`), animowanych GIF-ów (`image/gif`) i stron HTML. Czasy to milisekundy od 1970-01-01 UTC. Identyfikatory są liczbami całkowitymi. Identyfikatory partii mają do 16 cyfr, ale pozostają poniżej 2^53, więc liczba JSON (double) przechowuje je dokładnie.
    - Każda odpowiedź API zawiera `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` i `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (strony HTML mają własną politykę). Przy `TLS_MODE=native` zawiera także `Strict-Transport-Security: max-age=31536000`. Odpowiedzi, które moduł nasłuchujący zapisuje sam, przed API (na `GET` i `HEAD` endpointów kontroli stanu, 429 warstwy limitów na adres oraz 500 `internal_error`, gdy API całkowicie zawiedzie), mają te same nagłówki z wyjątkiem `Cross-Origin-Resource-Policy`, a ich `Content-Type` to `application/json` bez charsetu.
    - Odpowiedź musi zostać odczytana w ciągu 60 sekund od chwili, gdy serwer ma ją gotową, co ma znaczenie tylko dla dużych odpowiedzi (GIF, eksport danych, długi PGN). Czas, w którym serwer przygotowuje odpowiedź, nie liczy się ani do tego limitu, ani do 30 sekund bez żadnego bajtu przychodzącego lub wychodzącego, po których połączenie jest zamykane.

    Błędy mają jeden kształt (schemat `Error`):

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

    `message` służy do logów i jako rozwiązanie awaryjne; klient wybiera, co pokazać, na podstawie `error`. `retryAfter` (w sekundach) występuje tylko przy odmowach, które mijają z czasem, a odpowiedź ma wtedy także nagłówek `Retry-After` o tej samej wartości; jedynym wyjątkiem jest 503 `busy` przy odczytach historii, partii, graczy i tabeli liderów, które ma tylko pole. Niektóre błędy dodają pola: `field` (nieprawidłowe dane wejściowe), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` i `column` (`invalid_pgn`).

    Błędy, które może zwrócić każdy endpoint:

    | Status | `error` | Kiedy |
    |---|---|---|
    | 400 | `invalid_request` | Treść łamie schemat endpointu (`field` wskazuje miejsce), cel żądania lub `Content-Length` ma nieprawidłowy format, parametr ścieżki nie jest poprawnym kodowaniem URL albo treść została ucięta. |
    | 400 | `invalid_json` | Treść nie jest JSON-em. |
    | 401 | `unauthorized` | Brak nagłówka `Authorization` w endpoincie, który wymaga sesji. |
    | 401 | `invalid_token` | Token ma nieprawidłowy format, wygasł, został unieważniony lub należy do usuniętego konta; także w endpointach, w których sesja jest opcjonalna. |
    | 404 | `not_found` | Nie ma takiego endpointu. Używają go też niektóre endpointy: nie ma takiej partii, gracza lub sesji. |
    | 405 | `method_not_allowed` | Ścieżka istnieje dla innych metod (zob. `Allow`). |
    | 408 | `request_timeout` | Treść nie dotarła w ciągu 10 sekund. |
    | 413 | `payload_too_large` | Treść przekracza `HTTP_BODY_LIMIT` (135 168 bajtów dla `POST /gif`). |
    | 414 | `uri_too_long` | Cel żądania przekracza 4096 znaków. |
    | 415 | `unsupported_media_type` | Treść nie jest typu `application/json` albo jej kodowanie znaków nie jest UTF-8. |
    | 429 | `rate_limited` | Limit żądań (zob. niżej): `retryAfter` oraz nagłówek `Retry-After`. |
    | 500 | `internal_error` | Nieoczekiwany błąd. Serwer zapisuje go w logu. |
    | 503 | `timeout` | Serwer nie odpowiedział w ciągu 30 sekund (60 sekund dla eksportu, 45 sekund dla GIF-ów przy ustawieniach domyślnych). |
    | 503 | `server_busy` | Wyszukanie sesji lub zmiana konta natrafiły na zablokowaną bazę danych (`retryAfter` 1) albo kolejka haszowania haseł jest pełna (zob. niżej). Także, z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża: dla `POST`, `PUT`, `PATCH` lub `DELETE` baza danych ma zbyt wiele zapisów w kolejce; dla dowolnego żądania zbyt wiele wcześniejszych żądań wciąż działa po przekroczeniu swojego limitu czasu albo nie udało się sprawdzić tokenu sesji w ciągu 5 sekund. Żądanie nie zostało wykonane, a jego limity żądań niczego nie pobrały. |

    Endpointy odczytu (historia partii, partie, gracze, tabela liderów) i eksport odpowiadają 503 `busy` z `retryAfter: 1`, gdy baza danych pozostała zablokowana.

    Endpointy, które sprawdzają lub haszują hasło, mogą też zwrócić jeden z tych błędów, oba z losowym `retryAfter` od 5 do 15 sekund:

    - 503 `server_busy`: kolejka haszowania haseł serwera (`PASSWORD_HASH_QUEUE_MAX`) jest pełna albo czas oczekiwania minął.
    - 429 `rate_limited`: gdy kolejka jest już w połowie pełna, ten klient (adres IPv4 lub sieć IPv6 /48) ma już `PASSWORD_HASH_WAITERS_PER_SOURCE` haszowań w kolejce. Ten błąd 429 zwraca żetony limitów żądań, które pobrało żądanie.

    Nic nie zostało zmienione i nie policzono żadnej nieudanej próby (z wyjątkiem `POST /auth/sso/google/link`, w którym próbę biletu i niepowodzenie konta naliczono przed haszowaniem), a link do resetowania hasła pozostaje ważny.

    Strony HTML zwracają swoje błędy jako strony HTML z tymi samymi kodami statusu.

    ## Uwierzytelnianie

    Endpointy, które wymagają sesji, przyjmują token typu bearer w nagłówku `Authorization` (schemat `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Uzyskanie tokenu.** Token (`sct_`, a po nim 43 znaki base64url) pochodzi z `POST /auth/login`, a następnie z `POST /auth/login/mfa`, gdy weryfikacja dwuetapowa jest włączona, z `POST /auth/sso/google/finish` i `POST /auth/sso/google/link` (logowanie przez Google) oraz z `POST /auth/sso/complete`. Każdy z nich odpowiada `{ token, expiresAt, user }`. Serwer przechowuje tylko skrót SHA-256 tokenu.
    - **Wymagana sesja.** Brak nagłówka daje odpowiedź 401 `unauthorized` z `WWW-Authenticate: Bearer realm="scacelith"`. Nieprawidłowy token daje odpowiedź 401 `invalid_token` z `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Klient powinien zapomnieć token, który dostał `invalid_token`, i zalogować się ponownie.
    - **Opcjonalna sesja.** W `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` i `GET /players/{username}/games` sesja jest opcjonalna. Bez nagłówka zwracają widok publiczny; jeśli nagłówek zostanie wysłany, jego token musi być prawidłowy.
    - **Zajęty magazyn sesji.** Gdy baza danych pozostaje zablokowana podczas sprawdzania tokenu, endpoint, który wymaga lub przyjmuje sesję, odpowiada 503 `server_busy` z `retryAfter: 1` (i `Retry-After`); gdy sprawdzanie trwa dłużej niż 5 sekund, odpowiada 503 `server_busy` z `retryAfter` od 2 do 5. W obu przypadkach token nie został oceniony: zachowaj go i wyślij żądanie ponownie.
    - **Czas życia.** Sesja kończy się po upływie `SESSION_MAX_DAYS` (90) dni od zalogowania (to jest `expiresAt`) lub `SESSION_IDLE_DAYS` (30) dni bez użycia, zależnie od tego, co nastąpi wcześniej. Każde użycie odsuwa limit bezczynności; serwer zapisuje nową wartość najwyżej co 5 minut, w tle. Gdy jego baza danych ma wiele zapisów w kolejce, odkłada ten zapis do późniejszego żądania, z wyjątkiem ostatniej godziny przed limitem bezczynności, więc używana sesja nie wygasa. Konto ma najwyżej `MAX_SESSIONS_PER_USER` (10) sesji: nowe zalogowanie unieważnia najstarsze sesje ponad tę liczbę.
    - **Unieważnianie.** Wylogowanie (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) unieważnia sesje; podobnie zmiana hasła (pozostałe sesje), zresetowanie hasła i usunięcie konta (wszystkie sesje) oraz administrator. Unieważnienie przez API działa od razu, a WebSocket otwarty z unieważnioną sesją zostaje zamknięty. Zmiana lub zresetowanie hasła i usunięcie konta unieważniają sesje w tej samej transakcji co sama zmiana: gdy któraś z tych operacji się nie powiedzie, żadna sesja nie została unieważniona i nic innego też się nie zmieniło. Serwer przechowuje wyniki wyszukiwania sesji w pamięci podręcznej przez 30 sekund, więc unieważnienie poleceniem administracyjnym, które działa w osobnym procesie, zaczyna obowiązywać w ciągu 30 sekund.
    - **Zakres.** Token należy do jednego serwera i otwiera też jego WebSocket. Nigdy nie wysyłaj go do innego serwera.

    ## Limity żądań i inne ograniczenia

    Limity dotyczą jednego z dwóch zakresów. **Klient:** adres IPv4 lub sieć IPv6 /64 (w trybie proxy adres pochodzi z `X-Forwarded-For` wysłanego przez adres z `TRUSTED_PROXIES`); niektóre limity liczą też każdą sieć IPv6 /48 jako całość, oprócz każdej z jej sieci /64. **Gracz:** zalogowane konto, niezależnie od adresu; limit liczony na gracza w endpoincie, w którym sesja jest opcjonalna, dla żądania bez tokenu liczy się na klienta.

    Każdy limit to wiadro żetonów (token bucket), które mieści `limit` żądań i napełnia się w sposób ciągły w tempie `limit / window`; `retryAfter` to czas do następnego żetonu. Limity oznaczone jako *współdzielone* są też liczone w przesuwnym oknie tej samej długości, aby żadne okno nie zawierało dużo więcej niż `limit` żądań. Każdy limit jest liczony dla całego serwera. Gdy jeden z limitów endpointu odrzuca żądanie, żetony, które pozostałe limity pobrały za to żądanie, są zwracane. Odmowa to odpowiedź 429 `rate_limited` z `retryAfter` i `Retry-After`.

    - **Warstwa limitów na adres.** Każde żądanie (dowolna ścieżka i metoda, łącznie z endpointami kontroli stanu i żądaniami uaktualnienia do WebSocket) najpierw zabiera jeden żeton z budżetu swojego klienta: `HTTP_RATE_PER_IP` (600) na minutę, z dopuszczalną serią (burst) odpowiadającą połowie minuty, oraz `HTTP_RATE_PER_PREFIX` (4 × `HTTP_RATE_PER_IP`) dla sieci IPv6 /48. Klient może też mieć najwyżej `IP_MAX_INFLIGHT` (32 × `WORKERS`) żądań w toku (powyżej tego `retryAfter` 1). Klient, który nie przestaje mimo odmów, zostaje zablokowany: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) odmów w ciągu minuty blokuje go na 1 minutę, a potem na 4, 16 i 60 minut przy każdej kolejnej blokadzie w ciągu 6 godzin. Odmowa limitów `auth`, `auth_*` i `reauth` liczy się jako 5; odmowy limitów liczonych na gracza nigdy się nie liczą. Adresy z `ABUSE_EXEMPT` pomijają tę warstwę, ale nie budżet konta ani limity endpointów.
    - **Budżet konta.** Każde żądanie z prawidłowym tokenem sesji liczy się także do budżetu swojego konta: `USER_RATE_PER_MIN` (120) na minutę dla wszystkich endpointów łącznie, niezależnie od adresu, z dopuszczalną serią (burst) odpowiadającą połowie minuty.
    - **Limity endpointów:**

    | Limit | Wartość domyślna | Liczony na | Endpointy |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, współdzielony | klienta oraz `AUTH_RATE_PER_PREFIX` (5 × `AUTH_RATE_PER_IP`) na sieć 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) / godz., współdzielony | klienta, 3 razy tyle na sieć IPv6 /48 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / godz., współdzielony | klienta, 3 razy tyle na sieć IPv6 /48 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / godz., współdzielony | klienta, 3 razy tyle na sieć IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 godz., współdzielony | klienta, 3 razy tyle na sieć IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / godz., współdzielony | klienta, 3 razy tyle na sieć IPv6 /48 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | wartości `auth`, osobne wiadro, współdzielony | klienta i sieć IPv6 /48 | `POST /account/password`, `/account/mfa/totp/setup`, `/account/mfa/totp/enable`, `/account/mfa/totp/disable`, `/account/mfa/recovery-codes`, `/account/email`, `/account/export`, `/account/delete` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 min, współdzielony | gracza | te same endpointy co `reauth` |
    | `account` | 60 / min | gracza | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | gracza | `GET /account/games` |
    | `account_export` | 5 / godz., współdzielony | gracza | `POST /account/export` (sprawdzany przed `reauth`; liczy się każda próba) |
    | `sessions` | 60 / min | gracza | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | gracza (klienta bez tokenu) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (jedno wiadro dla czterech) |
    | `gif` | 30 / min | gracza | `GET /games/{id}/gif`, `POST /gif` (jedno wiadro dla obu) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min i `GIF_USER_RENDERS_PER_HOUR` (30) / godz., współdzielone | gracza | te same dwa, tylko gdy GIF trzeba wygenerować (a nie wziąć z pamięci podręcznej) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min i `GIF_IP_RENDERS_PER_HOUR` (120) / godz., współdzielone | klienta (wszystkie jego konta łącznie), 3 razy tyle na sieć IPv6 /48 | te same, jak wyżej |
    | `reports` | 30 / godz. | gracza | `POST /reports` |
    | `sso_start` | 30 / 10 min, współdzielony | klienta oraz 90 na sieć IPv6 /48 | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (łącznie) |
    | `sso_finish` | 30 / min | klienta | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | klienta | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` i `GET /leaderboard` nie mają własnego limitu: tylko warstwę limitów na adres. Endpoint z kilkoma limitami sprawdza je w kolejności podanej w jego opisie.

    Serwer obsługuje żądanie w tej kolejności: warstwa limitów na adres, potem endpointy kontroli stanu; dopasowanie endpointu; sprawdzenie, czy serwer mocno nie nadąża (503 `server_busy`): zbyt wiele wcześniejszych żądań wciąż działających po przekroczeniu limitu czasu oraz, dla `POST`, `PUT`, `PATCH` lub `DELETE`, zaległe zapisy bazy danych; uwierzytelnianie, w ciągu 5 sekund (dłużej: 503 `server_busy`); budżet konta, gdy żądanie ma sesję; limity endpointu; treść; sam endpoint (endpointy GIF pobierają swoje limity renderowania tylko wtedy, gdy mają GIF do wygenerowania). Dlatego żądanie odrzucone z powodu tokenu nie zużywa żadnych żetonów endpointu, a żądanie z nieprawidłową treścią je zużywa.

    Inne ograniczenia, stosowane przez same endpointy:

    - **Nieudane logowania na jeden login** (nazwa użytkownika lub e-mail): od `AUTH_FAILURES_PER_ACCOUNT` (5) niepowodzeń każda próba musi czekać dwa razy dłużej niż poprzednia (2 s, 4 s itd., do 15 minut): 429 `too_many_attempts` z `retryAfter`. Licznik jest zerowany po godzinie bez niepowodzeń.
    - **Nieudane drugie składniki przy logowaniu:** ta sama zasada od 5. błędnego kodu konta. Jeden krok logowania przyjmuje najwyżej 5 błędnych kodów.
    - **Kody drugiego składnika jednego konta:** najwyżej `AUTH_MFA_PER_ACCOUNT` (10) kodów (z aplikacji uwierzytelniającej lub kodów odzyskiwania, poprawnych lub błędnych) na 15 minut, z dowolnego adresu, przy logowaniu i ponownych uwierzytelnieniach; powyżej tego 429 `too_many_attempts` jeszcze przed sprawdzeniem kodu, więc kod odzyskiwania nie zostaje zużyty.
    - **Nieudane ponowne uwierzytelnienia konta** (błędne hasło lub kod): ta sama zasada (429 `too_many_attempts`), wspólna dla wszystkich endpointów, które ponownie uwierzytelniają.
    - **E-maile:** jeden e-mail z potwierdzeniem lub resetowaniem hasła na adres co 5 minut (odpowiedź pozostaje taka sama); jedno powiadomienie „someone tried to use your address” (ktoś próbował użyć twojego adresu) na adres na godzinę.
    - **Zgłoszenia:** `REPORTS_PER_DAY` (5) na gracza w ciągu 24 godzin: 429 `report_limit`.

    ## Dowód pracy

    `POST /auth/register` zawsze wymaga dowodu pracy, gdy `POW_REGISTER_BITS` jest większe od 0 (domyślnie 18; `GET /info` podaje tę wartość jako `pow.register`). `POST /auth/login` i `POST /auth/sso/google/link` (jeden rodzaj wyzwania dla obu) wymagają go tylko przez 5 minut po tym, jak serwer zauważy falę nieudanych logowań (`POW_LOGIN_TRIGGER_PER_MIN`, potem `POW_LOGIN_BITS`); klient dowiaduje się o tym z odpowiedzi.

    1. Żądanie bez dowodu (lub z odrzuconym dowodem) dostaje odpowiedź 428 `pow_required` z `reason` i wyzwaniem `pow` `{ challenge, bits, expiresAt }`.
    2. Znajdź nonce: ciąg dziesiętny o najwyżej 20 cyfrach, taki że `SHA-256(challenge + ":" + nonce)` zaczyna się od `bits` bitów zerowych (zaczynając od najbardziej znaczącego bitu pierwszego bajtu). 18 bitów wymaga średnio około 260 000 obliczeń skrótu.
    3. Wyślij to samo żądanie ponownie z `"pow": { "challenge": "...", "nonce": "123456" }` w treści.

    Wyzwanie jest ważne przez 2 minuty i tylko raz, dla jednego endpointu i jednej sieci klienta (adres IPv4 lub sieć IPv6 /64). Jest podpisane, więc serwer nie przechowuje o nim niczego, dopóki nie wróci. `reason` mówi, dlaczego dowód został odrzucony: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` lub `replayed`.

    ## Ponowne uwierzytelnienie

    Zmiany konta wymagają ponownego podania hasła, a gdy weryfikacja dwuetapowa jest włączona, także drugiego składnika:

    - tylko hasło: `POST /account/password` i `POST /account/mfa/totp/setup`;
    - hasło i kod z aplikacji uwierzytelniającej, przy czym kod odzyskiwania jest odrzucany: `POST /account/mfa/recovery-codes`;
    - hasło i kod z aplikacji uwierzytelniającej lub kod odzyskiwania: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` i `POST /account/delete`.

    W treści `code` zawiera 6-cyfrowy kod z aplikacji uwierzytelniającej, a `recoveryCode` kod odzyskiwania (`xxxx-xxxx-xx`; wielkość liter, spacje i myślniki nie mają znaczenia). Kod odzyskiwania można też wysłać w `code` tam, gdzie kody odzyskiwania są akceptowane. Każdy kod działa raz: użyty kod z aplikacji uwierzytelniającej jest odrzucany do następnego 30-sekundowego kroku, a kod odzyskiwania przepada po użyciu.

    | Status | `error` | Kiedy |
    |---|---|---|
    | 403 | `invalid_password` | Błędne hasło albo hasło zostało zresetowane lub zmienione w trakcie wykonywania żądania (nic nie zostało zmienione). |
    | 403 | `mfa_code_required` | Weryfikacja dwuetapowa jest włączona, a nie wysłano ani `code`, ani `recoveryCode`. |
    | 403 | `invalid_code` | Błędny lub już użyty kod. |
    | 400 | `password_not_set` | Konto logujące się tylko przez Google nie ma jeszcze hasła (ustawia się je przez „Nie pamiętasz hasła?”). |
    | 429 | `too_many_attempts` | Zbyt wiele niepowodzeń lub zbyt wiele wypróbowanych kodów na tym koncie. |
    | 503 / 429 | `server_busy` / `rate_limited` | Kolejka haszowania haseł jest zajęta. |

    Nieudane hasła i kody liczą się do licznika niepowodzeń konta i są zapisywane jako zdarzenia dotyczące bezpieczeństwa.

    ## Port metryk

    Oprócz portu API serwer odpowiada zwykłym HTTP na porcie metryk (`METRICS_PORT`, 9464, na adresie `METRICS_BIND`, domyślnie 127.0.0.1; nie udostępniaj go publicznie). Nie jest on częścią tego API: `GET /healthz` odpowiada `ok`; `GET /readyz` odpowiada `ready` od zakończenia uruchamiania (każdy shard odtworzył swój dziennik, a moduły nasłuchujące są powiązane z portami) aż do rozpoczęcia zamykania, a w przeciwnym razie 503 `not ready`; `GET /metrics` udostępnia metryki Prometheusa i, gdy ustawiony jest `METRICS_TOKEN`, wymaga `Authorization: Bearer <token>` z dokładnie tym tokenem.
  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: Kod źródłowy serwera Scacelith i jego dokumentacja referencyjna (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Serwer oficjalny.
  - url: https://{host}:{port}/api/v1
    description: Dowolny serwer Scacelith, np. serwer społecznościowy.
    variables:
      host:
        default: caissa.scacelith.com
        description: Publiczna nazwa hosta serwera (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: Publiczny port API (`PUBLIC_API_PORT`, a w przeciwnym razie `API_PORT`).
tags:
  - name: server-info
    x-displayName: Informacje o serwerze
    description: Co klient musi wiedzieć, zanim się zaloguje lub połączy.
  - name: health
    x-displayName: Kontrola stanu
    description: |-
      Żywotność (liveness) i gotowość (readiness) serwera, do monitorowania. Te endpointy odpowiadają na porcie API przed uwierzytelnianiem i przed każdym limitem endpointu, ale jak każde żądanie zabierają żeton warstwy limitów na adres; host monitorujący można wpisać do `ABUSE_EXEMPT`. Dla każdego endpointu działają obie ścieżki: w katalogu głównym serwera i pod `/api/v1`.

      Port metryk ma własne endpointy kontroli stanu, opisane we wprowadzeniu.
  - name: auth
    x-displayName: Rejestracja i logowanie
    description: |-
      Zakładanie konta, logowanie hasłem (i drugim składnikiem), e-maile z potwierdzeniem i do resetowania hasła.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` i `POST /auth/sso/complete` odpowiadają sesją `{ token, expiresAt, user }` (lub, w przypadku pierwszych z nich, drugim krokiem).
  - name: google-sign-in
    x-displayName: Logowanie przez Google
    description: |-
      Dostępne, gdy `GET /info` podaje `sso.google: true`; w przeciwnym razie każdy endpoint poniżej odpowiada 404 `sso_disabled`. Witryna loguje się w ten sam sposób przez własnego klienta, gdy `sso.googleWeb` ma wartość true (niżej, **W witrynie**). Gra loguje się przez przeglądarkę systemową w przepływie dla aplikacji instalowanych z RFC 8252 (klient typu „Desktop app”): Google odsyła przeglądarkę do gniazda nasłuchującego gry na `127.0.0.1`, nigdy do tego serwera, a gra nigdy nie widzi danych uwierzytelniających Google.

      1. Klient nasłuchuje na `127.0.0.1:0` (port wybiera system) i tworzy parę PKCE: `codeVerifier` o długości od 43 do 128 znaków `[A-Za-z0-9._~-]` oraz `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, który ma 43 znaki i nie ma dopełnienia (padding).
      2. `POST /auth/sso/google/start` z wyzwaniem i portem zwraca URL Google i `state` próby. Klient sprawdza URL (zob. niżej) i otwiera go w przeglądarce.
      3. Google kieruje przeglądarkę na `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Klient przyjmuje tylko `state` z odpowiedzi na start i niczego nie wysyła po przekierowaniu z `error=`.
      4. `POST /auth/sso/google/finish` z identyfikatorem próby, `codeVerifier`, `state` i `code` (oraz `iss`, jeśli Google go przesłało).
      5. Odpowiedź to sesja, krok weryfikacji dwuetapowej (dalej `POST /auth/login/mfa`), `needsUsername` dla nowego gracza (dalej `POST /auth/sso/complete`) albo `needsPassword`, gdy adresu używa konto z hasłem (dalej `POST /auth/sso/google/link`).

      **Znacznik źródła.** URI przekierowania zawiera znacznik serwera dodanego przez gracza, aby gra odrzucała URL uzyskany przez inny serwer dla jego własnych graczy. Źródło to host zapisany małymi literami (literał IPv6 w nawiasach kwadratowych), `:` i dziesiętny port API, zapisany zawsze, także 443: serwer bierze `SERVER_PUBLIC_HOST` i swój publiczny port API, gra – adres, z którym jest połączona. Znacznik to pierwsze 22 znaki base64url (bez dopełnienia) z SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + źródło). URI przekierowania to `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: zawsze literał IPv4 i port gniazda nasłuchującego gry (od 1024 do 65535). Serwer nigdy nie bierze od klienta URI, hosta ani ścieżki.

      | Źródło | Znacznik |
      |---|---|
      | `play.scacelith.example:443` | `IhcScoV7eDOzTEcSnqPUPt` |
      | `localhost:8443` | `TFGx7zQ_8QlGZW5zpqznCr` |
      | `[::1]:8443` | `XToJm0DG5PjciEVmZa9Cho` |
      | `127.0.0.1:50443` | `3r653wM5ZjYsHcAJljmCwY` |

      Dlatego logowanie przez Google działa tylko dla graczy, którzy dodali serwer dokładnie pod nazwą `SERVER_PUBLIC_HOST` i z jego publicznym portem API.

      **Co gra sprawdza przed otwarciem przeglądarki.** `authUrl` zaczyna się dokładnie od `https://accounts.google.com/o/oauth2/v2/auth?` i jest drukowalnym ciągiem ASCII krótszym niż 4096 znaków, a jego zapytanie ma dokładnie jeden `response_type=code`, dokładnie jeden `redirect_uri` równy URI, który gra oblicza ze swojego portu i swojego znacznika źródła, dokładnie jeden `state` równy `state` z odpowiedzi, `code_challenge_method=S256` i 43-znakowy `code_challenge`. W przeciwnym razie gra zatrzymuje swoje gniazdo nasłuchujące i niczego nie otwiera. Wysyła `finish` i `link` tylko do serwera, który odpowiedział na `start`.

      **W witrynie.** Witryna (oficjalna: <https://scacelith.com/>) loguje się przez klienta typu „Web application” z tego samego projektu Google (`GOOGLE_WEB_CLIENT_ID`). Google odsyła przeglądarkę na jedną stronę witryny, skonfigurowaną przez operatora (`GOOGLE_WEB_REDIRECT_URI`, wpisaną w „Authorized redirect URIs” klienta), a ta strona wysyła kod do `finish`; tu również serwer nie bierze od klienta żadnego URI przekierowania.

      1. Strona tworzy parę PKCE jak wyżej, przechowuje weryfikator w karcie (`sessionStorage`) i wysyła `POST /auth/sso/google/web/start` z wyzwaniem.
      2. Sprawdza `authUrl` tak jak gra, z tym że jej `redirect_uri` musi być stroną z jej własnego źródła, po czym przechodzi pod ten adres. Żaden skrypt Google nie jest ładowany.
      3. Google kieruje przeglądarkę na `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. Strona przyjmuje tylko `state` z odpowiedzi na start i niczego nie wysyła po przekierowaniu z `error=`.
      4. `finish`, `link` i `complete` przebiegają tak jak w grze. Próba pamięta, że rozpoczęła ją witryna: kod jest wymieniany z użyciem klienta i strony witryny, a token ID musi wskazywać tego klienta (kod lub token ID klienta gry kończy się błędem 502 `sso_failed`, i odwrotnie).

      Konto Google prowadzi do tego samego konta Scacelith z gry i z witryny: powiązanie przechowuje identyfikator podmiotu Google (`sub`), który identyfikuje konto Google niezależnie od klienta. Źródło witryny musi być w `CORS_ORIGINS` (w przeciwnym razie `check-config` wyświetla ostrzeżenie).

      **Do którego konta prowadzi logowanie przez Google:** do konta już powiązanego z tym kontem Google; w przeciwnym razie do aktywnego konta z adresem, który Google potwierdziło (jeśli konto ma hasło, `finish` odpowiada `needsPassword`, a powiązanie jest zapisywane dopiero wtedy, gdy przejdzie jego hasło, a potem jego drugi składnik, jeśli jest włączony; bez hasła 409 `sso_account_exists`); w przeciwnym razie do nowego konta (`needsUsername`). Konto Google nigdy nie jest wiązane z istniejącym kontem na podstawie samego adresu. Na adres konta wysyłany jest e-mail, gdy logowanie przez Google tworzy konto oraz gdy zostaje dodane do istniejącego konta.
  - name: sessions
    x-displayName: Sesje
    description: Zalogowane urządzenia konta i wylogowywanie.
  - name: account
    x-displayName: Konto
    description: Widok konta, jego preferencje i hasło.
  - name: two-step-verification
    x-displayName: Weryfikacja dwuetapowa
    description: Aplikacje uwierzytelniające (TOTP, RFC 6238) z SHA-1, 6 cyframi, 30-sekundowymi krokami i tolerancją jednego kroku w każdą stronę, a do tego jednorazowe kody odzyskiwania.
  - name: email-change
    x-displayName: Zmiana adresu e-mail
    description: Zmiana adresu konta, potwierdzana linkiem wysłanym na nowy adres.
  - name: data-export
    x-displayName: Eksport danych
    description: Wszystko, co serwer przechowuje o koncie, w jednym pliku JSON.
  - name: account-deletion
    x-displayName: Usuwanie konta
    description: Trwałe usunięcie konta.
  - name: game-history
    x-displayName: Historia partii
    description: Własne partie zalogowanego gracza, z filtrami i podziałem na strony.
  - name: games
    x-displayName: Partie i PGN
    description: |-
      Zapisy partii z ich ruchami i zegarami oraz ich pliki PGN.

      **Kody partii.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (zapisywane są tylko zakończone partie). `result`: `1-0`, `0-1`, `1/2-1/2` lub `*` (przerwana). `reason`, z nazwą (`termination`) i słowami, które kończą zapis ruchów w pliku PGN (w pliku po angielsku, niżej z tłumaczeniem); kody 7, 14 i 21 oznaczają remis (gracz, któremu skończył się czas, który poddał partię lub ją opuścił, miał przeciwnika, który nie mógł dać mata: FIDE 6.9 i 5.1.2; zasadę podaje `DESIGN.md` 6.3), a serwer nigdy nie kończy partii kodami 4 i 13 (należą one do wspólnej listy powodów):

      | Kod | `termination` | Słowa w PGN |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (mat) |
      | 2 | `Resignation` | Resignation (poddanie partii) |
      | 3 | `Timeout` | Loss on time (przegrana na czas) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (drugi nieprawidłowy ruch, przegrana) |
      | 5 | `Stalemate` | Stalemate (pat) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (martwa pozycja, niewystarczający materiał) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (opadnięcie chorągiewki, ale przeciwnik nie może dać mata) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (pięciokrotne powtórzenie pozycji) |
      | 9 | `SeventyFiveMoves` | 75-move rule (zasada 75 ruchów) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (trzykrotne powtórzenie pozycji, na żądanie) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (zasada 50 ruchów, na żądanie) |
      | 12 | `Agreement` | Draw by agreement (remis za zgodą) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (drugi nieprawidłowy ruch, ale przeciwnik nie może dać mata) |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate (poddanie partii, ale przeciwnik nie może dać mata) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (opuszczenie partii, zbyt długie rozłączenie) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (opuszczenie partii, ale przeciwnik nie może dać mata) |
      | 22 | `Aborted` | Game aborted (partia przerwana) |
      | 23 | `NoShow` | Aborted: first move not played in time (przerwana: brak pierwszego ruchu w wyznaczonym czasie) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (walkower za naruszenie zasad uczciwej gry) |
      | 25 | `ServerAborted` | Aborted by the server (przerwana przez serwer) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (przerwana: rozłączenie obu graczy) |

      Kod 14 jest nowy w wersji pomocniczej (minor) 1 protokołu: klient napisany przed nią powinien pokazywać nieznany kod jako zwykły koniec partii z jej `result`.
  - name: gifs
    x-displayName: Animowane GIF-y
    description: |-
      Partia jako animowany GIF, do zachowania lub udostępnienia: szachownica widziana z góry, jedna klatka na każdą pozycję od początkowej do końcowej, nazwy i rankingi graczy nad szachownicą, ostatni ruch pod nią, a na ostatniej klatce wynik i sposób zakończenia partii.

      **Koszt, pamięć podręczna i limity.** GIF jest generowany w osobnym wątku renderowania, nigdy w wątkach, które prowadzą partie, i z najniższym priorytetem CPU: `GIF_THREADS` wątków (domyślnie `WORKERS`), uruchamianych przy pierwszym GIF-ie i zatrzymywanych po minucie bez żadnego GIF-a. Najwyżej `GIF_QUEUE_MAX` (4 × `WORKERS`) GIF-ów czeka na wątek, każdy najwyżej `GIF_QUEUE_TIMEOUT_MS` (10 s); renderowanie może trwać `GIF_RENDER_TIMEOUT_MS` (30 s). Serwer przechowuje wygenerowane GIF-y w pamięci podręcznej o rozmiarze `GIF_CACHE_MB` MB (32 × `WORKERS`), z której najpierw usuwane są najdawniej używane; GIF z pamięci podręcznej albo taki, który jest właśnie generowany dla innego żądania, nie kosztuje renderowania. Klucz pamięci podręcznej obejmuje wszystko, co zmienia obraz, łącznie z nazwami.

      Każde żądanie liczy się do `gif` (30 na minutę na gracza dla obu endpointów). GIF, który trzeba wygenerować, liczy się także do limitów renderowania: na gracza `GIF_USER_RENDERS_PER_MIN` (4) na minutę i `GIF_USER_RENDERS_PER_HOUR` (30) na godzinę; na klienta, łącznie dla wszystkich jego kont, `GIF_IP_RENDERS_PER_MIN` (12) na minutę i `GIF_IP_RENDERS_PER_HOUR` (120) na godzinę, oraz 3 razy tyle na sieć IPv6 /48. Klient powinien zachować pobrany plik, zamiast prosić o niego ponownie, i odczekać `retryAfter` po błędzie 429 lub 503.

      Rozmiary: `small` (pola 32 px: 284 × 350 pikseli), `medium` (48 px: 424 × 515), `large` (72 px: 628 × 762); bez współrzędnych szachownicy 268 × 342, 400 × 503 i 600 × 748. Pozycja początkowa pozostaje widoczna co najmniej 1 s (albo przez czas opóźnienia, gdy jest ono dłuższe), pozycja końcowa 3 s, a GIF jest zapętlony; po pierwszej klatce zapisywana jest tylko ta część obrazu, która się zmienia. Partia 40-ruchowa zajmuje około 135, 205 i 325 KiB (small, medium, large), partia 150-ruchowa 0,5, 0,8 i 1,2 MiB.
  - name: players
    x-displayName: Gracze
    description: Profile publiczne i ostatnie partie. Wyłącznie dane publiczne, nigdy adres e-mail, sesja, sankcja ani poziom wiarygodności. Usunięte konto nie ma profilu.
  - name: leaderboard
    x-displayName: Tabela liderów
    description: Najlepsi gracze każdej oficjalnej kategorii.
  - name: reports
    x-displayName: Zgłoszenia
    description: Zgłaszanie moderatorom przeciwnika z niedawnej partii.
  - name: pages
    x-displayName: Strony HTML
    description: |-
      Strony dla przeglądarki, otwierane z linków w e-mailach. Ich linki prowadzą do `https://<SERVER_PUBLIC_HOST>` (z `:<PUBLIC_API_PORT>`, gdy nie jest to 443), poza `/api/v1`.

      - Strony nie uruchamiają JavaScriptu i nie ładują żadnych zasobów zewnętrznych. Są serwowane z `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - `GET` tylko wyświetla przycisk lub formularz, aby skaner poczty otwierający link go nie zużył. Zmiana następuje przy `POST`: formularz wysłany jako `application/x-www-form-urlencoded` (treść JSON też jest akceptowana), z tokenem linku w ukrytym polu. Pole podane dwukrotnie jest odrzucane.
      - Błędy (limity żądań, nieprawidłowe pola) również są stronami HTML, zatytułowanymi „Request refused” (Żądanie odrzucone) lub, od 500 wzwyż, „Server error” (Błąd serwera). Tylko błędy wykryte przed dopasowaniem strony (zbyt długi lub nieprawidłowy cel żądania, warstwa limitów na adres) dostają odpowiedź w JSON-ie.
x-tagGroups:
  - name: server
    x-displayName: Serwer
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Konta
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Partie
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Gracze i zgłoszenia
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Strony dla przeglądarki
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Pobranie nazwy, wersji, portów i zasad rejestracji serwera
      description: |-
        Czego klient potrzebuje, zanim się zaloguje lub połączy: nazwa i identyfikator serwera, wersje protokołu WebSocket i miejsce, w którym znajduje się WebSocket, zasady rejestracji, oficjalne tempa gry oraz limity, które klient może sprawdzić przed wysłaniem formularza.

        **Limity:** tylko warstwa limitów na adres.
      security: []
      responses:
        '200':
          description: Opis serwera.
          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`: warstwa limitów na adres.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Założenie konta
      description: |-
        Zakłada konto: po potwierdzeniu jego adresu e-mail linkiem wysłanym na ten adres albo od razu, bez potwierdzenia e-mailem.

        **Z potwierdzeniem e-mailem** (`REQUIRE_EMAIL_VERIFICATION`, domyślnie): 202 `verification_sent`. Konto jeszcze nie istnieje: rejestracja oczekuje przez 24 godziny i w tym czasie rezerwuje swoją nazwę użytkownika. Na adres wysyłany jest link ważny przez te 24 godziny, najwyżej jeden na adres co 5 minut (`POST /auth/verify-email/resend` wysyła nowy); konto zostaje utworzone, z potwierdzonym adresem, gdy link zostanie użyty (przycisk strony `/verify-email`), i gracz może się wtedy zalogować. Do tego czasu logowanie tą nazwą użytkownika dostaje odpowiedź `invalid_credentials`, jak dla nieznanego konta, a profil publiczny nie istnieje. Nowa rejestracja z tym samym adresem zastępuje oczekującą. Odpowiedź jest taka sama, gdy adresu używa już inne konto: link nie jest wtedy wysyłany, jego właściciel dostaje zamiast tego powiadomienie (najwyżej jedno na godzinę), a nazwa użytkownika jest rezerwowana w ten sam sposób, aby nic nie zdradzało, czy adres ma konto. Rejestracja, której link nie został użyty, jest usuwana po 24 godzinach, a jej nazwa użytkownika znów staje się wolna.

        **Bez potwierdzenia e-mailem** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, konto zostaje utworzone od razu i może się logować.

        **Zasady.** `username`: od `USERNAME_MIN` do `USERNAME_MAX` znaków (od 3 do 20), litery, cyfry, `_` i `-`, na początku litera lub cyfra (`GET /info` podaje te wartości w `limits`); nazwy zastrzeżone (`admin`, `moderator`, `deleted`...) i niektóre prefiksy są odrzucane; unikalna bez względu na wielkość liter. `email`: bez białych znaków na początku i końcu, zapisywany małymi literami, zwykłe ASCII z domeną zawierającą kropkę. `password`: co najmniej `PASSWORD_MIN_LENGTH` (10) znaków i najwyżej 256 bajtów UTF-8; nie może zawierać nazwy użytkownika ani części lokalnej adresu e-mail i nie może być popularnym hasłem.

        **Błędy**, sprawdzane w tej kolejności: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; błędy kolejki haszowania haseł; 409 `email_taken` (tylko bez potwierdzenia e-mailem).

        **Limity:** `auth`, potem `auth_register` (10 rejestracji na godzinę na klienta). **Dowód pracy:** zawsze, gdy `POW_REGISTER_BITS` jest większe od 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Pierwsza próba, bez dowodu pracy
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Ponowne wysłanie z dowodem pracy
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: Konto zostało utworzone i może się logować (na tym serwerze nie ma potwierdzenia e-mailem).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: Rejestracja czeka na swój link potwierdzający (albo adres ma już konto; odpowiedź tego nie zdradza).
          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` (z `field`), `invalid_json`, `invalid_username`, `invalid_email` lub `weak_password` z `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` lub `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: rejestracja na tym serwerze jest zamknięta (`GET /info` podaje `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: konto ma tę nazwę użytkownika albo rezerwuje ją oczekująca rejestracja z innym adresem. `email_taken`: inne konto używa tego adresu (tylko bez potwierdzenia e-mailem).'
        '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`: limit `auth` lub `auth_register` albo kolejka haszowania haseł.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (kolejka haszowania haseł albo baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Logowanie hasłem
      description: |-
        Loguje gracza przy użyciu nazwy użytkownika lub adresu e-mail oraz hasła. Możliwe są dwie odpowiedzi, obie ze statusem 200: sesja albo, gdy weryfikacja dwuetapowa jest włączona, drugi krok do wykonania w ciągu 5 minut przez `POST /auth/login/mfa`.

        Nieznane konto, błędne hasło i konto bez hasła dostają tę samą odpowiedź, po tym samym czasie: 401 `invalid_credentials`. Sprawdzenia konta (`banned`, `email_unverified`) następują dopiero po poprawnym haśle. Od `AUTH_FAILURES_PER_ACCOUNT` (5) niepowodzeń na jednym loginie każda próba musi czekać (429 `too_many_attempts`).

        **Limit:** `auth`. **Dowód pracy:** tylko w czasie fali nieudanych logowań (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: Sesja albo drugi krok logowania z weryfikacją dwuetapową.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Zalogowano
                  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: Weryfikacja dwuetapowa jest włączona
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: nieznane konto, błędne hasło lub konto bez hasła.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Tylko po poprawnym haśle: `banned` z `until` (ms od epoki Uniksa, `null` przy zawieszeniu na stałe) albo `email_unverified` (starsze konto, którego adres nie jest jeszcze potwierdzony).'
        '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` (opóźnienie po niepowodzeniach dla tego loginu) albo `rate_limited` (limit `auth`, kolejka haszowania haseł).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (kolejka haszowania haseł albo baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Dokończenie logowania drugim składnikiem
      description: |-
        Drugi krok logowania z weryfikacją dwuetapową, po tym jak `POST /auth/login` lub logowanie przez Google (`POST /auth/sso/google/finish` lub `POST /auth/sso/google/link`) odpowiedziało `mfaRequired`. Wyślij `code` (6-cyfrowy kod z aplikacji uwierzytelniającej lub kod odzyskiwania) albo `recoveryCode`. Kod odzyskiwania użyty tutaj przepada.

        Jeden krok przyjmuje najwyżej 5 błędnych kodów; krok kończy się też, gdy hasło zostanie zresetowane lub zmienione. Po kroku hasła powiązania z Google powiązanie jest zapisywane dopiero wtedy, gdy kod przejdzie tutaj.

        **Limity:** `auth` oraz najwyżej `AUTH_MFA_PER_ACCOUNT` (10) kodów na 15 minut dla konta, z dowolnego adresu; powyżej tego kod nie jest sprawdzany, więc kod odzyskiwania nie zostaje zużyty.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Kod z aplikacji uwierzytelniającej
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Kod odzyskiwania
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Zalogowano.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: nie wysłano ani `code`, ani `recoveryCode` albo treść łamie schemat; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: krok wygasł, został użyty lub zakończył się po 5 błędnych kodach albo hasło zostało zresetowane lub zmienione od pierwszego kroku (zaloguj się ponownie). `invalid_code`: błędny lub już użyty kod.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (z `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: tylko po kroku hasła powiązania z Google – konto Google zostało w międzyczasie powiązane z innym kontem.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: tylko po kroku hasła powiązania z Google – konto zmieniło się w międzyczasie; rozpocznij logowanie od nowa.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: opóźnienie po niepowodzeniach na koncie albo konto wykorzystało już swoje `AUTH_MFA_PER_ACCOUNT` kodów z ostatnich 15 minut. `rate_limited`: limit `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` z `retryAfter: 1`: baza danych pozostała zablokowana (po kroku powiązania z Google nic nie zostało powiązane: rozpocznij logowanie od nowa). `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Wylogowanie tej sesji
      description: |-
        Unieważnia sesję użytego tokenu. WebSocket otwarty z tym tokenem zostaje zamknięty. Bez treści (lub `{}`).

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Wylogowano.
          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`: treść inna niż `{}`; `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`: limit `sessions` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Wylogowanie wszystkich sesji
      description: |-
        Unieważnia wszystkie sesje konta, łącznie z tą. WebSockety otwarte z nimi zostają zamknięte. Bez treści (lub `{}`).

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Wszystkie sesje zostały wylogowane.
          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`: treść inna niż `{}`; `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`: limit `sessions` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Ponowne wysłanie linku potwierdzającego
      description: |-
        Ponownie wysyła link potwierdzający adres e-mail. Odpowiedź to 202 `accepted` niezależnie od adresu, aby nigdy nie zdradzała, czy używa go konto lub rejestracja.

        Żądanie działa najwyżej raz na 5 minut na adres. Oczekująca rejestracja z tym adresem znowu dostaje swoje 24 godziny, niezależnie od tego, czy adresu używa inne konto, aby jej nazwa użytkownika pozostawała zarezerwowana równie długo w obu przypadkach. Link jest wysyłany tylko dla tej rejestracji, gdy adres nie ma konta (nowy link, ważny 24 godziny, zastępuje poprzedni), albo dla aktywnego, niepotwierdzonego konta z tym adresem.

        **Limity:** `auth`, potem `auth_mail` (10 na godzinę na klienta).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Zaakceptowano (niezależnie od adresu).
          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`: limit `auth` lub `auth_mail` (niezależnie od adresu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` z `retryAfter: 1`: baza danych pozostała zablokowana podczas wyszukiwania konta lub zapisywania linku niepotwierdzonego konta (odnowienie oczekującej rejestracji jest wykonywane w miarę możliwości i nigdy nie powoduje niepowodzenia żądania); 5-minutowy okres adresu i tak się rozpoczyna, więc nowe żądanie w tym czasie nic nie robi. `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Wysłanie linku do resetowania hasła
      description: |-
        Wysyła link do resetowania hasła, ważny przez godzinę, który otwiera stronę `/reset-password`. Odpowiedź to 202 `accepted` niezależnie od adresu. Link trafia tylko do aktywnego konta, najwyżej raz na 5 minut na adres. Konto logujące się tylko przez Google ustawia w ten sposób swoje pierwsze hasło.

        Odzyskiwanie hasła ma najsurowsze limity w API, wszystkie liczone dla całego serwera: 3 żądania na godzinę (`AUTH_FORGOT_PER_HOUR`) i 10 na 24 godziny (`AUTH_FORGOT_PER_DAY`) na klienta (adres IPv4 lub sieć IPv6 /64), 3 razy tyle na sieć IPv6 /48, a do tego limit `auth` (20 na 10 minut) i jeden e-mail na adres co 5 minut. Ani 202, ani 429 nie zdradza, czy adresu używa jakieś konto. Ustawienie nowego hasła ma własny limit (`auth_reset`).

        **Limity:** `auth`, `auth_forgot`, potem `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Zaakceptowano (niezależnie od adresu).
          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`: limit `auth`, `auth_forgot` lub `auth_forgot_day` (niezależnie od adresu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` z `retryAfter: 1`: baza danych pozostała zablokowana podczas wyszukiwania konta lub zapisywania linku; 5-minutowy okres adresu i tak się rozpoczyna, więc nowe żądanie w tym czasie nie wysyła linku. `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Ustawienie nowego hasła tokenem z linku do resetowania hasła
      description: |-
        Ustawia nowe hasło przy użyciu tokenu z linku do resetowania hasła (strona `/reset-password` robi to samo). Nowe hasło podlega zasadom rejestracji.

        Zresetowanie hasła ma te skutki, w jednej transakcji (wszystkie albo żaden): unieważnia wszystkie sesje i anuluje oczekującą zmianę adresu e-mail; pozostałe linki do resetowania hasła tego konta przestają działać; adres uznaje się za potwierdzony (link tego dowiódł); właściciel dostaje e-mail. Weryfikacja dwuetapowa pozostaje bez zmian. Po błędzie kolejki haszowania haseł, 503 lub 500 nic się nie zmieniło: stare hasło i sesje pozostają, a link pozostaje ważny.

        **Limity:** `auth`, potem `auth_reset` (10 na godzinę na klienta, 30 na sieć IPv6 /48, wspólny ze stroną: każda próba haszuje hasło).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Hasło zostało zmienione, a wszystkie sesje wylogowane.
          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`: link jest nieprawidłowy, został użyty lub wygasł albo został wysłany na adres, którego konto już nie ma. `weak_password` (z `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`: limit `auth` lub `auth_reset` albo kolejka haszowania haseł.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: kolejka haszowania haseł albo baza danych pozostała zablokowana (`retryAfter: 1`, nic się nie zmieniło). `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Rozpoczęcie logowania przez Google
      description: |-
        Rozpoczyna próbę logowania przez Google dla wyzwania PKCE i portu gniazda nasłuchującego gry na `127.0.0.1`. Odpowiedź podaje URL Google do otwarcia w przeglądarce systemowej oraz `state` próby; próba jest ważna przez 10 minut.

        `authUrl` zawiera `client_id`, `redirect_uri` (zbudowany z `redirectPort` i znacznika źródła serwera), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 własnego weryfikatora serwera dla Google) z `code_challenge_method=S256` oraz `prompt=select_account`. Gra sprawdza go przed otwarciem (zob. opis sekcji).

        **Limit:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: Próba.
          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`: logowanie przez Google nie jest dostępne na tym serwerze.'
        '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`: limit `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Rozpoczęcie logowania przez Google w witrynie
      description: |-
        Rozpoczyna próbę logowania przez Google z witryny dla wyzwania PKCE jej strony. Odpowiedź jest taka sama jak dla gry: URL Google, pod który należy przejść, i `state` próby; próba jest ważna przez 10 minut. Dostępne, gdy `GET /info` podaje `sso.googleWeb: true`.

        `authUrl` zawiera `client_id` witryny i `redirect_uri=GOOGLE_WEB_REDIRECT_URI`, z tymi samymi pozostałymi parametrami co dla gry. Strona sprawdza go, zanim pod niego przejdzie (zob. **W witrynie** w opisie sekcji).

        **Limit:** `sso_start`, wspólny z rozpoczęciem logowania z gry.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      responses:
        '200':
          description: Próba.
          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-web.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fscacelith.com%2Faccount%2Fgoogle%2F&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'
          description: '`invalid_request`: brakujący lub nieprawidłowy `codeChallenge` albo dowolne inne pole (także `redirectPort`).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: logowanie przez Google nie jest dostępne na tym serwerze albo nie jest dostępne dla witryn.'
        '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`: limit `sso_start` (wspólny z rozpoczęciem logowania z gry).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Przekazanie odpowiedzi Google serwerowi
      description: |-
        Przekazuje serwerowi `code` i `state`, które Google wysłało do gniazda nasłuchującego gry lub do strony witryny, razem z identyfikatorem próby i weryfikatorem PKCE. Identyfikator próby jest bezużyteczny bez weryfikatora, a kod jest bezużyteczny bez własnego weryfikatora PKCE serwera i klucza tajnego klienta (client secret).

        Serwer sprawdza próbę (nieznana, użyta lub wygasła: 410), potem weryfikator (błędny pozostawia próbę do użycia), następnie zużywa próbę, sprawdza `state` i `iss`, wymienia kod z Google i weryfikuje token ID. Odpowiedź (200) to jedna z:

        - `{ token, expiresAt, user }`: zalogowano na powiązane konto;
        - `{ mfaRequired, mfaToken, expiresIn }`: dalej `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: nowe konto; dalej `POST /auth/sso/complete` w ciągu 10 minut. `suggestedUsername` pochodzi z nazwy w Google lub z adresu i jest `""`, gdy nic nie pasuje;
        - `{ needsPassword, linkTicket, username, expiresIn }`: konto z tym adresem ma hasło; dalej `POST /auth/sso/google/link`. Nic nie jest jeszcze powiązane.

        **Limit:** `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: Sesja, drugi krok albo następny krok pierwszego logowania przez Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Zalogowano na powiązane konto
                  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: Weryfikacja dwuetapowa jest włączona
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Nowy gracz
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Adresu używa konto z hasłem
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: weryfikator nie pasuje do wyzwania próby (próba pozostaje do użycia). `sso_email_unverified`: Google nie potwierdziło adresu. `registration_closed`. `account_disabled`. Tylko dla powiązanego konta: `banned` (z `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: logowanie przez Google nie jest dostępne na tym serwerze.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: adresu używa aktywne konto bez hasła (jego nazwa nie jest podawana).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: nieznana, użyta lub wygasła próba.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limit `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Powiązanie Google z istniejącym kontem przy użyciu jego hasła
      description: |-
        Po tym, jak `finish` odpowiedział `needsPassword`: wiąże Google z istniejącym kontem przy użyciu hasła tego konta, wpisanego w grze. Odpowiedź (200) to sesja (powiązanie zostaje zapisane, a gracz zalogowany) albo, gdy weryfikacja dwuetapowa jest włączona, `{ mfaRequired, mfaToken, expiresIn }`: dalej `POST /auth/login/mfa`, a powiązanie jest zapisywane dopiero wtedy, gdy kod przejdzie tam.

        Błędne hasło pozostawia bilet do użycia, łącznie na 5 prób. Próba jest naliczana tuż przed sprawdzeniem hasła: 429 `too_many_attempts` lub 428 `pow_required` nie zużywa żadnej, natomiast odmowa kolejki haszowania haseł (503 `server_busy`, 429 `rate_limited`) zużyła już jedną i liczy się jako niepowodzenie konta. Opóźnienie po niepowodzeniach to ten sam licznik co w `POST /auth/login`.

        **Limit:** `auth` (z liczeniem na sieć IPv6 /48). **Dowód pracy:** jak w `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: Sesja albo drugi krok logowania.
          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`: błędne hasło; bilet pozostaje do użycia, łącznie na 5 prób.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (z `until`), tylko po poprawnym haśle.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: logowanie przez Google nie jest dostępne na tym serwerze.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: w międzyczasie konto Google zostało powiązane z innym kontem.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: nieznany, użyty lub wygasły bilet, 5. błędne hasło, konto, którego status lub adres zmienił się od czasu `finish`, albo konto, którego status, adres, hasło lub weryfikacja dwuetapowa zmieniły się w trakcie zapisywania powiązania. Rozpocznij logowanie od nowa.'
        '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` (opóźnienie po niepowodzeniach na koncie) albo `rate_limited` (limit `auth`, kolejka haszowania haseł).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: kolejka haszowania haseł albo baza danych pozostała zablokowana (`retryAfter: 1`, nic nie zostało powiązane). `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Założenie konta przy pierwszym logowaniu przez Google
      description: |-
        Po tym, jak `finish` odpowiedział `needsUsername`: zakłada konto z wybraną nazwą użytkownika (obowiązują zasady rejestracji) i je loguje. Konto nie ma hasła: `hasPassword` ma wartość `false`, a opcja „Nie pamiętasz hasła?” (`POST /auth/password/forgot`) pozwala je ustawić.

        **Limit:** `auth` (z liczeniem na sieć 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: Konto zostało założone i zalogowane.
          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`: logowanie przez Google nie jest dostępne na tym serwerze.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (konto ma tę nazwę użytkownika albo rezerwuje ją oczekująca rejestracja z innym adresem), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: nieznany, użyty lub wygasły bilet.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limit `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Lista zalogowanych urządzeń
      description: |-
        Aktywne sesje konta (zalogowane urządzenia), od ostatnio używanej. `current` oznacza sesję, z której wysłano żądanie. `lastSeenAt` jest aktualizowane najwyżej co 5 minut (później, gdy serwer mocno nie nadąża); `expiresAt` to bezwzględny koniec, a limit bezczynności może zakończyć sesję wcześniej.

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Aktywne sesje.
          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`: limit `sessions` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Wylogowanie jednego urządzenia
      description: |-
        Wylogowuje jedną sesję konta; można wylogować także bieżącą. WebSocket otwarty z tą sesją zostaje zamknięty. Bez treści (lub `{}`).

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Sesja została wylogowana.
          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`: treść inna niż `{}` albo `id` z nieprawidłowym kodowaniem URL; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: na tym koncie nie ma aktywnej sesji o tym identyfikatorze.'
        '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`: limit `sessions` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Pobranie konta, jego rankingów i aktywnych sankcji
      description: |-
        Konto tak, jak widzi je jego gracz: widok konta (także `user` w każdej odpowiedzi logowania), jeden wpis rankingowy na każdą kategorię, w której gracz rozegrał partie rankingowe, aktywne sankcje i zawieszenie. Poziom wiarygodności nadany przez system wykrywania oszustw nigdy nie jest pokazywany.

        **Limit:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Konto.
          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` albo `invalid_token` (także gdy konto zostało usunięte).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limit `account` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Przyjmowanie lub odrzucanie bezpośrednich wyzwań
      description: |-
        Określa, czy inni gracze mogą wyzwać tego gracza, podając jego nazwę. Przy `none` bezpośrednie wyzwania są odrzucane: wyzywający dowiaduje się, że gracz jest niedostępny. Bez ponownego uwierzytelnienia.

        **Limit:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Preferencje obowiązujące od teraz.
          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`: limit `account` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Zmiana hasła
      description: |-
        Zmienia hasło. Wymagane jest obecne hasło, ale nie drugi składnik, nawet przy włączonej weryfikacji dwuetapowej. Nowe hasło podlega zasadom rejestracji (sprawdzanym po obecnym haśle).

        Zmiana ma następujące skutki, w jednej transakcji (wszystkie albo żaden): unieważnia wszystkie pozostałe sesje (ta pozostaje zalogowana), anuluje oczekującą zmianę adresu e-mail, sprawia, że linki do resetowania hasła tego konta przestają działać, i wysyła właścicielowi wiadomość e-mail.

        **Ponowne uwierzytelnienie:** tylko hasło. **Limity:** `reauth`, potem `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: Hasło zostało zmienione.
          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` (konto logujące się tylko przez Google), `weak_password` (z `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: błędne obecne hasło albo resetowanie lub zmiana hasła, które nastąpiły w trakcie sprawdzania żądania.'
        '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` (nieudane ponowne uwierzytelnienia konta) albo `rate_limited` (limit `reauth` lub `reauth_user`, budżet konta, kolejka haszowania haseł).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: kolejka haszowania haseł albo baza danych pozostała zablokowana (`retryAfter: 1`, nic się nie zmieniło). `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Rozpoczęcie włączania weryfikacji dwuetapowej
      description: |-
        Zapisuje nowy oczekujący sekret uwierzytelniający, który zastępuje każdy wcześniejszy oczekujący sekret, i zwraca go dla aplikacji uwierzytelniającej (jako tekst i jako URI `otpauth://` do wyświetlenia w postaci kodu QR). Weryfikacja dwuetapowa nie jest jeszcze włączona: włącza ją `POST /account/mfa/totp/enable` kodem z tego sekretu.

        **Ponowne uwierzytelnienie:** tylko hasło. **Limity:** `reauth`, potem `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Oczekujący sekret.
          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` (konto logujące się tylko przez 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` (sprawdzane przed hasłem).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (nieudane ponowne uwierzytelnienia konta) albo `rate_limited` (limit `reauth` lub `reauth_user`, budżet konta, kolejka haszowania haseł).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (kolejka haszowania haseł albo baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Dokończenie włączania weryfikacji dwuetapowej i pobranie kodów odzyskiwania
      description: |-
        Włącza weryfikację dwuetapową kodem z oczekującego sekretu (dokładnie 6 cyfr). Hasło nie jest tu wymagane; podano je przy konfiguracji. Odpowiedź zawiera 10 kodów odzyskiwania, pokazywanych tylko ten jeden raz.

        **Limity:** `reauth`, potem `reauth_user`; błędny kod liczy się do nieudanych ponownych uwierzytelnień konta.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Weryfikacja dwuetapowa jest włączona.
          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` nie ma dokładnie 6 cyfr albo treść łamie schemat; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: błędny kod (sprawdź czas na urządzeniu).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: brak oczekującego sekretu (najpierw wywołaj `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` (nieudane ponowne uwierzytelnienia konta) albo `rate_limited` (limit `reauth` lub `reauth_user`, budżet konta).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Wyłączenie weryfikacji dwuetapowej
      description: |-
        Wyłącza weryfikację dwuetapową. Sekret i kody odzyskiwania zostają usunięte, a właściciel dostaje wiadomość e-mail.

        **Ponowne uwierzytelnienie:** hasło oraz kod z aplikacji uwierzytelniającej lub kod odzyskiwania (wymagane jest jedno z pól `code` lub `recoveryCode`). **Limity:** `reauth`, potem `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: Weryfikacja dwuetapowa jest wyłączona.
          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` (konto logujące się tylko przez Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (nie podano ani `code`, ani `recoveryCode`; sprawdzane przed hasłem), `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` (nieudane ponowne uwierzytelnienia albo zbyt wiele kodów wypróbowanych na tym koncie) albo `rate_limited` (limit `reauth` lub `reauth_user`, budżet konta, kolejka haszowania haseł).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (kolejka haszowania haseł albo baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Wymiana kodów odzyskiwania
      description: |-
        Zastępuje kody odzyskiwania 10 nowymi; stare przestają działać.

        **Ponowne uwierzytelnienie:** hasło oraz kod z aplikacji uwierzytelniającej w `code` (kod odzyskiwania jest odrzucany z 403 `invalid_code`). **Limity:** `reauth`, potem `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: Nowe kody odzyskiwania, pokazywane tylko ten jeden raz.
          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` (konto logujące się tylko przez Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (także dla kodu odzyskiwania).'
        '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` (nieudane ponowne uwierzytelnienia albo zbyt wiele kodów wypróbowanych na tym koncie) albo `rate_limited` (limit `reauth` lub `reauth_user`, budżet konta, kolejka haszowania haseł).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (kolejka haszowania haseł albo baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Zmiana adresu e-mail
      description: |-
        Zmienia adres e-mail konta. Z `newEmail` usuwane są białe znaki na początku i na końcu, litery są zamieniane na małe, a potem adres jest sprawdzany tak jak adres podawany przy rejestracji.

        **Z potwierdzeniem e-mailem** (domyślnie): 202 `verification_sent`. Na nowy adres wysyłany jest link ważny przez 24 godziny; otwiera on `/confirm-email-change`, a adres zmienia się dopiero wtedy, gdy gracz naciśnie przycisk na tej stronie. Do tego czasu `GET /account/me` pokazuje nowy adres jako `pendingEmail`. Nowe żądanie zastępuje oczekujące; zmiana lub resetowanie hasła je anuluje. Na dany nowy adres wysyłany jest najwyżej jeden link co 5 minut, niezależnie od tego, kto o niego prosi (żądanie zmiany, która już oczekuje, zachowuje wcześniej wysłany link, który pozostaje ważny); odpowiedź jest taka sama. Obecny adres dostaje powiadomienie, że zażądano zmiany na zamaskowany adres (`a***@example.org`). Odpowiedź i `pendingEmail` są takie same, gdy nowego adresu używa już inne konto: link nie jest wtedy wysyłany, więc ta zmiana nigdy się nie dokona, a zamiast tego właściciel tego adresu dostaje powiadomienie (najwyżej jedno na godzinę).

        Po potwierdzeniu linku adres się zmienia i jest uznawany za potwierdzony, urządzenia pozostają zalogowane, wcześniej wysłane linki (potwierdzenie, resetowanie hasła, inne zmiany) przestają działać, a poprzedni adres dostaje o tym powiadomienie, w którym nowy adres jest zamaskowany.

        **Bez potwierdzenia e-mailem** (`REQUIRE_EMAIL_VERIFICATION=false`): adres zmienia się od razu (200 `email_changed`), a poprzedni adres dostaje o tym powiadomienie. Jeśli adresu używa inne konto, odpowiedzią jest 409 `email_taken`, a właściciel tego adresu dostaje powiadomienie.

        **Ponowne uwierzytelnienie:** hasło oraz, przy weryfikacji dwuetapowej, kod z aplikacji uwierzytelniającej lub kod odzyskiwania. `invalid_email` i `same_email` są sprawdzane przed hasłem, więc nie liczą się jako nieudana próba. **Limity:** `reauth`, potem `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: Adres zmienił się od razu (na tym serwerze nie ma potwierdzenia e-mailem).
          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: Nowy adres w postaci, w jakiej został zapisany.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Na nowy adres wysłano link potwierdzający (albo adres należy do innego konta; odpowiedź tego nie zdradza).
          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` (obecny adres konta), `password_not_set` (konto logujące się tylko przez Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (także gdy zmiana lub resetowanie hasła wyprzedziły żądanie: link nie jest wysyłany), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: tylko bez potwierdzenia e-mailem.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (nieudane ponowne uwierzytelnienia albo zbyt wiele kodów wypróbowanych na tym koncie) albo `rate_limited` (limit `reauth` lub `reauth_user`, budżet konta, kolejka haszowania haseł).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: kolejka haszowania haseł albo baza danych pozostała zablokowana (`retryAfter: 1`: nic się nie zmieniło i to samo żądanie można wysłać ponownie). `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Pobranie danych konta
      description: |-
        Wszystko, co serwer przechowuje o koncie, jako jeden plik JSON do zapisania (`format` `scacelith-account-export`, `version` 1). Eksport zapisuje zdarzenie dotyczące bezpieczeństwa (`account_exported`). Tablica `notes` dokumentu wyjaśnia graczowi prostym językiem angielskim, co zostało pominięte.

        **Nigdy w eksporcie:** skrót hasła, sekret weryfikacji dwuetapowej i kody odzyskiwania; jakikolwiek token sesji lub linku ani jego skrót; dane systemu wykrywania oszustw (poziom i wynik wiarygodności, anomalie, analiza partii, waga zgłoszenia); zgłoszenia innych graczy dotyczące tego gracza; tożsamość moderatorów; prywatne dane innych graczy (przeciwnicy występują pod publiczną nazwą i z rankingiem, nic nie zdradza, czy inny gracz został objęty sankcją, i nie jest dołączany żaden adres IP, który może należeć do innej osoby).

        **Ponowne uwierzytelnienie:** hasło oraz, przy weryfikacji dwuetapowej, kod z aplikacji uwierzytelniającej lub kod odzyskiwania. **Limity:** `account_export` (5 na godzinę na gracza, dla całego serwera; liczy się każda próba, także nieudana; sprawdzany jako pierwszy), potem `reauth` i `reauth_user`. **Limit czasu:** 60 sekund.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Dokument eksportu jako załącznik.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Znaki nazwy użytkownika inne niż litery, cyfry, `_`, `.` i `-` są zamieniane na `_`.'
              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` (konto logujące się tylko przez 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` (nieudane ponowne uwierzytelnienia albo zbyt wiele kodów wypróbowanych na tym koncie) albo `rate_limited` (limit `account_export`, `reauth` lub `reauth_user`, budżet konta, kolejka haszowania haseł).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza danych pozostała zablokowana, z nagłówkiem `Retry-After: 1`), `server_busy` (kolejka haszowania haseł), `timeout` (60 sekund). Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Usunięcie konta
      description: |-
        Usuwa konto; tej operacji nie można cofnąć.

        - Wszystkie sesje zostają od razu unieważnione: od tej chwili token dostaje 401 `invalid_token`.
        - Nazwa użytkownika zmienia się na `deleted#<id>`, na koncie i w każdym zapisie partii.
        - Wymazywane są: adres e-mail, skrót hasła, sekret weryfikacji dwuetapowej i kody odzyskiwania, sesje i tokeny linków, powiązanie z Google, wpis wiarygodności w systemie wykrywania oszustw oraz adresy IP zapisane ze zdarzeniami dotyczącymi bezpieczeństwa.
        - Rankingi i partie są zachowywane. Partie pozostają dostępne do odczytu pod anonimową nazwą, a `GET /players/{username}` odpowiada 404 dla poprzedniej nazwy.

        **Ponowne uwierzytelnienie:** hasło oraz, przy weryfikacji dwuetapowej, kod z aplikacji uwierzytelniającej lub kod odzyskiwania. **Limity:** `reauth`, potem `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: Konto zostało usunięte.
          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` (konto logujące się tylko przez 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` (nieudane ponowne uwierzytelnienia albo zbyt wiele kodów wypróbowanych na tym koncie) albo `rate_limited` (limit `reauth` lub `reauth_user`, budżet konta, kolejka haszowania haseł).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (kolejka haszowania haseł albo baza danych pozostała zablokowana), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Lista partii gracza z filtrami i podziałem na strony
      description: |-
        Partie zalogowanego gracza, od najnowszej, z filtrami i podziałem na strony, wraz z liczbą partii pasujących do filtra. Każdy parametr zapytania jest opcjonalny, a pusta wartość liczy się jak jego brak.

        Podział na strony: przekaż `next` z poprzedniej strony jako `before`; na ostatniej stronie `next` ma wartość `null`. `total` liczy partie pasujące do filtra na wszystkich stronach.

        **Limit:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: Identyfikator oficjalnej kategorii (`3+2` lub `3%2B2`) albo `custom` dla wszystkich partii z innym tempem gry.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Tylko partie rankingowe (`true`) lub towarzyskie (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Tylko partie wygrane, przegrane lub zremisowane, z perspektywy gracza. Partie przerwane pojawiają się tylko bez tego filtra.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Jedna strona historii.
          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` nie jest identyfikatorem partii), `invalid_limit` albo `invalid_filter` (`category`, `rated` lub `result`), każdy z `field` wskazującym parametr.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limit `account_games` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza danych pozostała zablokowana; `retryAfter: 1` w treści, bez nagłówka `Retry-After`), `server_busy` (wyszukiwanie sesji natrafiło na zablokowaną bazę danych), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Pobranie zapisu partii z ruchami i zegarami
      description: |-
        Jeden zapis partii, z ruchami i zegarami. Bez tokenu albo z tokenem gracza, który nie grał w tej partii, odpowiedź jest publiczna. Gdy w partii grał gracz, do którego należy token, odpowiedź zawiera dodatkowo `you` i `reportable`.

        **Limit:** `public_read` (na gracza z tokenem, na klienta bez tokenu).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Zapis partii.
          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`: nie jest to dodatnia liczba całkowita o najwyżej 16 cyfrach (poniżej 2^53); `invalid_request`: nieprawidłowe kodowanie URL.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: wysłano token, który jest nieprawidłowy.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: nie ma takiej partii.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limit `public_read` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza danych pozostała zablokowana; `retryAfter: 1` w treści, bez nagłówka `Retry-After`), `server_busy` (wyszukiwanie sesji natrafiło na zablokowaną bazę danych), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Pobranie partii jako pliku PGN
      description: |-
        Ta sama partia jako plik PGN: jedna partia z końcami wierszy `\n` i zapisem ruchów w wierszach krótszych niż 80 kolumn. Odpowiedź jest taka sama z tokenem i bez niego.

        Znaczniki, w tej kolejności: `Event` (`<SERVER_NAME> rated <category>` lub `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (data rozpoczęcia w UTC), `Round`, `White`, `Black`, `Result` (`*` dla partii przerwanej), `UTCDate` i `UTCTime` (rozpoczęcie), `WhiteElo` i `BlackElo` (rankingi na początku partii lub `-`), `WhiteRatingDiff` i `BlackRatingDiff` (zmiany, np. `+10` i `-10`, w każdej partii rankingowej, `+0`, gdy zasady rankingu pozostawiają ranking bez zmian; partia towarzyska, partia z własnym tempem gry i partia przerwana nie mają żadnego z nich), `TimeControl` (w sekundach), `Termination` (standardowa wartość PGN: `normal`; `time forfeit` – opadnięcie chorągiewki, także gdy partia kończy się remisem; `abandoned`; `rules infraction` – drugi nieprawidłowy ruch lub walkower za naruszenie zasad uczciwej gry; `unterminated` – partia przerwana), `PlyCount`, `ScacelithGameId` (identyfikator dziesiętny).

        Każdy ruch ma `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: stan zegara gracza wykonującego ruch po tym ruchu i czas naliczony za ten ruch, w dziesiątych częściach sekundy (z obcięciem). Wartość jest pomijana, gdy zapis partii jej nie zawiera. Po ostatnim ruchu następuje słownie podany powód zakończenia, a potem wynik.

        **Limit:** `public_read` (na gracza z tokenem, na klienta bez tokenu).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Plik 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: 'Tekst PGN w 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`: nie jest to dodatnia liczba całkowita o najwyżej 16 cyfrach (poniżej 2^53); `invalid_request`: nieprawidłowe kodowanie URL.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: wysłano token, który jest nieprawidłowy.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: nie ma takiej partii.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limit `public_read` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: między innymi gdy zapisanych ruchów nie da się odtworzyć aż do zapisanego zakończenia (serwer zapisuje to w logu).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza danych pozostała zablokowana; `retryAfter: 1` w treści, bez nagłówka `Retry-After`), `server_busy` (wyszukiwanie sesji natrafiło na zablokowaną bazę danych), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Pobranie partii z tego serwera jako animowanego GIF-a
      description: |-
        Partia jako animowany GIF. Nazwy i rankingi pochodzą z zapisu partii (rankingi na początku partii, usunięte konto jako `deleted#<id>`), podobnie jak wynik i sposób zakończenia (`Resignation`, `Loss on time`…).

        Sprawdzenia odbywają się w tej kolejności: `gif_disabled`, opcje obrazu (`size`, `orientation`, `delay`, `coords`), identyfikator partii, partia, jej długość.

        **Wymagana sesja:** limity są liczone na konto. **Limity:** `gif` przy każdym żądaniu; `gif_user_min`, `gif_user_hour`, `gif_ip_min` i `gif_ip_hour` tylko wtedy, gdy GIF trzeba wygenerować (zob. opis sekcji). **Limit czasu:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 sekund (domyślnie 45 sekund).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: 'Rozmiar obrazu: `small` (pola 32 px), `medium` (48 px) lub `large` (72 px).'
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Strona, która jest na dole szachownicy.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Milisekundy na ruch (od 1 do 6 cyfr dziesiętnych).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Czy litery kolumn i numery rzędów są rysowane wokół szachownicy (`1`), czy nie (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` z `field` (`size`, `orientation`, `delay` lub `coords`): wartość spoza dozwolonych. `invalid_game_id`. `invalid_request`: nieprawidłowe kodowanie URL.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: nie ma takiej partii. `gif_disabled`: serwer wyłączył GIF-y (`GIF_ENABLED=false`; żeton `gif` jest zwracany).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limit `gif`, limit renderowania lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: nie udało się wygenerować GIF-a (serwer zapisuje przyczynę w logu). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` z `retryAfter` (od 3 do 10 sekund) i `Retry-After`: kolejka renderowania jest pełna albo GIF czekał `GIF_QUEUE_TIMEOUT_MS` (10 sekund) na wolny wątek; wszystkie żetony limitów pobrane przez żądanie są zwracane. `busy`: baza danych pozostała zablokowana (`retryAfter: 1` tylko w treści). `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Utworzenie animowanego GIF-a z dowolnej partii przesłanej jako PGN
      description: |-
        Ten sam obraz co `GET /games/{id}/gif` dla dowolnej partii przesłanej jako tekst PGN: partii zapisanej przez grę, eksportu z innego serwisu, partii wpisanej ręcznie. Używana jest tylko pierwsza partia z tekstu.

        - Parser PGN przyjmuje to samo, co własny parser gry: każdy PGN zapisany przez ten serwer i typowe eksporty z innych serwisów (komentarze, warianty, NAG-i i adnotacje zegara są pomijane; numery ruchów i SAN są odczytywane tolerancyjnie; znacznik `FEN` określa pozycję początkową, chyba że `SetUp` ma wartość `"0"`).
        - Nazwy i rankingi pochodzą ze znaczników `White`, `Black`, `WhiteElo` i `BlackElo`. Litery ze znakami diakrytycznymi tracą te znaki, pozostałe znaki spoza drukowalnego ASCII są zamieniane na `?`, a długie nazwy są przycinane (48 znaków). Wynik pochodzi ze znacznika `Result`, a w razie jego braku z końca zapisu ruchów. Znacznik `Termination` jest pokazywany, chyba że ma wartość `normal`: wtedy wszystko mówi pozycja końcowa (mat, pat).
        - Ten endpoint sam sprawdza swoją treść: nieznane pole albo brakujące lub niebędące ciągiem znaków `pgn` daje odpowiedź 400 `invalid_request` z `field`; błędna opcja daje odpowiedź 400 `invalid_option` (`delayMs` musi być liczbą JSON, `coords` wartością logiczną; `null` jest odrzucane).

        **Wymagana sesja:** limity są liczone na konto. **Limity:** jak dla `GET /games/{id}/gif`. **Limit rozmiaru treści:** 135 168 bajtów, niezależnie od `HTTP_BODY_LIMIT` (PGN jako ciąg JSON, łącznie z sekwencjami ucieczki). **Limit czasu:** domyślnie 45 sekund.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Krótka partia wpisana ręcznie, bez współrzędnych
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Plik PGN ze wszystkimi opcjami
                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` z `field` (nieznane pole albo brakujące lub niebędące ciągiem znaków `pgn`; bez `field`, gdy treść nie jest obiektem); `invalid_json`; `invalid_option` z `field` (`size`, `orientation`, `delayMs` lub `coords`); `invalid_pgn` z `line` i `column` (liczonymi od 1, kolumny w znakach) oraz `message` parsera: nieprawidłowy lub niejednoznaczny ruch, uszkodzony znacznik, nieznany wariant, ponad 65 536 bajtów.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: serwer wyłączył GIF-y (`GIF_ENABLED=false`; żeton `gif` jest zwracany).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: treść większa niż 135 168 bajtów.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limit `gif`, limit renderowania lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: nie udało się wygenerować GIF-a (serwer zapisuje przyczynę w logu). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` z `retryAfter` (od 3 do 10 sekund) i `Retry-After`: kolejka renderowania jest pełna albo GIF zbyt długo czekał na wolny wątek; wszystkie żetony limitów pobrane przez żądanie są zwracane. `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Pobranie publicznego profilu gracza
      description: |-
        Publiczny profil gracza: rankingi w oficjalnych kategoriach (w kolejności serwera) i liczby partii. `games.total` liczy wszystkie zapisane partie, łącznie z towarzyskimi i przerwanymi; `games.rated`, `wins`, `draws` i `losses` są sumowane z wpisów rankingowych, więc obejmują tylko partie rankingowe. Odpowiedź jest taka sama z tokenem i bez niego.

        **Limit:** `public_read` (na gracza z tokenem, na klienta bez tokenu).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Profil.
          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`: nie jest to od 2 do 24 znaków z `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: wysłano token, który jest nieprawidłowy.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: nie ma takiego gracza albo konto zostało usunięte.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limit `public_read` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza danych pozostała zablokowana; `retryAfter: 1` w treści, bez nagłówka `Retry-After`), `server_busy` (wyszukiwanie sesji natrafiło na zablokowaną bazę danych), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Lista ostatnich partii gracza
      description: |-
        Ostatnie partie gracza, od najnowszej, podzielone na strony jak historia, ale bez filtrów i bez łącznej liczby partii. `color` to strona, którą grał ten gracz. `next` jest identyfikatorem ostatniej partii zawsze, gdy strona jest pełna, więc następna strona może być pusta. Gracz jest wyszukiwany najpierw: nieznany gracz daje 404 bez względu na parametry zapytania.

        **Limit:** `public_read` (na gracza z tokenem, na klienta bez tokenu).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Jedna strona partii gracza.
          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` nie jest identyfikatorem partii), `invalid_limit` (te dwa bez `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: wysłano token, który jest nieprawidłowy.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: nie ma takiego gracza albo konto zostało usunięte.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limit `public_read` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza danych pozostała zablokowana; `retryAfter: 1` w treści, bez nagłówka `Retry-After`), `server_busy` (wyszukiwanie sesji natrafiło na zablokowaną bazę danych), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Pobranie najlepszych graczy kategorii
      description: |-
        100 najlepszych wpisów rankingowych oficjalnej kategorii z co najmniej `minGames` (`PROVISIONAL_GAMES`) partiami wliczonymi do rankingu, z pominięciem usuniętych kont i potwierdzonych oszustów. Serwer przelicza każdą tabelę najwyżej co 10 sekund; `updatedAt` mówi, kiedy to nastąpiło. Dla danej kategorii naraz trwa tylko jedno obliczenie: w tym czasie pozostałe żądania dostają poprzednią listę albo czekają na obliczenie, jeśli listy jeszcze nie ma.

        **Limity:** tylko warstwa limitów na adres.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Identyfikator oficjalnej kategorii (`3+2` lub `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Liczba graczy, od 1 do 100. Większa liczba o najwyżej 3 cyfrach liczy się jako 100; pusta wartość liczy się jak brak parametru.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: Tabela.
          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` (brak kategorii albo nie jest to oficjalna kategoria), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: warstwa limitów na adres.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza danych pozostała zablokowana; `retryAfter: 1` w treści, bez nagłówka `Retry-After`), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Zgłoszenie przeciwnika z niedawnej partii
      description: |-
        Zgłasza przeciwnika z jednej z własnych partii gracza, która zakończyła się w ciągu ostatnich 7 dni. Zgłoszenie samo w sobie nigdy nie zmienia rankingu, sankcji ani poziomu wiarygodności. Podnosi priorytet przeglądu widoczny dla moderatorów i, z wyjątkiem `abuse`, zleca analizę partii przez silnik.

        Ponowne zgłoszenie tego samego przeciwnika za tę samą partię dostaje tę samą odpowiedź i niczego nie zmienia; odpowiedź nigdy nie zdradza niczego o zgłoszonym koncie. `GET /games/{id}` z góry informuje graczy partii, czy zgłoszenie zostałoby przyjęte (`reportable`).

        Ten endpoint sam sprawdza swoją treść, w kolejności `gameId`, `reported`, `category`, `comment`: błąd daje odpowiedź 400 `invalid_request` bez `field`. Nieznane mu pola są ignorowane.

        **Limity:** `reports` (na gracza), potem `REPORTS_PER_DAY` (5) zgłoszeń na gracza w ciągu 24 godzin (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: Zgłoszenie zostało przyjęte (albo złożono je już wcześniej).
          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` (bez `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: nie jest to przeciwnik zgłaszającego w partii, która zakończyła się w ciągu ostatnich 7 dni.'
        '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`, z nagłówkiem `Retry-After`): `REPORTS_PER_DAY` zgłoszeń w ciągu ostatnich 24 godzin. `rate_limited`: limit `reports` lub budżet konta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (wyszukiwanie sesji natrafiło na zablokowaną bazę danych), `timeout`. Także `server_busy` z `retryAfter` od 2 do 5, gdy serwer mocno nie nadąża (zob. wprowadzenie): żądanie nie zostało wykonane.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Serwer oficjalny (strony znajdują się w katalogu głównym serwera, poza `/api/v1`).
      - url: https://{host}:{port}
        description: Dowolny serwer Scacelith (strony znajdują się w katalogu głównym serwera, poza `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Publiczna nazwa hosta serwera (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Publiczny port API (`PUBLIC_API_PORT`, w przeciwnym razie `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Wyświetlenie strony potwierdzenia adresu e-mail
      description: |-
        Strona linku potwierdzającego adres e-mail (rejestracja albo ponownie wysłana wiadomość z potwierdzeniem). Pokazuje tylko przycisk „Confirm my e-mail address” (Potwierdź mój adres e-mail), aby skaner poczty otwierający link go nie zużył; przycisk wysyła formularz do `POST /verify-email`.

        **Limit:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Strona z przyciskiem potwierdzenia.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Link jest nieprawidłowy lub wygasł (strona HTML).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Limit `page` (strona HTML) albo warstwa limitów na adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Potwierdzenie adresu e-mail
      description: |-
        Formularz strony potwierdzenia. Potwierdza adres; w przypadku nowej rejestracji wtedy zakłada konto, a gracz może się zalogować.

        **Limit:** `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: Adres został potwierdzony (a konto z nowej rejestracji założone).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Link jest nieprawidłowy, został użyty lub wygasł albo formularz jest nieprawidłowy (strona HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Link nowej rejestracji, której nazwę użytkownika lub adres zajęło w międzyczasie inne konto; konto nie zostaje założone (strona HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Limit `auth` (strona HTML) albo warstwa limitów na adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Baza danych pozostała zablokowana (`Retry-After: 1`) albo serwer mocno nie nadąża (`Retry-After` od 2 do 5, zob. wprowadzenie): nic się nie zmieniło, a link nadal działa. Także przekroczenie limitu czasu obsługi żądania.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Serwer oficjalny (strony znajdują się w katalogu głównym serwera, poza `/api/v1`).
      - url: https://{host}:{port}
        description: Dowolny serwer Scacelith (strony znajdują się w katalogu głównym serwera, poza `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Publiczna nazwa hosta serwera (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Publiczny port API (`PUBLIC_API_PORT`, w przeciwnym razie `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Wyświetlenie formularza resetowania hasła
      description: |-
        Strona linku do resetowania hasła: formularz nowego hasła (hasło podawane dwukrotnie). Formularz jest wysyłany do `POST /reset-password`.

        **Limit:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Formularz nowego hasła.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Link jest nieprawidłowy (także gdy został wysłany na adres, którego konto już nie ma).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Limit `page` (strona HTML) albo warstwa limitów na adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Ustawienie nowego hasła z formularza resetowania
      description: |-
        Formularz strony resetowania; robi to samo co `POST /auth/password/reset`, w jednej transakcji: wszystkie urządzenia zostają wylogowane, oczekująca zmiana adresu e-mail zostaje anulowana, pozostałe linki do resetowania przestają działać, adres jest uznawany za potwierdzony, a właściciel dostaje wiadomość e-mail.

        Najpierw sprawdzany jest link, potem zgodność obu haseł, a następnie zasady dotyczące haseł. Gdy serwer jest zajęty, formularz wraca z `Retry-After`, a link pozostaje ważny.

        **Limity:** `auth`, potem `auth_reset` (współdzielony z `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: Hasło zostało zmienione, a wszystkie urządzenia wylogowane.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ponownie formularz z błędem (hasła się różnią, słabe hasło), nieprawidłowy link albo nieprawidłowy formularz (strona 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: Limit `auth` lub `auth_reset` (strona HTML), ponownie formularz z `Retry-After`, gdy ten klient ma zbyt wiele haseł oczekujących na haszowanie (link pozostaje ważny), albo warstwa limitów na adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Ponownie formularz z `Retry-After`, gdy serwer jest zajęty (kolejka haszowania haseł albo baza danych pozostała zablokowana); link pozostaje ważny. Gdy serwer mocno nie nadąża (zob. wprowadzenie), zamiast tego strona „Server error” (Błąd serwera) z `Retry-After` (od 2 do 5): nic nie zostało wykonane, a link pozostaje ważny. Także przekroczenie limitu czasu obsługi żądania.'
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Serwer oficjalny (strony znajdują się w katalogu głównym serwera, poza `/api/v1`).
      - url: https://{host}:{port}
        description: Dowolny serwer Scacelith (strony znajdują się w katalogu głównym serwera, poza `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Publiczna nazwa hosta serwera (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Publiczny port API (`PUBLIC_API_PORT`, w przeciwnym razie `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Wyświetlenie strony potwierdzenia zmiany adresu e-mail
      description: |-
        Strona linku do zmiany adresu e-mail: pokazuje nowy adres i nazwę konta oraz przycisk „Use this e-mail address” (Użyj tego adresu e-mail), który wysyła formularz do `POST /confirm-email-change`.

        **Limit:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Strona z nowym adresem i przyciskiem jego potwierdzenia.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Link jest nieprawidłowy lub wygasł (także gdy adres konta zmienił się od czasu żądania).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Limit `page` (strona HTML) albo warstwa limitów na adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Potwierdzenie nowego adresu e-mail
      description: |-
        Formularz strony zmiany adresu e-mail. Adres się zmienia i jest uznawany za potwierdzony; urządzenia pozostają zalogowane; wcześniej wysłane linki (potwierdzenie, resetowanie hasła, inne zmiany) przestają działać; poprzedni adres dostaje o tym powiadomienie, w którym nowy adres jest zamaskowany.

        **Limit:** `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: Adres został zmieniony.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Link jest nieprawidłowy, został użyty lub wygasł albo formularz jest nieprawidłowy (strona HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: W międzyczasie adres zajęło inne konto (strona HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Limit `auth` (strona HTML) albo warstwa limitów na adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Baza danych pozostała zablokowana (`Retry-After: 1`) albo serwer mocno nie nadąża (`Retry-After` od 2 do 5, zob. wprowadzenie): nic się nie zmieniło, a link nadal działa. Także przekroczenie limitu czasu obsługi żądania.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Serwer oficjalny, w katalogu głównym.
      - url: https://caissa.scacelith.com/api/v1
        description: Serwer oficjalny, pod `/api/v1`.
      - url: https://{host}:{port}
        description: Dowolny serwer Scacelith, w katalogu głównym.
        variables:
          host:
            default: caissa.scacelith.com
            description: Publiczna nazwa hosta serwera (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Publiczny port API (`PUBLIC_API_PORT`, w przeciwnym razie `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Dowolny serwer Scacelith, pod `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Publiczna nazwa hosta serwera (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Publiczny port API (`PUBLIC_API_PORT`, w przeciwnym razie `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Sprawdzenie, czy proces działa
      description: |-
        Odpowiada 200 `ok`, dopóki proces działa. Działa także `HEAD`. Każda inna metoda daje odpowiedź 405 `method_not_allowed` z `Allow: GET, HEAD` (łącznie z `OPTIONS`).

        **Limity:** tylko warstwa limitów na adres.
      security: []
      responses:
        '200':
          description: Proces działa.
          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`: warstwa limitów na adres.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Serwer oficjalny, w katalogu głównym.
      - url: https://caissa.scacelith.com/api/v1
        description: Serwer oficjalny, pod `/api/v1`.
      - url: https://{host}:{port}
        description: Dowolny serwer Scacelith, w katalogu głównym.
        variables:
          host:
            default: caissa.scacelith.com
            description: Publiczna nazwa hosta serwera (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Publiczny port API (`PUBLIC_API_PORT`, w przeciwnym razie `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Dowolny serwer Scacelith, pod `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Publiczna nazwa hosta serwera (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Publiczny port API (`PUBLIC_API_PORT`, w przeciwnym razie `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Sprawdzenie, czy serwer przyjmuje graczy
      description: |-
        Odpowiada 200 `ready`, gdy serwer przyjmuje graczy, i 503 `not_ready`, gdy się uruchamia lub zatrzymuje. Działa także `HEAD`. Każda inna metoda daje odpowiedź 405 `method_not_allowed` z `Allow: GET, HEAD` (łącznie z `OPTIONS`).

        **Limity:** tylko warstwa limitów na adres.
      security: []
      responses:
        '200':
          description: Serwer przyjmuje graczy.
          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`: warstwa limitów na adres.'
        '503':
          description: Serwer się uruchamia lub zatrzymuje.
          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: |-
        Token sesji (`sct_` i 43 znaki base64url) z odpowiedzi logowania, w nagłówku `Authorization`: `Authorization: Bearer <token>`. Prefiks to dokładnie `Bearer` (z rozróżnianiem wielkości liter) i jedna spacja; wartość bez niego albo token, który nie składa się z od 1 do 512 drukowalnych znaków ASCII, daje odpowiedź 401 `invalid_token`, jak każdy inny nieprawidłowy token. Pusty nagłówek liczy się jak brak nagłówka.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: 'Identyfikator partii: dodatnia liczba całkowita w zapisie dziesiętnym, o najwyżej 16 cyfrach, bez zer wiodących, poniżej 2^53.'
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: Identyfikator sesji w postaci podawanej przez `GET /auth/sessions`, zapisany bez zer wiodących (`03` to nie sesja 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Nazwa użytkownika gracza, bez rozróżniania wielkości liter. Serwer przyjmuje każdą nazwę złożoną z od 2 do 24 znaków z `[A-Za-z0-9_.-]` (najszersza reguła, jaką kiedykolwiek dopuszczał).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Identyfikator partii; wyświetlane są tylko starsze partie. Przekaż `next` z poprzedniej strony. Pusta wartość liczy się jak brak parametru.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Rozmiar strony, od 1 do 50. Większa liczba o najwyżej 3 cyfrach liczy się jako 50; pusta wartość liczy się jak brak parametru.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Token linku z wiadomości e-mail (43 znaki base64url). Gdy go brakuje lub jest błędny, wyświetlana jest strona „link invalid or expired” (link nieprawidłowy lub wygasł).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Każda odpowiedź serwera zabrania zapisywania w pamięci podręcznej.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Liczba sekund oczekiwania przed ponowną próbą; ta sama wartość co `retryAfter` w treści.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Wyzwanie bearer, z `error="invalid_token"` dla nieprawidłowego tokenu.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: Serwer zamyka połączenie po tej odpowiedzi.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (z `field`, gdy błąd dotyczy pola treści) albo `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Pole łamie schemat
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Treść nie jest JSON-em
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Słabe hasło
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: PGN, którego nie da się odczytać
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (brak nagłówka `Authorization`) albo `invalid_token` (token ma nieprawidłowy format, wygasł, został unieważniony lub należy do usuniętego konta).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Brak sesji
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Nieprawidłowy token
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: Żądanie zostało odrzucone.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Błędne hasło przy ponownym uwierzytelnieniu
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Zawieszone konto
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found` albo funkcja wyłączona na tym serwerze.'
      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`: treść nie dotarła w ciągu 10 sekund. Serwer zamyka połączenie.'
      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: Żądanie jest sprzeczne ze stanem serwera.
      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`: próba logowania przez Google lub bilet są nieznane, użyte lub wygasłe; rozpocznij logowanie od nowa.'
      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`: treść przekracza `HTTP_BODY_LIMIT` (domyślnie 16 384 bajty). Serwer zamyka połączenie.'
      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`: cel żądania przekracza 4096 znaków.'
      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`: treść nie jest typu `application/json` albo jej kodowanie znaków nie jest 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`: partia ma więcej niż `GIF_MAX_PLIES` (600) półruchów.'
      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`: rozwiąż wyzwanie `pow` i wyślij to samo żądanie ponownie z `pow: { challenge, nonce }`. `reason` podaje przyczynę: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` lub `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` (limit żądań, budżet konta lub kolejka haszowania haseł) albo `too_many_attempts` (ograniczenie dotyczące konta), z `retryAfter` i nagłówkiem `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: Limit żądań
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Zbyt wiele nieudanych prób na tym koncie
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: nieoczekiwany błąd. Serwer zapisuje go w logu.'
      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`: błędny `state` lub `iss` albo Google odrzuciło kod lub przesłało token ID, którego nie da się zweryfikować (także kod lub token ID drugiego klienta – gry lub witryny). Odpowiedź nigdy nie zawiera tekstu od 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` lub `timeout`: spróbuj ponownie po `retryAfter`, jeśli ta wartość jest podana.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Serwer jest zajęty
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: Baza danych pozostała zablokowana (odczyt)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Serwer potrzebował zbyt dużo czasu
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: Animowany GIF jako załącznik.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` dla partii z tego serwera, `attachment; filename="scacelith-game.gif"` dla PGN przesłanego przez `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: Rozmiar pliku w bajtach.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: Strona HTML.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: Strona HTML z wyjaśnieniem odmowy.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Formularz nie dotarł w ciągu 10 sekund (strona HTML). Serwer zamyka połączenie.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: Strona HTML z wyjaśnieniem konfliktu.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Formularz przekracza `HTTP_BODY_LIMIT` (strona HTML). Serwer zamyka połączenie.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Treść nie jest ani formularzem (`application/x-www-form-urlencoded`), ani JSON-em albo jej kodowanie znaków nie jest UTF-8 (strona HTML).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Limit żądań (strona HTML), z `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: Nieoczekiwany błąd, w postaci strony HTML zatytułowanej „Server error” (Błąd serwera). Serwer zapisuje go w logu.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Serwer jest zajęty (baza danych pozostała zablokowana albo serwer mocno nie nadąża; z `Retry-After`) lub potrzebował zbyt dużo czasu; strona HTML zatytułowana „Server error” (Błąd serwera).
      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: Jedyny kształt każdej odpowiedzi z błędem w API JSON.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: Kod błędu w formacie `snake_case`. Na jego podstawie klient wybiera, co pokazać.
          examples:
            - rate_limited
        message:
          type: string
          description: Zdanie po angielsku, przeznaczone do logów i jako rozwiązanie awaryjne.
        retryAfter:
          type: integer
          minimum: 1
          description: Liczba sekund oczekiwania przed ponowną próbą, obecna tylko przy odmowach, które mijają z czasem. Odpowiedź zawiera wtedy także nagłówek `Retry-After` z tą samą wartością, z wyjątkiem 503 `busy` przy odczytach historii, partii, graczy i tabeli liderów.
        field:
          type: string
          description: Pole lub parametr zapytania, którego dotyczy błąd (`invalid_request`, `invalid_option` oraz `invalid_cursor`, `invalid_limit` i `invalid_filter` z `GET /account/games`). Pole zagnieżdżone jest zapisywane z kropką (`pow.nonce`).
        reason:
          type: string
          description: 'Przyczyna: reguła, którą łamie hasło w przypadku `weak_password`, albo powód zażądania lub odrzucenia dowodu pracy (`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: 'Tylko `banned`: koniec zawieszenia (ms od epoki Uniksa), `null` przy zawieszeniu na stałe.'
        line:
          type: integer
          minimum: 1
          description: 'Tylko `invalid_pgn`: wiersz z błędem, liczony od 1.'
        column:
          type: integer
          minimum: 1
          description: 'Tylko `invalid_pgn`: kolumna z błędem, liczona od 1, w znakach.'
    PowChallenge:
      type: object
      description: Wyzwanie dowodu pracy (`pow` w odpowiedzi 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: Podpisane wyzwanie, do odesłania bez zmian.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Liczba wiodących bitów zerowych, które musi mieć `SHA-256(challenge + ":" + nonce)`.
        expiresAt:
          type: integer
          format: int64
          description: 'Kiedy wyzwanie wygasa (ms od epoki Uniksa): 2 minuty po jego wydaniu.'
    PowAnswer:
      type: object
      description: Odpowiedź na wyzwanie dowodu pracy.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: '`challenge` z odpowiedzi 428, w niezmienionej postaci.'
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Ciąg dziesiętny o najwyżej 20 cyfrach, taki że `SHA-256(challenge + ":" + nonce)` zaczyna się od `bits` bitów zerowych.
    ServerInfo:
      type: object
      description: To, czego klient potrzebuje, zanim się zaloguje lub połączy.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Nazwa serwera (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: UUID nadawany bazie danych przy jej pierwszym uruchomieniu. Nie zmienia się po ponownych uruchomieniach. Ma wartość `null`, gdy baza danych nie może go podać.
        motd:
          type: string
          description: Wiadomość dnia (`SERVER_MOTD`), może być pusta.
        protocol:
          type: object
          description: Protokół WebSocket (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: Najstarsza wersja protokołu obsługiwana przez serwer.
            max:
              type: integer
              minimum: 1
              description: Najnowsza wersja protokołu obsługiwana przez serwer.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 'Odcisk schematu protokołu (pierwsze 4 bajty skrótu SHA-256 jego postaci kanonicznej jako liczba całkowita bez znaku: `0xc649140f`, 3326678031, dla wersji pomocniczej (minor) 2 protokołu); wyłącznie informacyjny, nigdy nie jest porównywany.'
            subprotocol:
              type: string
              description: Token podprotokołu WebSocket (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: Port WebSocket, którego używają gracze (`PUBLIC_WS_PORT`, w przeciwnym razie `WS_PORT`, a w przeciwnym razie `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Ścieżka WebSocketu.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Czy można zakładać nowe konta.
        emailVerification:
          type: boolean
          description: Czy nowe konta potwierdzają swój adres (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Czy logowanie przez Google jest dostępne dla gry.
            googleWeb:
              type: boolean
              description: Czy logowanie przez Google jest dostępne także dla witryny (`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: Weryfikacja dwuetapowa jest zawsze dostępna.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Liczba bitów dowodu pracy wymagana przy rejestracji (0, gdy dowód nie jest wymagany).
        categories:
          type: array
          description: Oficjalne (rankingowe) tempa gry (`RATED_CATEGORIES`), w kolejności serwera. Każde inne tempo gry to `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Zasady, które klient może sprawdzić przed wysłaniem formularza.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: Minimalna długość nazwy użytkownika (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: Maksymalna długość nazwy użytkownika (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: Wyrażenie regularne, do którego musi pasować nowa nazwa użytkownika.
            passwordMinLength:
              type: integer
              description: Minimalna długość hasła w znakach (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: Maksymalna długość hasła w bajtach UTF-8.
            customTimeControls:
              type: boolean
              description: Czy w wyzwaniach i partiach prywatnych można używać własnego tempa gry.
            reportsPerDay:
              type: integer
              description: Ile zgłoszeń gracz może złożyć w ciągu 24 godzin (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: Maksymalny rozmiar wiadomości WebSocket, jaką może wysłać klient, w bajtach.
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 3326678031
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
          googleWeb: 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: Oficjalne tempo gry.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 'Identyfikator kategorii: minuty i sekundy dodatku (`3+2`).'
        baseSec:
          type: number
          minimum: 0
          description: Czas podstawowy w sekundach.
        incSec:
          type: number
          minimum: 0
          description: Dodatek za ruch w sekundach.
    AccountView:
      type: object
      description: Konto tak, jak widzi je jego gracz (`user` w odpowiedziach logowania i w `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: Identyfikator konta.
        username:
          type: string
          description: Nazwa użytkownika.
        email:
          type: string
          format: email
          description: Adres e-mail.
        emailVerified:
          type: boolean
          description: Czy adres jest potwierdzony.
        mfaEnabled:
          type: boolean
          description: Czy weryfikacja dwuetapowa jest włączona.
        googleLinked:
          type: boolean
          description: Czy powiązano konto Google.
        hasPassword:
          type: boolean
          description: '`false` dla konta założonego przez Google, które nie ma jeszcze ustawionego hasła.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Czy bezpośrednie wyzwania po nazwie są przyjmowane (zob. `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Kiedy konto zostało założone (ms od epoki Uniksa).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Ostatnie logowanie (ms od epoki Uniksa) lub `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Nowy adres w zmianie adresu e-mail, która czeka na swój link, lub `null`.
    SessionAnswer:
      type: object
      description: Nowa sesja.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Token sesji do nagłówka `Authorization` i do WebSocketu.
        expiresAt:
          type: integer
          format: int64
          description: Bezwzględny koniec sesji (ms od epoki Uniksa); limit bezczynności może zakończyć ją wcześniej.
        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: Drugi krok logowania z weryfikacją dwuetapową; dalej `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: Token tego kroku, do `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Liczba sekund pozostałych na wykonanie tego kroku.
    LoginAnswer:
      description: Sesja albo drugi krok logowania z weryfikacją dwuetapową.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Pierwsze logowanie przez Google; dalej `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: Bilet do `POST /auth/sso/complete`, ważny przez 10 minut.
        suggestedUsername:
          type: string
          description: Nazwa użytkownika utworzona z nazwy w Google lub z adresu albo `""`, gdy nic nie pasuje.
    SsoNeedsPassword:
      type: object
      description: Adresu używa konto z hasłem; dalej `POST /auth/sso/google/link`. Nic nie jest jeszcze powiązane.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Bilet do `POST /auth/sso/google/link`.
        username:
          type: string
          description: Nazwa użytkownika tego konta (widzi ją tylko ktoś, kto udowodnił przed Google, że ten adres należy do niego).
        expiresIn:
          type: integer
          const: 600
          description: Liczba sekund ważności biletu.
    SsoFinishAnswer:
      description: Odpowiedź `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: Próba logowania przez Google.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Próba, do `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: URL Google do otwarcia w przeglądarce systemowej po sprawdzeniu.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state`, który Google musi odesłać.'
        expiresIn:
          type: integer
          const: 600
          description: Liczba sekund ważności próby.
    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: Nazwa użytkownika; następnie obowiązują zasady serwera (zob. opis).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Adres e-mail. Usuwane są białe znaki na początku i na końcu, a adres jest zapisywany małymi literami.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Hasło; następnie obowiązują zasady dotyczące haseł.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Nazwa użytkownika lub adres e-mail (dowolny tekst zawierający `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Opcjonalne. Wyświetlane na liście zalogowanych urządzeń (np. `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: '`mfaToken` z odpowiedzi logowania (lub z odpowiedzi logowania przez Google).'
        code:
          type: string
          maxLength: 32
          description: Opcjonalne. 6-cyfrowy kod z aplikacji uwierzytelniającej lub kod odzyskiwania.
        recoveryCode:
          type: string
          maxLength: 32
          description: Opcjonalne. Kod odzyskiwania. Wymagane jest jedno z pól `code` i `recoveryCode`.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Adres e-mail.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Parametr `token` linku do resetowania hasła.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Nowe hasło; obowiązują zasady dotyczące haseł przy rejestracji.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Wyzwanie PKCE S256 dla `codeVerifier` klienta (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: Port gniazda nasłuchującego klienta na `127.0.0.1`.
    SsoWebStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Wyzwanie PKCE S256 dla `codeVerifier` strony (`BASE64URL(SHA-256(codeVerifier))`).
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: '`attemptId` z `POST /auth/sso/google/start`.'
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: Weryfikator PKCE; jego SHA-256 musi odpowiadać wyzwaniu przekazanemu do `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state` w postaci odesłanej przez Google.'
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: '`code` w postaci odesłanej przez Google (drukowalne znaki ASCII bez spacji).'
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Opcjonalne. `iss` w postaci odesłanej przez Google, jeśli Google go odesłało.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`linkTicket` z `finish`, ważny przez 10 minut.'
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Hasło konta.
        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` z `finish`, ważny przez 10 minut.'
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Nazwa użytkownika; obowiązują zasady rejestracji.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Aktywne sesje, od ostatnio używanej.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Aktywna sesja (zalogowane urządzenie).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: Identyfikator sesji, do `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: Moment logowania (ms od epoki Uniksa).
        lastSeenAt:
          type: integer
          format: int64
          description: Ostatnie użycie (ms od epoki Uniksa), aktualizowane najwyżej co 5 minut (później, gdy serwer mocno nie nadąża).
        expiresAt:
          type: integer
          format: int64
          description: Bezwzględny koniec (ms od epoki Uniksa); limit bezczynności może zakończyć sesję wcześniej.
        clientLabel:
          type:
            - string
            - 'null'
          description: '`clientLabel` z logowania lub `null`, gdy go nie przesłano.'
        current:
          type: boolean
          description: Czy jest to sesja, z której wysłano żądanie.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Jeden wpis na każdą kategorię, w której gracz rozegrał partie rankingowe.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Aktywne sankcje.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` w czasie zawieszenia, w przeciwnym razie `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Wpis rankingowy jednej kategorii.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: Identyfikator kategorii.
        rating:
          type: integer
          description: Ranking.
        games:
          type: integer
          minimum: 0
          description: Partie rozegrane w kategorii (wliczone do rankingu lub nie).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Najwyższy osiągnięty ranking.
        provisional:
          type: boolean
          description: '`true`, dopóki ranking nie jest jeszcze ustalony lub ma mniej niż `PROVISIONAL_GAMES` partii wliczonych do rankingu (gra pokazuje go jako `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Rodzaj sankcji.
        reason:
          type:
            - string
            - 'null'
          description: Podany powód, jeśli jest.
        startsAt:
          type: integer
          format: int64
          description: Początek (ms od epoki Uniksa).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Koniec (ms od epoki Uniksa), `null`, gdy sankcja jest nałożona na stałe.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Koniec zawieszenia (ms od epoki Uniksa), `null` przy zawieszeniu na stałe.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all`, aby przyjmować bezpośrednie wyzwania po nazwie, `none`, aby je odrzucać.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Obecne hasło.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Nowe hasło; obowiązują zasady dotyczące haseł przy rejestracji.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Hasło konta.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Kod z oczekującego sekretu, dokładnie 6 cyfr.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Hasło konta.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcjonalne; wymagane przy weryfikacji dwuetapowej (albo `recoveryCode`). Kod z aplikacji uwierzytelniającej lub kod odzyskiwania.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcjonalne. Kod odzyskiwania (`xxxx-xxxx-xx`; wielkość liter, spacje i myślniki nie mają znaczenia).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Hasło konta.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Kod z aplikacji uwierzytelniającej (kod odzyskiwania jest odrzucany).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Nowy adres; po usunięciu białych znaków na początku i na końcu oraz zamianie liter na małe jest sprawdzany tak jak adres podawany przy rejestracji.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Hasło konta.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcjonalne; wymagane przy weryfikacji dwuetapowej (albo `recoveryCode`). Kod z aplikacji uwierzytelniającej lub kod odzyskiwania.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcjonalne. Kod odzyskiwania.
    TotpSetup:
      type: object
      description: Oczekujący sekret dla aplikacji uwierzytelniającej.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Sekret w base32 do wpisania w aplikacji.
        uri:
          type: string
          format: uri
          description: URI `otpauth://totp/...` do wyświetlenia jako kod QR.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Długość kroku w sekundach.
    RecoveryCodeList:
      type: array
      description: 10 kodów odzyskiwania, pokazywanych tylko ten jeden raz. Każdy działa jednokrotnie.
      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: Jedna ze stron partii.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Nazwa w zapisie partii (`deleted#<id>` dla usuniętego konta).
        rating:
          type:
            - integer
            - 'null'
          description: Ranking na początku partii, `null`, gdy nieznany.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Ranking po partii. Partia rankingowa zawsze go ma; `null` tylko dla partii, która nie liczy się do rankingów (towarzyskiej, z własnym tempem gry, przerwanej).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: Zmiana rankingu, `0`, gdy zasady rankingu pozostawiają ranking bez zmian (reguła FIDE dotycząca zerowego wyniku, przeciwnik bez ustalonego rankingu, pierwsze partie gracza bez ustalonego rankingu); `null` w tych samych przypadkach co `ratingAfter`.
    GameSummary:
      type: object
      description: Podsumowanie zapisanej partii.
      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: Identyfikator partii.
        category:
          type: string
          description: Identyfikator oficjalnej kategorii (`3+2`) lub `custom`.
        rated:
          type: boolean
          description: Czy partia liczy się do rankingów.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Tempo gry w sekundach (`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` (zob. kody partii).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Kod powodu zakończenia (zob. kody partii).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Wynik, `*` dla partii przerwanej.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Liczba półruchów.
        startedAt:
          type: integer
          format: int64
          description: Rozpoczęcie (ms od epoki Uniksa).
        endedAt:
          type: integer
          format: int64
          description: Zakończenie (ms od epoki Uniksa).
    Termination:
      type: string
      description: Nazwa powodu zakończenia (zob. kody partii); `Unknown` dla kodu, którego protokół nie zna.
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - ResignationVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: Podsumowanie partii z perspektywy jednego gracza.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Strona gracza.
    HistoryGameSummary:
      description: Partia z historii zalogowanego gracza.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Czas podstawowy w milisekundach.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Dodatek w milisekundach.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: Rezultat z perspektywy gracza.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Partie na tej stronie, od najnowszej.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Identyfikator do przekazania jako `before` dla następnej strony albo `null` na ostatniej stronie.
        total:
          type: integer
          minimum: 0
          description: Liczba partii pasujących do filtra na wszystkich stronach.
    MoveRecord:
      type: object
      description: Jeden półruch partii.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: Ruch w notacji UCI, z dodanym `n`, `b`, `r` lub `q` przy promocji.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Czas naliczony za ruch (ms), `null`, gdy zapis partii go nie zawiera.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Stan zegara gracza wykonującego ruch po tym ruchu (ms), `null`, gdy zapis partii go nie zawiera.
    PgnTagsView:
      type: object
      description: Główne znaczniki PGN, do wyświetlania. `Termination` jest tu nazwą powodu zakończenia; plik PGN używa standardowych wartości PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` lub `<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: Data rozpoczęcia w 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: Ranking na początku partii lub `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Ranking na początku partii lub `"-"`.
          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: Zapis partii z ruchami i zegarami. Ma pola podsumowania partii, bez `color` i `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Czas podstawowy w milisekundach.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Dodatek w milisekundach.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: Nazwa wartości `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: Identyfikator partii, której rewanżem jest ta partia, lub `null`.
            moves:
              type: array
              description: Jeden element na każdy półruch.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Tylko gdy w tej partii grał gracz, do którego należy token; jego strona.
            reportable:
              type: boolean
              description: Tylko gdy w tej partii grał gracz, do którego należy token; `true`, gdy `POST /reports` przyjąłby teraz zgłoszenie przeciwnika za tę partię (partia zakończyła się w ciągu 7 dni, dzienny limit nie został wyczerpany, a przeciwnik nie został jeszcze zgłoszony za tę partię).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Nazwa użytkownika w pisowni nadanej przez gracza.
        createdAt:
          type: integer
          format: int64
          description: Kiedy konto zostało założone (ms od epoki Uniksa).
        ratings:
          type: array
          description: Tylko oficjalne kategorie, w kolejności serwera.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Wszystkie zapisane partie, łącznie z towarzyskimi i przerwanymi.
            rated:
              type: integer
              minimum: 0
              description: Partie rankingowe, zsumowane z wpisów rankingowych.
            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: Nazwa użytkownika w pisowni nadanej przez gracza.
        games:
          type: array
          description: Partie na tej stronie, od najnowszej.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Identyfikator ostatniej partii zawsze, gdy strona jest pełna (następna strona może wtedy być pusta), w przeciwnym razie `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: Identyfikator kategorii.
        minGames:
          type: integer
          minimum: 0
          description: Liczba partii wliczonych do rankingu, jakiej wpis potrzebuje, aby znaleźć się na liście (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Kiedy tabela została obliczona (ms od epoki Uniksa).
        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: Tekst PGN, najwyżej 65 536 bajtów w UTF-8. Używana jest tylko pierwsza partia.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Opcjonalne. Rozmiar obrazu.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Opcjonalne. Strona, która jest na dole szachownicy.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Opcjonalne. Milisekundy na ruch, liczba JSON o wartości całkowitej.
        coords:
          type: boolean
          default: true
          description: Opcjonalne. Czy litery kolumn i numery rzędów są rysowane wokół szachownicy.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: Partia, jako liczba całkowita lub ciąg od 1 do 16 cyfr.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: Nazwa użytkownika przeciwnika, taka jak w zapisie partii lub obecna, bez rozróżniania wielkości liter. Nie może być pusta.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: Czego dotyczy zgłoszenie.
        comment:
          type:
            - string
            - 'null'
          description: Opcjonalne. Najwyżej 500 znaków po usunięciu znaków sterujących (innych niż tabulator i znak nowego wiersza) oraz białych znaków na początku i na końcu tekstu.
    AccountExport:
      type: object
      description: Wszystko, co serwer przechowuje o koncie (czasy w ms od epoki Uniksa).
      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: Kiedy wykonano eksport.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Co plik zawiera, a co pomija – prostym językiem angielskim, dla gracza.
          items:
            type: string
        account:
          description: Widok konta z `GET /account/me`, wraz z `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: Adres powiązanego konta Google lub `null`.
        ratings:
          type: array
          description: Pełne wpisy rankingowe.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Punkty rankingowe zwrócone po wykryciu oszukiwania u przeciwnika, zsumowane według dnia (UTC) i kategorii, od najnowszych. Nie są podawane ani partie, ani oszuści.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Wszystkie zapisane sesje, od najnowszej, bez żadnych tokenów. Czyszczenie po okresie przechowywania usuwa sesję wygasłą, a wylogowaną – dzień po wylogowaniu.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Zdarzenia dotyczące bezpieczeństwa, od najnowszego, przechowywane przez `RETENTION_SECURITY_DAYS` dni.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Wszystkie sankcje, łącznie ze zniesionymi. Nazwa moderatora nigdy nie jest dołączana.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Zdarzenia dotyczące zachowania zapisane dla partii gracza opuszczonych, przerwanych i tych, w których nie wykonano pierwszego ruchu w wyznaczonym czasie, przechowywane przez 30 dni.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Zgłoszenia złożone przez gracza.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Liczba partii.
            list:
              type: array
              description: Wszystkie partie, od najnowszej, jako podsumowania w formacie `GET /account/games`. Ruchy są dostępne w `GET /games/{id}`, a PGN w `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Pełny wpis rankingowy.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Czy gracz wyszedł już z fazy bez ustalonego rankingu.
            countedGames:
              type: integer
              minimum: 0
              description: Partie wliczone do rankingu.
            updatedAt:
              type: integer
              format: int64
              description: Kiedy wpis ostatnio się zmienił.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: Godzina 00:00 UTC danego dnia (ms od epoki Uniksa).
        category:
          type: string
        points:
          type: integer
          description: Punkty zwrócone tego dnia w tej kategorii.
    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: Kiedy sesja została wylogowana, lub `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: Adres, z którego nastąpiło logowanie, wymazywany po `RETENTION_IP_DAYS` dniach.
    SecurityEvent:
      type: object
      description: |-
        Zdarzenie dotyczące bezpieczeństwa. `ip` jest podawane tylko dla czynności wykonanych po zalogowaniu, z użyciem hasła konta (i drugiego składnika) lub linku wysłanego na jego adres: `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` i `account_exported`; każdy inny rodzaj ma `ip: null`, a `ip` jest wymazywane po `RETENTION_IP_DAYS` dniach.

        `detail` zachowuje tylko te pola: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` i `method` (`password` lub `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` i `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. Zdarzenie `moderator_action` zachowuje tylko `{ action }`, dla akcji `ban`, `unban`, `reset_mfa`, `verify_email` i `revoke_sessions` (pozostałe akcje moderatorów są pomijane). Każdy inny rodzaj ma `detail: null`. Zdarzenia `rating_refund` są pomijane: ich punkty znajdują się w `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Rodzaj zdarzenia (`login`, `password_changed`…).
        at:
          type: integer
          format: int64
          description: Kiedy nastąpiło.
        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: Obecna publiczna nazwa zgłoszonego gracza.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Czy zgłoszenie jest nadal otwarte (nie jest podawane, czy zgłoszony gracz został objęty sankcją).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Token linku z wiadomości e-mail (ukryte pole formularza strony).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Token linku do resetowania hasła (ukryte pole formularza).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Nowe hasło; obowiązują zasady dotyczące haseł przy rejestracji.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ponownie nowe hasło; musi być takie samo.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: Strona HTML w UTF-8 (`text/html; charset=utf-8`), bez JavaScriptu i zasobów zewnętrznych.
