openapi: 3.1.1
info:
  title: API-ul HTTPS al serverului Scacelith
  version: "1.1.0"
  summary: API-ul HTTPS al oricărui server Scacelith, atât al celui oficial, cât și al serverelor comunității.
  description: |-
    Orice server Scacelith, fie cel oficial, `caissa.scacelith.com`, fie un server al comunității, răspunde la acest API HTTPS. Jocul îl folosește pentru tot ce se află în afara partidei propriu-zise: înregistrarea și autentificarea (inclusiv autentificarea în doi pași și cea cu Google), pagina contului, istoricul partidelor, descărcările PGN și GIF-urile animate ale partidelor, dispozitivele conectate, descărcarea datelor, ștergerea contului și raportările.

    Jocul în timp real (căutarea adversarului, provocările, mutările, ceasurile) trece prin WebSocket-ul aceluiași server (`wss://<host>/ws`), deschis cu un token de sesiune al acestui API; este descris în `PROTOCOL.md`, nu aici.

    Valorile implicite de mai jos sunt cele ale unui server cu configurația neschimbată; un server al comunității le poate modifica (`CONFIG.md` enumeră fiecare setare).

    ## URL de bază, port și transport

    - Serverul oficial: `https://caissa.scacelith.com/api/v1`, portul TCP 443.
    - Un server al comunității: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` este implicit 443; în spatele unui NAT sau al unui proxy, portul folosit de jucători este `PUBLIC_API_PORT`.
    - WebSocket-ul jocului se află implicit pe același port (`WS_PORT` îl mută; `GET /info` îi spune clientului unde se află).
    - HTTP/1.1 peste TLS. Cu `TLS_MODE=proxy`, un proxy invers aflat în fața serverului face terminarea TLS. `TLS_MODE=off` (HTTP simplu) este doar pentru dezvoltare locală.
    - Paginile HTML deschise din linkurile din e-mailuri (`/verify-email`, `/reset-password`, `/confirm-email-change`) se află la rădăcina serverului, în afara `/api/v1`. Endpointurile de stare răspund atât la rădăcină, cât și sub `/api/v1`. Autentificarea cu Google nu are nicio pagină pe server: Google trimite browserul înapoi chiar la joc, pe `127.0.0.1`, sau la o pagină a site-ului web care a inițiat-o (`GOOGLE_WEB_REDIRECT_URI`).
    - O bară oblică finală este ignorată (`/api/v1/info/` este `/api/v1/info`), iar parametrii de cale sunt decodificați din codificarea URL (pentru un parametru care nu este o codificare URL validă, răspunsul este 400 `invalid_request`).

    ## Cereri

    - **Corpuri JSON.** Cererile `POST`, `PUT` și `DELETE` conțin un obiect JSON cu `Content-Type: application/json`. Un `charset` diferit de UTF-8 este refuzat, la fel ca orice alt tip de conținut (415 `unsupported_media_type`). Un corp gol contează ca `{}`, adică exact ce primesc endpointurile fără parametri (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Dimensiune și timp.** Un corp este limitat la `HTTP_BODY_LIMIT` octeți (implicit 16.384; `POST /gif` are propria limită, de 135.168 de octeți): 413 `payload_too_large`. Trebuie să sosească în cel mult 10 secunde: 408 `request_timeout`. După oricare dintre aceste erori, serverul închide conexiunea.
    - **Scheme stricte.** Un câmp pe care endpointul nu îl cunoaște este refuzat, un câmp care nu este marcat ca opțional este obligatoriu, iar tipurile și lungimile sunt verificate. Oricare dintre aceste erori duce la răspunsul 400 `invalid_request`, cu `field` indicând câmpul (cu puncte pentru un câmp imbricat, de exemplu `pow.nonce`). Șirurile nu pot conține caractere de control. Lungimile se numără în unități de cod UTF-16, deci un caracter din afara Planului Multilingv de Bază (un emoji) contează de două ori. Pentru un corp care nu este JSON, răspunsul este 400 `invalid_json`. `POST /gif` și `POST /reports` își verifică singure corpurile (vezi fiecare endpoint).
    - **Șiruri de interogare.** Contează prima apariție a unui parametru, parametrii necunoscuți sunt ignorați, iar un `+` se decodifică drept spațiu: endpointurile care primesc o categorie de ritm de joc acceptă atât `3%2B2`, cât și `3+2`.
    - **Metode.** `HEAD` este `GET` fără corp. `OPTIONS` pe o cale existentă primește răspunsul 204 cu un antet `Allow` (și anteturile unei cereri preflight CORS, mai jos). Orice altă metodă pe care calea nu o are primește răspunsul 405 `method_not_allowed` cu `Allow`.
    - **Ținta cererii.** Nu poate depăși 4096 de caractere (414 `uri_too_long`). O cerere care nu poate fi analizată deloc sau a cărei parte de început (linia de cerere și anteturile) este prea mare ori sosește prea încet primește un răspuns 400, 431 sau 408 gol, iar conexiunea este închisă.
    - **CORS: o listă de permisiuni, dezactivată implicit.** API-ul servește jocul, care nu trimite niciun `Origin`. Cu `CORS_ORIGINS` gol (valoarea implicită), nu se trimite niciodată vreun antet `Access-Control-*`, deci nicio pagină web nu poate citi un răspuns. `CORS_ORIGINS` enumeră originile web ale căror pagini pot apela API-ul dintr-un browser, fiecare exact așa cum o trimit browserele în `Origin`: `https://host` sau `https://host:port`, cu litere mici, fără cale, fără bară oblică finală și fără portul implicit; `http://` doar pentru `localhost`, `127.0.0.1` și `[::1]` (dezvoltare locală); fără caracter wildcard. Serverul oficial setează `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com` pentru site-ul său web. În acest caz:
      - Orice răspuns pentru o cale de sub `/api/v1`, cu excepția endpointurilor de stare, fie succes, fie eroare (inclusiv răspunsul 429 al nivelului per adresă), conține `Vary: Origin`. Când `Origin` din cerere este, octet cu octet, una dintre originile din listă, răspunsul conține și `Access-Control-Allow-Origin: <that origin>` și, cu excepția răspunsului la o cerere preflight, `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, pentru ca pagina să citească codurile de eroare, `Retry-After` și numele fișierelor descărcate PGN și GIF.
      - O cerere preflight (`OPTIONS` cu `Access-Control-Request-Method`) de la o origine din listă, pe o cale existentă, primește răspunsul 204, fără corp, cu `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (metodele din `Allow`), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600` și `Vary: Origin`. Nu are nevoie de niciun token și nu se contabilizează nici în bugetul vreunui cont, nici în vreo limită de endpoint; ca orice cerere, consumă un jeton al nivelului per adresă. Un browser refolosește o cerere preflight timp de 10 minute, dar numai pentru exact acel URL, inclusiv șirul de interogare. O cerere fără `Authorization` și fără corp JSON, cum ar fi `GET /info` sau `GET /leaderboard`, nu are nevoie de preflight.
      - Orice alt răspuns la o cerere preflight înseamnă un preflight eșuat, chiar și cu anteturile CORS: un 404 pentru o cale care nu există sau răspunsul 429 al nivelului per adresă. Browserul nu mai trimite atunci cererea, iar pagina vede o eroare de rețea, fără răspuns și fără antetul său `Retry-After`.
      - Nu se trimite niciodată `Access-Control-Allow-Credentials`, nici `Access-Control-Allow-Origin: *`: o pagină își trimite tokenul de sesiune în `Authorization: Bearer`, la fel ca jocul, iar API-ul nu setează niciun cookie. Paginile HTML, endpointurile de stare și răspunsurile goale la cererile care nu pot fi analizate nu conțin niciun antet CORS. WebSocket-ul nu folosește CORS: `WS_ALLOWED_ORIGINS` enumeră paginile care îl pot deschide.
      - O pagină a unei origini care nu figurează în listă nu citește nimic, iar scrierile sale cross-site nu sunt trimise niciodată: corpurile JSON și `Authorization` necesită o cerere preflight, al cărei răspuns nu conține `Access-Control-Allow-Origin` pentru acea origine. `Cross-Origin-Resource-Policy: same-origin` rămâne pe orice răspuns: se aplică doar cererilor `no-cors` (un alt site care încorporează un răspuns), niciodată cererilor `cors` ale unei pagini din listă.

    ## Răspunsuri și erori

    - Răspunsurile sunt JSON în UTF-8, cu excepția descărcării PGN (`application/x-chess-pgn`), a GIF-urilor animate (`image/gif`) și a paginilor HTML. Momentele de timp sunt exprimate în milisecunde de la 1970-01-01 UTC. Id-urile sunt numere întregi. Id-urile partidelor au până la 16 cifre, dar rămân sub 2^53, deci un număr JSON (un double) le reprezintă exact.
    - Orice răspuns al API-ului conține `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'` (paginile HTML au propria politică). Cu `TLS_MODE=native`, conține și `Strict-Transport-Security: max-age=31536000`. Răspunsurile pe care listenerul le scrie singur, înaintea API-ului (cele la `GET` și `HEAD` ale endpointurilor de stare, răspunsul 429 al nivelului per adresă și un 500 `internal_error` când API-ul eșuează complet), conțin aceleași anteturi, cu excepția `Cross-Origin-Resource-Policy`, iar `Content-Type` al lor este `application/json` fără charset.
    - Un răspuns trebuie citit în cel mult 60 de secunde din momentul în care serverul îl are pregătit, ceea ce contează doar pentru răspunsurile mari (un GIF, exportul datelor, un PGN lung). Timpul în care serverul pregătește un răspuns nu se socotește nici în acest termen, nici în cele 30 de secunde fără niciun octet primit sau trimis după care o conexiune este închisă.

    Erorile au o singură formă (schema `Error`):

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

    `message` este destinat jurnalelor și servește ca soluție de rezervă; un client alege ce afișează pe baza lui `error`. `retryAfter` (în secunde) este prezent doar la refuzurile care expiră cu timpul, iar răspunsul conține atunci și un antet `Retry-After` cu aceeași valoare; singura excepție este 503 `busy` al citirilor de istoric, partide, jucători și clasament, care are doar câmpul. Unele erori adaugă câmpuri: `field` (date de intrare nevalide), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` și `column` (`invalid_pgn`).

    Erori pe care le poate da orice endpoint:

    | Cod de stare | `error` | Când |
    |---|---|---|
    | 400 | `invalid_request` | Corpul încalcă schema endpointului (`field` indică unde), ținta cererii sau antetul `Content-Length` este malformat, un parametru de cale nu este o codificare URL validă sau corpul a fost trunchiat. |
    | 400 | `invalid_json` | Corpul nu este JSON. |
    | 401 | `unauthorized` | Lipsește antetul `Authorization` pe un endpoint care necesită o sesiune. |
    | 401 | `invalid_token` | Tokenul este malformat, a expirat, a fost revocat sau aparține unui cont șters; inclusiv pe endpointurile unde sesiunea este opțională. |
    | 404 | `not_found` | Endpointul nu există. Unele endpointuri îl folosesc și ele: partida, jucătorul sau sesiunea nu există. |
    | 405 | `method_not_allowed` | Calea există pentru alte metode (vezi `Allow`). |
    | 408 | `request_timeout` | Corpul nu a sosit în 10 secunde. |
    | 413 | `payload_too_large` | Corpul depășește `HTTP_BODY_LIMIT` (135.168 de octeți pentru `POST /gif`). |
    | 414 | `uri_too_long` | Ținta cererii depășește 4096 de caractere. |
    | 415 | `unsupported_media_type` | Corpul nu este `application/json` sau setul său de caractere nu este UTF-8. |
    | 429 | `rate_limited` | O limită de frecvență (vezi mai jos): `retryAfter` plus un antet `Retry-After`. |
    | 500 | `internal_error` | O defecțiune neașteptată. Serverul o înregistrează în jurnal. |
    | 503 | `timeout` | Serverul nu a răspuns în 30 de secunde (60 de secunde pentru export, 45 de secunde pentru GIF-uri cu setările implicite). |
    | 503 | `server_busy` | O căutare de sesiune sau o modificare a contului a găsit baza de date blocată (`retryAfter` 1) sau coada de hashing a parolelor este plină (vezi mai jos). De asemenea, cu un `retryAfter` de 2 până la 5, când serverul a rămas mult în urmă: pentru un `POST`, `PUT`, `PATCH` sau `DELETE`, baza de date are prea multe scrieri în așteptare; pentru orice cerere, prea multe cereri anterioare încă rulează după expirarea timpului lor sau tokenul de sesiune nu a putut fi verificat în 5 secunde. Cererea nu a fost executată, iar limitele sale de frecvență nu au consumat nimic. |

    Endpointurile de citire (istoricul partidelor, partide, jucători, clasament) și exportul răspund cu 503 `busy` și `retryAfter: 1` când baza de date a rămas blocată.

    Endpointurile care verifică o parolă sau îi calculează hash-ul pot răspunde și cu una dintre aceste erori, ambele cu un `retryAfter` aleatoriu de 5 până la 15 secunde:

    - 503 `server_busy`: coada de hashing a parolelor a serverului (`PASSWORD_HASH_QUEUE_MAX`) este plină sau timpul de așteptare a expirat.
    - 429 `rate_limited`: odată ce coada este pe jumătate plină, acest client (o adresă IPv4 sau un prefix IPv6 /48) are deja `PASSWORD_HASH_WAITERS_PER_SOURCE` hash-uri în așteptare. Acest 429 restituie jetoanele limitelor de frecvență pe care cererea le consumase.

    Nu s-a modificat nimic și nu s-a contabilizat nicio încercare eșuată (cu excepția `POST /auth/sso/google/link`, unde încercarea tichetului și eșecul contului au fost luate în calcul înaintea calculului hash-ului), iar un link de resetare rămâne valid.

    Paginile HTML își returnează erorile sub formă de pagini HTML, cu aceleași coduri de stare.

    ## Autentificare

    Endpointurile care necesită o sesiune primesc un token bearer în antetul `Authorization` (schema `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Obținerea unui token.** Un token (`sct_` urmat de 43 de caractere base64url) se obține de la `POST /auth/login`, apoi de la `POST /auth/login/mfa` când autentificarea în doi pași este activată, de la `POST /auth/sso/google/finish` și `POST /auth/sso/google/link` (autentificarea cu Google) și de la `POST /auth/sso/complete`. Fiecare dintre ele răspunde cu `{ token, expiresAt, user }`. Serverul stochează doar un hash SHA-256 al tokenului.
    - **Sesiune obligatorie.** Dacă antetul lipsește, răspunsul este 401 `unauthorized` cu `WWW-Authenticate: Bearer realm="scacelith"`. Pentru un token nevalid, răspunsul este 401 `invalid_token` cu `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Un client ar trebui să uite un token care primește `invalid_token` și să se autentifice din nou.
    - **Sesiune opțională.** Pe `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` și `GET /players/{username}/games`, sesiunea este opțională. Fără antet, acestea răspund cu vizualizarea publică; dacă se trimite un antet, tokenul său trebuie să fie valid.
    - **Stocare ocupată.** Când baza de date rămâne blocată în timp ce un token este verificat, un endpoint care necesită sau acceptă o sesiune răspunde cu 503 `server_busy` și `retryAfter: 1` (și `Retry-After`); când verificarea durează mai mult de 5 secunde, răspunde cu 503 `server_busy` și un `retryAfter` de 2 până la 5. În ambele cazuri, tokenul nu a fost evaluat: păstrați-l și trimiteți din nou cererea.
    - **Durata de viață.** O sesiune se încheie la primul dintre aceste termene: `SESSION_MAX_DAYS` (90) zile după autentificare (adică `expiresAt`) sau `SESSION_IDLE_DAYS` (30) zile fără utilizare. Fiecare utilizare amână limita de inactivitate; serverul scrie noua valoare cel mult o dată la 5 minute, în fundal. Cât timp baza sa de date are multe scrieri în așteptare, amână această scriere pentru o cerere ulterioară, cu excepția ultimei ore dinaintea limitei de inactivitate, astfel încât o sesiune folosită să nu expire. Un cont păstrează cel mult `MAX_SESSIONS_PER_USER` (10) sesiuni: o autentificare nouă revocă sesiunile cele mai vechi care depășesc acest număr.
    - **Revocare.** Deconectarea (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) revocă sesiuni; la fel o schimbare a parolei (celelalte sesiuni), o resetare a parolei și ștergerea contului (toate sesiunile), precum și un administrator. O revocare prin API are efect imediat, iar WebSocket-ul deschis cu o sesiune revocată este închis. O schimbare sau o resetare a parolei și ștergerea contului revocă sesiunile în aceeași tranzacție cu modificarea însăși: când una dintre ele eșuează, nicio sesiune nu a fost revocată și nici nimic altceva nu s-a schimbat. Serverul păstrează în cache căutările de sesiune timp de 30 de secunde, astfel că o revocare prin comanda de administrare, un proces separat, are efect în cel mult 30 de secunde.
    - **Domeniu.** Un token aparține unui singur server și deschide și WebSocket-ul acestuia. Nu-l trimiteți niciodată altui server.

    ## Limite de frecvență și alte restricții

    Limitele se aplică la unul dintre două domenii. **Client:** o adresă IPv4 sau un prefix IPv6 /64 (în modul proxy, adresa provine din `X-Forwarded-For`, trimis de o adresă din `TRUSTED_PROXIES`); unele limite numără și fiecare prefix IPv6 /48 în ansamblu, pe lângă fiecare dintre rețelele sale /64. **Jucător:** contul autentificat, indiferent de adresă; o limită numărată per jucător pe un endpoint unde sesiunea este opțională se numără per client pentru o cerere fără token.

    Fiecare limită este o găleată de jetoane care cuprinde `limit` cereri și se reumple continuu cu ritmul `limit / window`; `retryAfter` este timpul până la următorul jeton. Limitele marcate *partajate* sunt numărate și pe o fereastră glisantă de aceeași lungime, astfel încât nicio fereastră să nu cuprindă mult mai mult de `limit` cereri. Fiecare limită este numărată pentru întregul server. Când una dintre limitele unui endpoint refuză o cerere, jetoanele pe care celelalte limite ale sale le-au consumat pentru acea cerere sunt restituite. La un refuz, răspunsul este 429 `rate_limited` cu `retryAfter` și `Retry-After`.

    - **Nivelul per adresă.** Orice cerere (orice cale și metodă, inclusiv endpointurile de stare și upgrade-urile la WebSocket) consumă mai întâi un jeton din bugetul clientului său: `HTTP_RATE_PER_IP` (600) pe minut, cu o rafală de jumătate de minut, și `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) pentru un prefix IPv6 /48. Un client poate avea, de asemenea, cel mult `IP_MAX_INFLIGHT` (32 x `WORKERS`) cereri în curs (`retryAfter` 1 peste această valoare). Un client care continuă după refuzuri este blocat: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) refuzuri într-un minut îl blochează pentru 1 minut, apoi pentru 4, 16 și 60 de minute la fiecare nouă blocare în decurs de 6 ore. Un refuz al limitelor `auth`, `auth_*` și `reauth` contează cât 5; refuzurile limitelor numărate per jucător nu contează niciodată. Adresele din `ABUSE_EXEMPT` ocolesc acest nivel, dar nu și bugetul contului sau limitele endpointurilor.
    - **Bugetul contului.** Orice cerere care conține un token de sesiune valid se contabilizează și în bugetul contului său: `USER_RATE_PER_MIN` (120) pe minut pentru toate endpointurile la un loc, indiferent de adresă, cu o rafală de jumătate de minut.
    - **Limitele endpointurilor:**

    | Limită | Valoare implicită | Numărată per | Endpointuri |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, partajată | client, și `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) per prefix 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) / oră, partajată | client, de 3 ori mai mult per prefix IPv6 /48 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / oră, partajată | client, de 3 ori mai mult per prefix IPv6 /48 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / oră, partajată | client, de 3 ori mai mult per prefix IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 de ore, partajată | client, de 3 ori mai mult per prefix IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / oră, partajată | client, de 3 ori mai mult per prefix IPv6 /48 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | valorile lui `auth`, într-o găleată proprie, partajată | client și prefix 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, partajată | jucător | aceleași endpointuri ca `reauth` |
    | `account` | 60 / min | jucător | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | jucător | `GET /account/games` |
    | `account_export` | 5 / oră, partajată | jucător | `POST /account/export` (verificată înaintea `reauth`; fiecare încercare contează) |
    | `sessions` | 60 / min | jucător | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | jucător (client fără token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (o singură găleată pentru toate patru) |
    | `gif` | 30 / min | jucător | `GET /games/{id}/gif`, `POST /gif` (o singură găleată pentru amândouă) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min și `GIF_USER_RENDERS_PER_HOUR` (30) / oră, partajate | jucător | aceleași două, numai când GIF-ul trebuie generat (nu vine din cache) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min și `GIF_IP_RENDERS_PER_HOUR` (120) / oră, partajate | client (toate conturile sale la un loc), de 3 ori mai mult per prefix IPv6 /48 | aceleași, ca mai sus |
    | `reports` | 30 / oră | jucător | `POST /reports` |
    | `sso_start` | 30 / 10 min, partajată | client, și 90 per prefix IPv6 /48 | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (împreună) |
    | `sso_finish` | 30 / min | client | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | client | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` și `GET /leaderboard` nu au o limită proprie: doar nivelul per adresă. Un endpoint cu mai multe limite le verifică în ordinea dată în descrierea sa.

    Serverul tratează o cerere în această ordine: nivelul per adresă, apoi endpointurile de stare; identificarea endpointului; verificările că serverul nu a rămas mult în urmă (503 `server_busy`): prea multe cereri anterioare care încă rulează după expirarea timpului lor și, pentru un `POST`, `PUT`, `PATCH` sau `DELETE`, scrierile restante ale bazei de date; autentificarea, în cel mult 5 secunde (503 `server_busy` peste acest timp); bugetul contului, când cererea conține o sesiune; limitele endpointului; corpul; endpointul propriu-zis (endpointurile GIF își aplică limitele de randare doar când au de generat un GIF). Astfel, o cerere refuzată din cauza tokenului său nu consumă niciunul dintre jetoanele endpointului, iar o cerere cu un corp nevalid le consumă.

    Alte restricții, aplicate chiar de endpointuri:

    - **Autentificări eșuate pentru același identificator** (nume de utilizator sau e-mail): începând de la `AUTH_FAILURES_PER_ACCOUNT` (5) eșecuri, fiecare încercare trebuie să aștepte de două ori mai mult decât cea anterioară (2 s, 4 s și așa mai departe, până la 15 minute): 429 `too_many_attempts` cu `retryAfter`. Contorul se resetează după o oră fără eșecuri.
    - **Al doilea factor greșit la autentificare:** aceeași regulă, începând cu al 5-lea cod greșit al contului. Un pas de autentificare acceptă cel mult 5 coduri greșite.
    - **Codurile de al doilea factor ale unui cont:** cel mult `AUTH_MFA_PER_ACCOUNT` (10) coduri (coduri din aplicația de autentificare sau coduri de recuperare, corecte sau greșite) la 15 minute, de la orice adresă, la autentificare și la reautentificări; peste această limită, 429 `too_many_attempts` înainte de verificarea codului, astfel încât un cod de recuperare nu este consumat.
    - **Reautentificări eșuate ale unui cont** (parolă sau cod greșit): aceeași regulă (429 `too_many_attempts`), comună tuturor endpointurilor care cer reautentificare.
    - **E-mailuri:** un e-mail de confirmare sau de resetare per adresă la fiecare 5 minute (răspunsul rămâne același); o notificare „someone tried to use your address” (cineva a încercat să vă folosească adresa) per adresă pe oră.
    - **Raportări:** `REPORTS_PER_DAY` (5) per jucător în 24 de ore: 429 `report_limit`.

    ## Dovadă de lucru

    `POST /auth/register` necesită întotdeauna o dovadă de lucru când `POW_REGISTER_BITS` este peste 0 (implicit 18; `GET /info` îl indică în `pow.register`). `POST /auth/login` și `POST /auth/sso/google/link` (un singur tip de challenge pentru amândouă) o necesită doar timp de 5 minute după ce serverul observă un val de autentificări eșuate (`POW_LOGIN_TRIGGER_PER_MIN`, apoi `POW_LOGIN_BITS`); clientul află acest lucru din răspuns.

    1. Cererea fără dovadă (sau cu o dovadă refuzată) primește răspunsul 428 `pow_required` cu `reason` și un challenge `pow` `{ challenge, bits, expiresAt }`.
    2. Găsiți un nonce: un șir zecimal de cel mult 20 de cifre astfel încât `SHA-256(challenge + ":" + nonce)` să înceapă cu `bits` biți zero (începând cu bitul cel mai semnificativ al primului octet). 18 biți necesită în medie aproximativ 260.000 de hash-uri.
    3. Trimiteți din nou aceeași cerere cu `"pow": { "challenge": "...", "nonce": "123456" }` în corp.

    Un challenge este valabil 2 minute și o singură dată, pentru un singur endpoint și o singură rețea a clientului (o adresă IPv4 sau un prefix IPv6 /64). Este semnat, deci serverul nu păstrează nimic despre el până când acesta revine. `reason` indică de ce a fost refuzată o dovadă: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` sau `replayed`.

    ## Reautentificare

    Modificările contului cer din nou parola și, când autentificarea în doi pași este activată, un al doilea factor:

    - doar parola: `POST /account/password` și `POST /account/mfa/totp/setup`;
    - parola și un cod din aplicația de autentificare, un cod de recuperare fiind refuzat: `POST /account/mfa/recovery-codes`;
    - parola și un cod din aplicația de autentificare sau un cod de recuperare: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` și `POST /account/delete`.

    În corpuri, `code` conține un cod de 6 cifre din aplicația de autentificare, iar `recoveryCode` un cod de recuperare (`xxxx-xxxx-xx`; literele mari sau mici, spațiile și cratimele nu contează). Un cod de recuperare poate fi trimis și în `code` acolo unde codurile de recuperare sunt acceptate. Fiecare cod funcționează o singură dată: un cod din aplicație deja folosit este refuzat până la următorul interval de 30 de secunde, iar un cod de recuperare dispare odată ce a fost folosit.

    | Cod de stare | `error` | Când |
    |---|---|---|
    | 403 | `invalid_password` | Parolă greșită sau parola a fost resetată ori schimbată în timpul executării cererii (nu s-a modificat nimic). |
    | 403 | `mfa_code_required` | Autentificarea în doi pași este activată și nu s-a trimis nici `code`, nici `recoveryCode`. |
    | 403 | `invalid_code` | Cod greșit sau deja folosit. |
    | 400 | `password_not_set` | Un cont care folosește doar Google nu are încă parolă („Ați uitat parola?” permite setarea uneia). |
    | 429 | `too_many_attempts` | Prea multe eșecuri sau prea multe coduri încercate pe acest cont. |
    | 503 / 429 | `server_busy` / `rate_limited` | Coada de hashing a parolelor este ocupată. |

    Parolele și codurile greșite se numără în contorul de eșecuri al contului și sunt înregistrate ca evenimente de securitate.

    ## Portul de metrici

    Pe lângă portul API-ului, serverul răspunde prin HTTP simplu pe portul de metrici (`METRICS_PORT`, 9464, legat la `METRICS_BIND`, implicit 127.0.0.1; păstrați-l privat). Acesta nu face parte din acest API: `GET /healthz` răspunde `ok`; `GET /readyz` răspunde `ready` după finalizarea pornirii (fiecare shard și-a reluat jurnalul, iar listenerele sunt legate) și până la începutul opririi, altfel 503 `not ready`; `GET /metrics` servește metricile Prometheus și, dacă `METRICS_TOKEN` este setat, necesită `Authorization: Bearer <token>` cu exact acel token.
  contact:
    name: Scacelith
    url: https://github.com/DarkCenobyte/scacelith-chess-server
  license:
    name: GPL-3.0-or-later
    identifier: GPL-3.0-or-later
externalDocs:
  description: Codul sursă al serverului Scacelith și documentația sa de referință (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Serverul oficial.
  - url: https://{host}:{port}/api/v1
    description: Orice server Scacelith, de exemplu un server al comunității.
    variables:
      host:
        default: caissa.scacelith.com
        description: Numele public de gazdă al serverului (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: Portul public al API-ului (`PUBLIC_API_PORT`, altfel `API_PORT`).
tags:
  - name: server-info
    x-displayName: Informații despre server
    description: Ce trebuie să știe un client înainte de a se autentifica sau de a se conecta.
  - name: health
    x-displayName: Starea serverului
    description: |-
      Dacă serverul funcționează (liveness) și dacă este pregătit (readiness), pentru monitorizare. Aceste endpointuri răspund pe portul API-ului înaintea autentificării și a oricărei limite de endpoint, dar, ca orice cerere, consumă un jeton al nivelului per adresă; o gazdă de monitorizare poate fi trecută în `ABUSE_EXEMPT`. Ambele căi funcționează pentru fiecare endpoint: la rădăcina serverului și sub `/api/v1`.

      Portul de metrici are propriile endpointuri de stare, descrise în introducere.
  - name: auth
    x-displayName: Înregistrare și autentificare
    description: |-
      Crearea unui cont, autentificarea cu parolă (și cu un al doilea factor), e-mailurile de confirmare și de resetare a parolei.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` și `POST /auth/sso/complete` răspund cu o sesiune `{ token, expiresAt, user }` (sau, pentru primele dintre ele, cu un al doilea pas).
  - name: google-sign-in
    x-displayName: Autentificare cu Google
    description: |-
      Disponibilă când `GET /info` indică `sso.google: true`; altfel, fiecare endpoint de mai jos răspunde cu 404 `sso_disabled`. Un site web se autentifică la fel, cu propriul client, când `sso.googleWeb` este true (mai jos, **Pe un site web**). Jocul se autentifică prin browserul sistemului, cu fluxul pentru aplicații instalate din RFC 8252 (un client de tip „Desktop app”): Google trimite browserul înapoi la un listener al jocului pe `127.0.0.1`, niciodată la acest server, iar jocul nu vede niciodată vreo credențială Google.

      1. Clientul ascultă pe `127.0.0.1:0` (sistemul alege portul) și creează o pereche PKCE: un `codeVerifier` de 43 până la 128 de caractere `[A-Za-z0-9._~-]` și `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, care are 43 de caractere și nu are padding.
      2. `POST /auth/sso/google/start`, cu challenge-ul și portul, returnează URL-ul Google și `state` al încercării. Clientul verifică URL-ul (mai jos) și îl deschide în browser.
      3. Google trimite browserul la `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Clientul acceptă doar `state` din răspunsul de pornire și nu trimite nimic după o redirecționare `error=`.
      4. `POST /auth/sso/google/finish` cu id-ul încercării, `codeVerifier`, `state` și `code` (și `iss`, când Google l-a trimis).
      5. Răspunsul este o sesiune, un pas de autentificare în doi pași (continuați cu `POST /auth/login/mfa`), `needsUsername` pentru un jucător nou (continuați cu `POST /auth/sso/complete`) sau `needsPassword` când un cont cu parolă folosește adresa (continuați cu `POST /auth/sso/google/link`).

      **Eticheta de origine.** URI-ul de redirecționare conține o etichetă a serverului adăugat de jucător, astfel încât jocul să refuze un URL pe care un alt server l-a obținut pentru propriii jucători. Originea este numele de gazdă cu litere mici (un literal IPv6 între paranteze drepte), `:` și portul API în zecimal, scris întotdeauna, inclusiv 443: serverul folosește `SERVER_PUBLIC_HOST` și portul său API public, iar jocul, adresa la care este conectat. Eticheta reprezintă primele 22 de caractere ale codificării base64url (fără padding) a SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + origine). URI-ul de redirecționare este `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: întotdeauna literalul IPv4 și portul listenerului jocului (de la 1024 la 65535). Serverul nu preia niciodată de la client un URI, o gazdă sau o cale.

      | Origine | Etichetă |
      |---|---|
      | `play.scacelith.example:443` | `IhcScoV7eDOzTEcSnqPUPt` |
      | `localhost:8443` | `TFGx7zQ_8QlGZW5zpqznCr` |
      | `[::1]:8443` | `XToJm0DG5PjciEVmZa9Cho` |
      | `127.0.0.1:50443` | `3r653wM5ZjYsHcAJljmCwY` |

      Prin urmare, autentificarea cu Google funcționează doar pentru jucătorii care au adăugat serverul exact cu `SERVER_PUBLIC_HOST` și portul său API public.

      **Ce verifică jocul înainte de a deschide browserul.** `authUrl` începe exact cu `https://accounts.google.com/o/oauth2/v2/auth?`, este ASCII imprimabil de sub 4096 de caractere, iar interogarea sa are exact un `response_type=code`, exact un `redirect_uri` egal cu URI-ul pe care jocul îl calculează din portul său și din eticheta sa de origine, exact un `state` egal cu `state` din răspuns, `code_challenge_method=S256` și un `code_challenge` de 43 de caractere. Altfel, jocul își oprește listenerul și nu deschide nimic. Trimite `finish` și `link` doar serverului care a răspuns la `start`.

      **Pe un site web.** Un site web (cel oficial: <https://scacelith.com/>) se autentifică cu un client „Web application” al aceluiași proiect Google (`GOOGLE_WEB_CLIENT_ID`). Google trimite browserul înapoi la o pagină a site-ului configurată de administratorul serverului (`GOOGLE_WEB_REDIRECT_URI`, trecută în „Authorized redirect URIs” ale clientului), iar acea pagină trimite codul la `finish`; nici aici serverul nu preia de la client vreun URI de redirecționare.

      1. Pagina creează o pereche PKCE ca mai sus, păstrează verificatorul în filă (`sessionStorage`) și trimite `POST /auth/sso/google/web/start` cu challenge-ul.
      2. Verifică `authUrl` la fel ca jocul, cu excepția faptului că `redirect_uri` trebuie să fie o pagină a propriei origini, apoi navighează la acest URL. Nu se încarcă niciun script Google.
      3. Google trimite browserul la `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. Pagina acceptă doar `state` din răspunsul de pornire și nu trimite nimic după o redirecționare `error=`.
      4. `finish`, `link` și `complete` urmează ca pentru joc. Încercarea reține că a fost inițiată de un site web: codul este schimbat folosind clientul și pagina site-ului, iar ID tokenul trebuie să numească acel client (un cod sau un ID token al clientului jocului eșuează cu 502 `sso_failed`, și invers).

      Un cont Google ajunge la același cont Scacelith din joc și de pe site-ul web: asocierea păstrează subiectul Google (`sub`), care identifică contul Google indiferent de client. Originea site-ului web trebuie să fie în `CORS_ORIGINS` (altfel, `check-config` emite un avertisment).

      **La ce cont ajunge autentificarea cu Google:** contul deja asociat cu acel cont Google; altfel, un cont activ cu adresa confirmată de Google (cu parolă, `finish` răspunde `needsPassword`, iar asocierea este stocată doar după ce trec parola contului, apoi al doilea factor, când este activat; fără parolă, 409 `sso_account_exists`); altfel, un cont nou (`needsUsername`). Un cont Google nu este niciodată asociat cu un cont existent doar pe baza adresei. Adresa contului primește un e-mail când autentificarea cu Google creează un cont și când este adăugată unui cont existent.
  - name: sessions
    x-displayName: Sesiuni
    description: Dispozitivele conectate ale contului și deconectarea.
  - name: account
    x-displayName: Cont
    description: Vizualizarea contului, preferințele și parola sa.
  - name: two-step-verification
    x-displayName: Autentificare în doi pași
    description: Aplicații de autentificare (TOTP, RFC 6238), cu SHA-1, 6 cifre, intervale de 30 de secunde și o toleranță de un interval în fiecare sens, plus coduri de recuperare de unică folosință.
  - name: email-change
    x-displayName: Schimbarea adresei de e-mail
    description: Schimbarea adresei contului, confirmată printr-un link trimis la noua adresă.
  - name: data-export
    x-displayName: Exportul datelor
    description: Tot ce păstrează serverul despre cont, într-un singur fișier JSON.
  - name: account-deletion
    x-displayName: Ștergerea contului
    description: Ștergerea definitivă a contului.
  - name: game-history
    x-displayName: Istoricul partidelor
    description: Partidele proprii ale jucătorului autentificat, filtrate și paginate.
  - name: games
    x-displayName: Partide și PGN
    description: |-
      Înregistrările partidelor, cu mutările și ceasurile lor, precum și fișierele lor PGN.

      **Codurile partidelor.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (se stochează doar partidele încheiate). `result`: `1-0`, `0-1`, `1/2-1/2` sau `*` (anulată). `reason`, cu numele său (`termination`) și cuvintele cu care se încheie textul mutărilor din fișierul PGN (în engleză în fișier, urmate mai jos de traducere); codurile 7, 14 și 21 sunt remize (jucătorul care a rămas fără timp, a abandonat sau a părăsit partida avea în față un adversar care nu putea da mat: FIDE 6.9 și 5.1.2; `DESIGN.md` 6.3 dă regula), iar serverul nu încheie niciodată o partidă cu codurile 4 și 13 (acestea fac parte din lista comună de motive):

      | Cod | `termination` | Cuvinte în PGN |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (șah mat) |
      | 2 | `Resignation` | Resignation (abandon) |
      | 3 | `Timeout` | Loss on time (pierdere la timp) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (a doua mutare ilegală, partidă pierdută) |
      | 5 | `Stalemate` | Stalemate (pat) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (poziție moartă, material insuficient) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (stegulețul a căzut, dar adversarul nu poate da mat) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (repetarea poziției de cinci ori) |
      | 9 | `SeventyFiveMoves` | 75-move rule (regula celor 75 de mutări) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (repetarea poziției de trei ori, cerută) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (regula celor 50 de mutări, cerută) |
      | 12 | `Agreement` | Draw by agreement (remiză de comun acord) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (a doua mutare ilegală, dar adversarul nu poate da mat) |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate (abandon, dar adversarul nu poate da mat) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (partidă părăsită, deconectare prea lungă) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (partidă părăsită, dar adversarul nu poate da mat) |
      | 22 | `Aborted` | Game aborted (partidă anulată) |
      | 23 | `NoShow` | Aborted: first move not played in time (anulată: prima mutare nu a fost făcută la timp) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (pierdere prin forfait, încălcarea regulilor de fair-play) |
      | 25 | `ServerAborted` | Aborted by the server (anulată de server) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (anulată: ambii jucători s-au deconectat) |

      Codul 14 este nou în versiunea minoră 1 a protocolului: un client scris înaintea acesteia ar trebui să afișeze un cod necunoscut ca pe o simplă încheiere a partidei, cu `result` aferent.
  - name: gifs
    x-displayName: GIF-uri animate
    description: |-
      O partidă sub formă de GIF animat, de păstrat sau de distribuit: tabla văzută de sus, câte un cadru pentru fiecare poziție, de la poziția inițială până la poziția finală, numele și ratingurile jucătorilor deasupra tablei, ultima mutare sub ea, iar pe ultimul cadru rezultatul și modul în care s-a încheiat partida.

      **Cost, cache și cote.** Un GIF este generat pe un fir de execuție de randare propriu, niciodată pe firele care rulează partidele, și cu cea mai mică prioritate CPU: `GIF_THREADS` fire de execuție (implicit `WORKERS`), pornite la primul GIF și oprite după un minut fără niciunul. Până la `GIF_QUEUE_MAX` (4 x `WORKERS`) GIF-uri așteaptă un fir de execuție, fiecare cel mult `GIF_QUEUE_TIMEOUT_MS` (10 s); o randare poate dura `GIF_RENDER_TIMEOUT_MS` (30 s). Serverul păstrează GIF-urile generate într-un cache de `GIF_CACHE_MB` MB (32 x `WORKERS`), cele folosite cel mai puțin recent fiind eliminate primele; un GIF din cache sau unul aflat în generare pentru o altă cerere nu costă nicio randare. Cheia cache-ului cuprinde tot ce schimbă imaginea, inclusiv numele.

      Fiecare cerere se numără în `gif` (30 pe minut per jucător pentru ambele endpointuri). Un GIF care trebuie generat se numără și în limitele de randare: per jucător, `GIF_USER_RENDERS_PER_MIN` (4) pe minut și `GIF_USER_RENDERS_PER_HOUR` (30) pe oră; per client, toate conturile sale la un loc, `GIF_IP_RENDERS_PER_MIN` (12) pe minut și `GIF_IP_RENDERS_PER_HOUR` (120) pe oră, și de 3 ori mai mult per prefix IPv6 /48. Un client ar trebui să păstreze fișierul descărcat în loc să-l ceară din nou și să aștepte `retryAfter` după un 429 sau un 503.

      Dimensiuni: `small` (câmpuri de 32 px: 284 x 350 pixeli), `medium` (48 px: 424 x 515), `large` (72 px: 628 x 762); fără coordonate, 268 x 342, 400 x 503 și 600 x 748. Poziția inițială rămâne cel puțin 1 s (sau cât întârzierea, când aceasta este mai lungă), poziția finală 3 s, iar GIF-ul se repetă în buclă; după primul cadru, se stochează doar partea din imagine care se schimbă. O partidă de 40 de mutări ocupă aproximativ 135, 205 și 325 KiB (small, medium, large), o partidă de 150 de mutări 0,5; 0,8 și 1,2 MiB.
  - name: players
    x-displayName: Jucători
    description: Profiluri publice și partide recente. Doar date publice, niciodată o adresă de e-mail, o sesiune, o sancțiune sau un nivel de integritate. Un cont șters nu are profil.
  - name: leaderboard
    x-displayName: Clasament
    description: Cei mai buni jucători din fiecare categorie oficială.
  - name: reports
    x-displayName: Raportări
    description: Raportarea adversarului dintr-o partidă recentă către moderatori.
  - name: pages
    x-displayName: Pagini HTML
    description: |-
      Pagini pentru browser, deschise din linkurile din e-mailuri. Linkurile lor duc la `https://<SERVER_PUBLIC_HOST>` (cu `:<PUBLIC_API_PORT>` când acesta nu este 443), în afara `/api/v1`.

      - Paginile nu rulează JavaScript și nu încarcă nicio resursă externă. Sunt servite cu `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - Un `GET` doar afișează un buton sau un formular, astfel încât un scanner de e-mail care deschide linkul să nu-l consume. Modificarea are loc la `POST`: un formular trimis ca `application/x-www-form-urlencoded` (se acceptă și un corp JSON), cu tokenul linkului într-un câmp ascuns. Un câmp trimis de două ori este refuzat.
      - Erorile (limite de frecvență, câmpuri nevalide) sunt tot pagini HTML, cu titlul „Request refused” (cerere refuzată) sau, de la 500 în sus, „Server error” (eroare de server). Doar erorile detectate înainte de identificarea paginii (o țintă a cererii prea lungă sau malformată, nivelul per adresă) primesc răspuns în JSON.
x-tagGroups:
  - name: server
    x-displayName: Server
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Conturi
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Partide
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Jucători și raportări
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Pagini pentru browser
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Obținerea numelui, versiunilor, porturilor și regulilor de înregistrare ale serverului
      description: |-
        Ce îi trebuie unui client înainte de a se autentifica sau de a se conecta: numele și id-ul serverului, versiunile protocolului WebSocket și locul unde se află WebSocket-ul, regulile de înregistrare, ritmurile de joc oficiale și limitele pe care un client le poate verifica înainte de a trimite un formular.

        **Limite:** doar nivelul per adresă.
      security: []
      responses:
        '200':
          description: Descrierea serverului.
          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`: nivelul per adresă.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Crearea unui cont
      description: |-
        Creează un cont: după ce adresa sa de e-mail este confirmată prin linkul trimis la ea sau imediat, fără confirmarea adresei de e-mail.

        **Cu confirmarea adresei de e-mail** (`REQUIRE_EMAIL_VERIFICATION`, valoarea implicită): 202 `verification_sent`. Încă nu există niciun cont: înregistrarea așteaptă 24 de ore și între timp își rezervă numele de utilizator. La adresă se trimite un link valabil în aceste 24 de ore, cel mult unul per adresă la fiecare 5 minute (`POST /auth/verify-email/resend` trimite unul nou); contul este creat, cu adresa confirmată, când linkul este folosit (butonul paginii `/verify-email`), iar jucătorul se poate autentifica apoi. Până atunci, o autentificare cu acel nume de utilizator primește răspunsul `invalid_credentials`, ca pentru un cont necunoscut, iar profilul public nu există. O nouă înregistrare cu aceeași adresă o înlocuiește pe cea în așteptare. Răspunsul este același când un alt cont folosește deja adresa: atunci nu se trimite niciun link, proprietarul adresei primește în schimb o notificare (cel mult una pe oră), iar numele de utilizator este rezervat la fel, astfel încât nimic nu spune dacă adresa are un cont. O înregistrare al cărei link nu a fost folosit este eliminată după 24 de ore, iar numele său de utilizator devine din nou liber.

        **Fără confirmarea adresei de e-mail** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, contul este creat imediat și se poate autentifica.

        **Reguli.** `username`: între `USERNAME_MIN` și `USERNAME_MAX` caractere (de la 3 la 20), litere, cifre, `_` și `-`, începând cu o literă sau o cifră (`GET /info` le indică în `limits`); numele rezervate (`admin`, `moderator`, `deleted`…) și unele prefixe sunt refuzate; unic, fără a ține cont de literele mari sau mici. `email`: fără spațiile de la capete și stocat cu litere mici, ASCII simplu, cu un domeniu care conține un punct. `password`: cel puțin `PASSWORD_MIN_LENGTH` (10) caractere și cel mult 256 de octeți UTF-8; nu trebuie să conțină numele de utilizator sau partea locală a adresei de e-mail și nu trebuie să fie o parolă comună.

        **Erori**, verificate în această ordine: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; erorile cozii de hashing a parolelor; 409 `email_taken` (doar fără confirmarea adresei de e-mail).

        **Limite:** `auth`, apoi `auth_register` (10 înregistrări pe oră per client). **Dovadă de lucru:** întotdeauna, când `POW_REGISTER_BITS` este peste 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Prima încercare, fără dovadă de lucru
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Trimisă din nou cu dovada de lucru
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: Contul este creat și se poate autentifica (fără confirmarea adresei de e-mail pe acest server).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: Înregistrarea își așteaptă linkul de confirmare (sau adresa are deja un cont; răspunsul nu spune acest lucru).
          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` (cu `field`), `invalid_json`, `invalid_username`, `invalid_email` sau `weak_password` cu `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` sau `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: înregistrarea este închisă pe acest server (`GET /info` indică `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: un cont are acest nume de utilizator sau îl rezervă o înregistrare în așteptare a altei adrese. `email_taken`: un alt cont folosește adresa (doar fără confirmarea adresei de e-mail).'
        '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`: limita `auth` sau `auth_register` ori coada de hashing a parolelor.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (coada de hashing a parolelor sau baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Autentificarea cu parolă
      description: |-
        Efectuează autentificarea cu un nume de utilizator sau o adresă de e-mail și o parolă. Există două răspunsuri posibile, ambele cu codul 200: o sesiune sau, când autentificarea în doi pași este activată, un al doilea pas de finalizat în 5 minute cu `POST /auth/login/mfa`.

        Un cont necunoscut, o parolă greșită și un cont fără parolă primesc același răspuns, după același timp: 401 `invalid_credentials`. Verificările contului (`banned`, `email_unverified`) au loc doar după o parolă corectă. De la `AUTH_FAILURES_PER_ACCOUNT` (5) eșecuri pentru același identificator, fiecare încercare trebuie să aștepte (429 `too_many_attempts`).

        **Limită:** `auth`. **Dovadă de lucru:** doar în timpul unui val de autentificări eșuate (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: O sesiune sau al doilea pas al unei autentificări în doi pași.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Autentificare reușită
                  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: Autentificarea în doi pași este activată
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: cont necunoscut, parolă greșită sau cont fără parolă.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Doar după o parolă corectă: `banned`, cu `until` (ms de la epoca Unix, `null` pentru o suspendare permanentă), sau `email_unverified` (un cont mai vechi a cărui adresă nu este încă confirmată).'
        '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` (timpul de așteptare după eșecuri al acestui identificator) sau `rate_limited` (limita `auth`, coada de hashing a parolelor).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (coada de hashing a parolelor sau baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Finalizarea unei autentificări cu al doilea factor
      description: |-
        Al doilea pas al unei autentificări în doi pași, după ce `POST /auth/login` sau o autentificare cu Google (`POST /auth/sso/google/finish` ori `POST /auth/sso/google/link`) a răspuns `mfaRequired`. Trimiteți `code` (un cod de 6 cifre din aplicația de autentificare sau un cod de recuperare) ori `recoveryCode`. Un cod de recuperare folosit aici dispare.

        Un pas acceptă cel mult 5 coduri greșite; pasul se încheie și când parola este resetată sau schimbată. După pasul parolei al unei asocieri Google, asocierea este stocată doar când codul trece aici.

        **Limite:** `auth` și cel mult `AUTH_MFA_PER_ACCOUNT` (10) coduri la 15 minute pentru cont, de la orice adresă; peste această limită, codul nu este verificat, astfel încât un cod de recuperare nu este consumat.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Un cod din aplicația de autentificare
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Un cod de recuperare
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Autentificare reușită.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: nu s-a trimis nici `code`, nici `recoveryCode` sau corpul încalcă schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: pasul a expirat, a fost folosit sau s-a încheiat după 5 coduri greșite ori parola a fost resetată sau schimbată de la primul pas (autentificați-vă din nou). `invalid_code`: un cod greșit sau deja folosit.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (cu `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: doar după pasul parolei al unei asocieri Google, contul Google a fost asociat între timp cu un alt cont.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: doar după pasul parolei al unei asocieri Google, contul s-a schimbat între timp; reîncepeți autentificarea.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: timpul de așteptare al contului după eșecuri sau cele `AUTH_MFA_PER_ACCOUNT` coduri ale sale din ultimele 15 minute au fost epuizate. `rate_limited`: limita `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` cu `retryAfter: 1`: baza de date a rămas blocată (după un pas de asociere Google, nu s-a asociat nimic: reîncepeți autentificarea). `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Deconectarea acestei sesiuni
      description: |-
        Revocă sesiunea tokenului folosit. WebSocket-ul deschis cu ea este închis. Fără corp (sau `{}`).

        **Limită:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Deconectare reușită.
          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`: un corp diferit de `{}`; `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`: limita `sessions` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Deconectarea tuturor sesiunilor
      description: |-
        Revocă toate sesiunile contului, inclusiv pe aceasta. WebSocket-urile deschise cu ele sunt închise. Fără corp (sau `{}`).

        **Limită:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Toate sesiunile sunt deconectate.
          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`: un corp diferit de `{}`; `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`: limita `sessions` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Retrimiterea linkului de confirmare
      description: |-
        Retrimite linkul de confirmare a adresei de e-mail. Răspunsul este 202 `accepted` indiferent de adresă, astfel încât nu spune niciodată dacă un cont sau o înregistrare o folosește.

        O cerere are efect cel mult o dată la 5 minute per adresă. O înregistrare în așteptare cu acea adresă primește din nou cele 24 de ore, indiferent dacă un alt cont folosește sau nu adresa, astfel încât numele său de utilizator rămâne rezervat la fel de mult în ambele cazuri. Un link se trimite doar pentru acea înregistrare, când adresa nu are niciun cont (un link nou, valabil 24 de ore, îl înlocuiește pe cel anterior), sau pentru un cont activ, neconfirmat, cu acea adresă.

        **Limite:** `auth`, apoi `auth_mail` (10 pe oră per client).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Acceptată (indiferent de adresă).
          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`: limita `auth` sau `auth_mail` (indiferent de adresă).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` cu `retryAfter: 1`: baza de date a rămas blocată în timpul căutării contului sau al stocării linkului unui cont neconfirmat (reînnoirea unei înregistrări în așteptare se face în măsura posibilului și nu face niciodată cererea să eșueze); cele 5 minute ale adresei încep totuși, așa că o nouă cerere în acest interval nu face nimic. `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Trimiterea unui link de resetare a parolei
      description: |-
        Trimite un link de resetare a parolei, valabil o oră, care deschide pagina `/reset-password`. Răspunsul este 202 `accepted` indiferent de adresă. Un link este trimis doar unui cont activ, cel mult o dată la 5 minute per adresă. Un cont care folosește doar Google își setează astfel prima parolă.

        Recuperarea parolei are cele mai stricte limite ale API-ului, toate numărate pentru întregul server: 3 cereri pe oră (`AUTH_FORGOT_PER_HOUR`) și 10 la 24 de ore (`AUTH_FORGOT_PER_DAY`) per client (o adresă IPv4 sau un prefix IPv6 /64), de 3 ori aceste valori per prefix IPv6 /48, pe lângă limita `auth` (20 la 10 minute) și un singur e-mail per adresă la fiecare 5 minute. Nici 202, nici 429 nu spun dacă un cont folosește adresa. Setarea noii parole are propria limită (`auth_reset`).

        **Limite:** `auth`, `auth_forgot`, apoi `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Acceptată (indiferent de adresă).
          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`: limita `auth`, `auth_forgot` sau `auth_forgot_day` (indiferent de adresă).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` cu `retryAfter: 1`: baza de date a rămas blocată în timpul căutării contului sau al stocării linkului; cele 5 minute ale adresei încep totuși, așa că o nouă cerere în acest interval nu trimite niciun link. `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Setarea unei parole noi cu tokenul unui link de resetare
      description: |-
        Setează o parolă nouă cu tokenul unui link de resetare (pagina `/reset-password` face același lucru). Parola nouă respectă regulile înregistrării.

        Resetarea are următoarele efecte, într-o singură tranzacție (toate sau niciunul): revocă toate sesiunile și anulează o schimbare de e-mail în așteptare; celelalte linkuri de resetare ale contului nu mai funcționează; adresa este considerată confirmată (linkul a dovedit-o); proprietarul primește un e-mail. Autentificarea în doi pași nu este afectată. După o eroare a cozii de hashing a parolelor, un 503 sau un 500, nu s-a schimbat nimic: parola veche și sesiunile rămân, iar linkul rămâne valabil.

        **Limite:** `auth`, apoi `auth_reset` (10 pe oră per client, 30 per prefix IPv6 /48, partajată cu pagina: fiecare încercare calculează hash-ul unei parole).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Parola este schimbată, iar toate sesiunile sunt deconectate.
          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`: linkul este nevalid, folosit sau expirat ori a fost trimis la o adresă pe care contul nu o mai are. `weak_password` (cu `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`: limita `auth` sau `auth_reset` ori coada de hashing a parolelor.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: coada de hashing a parolelor sau baza de date a rămas blocată (`retryAfter: 1`, nu s-a schimbat nimic). `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Începerea unei autentificări cu Google
      description: |-
        Începe o încercare de autentificare cu Google pentru challenge-ul PKCE și portul listenerului jocului pe `127.0.0.1`. Răspunsul conține URL-ul Google de deschis în browserul sistemului și `state` al încercării; încercarea este valabilă 10 minute.

        `authUrl` conține `client_id`, `redirect_uri` (construit din `redirectPort` și eticheta de origine a serverului), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 al verificatorului propriu al serverului pentru Google) cu `code_challenge_method=S256` și `prompt=select_account`. Jocul îl verifică înainte de a-l deschide (vezi descrierea secțiunii).

        **Limită:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: Încercarea.
          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`: autentificarea cu Google nu este oferită pe acest server.'
        '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`: limita `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Începerea unei autentificări cu Google pe un site web
      description: |-
        Începe o încercare de autentificare cu Google a unui site web pentru challenge-ul PKCE al paginii sale. Răspunsul este același ca pentru joc: URL-ul Google către care se navighează și `state` al încercării; încercarea este valabilă 10 minute. Disponibil când `GET /info` indică `sso.googleWeb: true`.

        `authUrl` conține `client_id` al site-ului web și `redirect_uri=GOOGLE_WEB_REDIRECT_URI`, cu aceiași alți parametri ca pentru joc. Pagina îl verifică înainte de a naviga la el (vezi **Pe un site web** în descrierea secțiunii).

        **Limită:** `sso_start`, partajată cu pornirea din joc.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      responses:
        '200':
          description: Încercarea.
          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`: un `codeChallenge` lipsă sau malformat ori orice alt câmp (inclusiv `redirectPort`).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: autentificarea cu Google nu este oferită pe acest server sau nu este oferită site-urilor web.'
        '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`: limita `sso_start` (partajată cu pornirea din joc).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Transmiterea răspunsului Google către server
      description: |-
        Transmite serverului `code` și `state` trimise de Google listenerului jocului sau paginii site-ului web, împreună cu id-ul încercării și verificatorul PKCE. Un id de încercare este inutil fără verificator, iar codul este inutil fără verificatorul PKCE propriu al serverului și fără secretul clientului.

        Serverul verifică încercarea (necunoscută, folosită sau expirată: 410), apoi verificatorul (unul greșit lasă încercarea utilizabilă), apoi folosește încercarea o singură dată, verifică `state` și `iss`, face schimbul codului cu Google și verifică ID tokenul. Răspunsul (200) este unul dintre:

        - `{ token, expiresAt, user }`: autentificare în contul asociat;
        - `{ mfaRequired, mfaToken, expiresIn }`: continuați cu `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: un cont nou; continuați cu `POST /auth/sso/complete` în cel mult 10 minute. `suggestedUsername` provine din numele Google sau din adresă și este `""` când nu se potrivește nimic;
        - `{ needsPassword, linkTicket, username, expiresIn }`: contul cu acea adresă are parolă; continuați cu `POST /auth/sso/google/link`. Încă nu s-a asociat nimic.

        **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: O sesiune, un al doilea pas sau pasul următor al unei prime autentificări cu Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Autentificare în contul asociat
                  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: Autentificarea în doi pași este activată
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Un jucător nou
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Un cont cu parolă folosește adresa
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: verificatorul nu corespunde challenge-ului încercării (încercarea rămâne utilizabilă). `sso_email_unverified`: Google nu a confirmat adresa. `registration_closed`. `account_disabled`. Doar pentru un cont asociat: `banned` (cu `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: autentificarea cu Google nu este oferită pe acest server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: un cont activ fără parolă folosește adresa (contul nu este numit).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: o încercare necunoscută, folosită sau expirată.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limita `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Asocierea Google cu un cont existent prin parola acestuia
      description: |-
        După ce `finish` a răspuns `needsPassword`: asociază Google cu contul existent folosind parola acelui cont, introdusă în joc. Răspunsul (200) este o sesiune (asocierea este stocată, iar jucătorul este autentificat) sau, când autentificarea în doi pași este activată, `{ mfaRequired, mfaToken, expiresIn }`: continuați cu `POST /auth/login/mfa`, iar asocierea este stocată doar când un cod trece acolo.

        O parolă greșită lasă tichetul utilizabil, pentru 5 încercări în total. O încercare este consumată chiar înainte de verificarea parolei: un 429 `too_many_attempts` sau un 428 `pow_required` nu consumă niciuna, în timp ce un refuz al cozii de hashing a parolelor (503 `server_busy`, 429 `rate_limited`) a consumat deja una și contează ca un eșec al contului. Timpul de așteptare după eșecuri folosește același contor ca `POST /auth/login`.

        **Limită:** `auth` (cu numărarea sa per prefix IPv6 /48). **Dovadă de lucru:** ca pentru `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: O sesiune sau al doilea pas al autentificării.
          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`: parolă greșită; tichetul se păstrează, pentru 5 încercări în total.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (cu `until`), numai după o parolă corectă.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: autentificarea cu Google nu este oferită pe acest server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: între timp, contul Google a fost asociat cu un alt cont.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: un tichet necunoscut, folosit sau expirat, a 5-a parolă greșită, un cont a cărui stare sau adresă s-a schimbat de la `finish` încoace ori unul a cărui stare, adresă, parolă sau autentificare în doi pași s-a schimbat în timp ce asocierea era salvată. Reîncepeți autentificarea.'
        '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` (timpul de așteptare al contului după eșecuri) sau `rate_limited` (limita `auth`, coada de hashing a parolelor).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: coada de hashing a parolelor sau baza de date a rămas blocată (`retryAfter: 1`, nu s-a asociat nimic). `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Crearea contului la prima autentificare cu Google
      description: |-
        După ce `finish` a răspuns `needsUsername`: creează contul cu numele de utilizator ales (se aplică regulile înregistrării) și îl autentifică. Contul nu are parolă: `hasPassword` este `false`, iar „Ați uitat parola?” (`POST /auth/password/forgot`) îi dă una.

        **Limită:** `auth` (cu numărarea sa per prefix 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: Contul este creat și autentificat.
          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`: autentificarea cu Google nu este oferită pe acest server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (un cont are acest nume de utilizator sau îl rezervă o înregistrare în așteptare a altei adrese), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: un tichet necunoscut, folosit sau expirat.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limita `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Listarea dispozitivelor conectate
      description: |-
        Sesiunile active ale contului (dispozitivele conectate), cele folosite cel mai recent primele. `current` marchează sesiunea care face cererea. `lastSeenAt` este actualizat cel mult o dată la 5 minute (mai târziu cât timp serverul a rămas mult în urmă); `expiresAt` este sfârșitul absolut, iar limita de inactivitate poate încheia sesiunea mai devreme.

        **Limită:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Sesiunile active.
          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`: limita `sessions` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Deconectarea unui dispozitiv
      description: |-
        Deconectează o sesiune a contului; poate fi deconectată și cea curentă. WebSocket-ul deschis cu ea este închis. Fără corp (sau `{}`).

        **Limită:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Sesiunea este deconectată.
          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`: un corp diferit de `{}` sau un `id` care nu este o codificare URL validă; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: nicio sesiune activă cu acest id pe acest cont.'
        '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`: limita `sessions` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Obținerea contului, a ratingurilor și a sancțiunilor active
      description: |-
        Contul așa cum îl vede jucătorul său: vizualizarea contului (aceeași ca `user` din orice răspuns de autentificare), câte o fișă de rating pentru fiecare categorie în care jucătorul a jucat partide cu rating, sancțiunile active și suspendarea. Nivelul de integritate al sistemului anti-trișare nu este afișat niciodată.

        **Limită:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Contul.
          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` sau `invalid_token` (și când contul a fost șters).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limita `account` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Acceptarea sau refuzarea provocărilor directe
      description: |-
        Stabilește dacă alți jucători îl pot provoca pe acest jucător după nume. Cu `none`, provocările directe sunt refuzate: celui care provoacă i se spune că jucătorul nu este disponibil. Fără reautentificare.

        **Limită:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Preferințele aflate acum în vigoare.
          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`: limita `account` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Schimbarea parolei
      description: |-
        Schimbă parola. Este necesară parola curentă, dar nu și un al doilea factor, nici măcar cu autentificarea în doi pași activată. Parola nouă respectă regulile înregistrării (verificate după parola curentă).

        Schimbarea are aceste efecte, într-o singură tranzacție (toate sau niciunul): revocă toate celelalte sesiuni (aceasta rămâne conectată), anulează o schimbare de e-mail în așteptare, face ca linkurile de resetare a parolei ale contului să nu mai funcționeze și îi trimite titularului un e-mail.

        **Reautentificare:** numai parola. **Limite:** `reauth`, apoi `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: Parola este schimbată.
          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` (un cont doar cu autentificare Google), `weak_password` (cu `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: parola curentă greșită sau o resetare ori schimbare a parolei survenită în timp ce cererea era verificată.'
        '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` (reautentificări eșuate ale contului) sau `rate_limited` (limita `reauth` sau `reauth_user`, bugetul contului, coada de hashing a parolelor).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: coada de hashing a parolelor sau baza de date a rămas blocată (`retryAfter: 1`, nu s-a schimbat nimic). `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Începerea activării autentificării în doi pași
      description: |-
        Salvează un nou secret de autentificare în așteptare, care înlocuiește orice secret anterior aflat în așteptare, și îl returnează pentru aplicația de autentificare (ca text și ca URI `otpauth://` de afișat sub formă de cod QR). Autentificarea în doi pași nu este încă activată: `POST /account/mfa/totp/enable` o activează cu un cod al acestui secret.

        **Reautentificare:** numai parola. **Limite:** `reauth`, apoi `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Secretul în așteptare.
          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` (un cont doar cu autentificare 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` (verificat înaintea parolei).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautentificări eșuate ale contului) sau `rate_limited` (limita `reauth` sau `reauth_user`, bugetul contului, coada de hashing a parolelor).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (coada de hashing a parolelor sau baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Finalizarea activării autentificării în doi pași și obținerea codurilor de recuperare
      description: |-
        Activează autentificarea în doi pași cu un cod al secretului în așteptare (exact 6 cifre). Aici nu se cere parola; a fost dată la configurare. Răspunsul conține 10 coduri de recuperare, afișate doar de această dată.

        **Limite:** `reauth`, apoi `reauth_user`; un cod greșit se numără printre eșecurile de reautentificare ale contului.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Autentificarea în doi pași este activată.
          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` nu are exact 6 cifre sau corpul încalcă schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: cod greșit (verificați ora dispozitivului).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: niciun secret în așteptare (apelați mai întâi `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` (reautentificări eșuate ale contului) sau `rate_limited` (limita `reauth` sau `reauth_user`, bugetul contului).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Dezactivarea autentificării în doi pași
      description: |-
        Dezactivează autentificarea în doi pași. Secretul și codurile de recuperare sunt șterse, iar titularul primește un e-mail.

        **Reautentificare:** parola și un cod din aplicația de autentificare sau un cod de recuperare (este obligatoriu unul dintre `code` și `recoveryCode`). **Limite:** `reauth`, apoi `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: Autentificarea în doi pași este dezactivată.
          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` (un cont doar cu autentificare Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (nici `code`, nici `recoveryCode`; verificat înaintea parolei), `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` (reautentificări eșuate sau prea multe coduri încercate pe acest cont) sau `rate_limited` (limita `reauth` sau `reauth_user`, bugetul contului, coada de hashing a parolelor).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (coada de hashing a parolelor sau baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Înlocuirea codurilor de recuperare
      description: |-
        Înlocuiește codurile de recuperare cu 10 coduri noi; cele vechi nu mai funcționează.

        **Reautentificare:** parola și un cod din aplicația de autentificare în `code` (un cod de recuperare este refuzat cu 403 `invalid_code`). **Limite:** `reauth`, apoi `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: Noile coduri de recuperare, afișate doar de această dată.
          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` (un cont doar cu autentificare Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (și pentru un cod de recuperare).'
        '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` (reautentificări eșuate sau prea multe coduri încercate pe acest cont) sau `rate_limited` (limita `reauth` sau `reauth_user`, bugetul contului, coada de hashing a parolelor).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (coada de hashing a parolelor sau baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Schimbarea adresei de e-mail
      description: |-
        Schimbă adresa de e-mail a contului. `newEmail` este curățat de spațiile de la capete și trecut la litere mici, apoi verificat ca o adresă de înregistrare.

        **Cu confirmare prin e-mail** (implicit): 202 `verification_sent`. Un link valabil 24 de ore este trimis la noua adresă; acesta deschide `/confirm-email-change`, iar adresa se schimbă numai când jucătorul apasă butonul acelei pagini. Până atunci, `GET /account/me` afișează noua adresă ca `pendingEmail`. O cerere nouă o înlocuiește pe cea în așteptare; o schimbare sau o resetare a parolei o anulează. La o anumită adresă nouă se trimite cel mult un link la fiecare 5 minute, indiferent cine îl cere (o cerere pentru schimbarea deja în așteptare păstrează linkul trimis anterior, care rămâne valabil); răspunsul este același. Adresa curentă primește o notificare că a fost cerută o schimbare către o adresă mascată (`a***@example.org`). Răspunsul și `pendingEmail` sunt aceleași când un alt cont folosește deja noua adresă: în acest caz nu se trimite niciun link, deci schimbarea nu se finalizează niciodată, iar titularul acelei adrese primește în schimb o notificare (cel mult una pe oră).

        Când linkul este confirmat, adresa se schimbă și este considerată confirmată, dispozitivele rămân conectate, linkurile trimise anterior (confirmare, resetarea parolei, alte schimbări) nu mai funcționează, iar fosta adresă este anunțată, cu noua adresă mascată.

        **Fără confirmare prin e-mail** (`REQUIRE_EMAIL_VERIFICATION=false`): adresa se schimbă imediat (200 `email_changed`), iar fosta adresă este anunțată. Dacă un alt cont folosește adresa, răspunsul este 409 `email_taken`, iar titularul acelei adrese primește notificarea.

        **Reautentificare:** parola și, cu autentificarea în doi pași, un cod din aplicația de autentificare sau un cod de recuperare. `invalid_email` și `same_email` sunt verificate înaintea parolei, deci nu se numără ca eșecuri. **Limite:** `reauth`, apoi `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: Adresa s-a schimbat imediat (fără confirmare prin e-mail pe acest server).
          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: Noua adresă, așa cum a fost salvată.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Un link de confirmare a fost trimis la noua adresă (sau adresa aparține altui cont; răspunsul nu spune acest lucru).
          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` (adresa curentă a contului), `password_not_set` (un cont doar cu autentificare Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (și când o schimbare sau o resetare a parolei a luat-o înaintea cererii: nu se trimite niciun link), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: numai fără confirmare prin e-mail.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautentificări eșuate sau prea multe coduri încercate pe acest cont) sau `rate_limited` (limita `reauth` sau `reauth_user`, bugetul contului, coada de hashing a parolelor).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: coada de hashing a parolelor sau baza de date a rămas blocată (`retryAfter: 1`: nu s-a schimbat nimic, iar aceeași cerere poate fi trimisă din nou). `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Descărcarea datelor contului
      description: |-
        Tot ce păstrează serverul despre cont, ca un singur fișier JSON de salvat (`format` `scacelith-account-export`, `version` 1). Exportul înregistrează un eveniment de securitate (`account_exported`). Tabloul `notes` al documentului îi spune jucătorului, în engleză simplă, ce este omis.

        **Niciodată în export:** hash-ul parolei, secretul autentificării în doi pași și codurile de recuperare; niciun token de sesiune sau de link și niciun hash al vreunuia; datele sistemului anti-trișare (nivelul și scorul de integritate, anomaliile, analiza partidelor, ponderea unui raport); rapoartele făcute de alți jucători despre jucător; identitatea moderatorilor; datele private ale altor jucători (adversarii apar cu numele public și ratingul lor, nimic nu arată dacă un alt jucător a fost sancționat și nu este inclusă nicio adresă IP care ar putea aparține altei persoane).

        **Reautentificare:** parola și, cu autentificarea în doi pași, un cod din aplicația de autentificare sau un cod de recuperare. **Limite:** `account_export` (5 pe oră per jucător, pentru întregul server; fiecare încercare contează, inclusiv cele eșuate; verificată prima), apoi `reauth` și `reauth_user`. **Timp maxim:** 60 de secunde.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Documentul de export, ca atașament.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Caracterele din numele de utilizator, altele decât litere, cifre, `_`, `.` și `-`, devin `_`.'
              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` (un cont doar cu autentificare 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` (reautentificări eșuate sau prea multe coduri încercate pe acest cont) sau `rate_limited` (limita `account_export`, `reauth` sau `reauth_user`, bugetul contului, coada de hashing a parolelor).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza de date a rămas blocată, cu un antet `Retry-After: 1`), `server_busy` (coada de hashing a parolelor), `timeout` (60 de secunde). De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Ștergerea contului
      description: |-
        Șterge contul; acțiunea este ireversibilă.

        - Toate sesiunile sunt revocate imediat: de atunci înainte, tokenul primește 401 `invalid_token`.
        - Numele de utilizator devine `deleted#<id>`, în cont și în înregistrarea fiecărei partide.
        - Sunt șterse: adresa de e-mail, hash-ul parolei, secretul autentificării în doi pași și codurile de recuperare, sesiunile și tokenurile de link, asocierea cu Google, fișa de integritate a sistemului anti-trișare și adresele IP salvate împreună cu evenimentele de securitate.
        - Ratingurile și partidele sunt păstrate. Partidele rămân lizibile sub numele anonim, iar `GET /players/{username}` răspunde 404 pentru fostul nume.

        **Reautentificare:** parola și, cu autentificarea în doi pași, un cod din aplicația de autentificare sau un cod de recuperare. **Limite:** `reauth`, apoi `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: Contul este șters.
          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` (un cont doar cu autentificare 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` (reautentificări eșuate sau prea multe coduri încercate pe acest cont) sau `rate_limited` (limita `reauth` sau `reauth_user`, bugetul contului, coada de hashing a parolelor).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (coada de hashing a parolelor sau baza de date a rămas blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Listarea partidelor jucătorului, filtrate și paginate
      description: |-
        Partidele jucătorului autentificat, cele mai noi primele, filtrate și paginate, cu numărul de partide care corespund filtrului. Fiecare parametru de interogare este opțional, iar o valoare goală contează ca absentă.

        Paginare: transmiteți `next` al paginii anterioare ca `before`; `next` este `null` pe ultima pagină. `total` numără partidele care corespund filtrului, pe toate paginile.

        **Limită:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: Un id de categorie oficială (`3+2` sau `3%2B2`) ori `custom` pentru orice partidă cu alt ritm de joc.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Numai partidele cu rating (`true`) sau amicale (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Numai partidele câștigate, pierdute sau încheiate cu remiză, din perspectiva jucătorului. Partidele anulate apar numai fără acest filtru.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: O pagină a istoricului.
          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` nu este un id de partidă), `invalid_limit` sau `invalid_filter` (`category`, `rated` ori `result`), fiecare cu `field` care numește parametrul.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limita `account_games` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza de date a rămas blocată; `retryAfter: 1` în corp, fără antet `Retry-After`), `server_busy` (căutarea sesiunii a găsit baza de date blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Obținerea înregistrării unei partide, cu mutările și ceasurile ei
      description: |-
        Înregistrarea unei partide, cu mutările și ceasurile ei. Fără token sau cu tokenul unui jucător care nu a jucat partida, răspunsul este cel public. Când jucătorul tokenului a jucat partida, răspunsul adaugă `you` și `reportable`.

        **Limită:** `public_read` (per jucător cu token, per client fără).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Înregistrarea partidei.
          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`: nu este un număr întreg pozitiv de cel mult 16 cifre (sub 2^53); `invalid_request`: codificare URL nevalidă.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: a fost trimis un token și nu este valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: partida nu există.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limita `public_read` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza de date a rămas blocată; `retryAfter: 1` în corp, fără antet `Retry-After`), `server_busy` (căutarea sesiunii a găsit baza de date blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Descărcarea unei partide ca fișier PGN
      description: |-
        Aceeași partidă ca fișier PGN: o singură partidă, cu sfârșituri de linie `\n`, iar textul mutărilor în linii sub 80 de coloane. Răspunsul este același cu sau fără token.

        Taguri, în această ordine: `Event` (`<SERVER_NAME> rated <category>` sau `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (data de început, UTC), `Round`, `White`, `Black`, `Result` (`*` pentru o partidă anulată), `UTCDate` și `UTCTime` (începutul), `WhiteElo` și `BlackElo` (ratingurile la început sau `-`), `WhiteRatingDiff` și `BlackRatingDiff` (variațiile, de exemplu `+10` și `-10`, în fiecare partidă cu rating, `+0` când regulile de rating lasă ratingul neschimbat; o partidă amicală, personalizată sau anulată nu are niciunul dintre ele), `TimeControl` (în secunde), `Termination` (o valoare standard PGN: `normal`; `time forfeit`, expirarea timpului pe ceas, chiar și când partida se termină cu remiză; `abandoned`; `rules infraction`, o a doua mutare ilegală sau o pierdere din motive de fair-play; `unterminated`, o partidă anulată), `PlyCount`, `ScacelithGameId` (id-ul zecimal).

        Fiecare mutare poartă `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: ceasul celui care a mutat, după mutare, și timpul socotit pentru ea, în zecimi de secundă (trunchiat). O valoare este omisă când înregistrarea nu o are. După ultima mutare urmează motivul încheierii, în cuvinte, apoi rezultatul.

        **Limită:** `public_read` (per jucător cu token, per client fără).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Fișierul 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: 'Textul PGN, în 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`: nu este un număr întreg pozitiv de cel mult 16 cifre (sub 2^53); `invalid_request`: codificare URL nevalidă.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: a fost trimis un token și nu este valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: partida nu există.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limita `public_read` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: printre altele, mutările salvate nu pot fi rejucate până la finalul salvat (serverul înregistrează eroarea în jurnal).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza de date a rămas blocată; `retryAfter: 1` în corp, fără antet `Retry-After`), `server_busy` (căutarea sesiunii a găsit baza de date blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Descărcarea unei partide de pe acest server ca GIF animat
      description: |-
        Partida ca GIF animat. Numele și ratingurile sunt cele din înregistrarea partidei (ratingurile la început, un cont șters ca `deleted#<id>`), la fel ca rezultatul și modul de încheiere (`Resignation`, `Loss on time`…).

        Verificările se fac în această ordine: `gif_disabled`, opțiunile imaginii (`size`, `orientation`, `delay`, `coords`), id-ul partidei, partida, lungimea ei.

        **Sesiune obligatorie:** cotele se numără per cont. **Limite:** `gif` la fiecare cerere; `gif_user_min`, `gif_user_hour`, `gif_ip_min` și `gif_ip_hour` numai când GIF-ul trebuie generat (vezi descrierea secțiunii). **Timp maxim:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 secunde (implicit 45 de secunde).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: 'Dimensiunea imaginii: `small` (câmpuri de 32 px), `medium` (48 px) sau `large` (72 px).'
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Partea aflată în josul tablei.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Milisecunde per mutare (de la 1 la 6 cifre zecimale).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Dacă literele coloanelor și numerele liniilor sunt desenate în jurul tablei (`1`) sau nu (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` cu `field` (`size`, `orientation`, `delay` sau `coords`): o valoare în afara celor permise. `invalid_game_id`. `invalid_request`: codificare URL nevalidă.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: partida nu există. `gif_disabled`: serverul a dezactivat GIF-urile (`GIF_ENABLED=false`; jetonul `gif` este restituit).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limita `gif`, o limită de randare sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: GIF-ul nu a putut fi generat (serverul înregistrează motivul în jurnal). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` cu `retryAfter` (între 3 și 10 secunde) și `Retry-After`: coada de randare este plină sau GIF-ul a așteptat `GIF_QUEUE_TIMEOUT_MS` (10 secunde) un fir de execuție liber; toate jetoanele de limită consumate de cerere sunt restituite. `busy`: baza de date a rămas blocată (`retryAfter: 1` numai în corp). `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Generarea unui GIF animat pentru orice partidă trimisă ca PGN
      description: |-
        Aceeași imagine ca `GET /games/{id}/gif`, pentru orice partidă trimisă ca text PGN: o partidă salvată de joc, un export de pe alt site, o partidă tastată de mână. Se folosește numai prima partidă din text.

        - Cititorul PGN acceptă ce acceptă și cititorul jocului: orice PGN scris de acest server și exporturile obișnuite ale altor site-uri (comentariile, variantele, NAG-urile și adnotările de ceas sunt ignorate; numerele mutărilor și SAN sunt citite cu toleranță; un tag `FEN` dă poziția de start, cu excepția cazului în care `SetUp` este `"0"`).
        - Numele și ratingurile provin din tagurile `White`, `Black`, `WhiteElo` și `BlackElo`. Literele accentuate își pierd accentele, celelalte caractere din afara ASCII imprimabil devin `?`, iar numele lungi sunt trunchiate (48 de caractere). Rezultatul provine din tagul `Result`, altfel din finalul textului mutărilor. Tagul `Termination` este afișat, cu excepția cazului în care este `normal`: atunci poziția finală spune povestea (mat, pat).
        - Acest endpoint își verifică singur corpul: un câmp necunoscut sau un `pgn` lipsă ori care nu este șir de caractere primește răspunsul 400 `invalid_request` cu `field`; o opțiune greșită primește răspunsul 400 `invalid_option` (`delayMs` trebuie să fie un număr JSON, `coords` un boolean; `null` este refuzat).

        **Sesiune obligatorie:** cotele se numără per cont. **Limite:** ca pentru `GET /games/{id}/gif`. **Dimensiune maximă a corpului:** 135.168 de octeți, indiferent de `HTTP_BODY_LIMIT` (PGN-ul ca șir JSON, inclusiv secvențele de escape). **Timp maxim:** implicit 45 de secunde.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: O partidă scurtă tastată, fără coordonate
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Un fișier PGN cu toate opțiunile
                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` cu `field` (un câmp necunoscut sau `pgn` lipsă ori care nu este șir de caractere; fără `field` când corpul nu este un obiect); `invalid_json`; `invalid_option` cu `field` (`size`, `orientation`, `delayMs` sau `coords`); `invalid_pgn` cu `line` și `column` (de la 1, coloanele în caractere) și `message` al cititorului: o mutare ilegală sau ambiguă, un tag deteriorat, o variantă de șah necunoscută, mai mult de 65.536 de octeți.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: serverul a dezactivat GIF-urile (`GIF_ENABLED=false`; jetonul `gif` este restituit).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: un corp de peste 135.168 de octeți.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limita `gif`, o limită de randare sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: GIF-ul nu a putut fi generat (serverul înregistrează motivul în jurnal). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` cu `retryAfter` (între 3 și 10 secunde) și `Retry-After`: coada de randare este plină sau GIF-ul a așteptat prea mult un fir de execuție liber; toate jetoanele de limită consumate de cerere sunt restituite. `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Obținerea profilului public al unui jucător
      description: |-
        Profilul public al unui jucător: ratingurile în categoriile oficiale (în ordinea serverului) și numărul de partide. `games.total` numără toate partidele salvate, inclusiv cele amicale și cele anulate; `games.rated`, `wins`, `draws` și `losses` sunt însumate pe fișele de rating, deci numai partidele cu rating. Răspunsul este același cu sau fără token.

        **Limită:** `public_read` (per jucător cu token, per client fără).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Profilul.
          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`: nu are între 2 și 24 de caractere din `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: a fost trimis un token și nu este valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: jucătorul nu există sau contul a fost șters.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limita `public_read` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza de date a rămas blocată; `retryAfter: 1` în corp, fără antet `Retry-After`), `server_busy` (căutarea sesiunii a găsit baza de date blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Listarea partidelor recente ale unui jucător
      description: |-
        Partidele recente ale jucătorului, cele mai noi primele, paginate ca istoricul, dar fără filtre și fără total. `color` este partea acestui jucător. `next` este id-ul ultimei partide ori de câte ori pagina este plină, deci pagina următoare poate fi goală. Jucătorul este căutat primul: un jucător necunoscut dă 404, orice ar spune interogarea.

        **Limită:** `public_read` (per jucător cu token, per client fără).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: O pagină a partidelor jucătorului.
          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` nu este un id de partidă), `invalid_limit` (ultimele două fără `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: a fost trimis un token și nu este valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: jucătorul nu există sau contul a fost șters.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: limita `public_read` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza de date a rămas blocată; `retryAfter: 1` în corp, fără antet `Retry-After`), `server_busy` (căutarea sesiunii a găsit baza de date blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Obținerea celor mai buni jucători dintr-o categorie
      description: |-
        Primele 100 de fișe de rating ale unei categorii oficiale cu cel puțin `minGames` (`PROVISIONAL_GAMES`) partide luate în calcul, fără conturile șterse și fără trișorii confirmați. Serverul recalculează fiecare clasament cel mult o dată la 10 secunde; `updatedAt` spune când. Pentru fiecare categorie rulează un singur calcul în același timp: între timp, celelalte cereri primesc lista anterioară sau așteaptă calculul când încă nu există niciuna.

        **Limite:** numai nivelul per adresă.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Un id de categorie oficială (`3+2` sau `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Câți jucători, de la 1 la 100. Un număr mai mare, de cel mult 3 cifre, contează ca 100; o valoare goală contează ca absentă.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: Clasamentul.
          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` (lipsă sau nu este o categorie oficială), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: nivelul per adresă.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza de date a rămas blocată; `retryAfter: 1` în corp, fără antet `Retry-After`), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Raportarea adversarului dintr-o partidă recentă
      description: |-
        Raportează adversarul dintr-una dintre partidele proprii ale jucătorului, încheiată în ultimele 7 zile. Un raport nu modifică niciodată, de unul singur, un rating, o sancțiune sau un nivel de integritate. El crește prioritatea de examinare pe care o văd moderatorii și, cu excepția `abuse`, solicită analiza partidei de către motor.

        Un raport despre același adversar pentru aceeași partidă primește același răspuns și nu schimbă nimic; răspunsul nu spune niciodată nimic despre contul raportat. `GET /games/{id}` le spune dinainte jucătorilor partidei dacă un raport ar fi acceptat (`reportable`).

        Acest endpoint își verifică singur corpul, în ordinea `gameId`, `reported`, `category`, `comment`: un eșec primește răspunsul 400 `invalid_request` fără `field`. Câmpurile pe care nu le cunoaște sunt ignorate.

        **Limite:** `reports` (per jucător), apoi `REPORTS_PER_DAY` (5) rapoarte per jucător în 24 de ore (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: Raportul este primit (sau fusese deja trimis).
          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` (fără `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: nu este adversarul celui care raportează într-o partidă încheiată în ultimele 7 zile.'
        '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`, cu un antet `Retry-After`): `REPORTS_PER_DAY` rapoarte în ultimele 24 de ore. `rate_limited`: limita `reports` sau bugetul contului.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (căutarea sesiunii a găsit baza de date blocată), `timeout`. De asemenea, `server_busy` cu `retryAfter` între 2 și 5 când serverul a rămas mult în urmă (vezi introducerea): cererea nu a fost executată.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Serverul oficial (paginile se află la rădăcină, în afara `/api/v1`).
      - url: https://{host}:{port}
        description: Orice server Scacelith (paginile se află la rădăcină, în afara `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Numele public de gazdă al serverului (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Portul public al API-ului (`PUBLIC_API_PORT`, altfel `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Afișarea paginii de confirmare a e-mailului
      description: |-
        Pagina unui link de confirmare a e-mailului (înregistrare sau un e-mail de confirmare retrimis). Afișează doar un buton „Confirm my e-mail address” (confirmați adresa mea de e-mail), pentru ca un scaner de e-mail care deschide linkul să nu-l consume; butonul trimite formularul la `POST /verify-email`.

        **Limită:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Pagina cu butonul ei de confirmare.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linkul este nevalid sau expirat (o pagină HTML).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Limita `page` (o pagină HTML) sau nivelul per adresă (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Confirmarea adresei de e-mail
      description: |-
        Formularul paginii de confirmare. Confirmă adresa; pentru o înregistrare nouă, abia atunci creează contul, iar jucătorul se poate autentifica.

        **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: Adresa este confirmată (iar contul unei înregistrări noi, creat).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linkul este nevalid, folosit sau expirat ori formularul este nevalid (o pagină HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Linkul unei înregistrări noi pentru care un alt cont a luat între timp numele de utilizator sau adresa; nu se creează niciun cont (o pagină HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Limita `auth` (o pagină HTML) sau nivelul per adresă (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Baza de date a rămas blocată (`Retry-After: 1`) sau serverul a rămas mult în urmă (`Retry-After` între 2 și 5, vezi introducerea): nu s-a schimbat nimic, iar linkul încă funcționează. De asemenea, la depășirea timpului maxim de procesare.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Serverul oficial (paginile se află la rădăcină, în afara `/api/v1`).
      - url: https://{host}:{port}
        description: Orice server Scacelith (paginile se află la rădăcină, în afara `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Numele public de gazdă al serverului (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Portul public al API-ului (`PUBLIC_API_PORT`, altfel `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Afișarea formularului de resetare a parolei
      description: |-
        Pagina unui link de resetare a parolei: formularul pentru parola nouă (parola de două ori). Formularul este trimis la `POST /reset-password`.

        **Limită:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Formularul pentru parola nouă.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linkul este nevalid (și când a fost trimis la o adresă pe care contul nu o mai are).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Limita `page` (o pagină HTML) sau nivelul per adresă (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Setarea unei parole noi din formularul de resetare
      description: |-
        Formularul paginii de resetare; face ce face `POST /auth/password/reset`, într-o singură tranzacție: toate dispozitivele sunt deconectate, o schimbare de e-mail în așteptare este anulată, celelalte linkuri de resetare nu mai funcționează, adresa este considerată confirmată, iar titularul primește un e-mail.

        Se verifică mai întâi linkul, apoi faptul că cele două parole sunt identice, apoi regulile parolei. Când serverul este ocupat, formularul revine cu `Retry-After`, iar linkul rămâne valabil.

        **Limite:** `auth`, apoi `auth_reset` (partajată cu `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: Parola este schimbată, iar toate dispozitivele sunt deconectate.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Din nou formularul, cu eroarea (parolele diferă, parolă slabă), linkul este nevalid sau formularul este nevalid (o pagină 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: Limita `auth` sau `auth_reset` (o pagină HTML), din nou formularul cu `Retry-After` când acest client are prea multe hash-uri de parolă în așteptare (linkul rămâne valabil) sau nivelul per adresă (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Din nou formularul, cu `Retry-After`, când serverul este ocupat (coada de hashing a parolelor sau baza de date a rămas blocată); linkul rămâne valabil. Când serverul a rămas mult în urmă (vezi introducerea), în schimb o pagină „Server error” (eroare de server) cu `Retry-After` (între 2 și 5): nu s-a executat nimic, iar linkul rămâne valabil. De asemenea, la depășirea timpului maxim de procesare.'
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Serverul oficial (paginile se află la rădăcină, în afara `/api/v1`).
      - url: https://{host}:{port}
        description: Orice server Scacelith (paginile se află la rădăcină, în afara `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Numele public de gazdă al serverului (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Portul public al API-ului (`PUBLIC_API_PORT`, altfel `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Afișarea paginii de confirmare a schimbării e-mailului
      description: |-
        Pagina unui link de schimbare a e-mailului: afișează noua adresă și numele contului, cu un buton „Use this e-mail address” (folosiți această adresă de e-mail) care trimite formularul la `POST /confirm-email-change`.

        **Limită:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Pagina cu noua adresă și butonul ei de confirmare.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linkul este nevalid sau expirat (și când adresa contului s-a schimbat de la cerere încoace).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Limita `page` (o pagină HTML) sau nivelul per adresă (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Confirmarea noii adrese de e-mail
      description: |-
        Formularul paginii de schimbare a e-mailului. Adresa se schimbă și este considerată confirmată; dispozitivele rămân conectate; linkurile trimise anterior (confirmare, resetarea parolei, alte schimbări) nu mai funcționează; fosta adresă este anunțată, cu noua adresă mascată.

        **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: Adresa este schimbată.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linkul este nevalid, folosit sau expirat ori formularul este nevalid (o pagină HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Un alt cont a luat adresa între timp (o pagină HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Limita `auth` (o pagină HTML) sau nivelul per adresă (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Baza de date a rămas blocată (`Retry-After: 1`) sau serverul a rămas mult în urmă (`Retry-After` între 2 și 5, vezi introducerea): nu s-a schimbat nimic, iar linkul încă funcționează. De asemenea, la depășirea timpului maxim de procesare.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Serverul oficial, la rădăcină.
      - url: https://caissa.scacelith.com/api/v1
        description: Serverul oficial, sub `/api/v1`.
      - url: https://{host}:{port}
        description: Orice server Scacelith, la rădăcină.
        variables:
          host:
            default: caissa.scacelith.com
            description: Numele public de gazdă al serverului (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Portul public al API-ului (`PUBLIC_API_PORT`, altfel `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Orice server Scacelith, sub `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Numele public de gazdă al serverului (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Portul public al API-ului (`PUBLIC_API_PORT`, altfel `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Verificarea faptului că procesul rulează
      description: |-
        Răspunde 200 `ok` cât timp procesul rulează. Funcționează și `HEAD`. Orice altă metodă primește răspunsul 405 `method_not_allowed` cu `Allow: GET, HEAD` (inclusiv `OPTIONS`).

        **Limite:** numai nivelul per adresă.
      security: []
      responses:
        '200':
          description: Procesul rulează.
          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`: nivelul per adresă.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Serverul oficial, la rădăcină.
      - url: https://caissa.scacelith.com/api/v1
        description: Serverul oficial, sub `/api/v1`.
      - url: https://{host}:{port}
        description: Orice server Scacelith, la rădăcină.
        variables:
          host:
            default: caissa.scacelith.com
            description: Numele public de gazdă al serverului (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Portul public al API-ului (`PUBLIC_API_PORT`, altfel `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Orice server Scacelith, sub `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Numele public de gazdă al serverului (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Portul public al API-ului (`PUBLIC_API_PORT`, altfel `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Verificarea faptului că serverul acceptă jucători
      description: |-
        Răspunde 200 `ready` când serverul acceptă jucători și 503 `not_ready` cât timp pornește sau se oprește. Funcționează și `HEAD`. Orice altă metodă primește răspunsul 405 `method_not_allowed` cu `Allow: GET, HEAD` (inclusiv `OPTIONS`).

        **Limite:** numai nivelul per adresă.
      security: []
      responses:
        '200':
          description: Serverul acceptă jucători.
          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`: nivelul per adresă.'
        '503':
          description: Serverul pornește sau se oprește.
          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: |-
        Un token de sesiune (`sct_` urmat de 43 de caractere base64url) dintr-un răspuns de autentificare, în antetul `Authorization`: `Authorization: Bearer <token>`. Prefixul este exact `Bearer` (sensibil la majuscule) și un spațiu; o valoare care nu îl are sau un token care nu are între 1 și 512 caractere ASCII imprimabile primește răspunsul 401 `invalid_token`, ca orice alt token nevalid. Un antet gol contează ca lipsa antetului.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: Id-ul partidei, un număr întreg zecimal pozitiv de cel mult 16 cifre, fără zerouri la început, sub 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: Id-ul sesiunii, așa cum îl dă `GET /auth/sessions`, scris fără zerouri la început (`03` nu este sesiunea 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Numele de utilizator al jucătorului, fără a ține cont de majuscule. Serverul acceptă orice nume de 2 până la 24 de caractere din `[A-Za-z0-9_.-]` (cea mai largă regulă pe care a permis-o vreodată).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Un id de partidă; sunt listate numai partidele mai vechi. Transmiteți `next` al paginii anterioare. O valoare goală contează ca absentă.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Dimensiunea paginii, de la 1 la 50. Un număr mai mare, de cel mult 3 cifre, contează ca 50; o valoare goală contează ca absentă.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Tokenul linkului din e-mail (43 de caractere base64url). Un token lipsă sau greșit afișează pagina „link invalid or expired” (link nevalid sau expirat).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Fiecare răspuns al serverului interzice stocarea în cache.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Numărul de secunde de așteptat înainte de a încerca din nou; aceeași valoare ca `retryAfter` din corp.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Challenge-ul Bearer, cu `error="invalid_token"` pentru un token nevalid.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: Serverul închide conexiunea după acest răspuns.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (cu `field` când un câmp al corpului este de vină) sau `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Un câmp încalcă schema
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Corpul nu este JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: O parolă slabă
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: Un PGN care nu poate fi citit
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (fără antet `Authorization`) sau `invalid_token` (tokenul este malformat, expirat, revocat sau aparține unui cont șters).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Nicio sesiune
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Un token nevalid
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: Cererea este refuzată.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: O parolă greșită la reautentificare
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Un cont suspendat
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found` sau o funcție pe care acest server a dezactivat-o.'
      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`: corpul nu a sosit în 10 secunde. Serverul închide conexiunea.'
      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: Cererea intră în conflict cu starea serverului.
      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`: tichetul sau încercarea de autentificare cu Google este necunoscută, folosită ori expirată; reîncepeți autentificarea.'
      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`: corpul depășește `HTTP_BODY_LIMIT` (implicit 16.384 de octeți). Serverul închide conexiunea.'
      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`: ținta cererii depășește 4096 de caractere.'
      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`: corpul nu este `application/json` sau setul său de caractere nu este 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`: partida are mai multe semimutări decât `GIF_MAX_PLIES` (600).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: game_too_long
            message: The game is too long for a GIF (742 plies, at most 600).
    PowRequired:
      description: '`pow_required`: rezolvați challenge-ul `pow` și trimiteți din nou aceeași cerere cu `pow: { challenge, nonce }`. `reason` spune de ce: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` sau `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` (o limită de frecvență, bugetul contului sau coada de hashing a parolelor) ori `too_many_attempts` (o restricție a contului), cu `retryAfter` și un antet `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: O limită de frecvență
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Prea multe eșecuri pe acest cont
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: o eroare neașteptată. Serverul o înregistrează în jurnal.'
      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`: un `state` sau un `iss` greșit ori Google a refuzat codul sau a trimis un ID token care nu trece verificarea (inclusiv un cod sau un ID token al celuilalt client, al jocului ori al site-ului web). Nu conține niciodată textul 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` sau `timeout`: încercați din nou după `retryAfter`, când este indicat.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Serverul este ocupat
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: Baza de date a rămas blocată (o citire)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Serverul a avut nevoie de prea mult timp
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: GIF-ul animat, ca atașament.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` pentru o partidă de pe acest server, `attachment; filename="scacelith-game.gif"` pentru un PGN trimis cu `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: Dimensiunea fișierului, în octeți.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: O pagină HTML.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: O pagină HTML care explică refuzul.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Formularul nu a sosit în 10 secunde (o pagină HTML). Serverul închide conexiunea.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: O pagină HTML care explică conflictul.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Formularul depășește `HTTP_BODY_LIMIT` (o pagină HTML). Serverul închide conexiunea.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Corpul nu este nici formular (`application/x-www-form-urlencoded`), nici JSON ori setul său de caractere nu este UTF-8 (o pagină HTML).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: O limită de frecvență (o pagină HTML), cu `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: O eroare neașteptată (o pagină HTML intitulată „Server error”, eroare de server). Serverul o înregistrează în jurnal.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Serverul este ocupat (baza de date a rămas blocată sau serverul a rămas mult în urmă; cu `Retry-After`) ori a avut nevoie de prea mult timp (o pagină HTML intitulată „Server error”, eroare de server).
      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: Forma unică a oricărui răspuns de eroare al API-ului JSON.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: Codul de eroare `snake_case`. Pe baza lui, clientul alege ce afișează.
          examples:
            - rate_limited
        message:
          type: string
          description: O propoziție în engleză, destinată jurnalelor și folosită ca soluție de rezervă.
        retryAfter:
          type: integer
          minimum: 1
          description: Numărul de secunde de așteptat înainte de a încerca din nou; prezent numai la refuzurile care expiră cu timpul. Atunci răspunsul poartă și un antet `Retry-After` cu aceeași valoare, cu excepția răspunsului 503 `busy` al citirilor de istoric, partide, jucători și clasament.
        field:
          type: string
          description: Câmpul sau parametrul de interogare de vină (`invalid_request`, `invalid_option`, precum și `invalid_cursor`, `invalid_limit` și `invalid_filter` din `GET /account/games`). Un câmp imbricat este scris cu puncte (`pow.nonce`).
        reason:
          type: string
          description: 'Motivul: regula pe care o încalcă un `weak_password` sau de ce a fost cerută ori refuzată o dovadă de lucru (`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: 'Numai `banned`: sfârșitul suspendării (ms de la epoca Unix), `null` pentru o suspendare permanentă.'
        line:
          type: integer
          minimum: 1
          description: 'Numai `invalid_pgn`: linia erorii, de la 1.'
        column:
          type: integer
          minimum: 1
          description: 'Numai `invalid_pgn`: coloana erorii, de la 1, în caractere.'
    PowChallenge:
      type: object
      description: Un challenge de dovadă de lucru (`pow` dintr-un răspuns 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: Challenge-ul semnat, de trimis înapoi neschimbat.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Numărul de biți zero inițiali pe care trebuie să-i aibă `SHA-256(challenge + ":" + nonce)`.
        expiresAt:
          type: integer
          format: int64
          description: Momentul în care expiră challenge-ul (ms de la epoca Unix), la 2 minute după emitere.
    PowAnswer:
      type: object
      description: Răspunsul la un challenge de dovadă de lucru.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: '`challenge` din răspunsul 428, așa cum a fost dat.'
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Un șir zecimal de cel mult 20 de cifre, astfel încât `SHA-256(challenge + ":" + nonce)` să înceapă cu `bits` biți zero.
    ServerInfo:
      type: object
      description: Ce îi trebuie unui client înainte de a se autentifica sau de a se conecta.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Numele serverului (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: Un UUID pe care baza de date îl primește la prima pornire. Rămâne același de la o repornire la alta. Este `null` când baza de date nu îl poate furniza.
        motd:
          type: string
          description: Mesajul zilei (`SERVER_MOTD`), eventual gol.
        protocol:
          type: object
          description: Protocolul WebSocket (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: Cea mai veche versiune de protocol pe care o înțelege serverul.
            max:
              type: integer
              minimum: 1
              description: Cea mai nouă versiune de protocol pe care o înțelege serverul.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 'Amprenta schemei protocolului (primii 4 octeți ai hash-ului SHA-256 al formei sale canonice, ca întreg fără semn: `0xc649140f`, 3326678031, pentru versiunea minoră 2 a protocolului); informativă, nu este comparată niciodată.'
            subprotocol:
              type: string
              description: Tokenul subprotocolului WebSocket (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: Portul WebSocket folosit de jucători (`PUBLIC_WS_PORT`, altfel `WS_PORT`, altfel `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Calea WebSocket-ului.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Dacă se pot crea conturi noi.
        emailVerification:
          type: boolean
          description: Dacă conturile noi își confirmă adresa (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Dacă autentificarea cu Google este oferită jocului.
            googleWeb:
              type: boolean
              description: Dacă autentificarea cu Google este oferită și unui site web (`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: Autentificarea în doi pași este întotdeauna disponibilă.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Biții de dovadă de lucru ceruți la înregistrare (0 dacă nu se cere).
        categories:
          type: array
          description: Ritmurile de joc oficiale, cu rating (`RATED_CATEGORIES`), în ordinea serverului. Orice alt ritm de joc este `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Regulile pe care un client le poate verifica înainte de a trimite un formular.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: Lungimea minimă a numelui de utilizator (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: Lungimea maximă a numelui de utilizator (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: Expresia regulată căreia trebuie să-i corespundă un nume de utilizator nou.
            passwordMinLength:
              type: integer
              description: Lungimea minimă a parolei, în caractere (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: Lungimea maximă a parolei, în octeți UTF-8.
            customTimeControls:
              type: boolean
              description: Dacă provocările și partidele private pot folosi ritmuri de joc personalizate.
            reportsPerDay:
              type: integer
              description: Câte rapoarte poate trimite un jucător în 24 de ore (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: Dimensiunea maximă a unui mesaj WebSocket pe care îl poate trimite un client, în octeți.
      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: Un ritm de joc oficial.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 'Id-ul categoriei: minutele și secundele de increment (`3+2`).'
        baseSec:
          type: number
          minimum: 0
          description: Timpul de bază, în secunde.
        incSec:
          type: number
          minimum: 0
          description: Incrementul per mutare, în secunde.
    AccountView:
      type: object
      description: Contul așa cum îl vede jucătorul său (`user` din răspunsurile de autentificare și din `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: Id-ul contului.
        username:
          type: string
          description: Numele de utilizator.
        email:
          type: string
          format: email
          description: Adresa de e-mail.
        emailVerified:
          type: boolean
          description: Dacă adresa este confirmată.
        mfaEnabled:
          type: boolean
          description: Dacă autentificarea în doi pași este activată.
        googleLinked:
          type: boolean
          description: Dacă este asociat un cont Google.
        hasPassword:
          type: boolean
          description: '`false` pentru un cont creat prin Google care nu și-a setat încă o parolă.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Dacă sunt acceptate provocările directe după nume (vezi `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Momentul creării contului (ms de la epoca Unix).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Ultima autentificare (ms de la epoca Unix) sau `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Noua adresă a unei schimbări de e-mail aflate în așteptarea linkului sau `null`.
    SessionAnswer:
      type: object
      description: O sesiune nouă.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Tokenul de sesiune, pentru antetul `Authorization` și pentru WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: Sfârșitul absolut al sesiunii (ms de la epoca Unix); limita de inactivitate o poate încheia mai devreme.
        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: Al doilea pas al unei autentificări când autentificarea în doi pași este activată; continuați cu `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: Tokenul pasului, pentru `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Secundele rămase pentru finalizarea pasului.
    LoginAnswer:
      description: O sesiune sau al doilea pas al unei autentificări când autentificarea în doi pași este activată.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: O primă autentificare cu Google; continuați cu `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: Tichetul pentru `POST /auth/sso/complete`, valabil 10 minute.
        suggestedUsername:
          type: string
          description: Un nume de utilizator format din numele Google sau din adresă ori `""` când nimic nu se potrivește.
    SsoNeedsPassword:
      type: object
      description: Un cont cu parolă folosește adresa; continuați cu `POST /auth/sso/google/link`. Încă nu s-a asociat nimic.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Tichetul pentru `POST /auth/sso/google/link`.
        username:
          type: string
          description: Numele de utilizator al acelui cont (îl vede doar cineva care a dovedit în fața Google că deține adresa).
        expiresIn:
          type: integer
          const: 600
          description: Numărul de secunde cât tichetul rămâne valabil.
    SsoFinishAnswer:
      description: Răspunsul lui `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: O încercare de autentificare cu Google.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Încercarea, pentru `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: URL-ul Google de deschis în browserul sistemului, după verificare.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state` pe care Google trebuie să-l trimită înapoi.'
        expiresIn:
          type: integer
          const: 600
          description: Numărul de secunde cât încercarea rămâne valabilă.
    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: Numele de utilizator; se aplică apoi regulile serverului (vezi descrierea).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Adresa de e-mail. Spațiile de la capete sunt eliminate, iar adresa este salvată cu litere mici.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Parola; se aplică apoi regulile parolei.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Numele de utilizator sau adresa de e-mail (orice text cu `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Opțional. Afișat în lista dispozitivelor conectate (de exemplu `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: '`mfaToken` din răspunsul de autentificare (sau din cel al unei autentificări cu Google).'
        code:
          type: string
          maxLength: 32
          description: Opțional. Un cod de 6 cifre din aplicația de autentificare sau un cod de recuperare.
        recoveryCode:
          type: string
          maxLength: 32
          description: Opțional. Un cod de recuperare. Este necesar unul dintre `code` și `recoveryCode`.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Adresa de e-mail.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Parametrul `token` al linkului de resetare.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Parola nouă; se aplică regulile de parolă ale înregistrării.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Challenge-ul PKCE S256 pentru `codeVerifier` al clientului (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: Portul listenerului clientului, pe `127.0.0.1`.
    SsoWebStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Challenge-ul PKCE S256 pentru `codeVerifier` al paginii (`BASE64URL(SHA-256(codeVerifier))`).
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: '`attemptId` din `POST /auth/sso/google/start`.'
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: Verificatorul PKCE; hash-ul său SHA-256 trebuie să corespundă challenge-ului dat la `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state` așa cum l-a trimis înapoi Google.'
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: '`code` așa cum l-a trimis înapoi Google (ASCII imprimabil, fără spații).'
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Opțional. `iss` așa cum l-a trimis înapoi Google, dacă l-a trimis.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`linkTicket` din `finish`, valabil 10 minute.'
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Parola contului.
        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` din `finish`, valabil 10 minute.'
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Numele de utilizator; se aplică regulile înregistrării.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Sesiunile active, cele folosite cel mai recent primele.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: O sesiune activă (un dispozitiv conectat).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: Id-ul sesiunii, pentru `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: Autentificarea (ms de la epoca Unix).
        lastSeenAt:
          type: integer
          format: int64
          description: Ultima utilizare (ms de la epoca Unix), actualizată cel mult o dată la 5 minute (mai târziu cât timp serverul a rămas mult în urmă).
        expiresAt:
          type: integer
          format: int64
          description: Sfârșitul absolut (ms de la epoca Unix); limita de inactivitate poate încheia sesiunea mai devreme.
        clientLabel:
          type:
            - string
            - 'null'
          description: '`clientLabel` al autentificării sau `null` când nu s-a trimis niciunul.'
        current:
          type: boolean
          description: Dacă aceasta este sesiunea care face cererea.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Câte o fișă pentru fiecare categorie în care jucătorul a jucat partide cu rating.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Sancțiunile active.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` cât timp contul este suspendat, altfel `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Fișa de rating a unei categorii.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: Id-ul categoriei.
        rating:
          type: integer
          description: Ratingul.
        games:
          type: integer
          minimum: 0
          description: Partidele jucate în categorie (luate în calcul sau nu).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Cel mai mare rating atins.
        provisional:
          type: boolean
          description: '`true` cât timp ratingul nu a ieșit din faza fără rating sau are mai puțin de `PROVISIONAL_GAMES` partide luate în calcul (jocul îl afișează ca `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Tipul sancțiunii.
        reason:
          type:
            - string
            - 'null'
          description: Motivul indicat, dacă există.
        startsAt:
          type: integer
          format: int64
          description: Începutul (ms de la epoca Unix).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Sfârșitul (ms de la epoca Unix), `null` când sancțiunea este permanentă.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Sfârșitul suspendării (ms de la epoca Unix), `null` când este permanentă.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` pentru a accepta provocările directe după nume, `none` pentru a le refuza.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Parola curentă.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Parola nouă; se aplică regulile de parolă ale înregistrării.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Parola contului.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Un cod al secretului în așteptare, exact 6 cifre.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Parola contului.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Opțional; necesar cu autentificarea în doi pași (sau `recoveryCode`). Un cod din aplicația de autentificare sau un cod de recuperare.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Opțional. Un cod de recuperare (`xxxx-xxxx-xx`; majusculele, spațiile și cratimele nu contează).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Parola contului.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Un cod din aplicația de autentificare (un cod de recuperare este refuzat).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Noua adresă; spațiile de la capete sunt eliminate și este convertită la litere mici, apoi este verificată ca o adresă de înregistrare.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Parola contului.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Opțional; necesar cu autentificarea în doi pași (sau `recoveryCode`). Un cod din aplicația de autentificare sau un cod de recuperare.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Opțional. Un cod de recuperare.
    TotpSetup:
      type: object
      description: Secretul în așteptare pentru aplicația de autentificare.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Secretul în base32, de introdus în aplicație.
        uri:
          type: string
          format: uri
          description: URI-ul `otpauth://totp/...`, de afișat ca cod QR.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Secunde per pas.
    RecoveryCodeList:
      type: array
      description: Cele 10 coduri de recuperare, afișate doar de această dată. Fiecare funcționează o singură dată.
      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: Una dintre părțile unei partide.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Numele din înregistrarea partidei (`deleted#<id>` pentru un cont șters).
        rating:
          type:
            - integer
            - 'null'
          description: Ratingul la început, `null` când nu este cunoscut.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Ratingul după partidă. O partidă cu rating îl are întotdeauna; `null` numai pentru o partidă care nu contează pentru rating (amicală, personalizată, anulată).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: Variația ratingului, `0` când regulile de rating lasă ratingul neschimbat (regula FIDE a scorului zero, un adversar fără rating, primele partide ale unui jucător fără rating); `null` ca în cazul `ratingAfter`.
    GameSummary:
      type: object
      description: Rezumatul unei partide salvate.
      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: Id-ul partidei.
        category:
          type: string
          description: Id-ul categoriei oficiale (`3+2`) sau `custom`.
        rated:
          type: boolean
          description: Dacă partida contează pentru rating.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Ritmul de joc în secunde (`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` (vezi codurile partidelor).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Codul motivului de încheiere (vezi codurile partidelor).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Rezultatul, `*` pentru o partidă anulată.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Numărul de semimutări.
        startedAt:
          type: integer
          format: int64
          description: Începutul (ms de la epoca Unix).
        endedAt:
          type: integer
          format: int64
          description: Sfârșitul (ms de la epoca Unix).
    Termination:
      type: string
      description: Numele motivului de încheiere (vezi codurile partidelor); `Unknown` pentru un cod pe care protocolul nu îl cunoaște.
      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: Rezumatul unei partide din perspectiva unui jucător.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Partea jucătorului.
    HistoryGameSummary:
      description: O partidă din istoricul jucătorului autentificat.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Timpul de bază, în milisecunde.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Incrementul, în milisecunde.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: Rezultatul din perspectiva jucătorului.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Partidele paginii, cele mai noi primele.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Id-ul de transmis ca `before` pentru pagina următoare sau `null` pe ultima pagină.
        total:
          type: integer
          minimum: 0
          description: Numărul de partide care corespund filtrului, pe toate paginile.
    MoveRecord:
      type: object
      description: O semimutare a unei partide.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: Mutarea în notație UCI, cu `n`, `b`, `r` sau `q` adăugat pentru o promovare.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Timpul socotit pentru mutare (ms), `null` când înregistrarea nu îl are.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Ceasul celui care a mutat, după mutare (ms), `null` când înregistrarea nu îl are.
    PgnTagsView:
      type: object
      description: Principalele taguri PGN, pentru afișare. Aici `Termination` este numele motivului de încheiere; fișierul PGN folosește valorile standard PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` sau `<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 de început, 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: Ratingul la început sau `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Ratingul la început sau `"-"`.
          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: Înregistrarea unei partide, cu mutările și ceasurile ei. Are câmpurile unui rezumat de partidă, fără `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: Timpul de bază, în milisecunde.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Incrementul, în milisecunde.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: Numele lui `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: Id-ul partidei a cărei revanșă este aceasta sau `null`.
            moves:
              type: array
              description: Câte o intrare pentru fiecare semimutare.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Numai când jucătorul tokenului a jucat această partidă; partea sa.
            reportable:
              type: boolean
              description: Numai când jucătorul tokenului a jucat această partidă; `true` când `POST /reports` ar accepta acum un raport despre adversar pentru această partidă (partida s-a încheiat în ultimele 7 zile, cota zilnică nu este epuizată, iar adversarul nu este deja raportat pentru această partidă).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Numele de utilizator, așa cum l-a scris jucătorul.
        createdAt:
          type: integer
          format: int64
          description: Momentul creării contului (ms de la epoca Unix).
        ratings:
          type: array
          description: Numai categoriile oficiale, în ordinea serverului.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Toate partidele salvate, inclusiv cele amicale și cele anulate.
            rated:
              type: integer
              minimum: 0
              description: Partidele cu rating, însumate pe fișele de rating.
            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: Numele de utilizator, așa cum l-a scris jucătorul.
        games:
          type: array
          description: Partidele paginii, cele mai noi primele.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Id-ul ultimei partide ori de câte ori pagina este plină (pagina următoare poate fi atunci goală), altfel `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: Id-ul categoriei.
        minGames:
          type: integer
          minimum: 0
          description: Partidele luate în calcul de care are nevoie o fișă pentru a fi listată (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Momentul calculării clasamentului (ms de la epoca Unix).
        players:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LeaderboardEntry'
    LeaderboardEntry:
      type: object
      required:
        - rank
        - username
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
      properties:
        rank:
          type: integer
          minimum: 1
        username:
          type: string
        rating:
          type: integer
        games:
          type: integer
          minimum: 0
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
    GifRequest:
      type: object
      additionalProperties: false
      required:
        - pgn
      properties:
        pgn:
          type: string
          description: Textul PGN, cel mult 65.536 de octeți UTF-8. Se folosește numai prima partidă.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Opțional. Dimensiunea imaginii.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Opțional. Partea aflată în josul tablei.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Opțional. Milisecunde per mutare, un număr JSON cu valoare întreagă.
        coords:
          type: boolean
          default: true
          description: Opțional. Dacă literele coloanelor și numerele liniilor sunt desenate în jurul tablei.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: Partida, ca număr întreg sau ca șir de 1 până la 16 cifre.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: Numele de utilizator al adversarului, ca în înregistrarea partidei sau așa cum este acum, fără a ține cont de majuscule. Nu poate fi gol.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: Despre ce este raportul.
        comment:
          type:
            - string
            - 'null'
          description: Opțional. Cel mult 500 de caractere după eliminarea caracterelor de control (altele decât tabulatorul și saltul de linie) și a spațiilor de la capetele textului.
    AccountExport:
      type: object
      description: Tot ce păstrează serverul despre cont (momentele în ms de la epoca Unix).
      required:
        - format
        - version
        - exportedAt
        - server
        - notes
        - account
        - ratings
        - ratingRefunds
        - sessions
        - securityEvents
        - sanctions
        - conduct
        - reportsFiled
        - games
      properties:
        format:
          type: string
          const: scacelith-account-export
        version:
          type: integer
          const: 1
        exportedAt:
          type: integer
          format: int64
          description: Momentul realizării exportului.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Ce conține fișierul și ce omite, în engleză simplă, pentru jucător.
          items:
            type: string
        account:
          description: Vizualizarea contului din `GET /account/me`, cu `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: Adresa contului Google asociat sau `null`.
        ratings:
          type: array
          description: Fișele de rating complete.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Punctele de rating restituite după ce s-a constatat că un adversar a trișat, însumate pe zi UTC și pe categorie, cele mai noi primele. Nici partidele, nici trișorii nu sunt numiți.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Toate sesiunile salvate, cele mai noi primele, fără niciun token. Curățarea de retenție șterge o sesiune expirată, iar pe una deconectată, la o zi după deconectare.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Evenimentele de securitate, cele mai noi primele, păstrate `RETENTION_SECURITY_DAYS` zile.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Toate sancțiunile, inclusiv cele ridicate. Numele moderatorului nu este inclus niciodată.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Evenimentele de conduită înregistrate pentru partidele părăsite, anulate și cele la care jucătorul nu s-a prezentat, păstrate 30 de zile.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Rapoartele făcute de jucător.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Numărul de partide.
            list:
              type: array
              description: Toate partidele, cele mai noi primele, ca rezumate din `GET /account/games`. Mutările se află la `GET /games/{id}`, iar PGN-ul la `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: O fișă de rating completă.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Dacă jucătorul a ieșit din faza fără rating.
            countedGames:
              type: integer
              minimum: 0
              description: Partidele care au intrat în calculul ratingului.
            updatedAt:
              type: integer
              format: int64
              description: Momentul ultimei modificări a fișei.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: Ora 00:00 UTC a zilei (ms de la epoca Unix).
        category:
          type: string
        points:
          type: integer
          description: Punctele restituite în acea zi, în acea categorie.
    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: Momentul deconectării sesiunii sau `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: Adresa de la care s-a făcut autentificarea, ștearsă după `RETENTION_IP_DAYS` zile.
    SecurityEvent:
      type: object
      description: |-
        Un eveniment de securitate. `ip` este indicat numai pentru ceea ce s-a făcut în timpul unei sesiuni autentificate, cu parola contului (și al doilea factor) sau cu un link trimis la adresa sa: `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`; toate celelalte tipuri au `ip: null`, iar `ip` este șters după `RETENTION_IP_DAYS` zile.

        `detail` păstrează numai aceste câmpuri: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` și `method` (`password` sau `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`. Un eveniment `moderator_action` păstrează numai `{ action }`, pentru acțiunile `ban`, `unban`, `reset_mfa`, `verify_email` și `revoke_sessions` (celelalte acțiuni ale moderatorilor sunt omise). Toate celelalte tipuri au `detail: null`. Evenimentele `rating_refund` sunt omise: punctele lor se află în `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Tipul evenimentului (`login`, `password_changed`…).
        at:
          type: integer
          format: int64
          description: Momentul producerii.
        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: Numele public actual al jucătorului raportat.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Dacă raportul este încă deschis (nu se spune dacă jucătorul raportat a fost sancționat).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Tokenul linkului din e-mail (câmpul ascuns al formularului paginii).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Tokenul linkului de resetare (câmpul ascuns al formularului).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Parola nouă; se aplică regulile de parolă ale înregistrării.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Parola nouă, din nou; trebuie să fie identică.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: O pagină HTML în UTF-8 (`text/html; charset=utf-8`), fără JavaScript și fără resurse externe.
