openapi: 3.1.1
info:
  title: API HTTPS del server Scacelith
  version: "1.1.0"
  summary: L’API HTTPS di ogni server Scacelith, quello ufficiale e i server della community.
  description: |-
    Ogni server Scacelith, quello ufficiale `caissa.scacelith.com` e qualsiasi server della community, espone questa API HTTPS. Il gioco la usa per tutto ciò che sta al di fuori della partita vera e propria: registrazione e accesso (autenticazione a due fattori e Google compresi), la pagina dell’account, lo storico delle partite, i download PGN e le GIF animate delle partite, i dispositivi connessi, il download dei dati, l’eliminazione dell’account e le segnalazioni.

    Il gioco in tempo reale (abbinamento, sfide, mosse, orologi) passa per il WebSocket dello stesso server (`wss://<host>/ws`), aperto con un token di sessione di questa API; è descritto in `PROTOCOL.md`, non qui.

    I valori predefiniti indicati di seguito sono quelli di un server con la configurazione non modificata; un server della community può cambiarli (`CONFIG.md` elenca tutte le impostazioni).

    ## URL di base, porta e trasporto

    - Server ufficiale: `https://caissa.scacelith.com/api/v1`, porta TCP 443.
    - Un server della community: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` è 443 per impostazione predefinita; dietro un NAT o un proxy, la porta usata dai giocatori è `PUBLIC_API_PORT`.
    - Il WebSocket del gioco è sulla stessa porta per impostazione predefinita (`WS_PORT` lo sposta; `GET /info` indica al client dove si trova).
    - HTTP/1.1 su TLS. Con `TLS_MODE=proxy`, un reverse proxy posto davanti al server termina il TLS. `TLS_MODE=off` (HTTP in chiaro) è solo per lo sviluppo locale.
    - Le pagine HTML aperte dai link delle e-mail (`/verify-email`, `/reset-password`, `/confirm-email-change`) si trovano nella radice del server, fuori da `/api/v1`. Gli endpoint di controllo dello stato rispondono sia nella radice sia sotto `/api/v1`. L’accesso con Google non ha alcuna pagina sul server: Google rimanda il browser al gioco stesso, su `127.0.0.1`, o a una pagina del sito web che lo ha avviato (`GOOGLE_WEB_REDIRECT_URI`).
    - Una barra finale viene ignorata (`/api/v1/info/` equivale a `/api/v1/info`), e i parametri di percorso vengono decodificati come URL (per un parametro che non è una codifica URL valida la risposta è 400 `invalid_request`).

    ## Richieste

    - **Corpi JSON.** Le richieste `POST`, `PUT` e `DELETE` contengono un oggetto JSON con `Content-Type: application/json`. Un `charset` diverso da UTF-8 viene rifiutato, così come qualsiasi altro tipo di contenuto (415 `unsupported_media_type`). Un corpo vuoto vale come `{}`, che è ciò che accettano gli endpoint senza parametri (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Dimensione e tempo.** Un corpo è limitato a `HTTP_BODY_LIMIT` byte (16.384 per impostazione predefinita; `POST /gif` ha un proprio limite di 135.168 byte): 413 `payload_too_large`. Deve arrivare entro 10 secondi: 408 `request_timeout`. Dopo l’uno o l’altro errore il server chiude la connessione.
    - **Schemi rigorosi.** Un campo che l’endpoint non conosce viene rifiutato, un campo non contrassegnato come facoltativo è obbligatorio, e tipi e lunghezze vengono verificati. Per ciascuno di questi errori la risposta è 400 `invalid_request`, con `field` che indica il campo (in notazione puntata per un campo annidato, come `pow.nonce`). Le stringhe non possono contenere caratteri di controllo. Le lunghezze contano le unità di codice UTF-16, quindi un carattere al di fuori del piano multilingue di base (un’emoji) conta due volte. Per un corpo che non è JSON la risposta è 400 `invalid_json`. `POST /gif` e `POST /reports` verificano da sé i propri corpi (vedi ciascun endpoint).
    - **Query string.** Conta la prima occorrenza di un parametro, i parametri sconosciuti vengono ignorati e un `+` viene decodificato come spazio: gli endpoint che accettano una categoria di cadenza accettano sia `3%2B2` sia `3+2`.
    - **Metodi.** `HEAD` è `GET` senza il corpo. `OPTIONS` su un percorso esistente risponde 204 con un header `Allow` (e gli header di un preflight CORS, vedi sotto). Per qualsiasi altro metodo che il percorso non prevede la risposta è 405 `method_not_allowed` con `Allow`.
    - **Destinazione della richiesta.** Non può superare i 4096 caratteri (414 `uri_too_long`). Una richiesta che non può essere analizzata affatto, o la cui intestazione è troppo grande o arriva troppo lentamente, riceve una risposta 400, 431 o 408 vuota e la connessione viene chiusa.
    - **CORS: una lista di origini consentite, disattivata per impostazione predefinita.** L’API è al servizio del gioco, che non invia `Origin`. Con `CORS_ORIGINS` vuoto (il valore predefinito) non viene mai inviato alcun header `Access-Control-*`, quindi nessuna pagina web può leggere una risposta. `CORS_ORIGINS` elenca le origini web le cui pagine possono chiamare l’API da un browser, ciascuna esattamente come i browser la inviano in `Origin`: `https://host` o `https://host:port`, in minuscolo, senza percorso, barra finale o porta predefinita; `http://` solo per `localhost`, `127.0.0.1` e `[::1]` (sviluppo locale); nessun carattere jolly. Il server ufficiale imposta `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com` per il proprio sito web. In tal caso:
      - Ogni risposta per un percorso sotto `/api/v1`, tranne gli endpoint di controllo dello stato, sia di successo sia di errore (compreso il 429 del livello per indirizzo), contiene `Vary: Origin`. Quando l’`Origin` della richiesta corrisponde byte per byte a una delle origini elencate, contiene anche `Access-Control-Allow-Origin: <that origin>` e, tranne nella risposta a un preflight, `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, così che la pagina possa leggere i codici di errore, `Retry-After` e i nomi dei file dei download PGN e GIF.
      - Un preflight (`OPTIONS` con `Access-Control-Request-Method`) da un’origine elencata su un percorso esistente risponde 204, senza corpo, con `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (i metodi di `Allow`), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600` e `Vary: Origin`. Non richiede alcun token e non conta né su alcun budget dell’account né su alcun limite di endpoint; come ogni richiesta, consuma un gettone del livello per indirizzo. Un browser riutilizza un preflight per 10 minuti, ma solo per quell’URL esatto, query string compresa. Una richiesta senza `Authorization` e senza corpo JSON, come `GET /info` o `GET /leaderboard`, non richiede alcun preflight.
      - Qualsiasi altra risposta a un preflight è un preflight fallito, anche con gli header CORS: un 404 per un percorso che non esiste, o il 429 del livello per indirizzo. Il browser allora non invia la richiesta, e la pagina vede un errore di rete, senza la risposta né il suo `Retry-After`.
      - `Access-Control-Allow-Credentials` non viene mai inviato, e nemmeno `Access-Control-Allow-Origin: *`: una pagina invia il proprio token di sessione in `Authorization: Bearer`, come fa il gioco, e l’API non imposta alcun cookie. Le pagine HTML, gli endpoint di controllo dello stato e le risposte vuote alle richieste illeggibili non contengono alcun header CORS. Il WebSocket non usa CORS: `WS_ALLOWED_ORIGINS` elenca le pagine che possono aprirlo.
      - Una pagina di un’origine non elencata non legge nulla, e le sue scritture cross-site non vengono mai inviate: i corpi JSON e `Authorization` richiedono un preflight, la cui risposta non contiene `Access-Control-Allow-Origin` per quell’origine. `Cross-Origin-Resource-Policy: same-origin` resta su ogni risposta: si applica solo alle richieste `no-cors` (un altro sito che incorpora una risposta), mai alle richieste `cors` di una pagina elencata.

    ## Risposte ed errori

    - Le risposte sono in JSON con codifica UTF-8, tranne il download PGN (`application/x-chess-pgn`), le GIF animate (`image/gif`) e le pagine HTML. Gli istanti sono espressi in millisecondi dal 01/01/1970 UTC. Gli id sono numeri interi. Gli id delle partite hanno fino a 16 cifre ma restano sotto 2^53, quindi un numero JSON (un double) li rappresenta esattamente.
    - Ogni risposta dell’API contiene `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` e `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (le pagine HTML hanno una policy propria). Con `TLS_MODE=native` contiene anche `Strict-Transport-Security: max-age=31536000`. Le risposte che il listener scrive da sé, prima dell’API (il `GET` e l’`HEAD` degli endpoint di controllo dello stato, il 429 del livello per indirizzo e un 500 `internal_error` quando l’API fallisce del tutto), contengono gli stessi header tranne `Cross-Origin-Resource-Policy`, e il loro `Content-Type` è `application/json` senza charset.
    - Una risposta deve essere letta entro 60 secondi dal momento in cui il server l’ha pronta, il che conta solo per quelle grandi (una GIF, l’esportazione dei dati, un PGN lungo). Il tempo che il server impiega a preparare una risposta non conta né per questo limite né per i 30 secondi senza alcun byte in entrata o in uscita dopo i quali una connessione viene chiusa.

    Gli errori hanno un’unica forma (lo schema `Error`):

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

    `message` è pensato per i log e come ripiego; un client sceglie cosa mostrare in base a `error`. `retryAfter` (in secondi) è presente solo nei rifiuti che cessano con il passare del tempo, e in tal caso la risposta contiene anche un header `Retry-After` con lo stesso valore; l’unica eccezione è il 503 `busy` delle letture di storico, partite, giocatori e classifica, che ha solo il campo. Alcuni errori aggiungono dei campi: `field` (input non valido), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` e `column` (`invalid_pgn`).

    Errori che qualsiasi endpoint può restituire:

    | Stato | `error` | Quando |
    |---|---|---|
    | 400 | `invalid_request` | Il corpo viola lo schema dell’endpoint (`field` indica dove), la destinazione della richiesta o `Content-Length` è malformato, un parametro di percorso non è una codifica URL valida, oppure il corpo è stato troncato. |
    | 400 | `invalid_json` | Il corpo non è JSON. |
    | 401 | `unauthorized` | Manca l’header `Authorization` su un endpoint che richiede una sessione. |
    | 401 | `invalid_token` | Il token è malformato, scaduto, revocato o appartiene a un account eliminato; anche sugli endpoint in cui la sessione è facoltativa. |
    | 404 | `not_found` | L’endpoint non esiste. Lo usano anche alcuni endpoint: la partita, il giocatore o la sessione non esiste. |
    | 405 | `method_not_allowed` | Il percorso esiste per altri metodi (vedi `Allow`). |
    | 408 | `request_timeout` | Il corpo non è arrivato entro 10 secondi. |
    | 413 | `payload_too_large` | Il corpo supera `HTTP_BODY_LIMIT` (135.168 byte per `POST /gif`). |
    | 414 | `uri_too_long` | La destinazione della richiesta supera i 4096 caratteri. |
    | 415 | `unsupported_media_type` | Il corpo non è `application/json`, oppure il suo charset non è UTF-8. |
    | 429 | `rate_limited` | Un limite di frequenza (vedi sotto): `retryAfter` più un header `Retry-After`. |
    | 500 | `internal_error` | Un errore imprevisto. Il server lo registra nei log. |
    | 503 | `timeout` | Il server non ha risposto entro 30 secondi (60 secondi per l’esportazione, 45 secondi per le GIF con le impostazioni predefinite). |
    | 503 | `server_busy` | Una verifica della sessione o una modifica dell’account ha trovato il database bloccato (`retryAfter` 1), oppure la coda di hashing delle password è piena (vedi sotto). Inoltre, con un `retryAfter` da 2 a 5, quando il server è molto in ritardo: per un `POST`, `PUT`, `PATCH` o `DELETE`, il database ha troppe scritture in attesa; per qualsiasi richiesta, troppe richieste precedenti sono ancora in esecuzione oltre il loro timeout, oppure non è stato possibile verificare il token di sessione entro 5 secondi. La richiesta non è stata eseguita, e i suoi limiti di frequenza non hanno consumato nulla. |

    Gli endpoint di lettura (storico delle partite, partite, giocatori, classifica) e l’esportazione rispondono 503 `busy` con `retryAfter: 1` quando il database è rimasto bloccato.

    Gli endpoint che verificano una password o ne calcolano l’hash possono anche rispondere con uno di questi errori, entrambi con un `retryAfter` casuale da 5 a 15 secondi:

    - 503 `server_busy`: la coda di hashing delle password del server (`PASSWORD_HASH_QUEUE_MAX`) è piena, oppure l’attesa è scaduta.
    - 429 `rate_limited`: con la coda piena almeno per metà, questo client (un indirizzo IPv4 o un /48 IPv6) ha già `PASSWORD_HASH_WAITERS_PER_SOURCE` hash in attesa. Questo 429 restituisce i gettoni dei limiti di frequenza che la richiesta aveva consumato.

    Non è stato modificato nulla e non è stato conteggiato alcun tentativo fallito (tranne su `POST /auth/sso/google/link`, per cui il tentativo sul ticket e il tentativo fallito dell’account sono stati conteggiati prima dell’hash), e un link di reimpostazione resta valido.

    Le pagine HTML restituiscono i propri errori come pagine HTML, con gli stessi codici di stato.

    ## Autenticazione

    Gli endpoint che richiedono una sessione accettano un bearer token nell’header `Authorization` (lo schema `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Ottenere un token.** Un token (`sct_` seguito da 43 caratteri base64url) proviene da `POST /auth/login`, poi da `POST /auth/login/mfa` quando l’autenticazione a due fattori è attiva, da `POST /auth/sso/google/finish` e `POST /auth/sso/google/link` (accesso con Google) e da `POST /auth/sso/complete`. Ognuno di essi restituisce `{ token, expiresAt, user }`. Il server memorizza solo uno SHA-256 del token.
    - **Sessione obbligatoria.** Se l’header manca, la risposta è 401 `unauthorized` con `WWW-Authenticate: Bearer realm="scacelith"`. Per un token non valido la risposta è 401 `invalid_token` con `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Un client dovrebbe dimenticare un token che riceve `invalid_token` e accedere di nuovo.
    - **Sessione facoltativa.** Su `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` e `GET /players/{username}/games` la sessione è facoltativa. Senza l’header rispondono con la vista pubblica; se viene inviato un header, il suo token deve essere valido.
    - **Archivio occupato.** Quando il database resta bloccato mentre viene verificato un token, un endpoint che richiede o accetta una sessione risponde 503 `server_busy` con `retryAfter: 1` (e `Retry-After`); quando la verifica richiede più di 5 secondi, risponde 503 `server_busy` con un `retryAfter` da 2 a 5. In entrambi i casi il token non è stato giudicato: conservalo e invia di nuovo la richiesta.
    - **Durata.** Una sessione termina alla prima di queste due scadenze: `SESSION_MAX_DAYS` (90) giorni dopo l’accesso (cioè `expiresAt`) o `SESSION_IDLE_DAYS` (30) giorni senza utilizzo. Ogni utilizzo sposta in avanti il limite di inattività; il server scrive il nuovo valore al massimo ogni 5 minuti, in background. Finché il suo database ha molte scritture in attesa, rimanda questa scrittura a una richiesta successiva, tranne nell’ultima ora prima del limite di inattività, così una sessione in uso non scade. Un account conserva al massimo `MAX_SESSIONS_PER_USER` (10) sessioni: un nuovo accesso revoca le più vecchie oltre questo numero.
    - **Revoca.** La disconnessione (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) revoca delle sessioni; lo stesso fanno un cambio di password (le altre sessioni), una reimpostazione della password e l’eliminazione dell’account (tutte le sessioni), nonché un amministratore. Una revoca tramite l’API ha effetto immediato, e il WebSocket aperto con una sessione revocata viene chiuso. Un cambio o una reimpostazione della password e l’eliminazione dell’account revocano le sessioni nella stessa transazione della modifica stessa: quando una di queste operazioni fallisce, nessuna sessione è stata revocata e non è cambiato nient’altro. Il server tiene in cache le verifiche delle sessioni per 30 secondi, quindi una revoca tramite il comando di amministrazione, che è un processo separato, ha effetto entro 30 secondi.
    - **Ambito.** Un token appartiene a un solo server e apre anche il suo WebSocket. Non inviarlo mai a un altro server.

    ## Limiti di frequenza e altre restrizioni

    I limiti si applicano a uno di due ambiti. **Client:** un indirizzo IPv4, o un /64 IPv6 (in modalità proxy, l’indirizzo proviene da `X-Forwarded-For` inviato da un indirizzo di `TRUSTED_PROXIES`); alcuni limiti contano anche ogni /48 IPv6 nel suo insieme, oltre a ciascuna delle sue reti /64. **Giocatore:** l’account connesso, qualunque sia il suo indirizzo; un limite contato per giocatore su un endpoint in cui la sessione è facoltativa viene contato per client per una richiesta senza token.

    Ogni limite è un token bucket (un secchio di gettoni) che contiene `limit` richieste e si ricarica in modo continuo al ritmo di `limit / window`; `retryAfter` è il tempo che manca al gettone successivo. I limiti contrassegnati come *condivisi* vengono contati anche su una finestra scorrevole della stessa durata, così che nessuna finestra contenga molte più di `limit` richieste. Ogni limite è contato sull’intero server. Quando uno dei limiti di un endpoint rifiuta una richiesta, i gettoni che gli altri suoi limiti avevano consumato per quella richiesta vengono restituiti. La risposta a un rifiuto è 429 `rate_limited` con `retryAfter` e `Retry-After`.

    - **Livello per indirizzo.** Ogni richiesta (qualsiasi percorso e metodo, compresi gli endpoint di controllo dello stato e gli upgrade a WebSocket) consuma per prima cosa un gettone del budget del suo client: `HTTP_RATE_PER_IP` (600) al minuto con un burst di mezzo minuto, e `HTTP_RATE_PER_PREFIX` (4 × `HTTP_RATE_PER_IP`) per un /48 IPv6. Un client può inoltre avere al massimo `IP_MAX_INFLIGHT` (32 × `WORKERS`) richieste in corso (`retryAfter` 1 oltre questo numero). Un client che insiste dopo i rifiuti viene bloccato: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) rifiuti in un minuto lo bloccano per 1 minuto, poi per 4, 16 e 60 minuti a ogni nuovo blocco entro 6 ore. Un rifiuto dei limiti `auth`, `auth_*` e `reauth` conta per 5; i limiti contati per giocatore non contano mai. Gli indirizzi in `ABUSE_EXEMPT` saltano questo livello, ma non il budget dell’account né i limiti degli endpoint.
    - **Budget dell’account.** Ogni richiesta che contiene un token di sessione valido conta anche sul suo account: `USER_RATE_PER_MIN` (120) al minuto per tutti gli endpoint insieme, qualunque sia l’indirizzo, con un burst di mezzo minuto.
    - **Limiti degli endpoint:**

    | Limite | Predefinito | Contato per | Endpoint |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, condiviso | client, e `AUTH_RATE_PER_PREFIX` (5 × `AUTH_RATE_PER_IP`) per /48 IPv6 | `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) / ora, condiviso | client, 3 volte tanto per /48 IPv6 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / ora, condiviso | client, 3 volte tanto per /48 IPv6 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / ora, condiviso | client, 3 volte tanto per /48 IPv6 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 ore, condiviso | client, 3 volte tanto per /48 IPv6 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / ora, condiviso | client, 3 volte tanto per /48 IPv6 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | i valori di `auth`, con un bucket a sé, condiviso | client e /48 IPv6 | `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, condiviso | giocatore | gli stessi endpoint di `reauth` |
    | `account` | 60 / min | giocatore | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | giocatore | `GET /account/games` |
    | `account_export` | 5 / ora, condiviso | giocatore | `POST /account/export` (verificato prima di `reauth`; ogni tentativo conta) |
    | `sessions` | 60 / min | giocatore | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | giocatore (client senza token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (un solo bucket per i quattro) |
    | `gif` | 30 / min | giocatore | `GET /games/{id}/gif`, `POST /gif` (un solo bucket per i due) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min e `GIF_USER_RENDERS_PER_HOUR` (30) / ora, condivisi | giocatore | gli stessi due, solo quando la GIF deve essere creata (non dalla cache) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min e `GIF_IP_RENDERS_PER_HOUR` (120) / ora, condivisi | client (tutti i suoi account insieme), 3 volte tanto per /48 IPv6 | gli stessi, come sopra |
    | `reports` | 30 / ora | giocatore | `POST /reports` |
    | `sso_start` | 30 / 10 min, condiviso | client, e 90 per /48 IPv6 | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (insieme) |
    | `sso_finish` | 30 / min | client | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | client | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` e `GET /leaderboard` non hanno un limite proprio: solo il livello per indirizzo. Un endpoint con più limiti li verifica nell’ordine indicato nella sua descrizione.

    Il server gestisce una richiesta in questo ordine: il livello per indirizzo, poi gli endpoint di controllo dello stato; l’individuazione dell’endpoint; le verifiche che il server non sia molto in ritardo (503 `server_busy`): troppe richieste precedenti ancora in esecuzione oltre il loro timeout e, per un `POST`, `PUT`, `PATCH` o `DELETE`, l’arretrato di scritture del database; l’autenticazione, entro 5 secondi (503 `server_busy` oltre); il budget dell’account, quando la richiesta contiene una sessione; i limiti dell’endpoint; il corpo; l’endpoint stesso (gli endpoint delle GIF consumano i loro limiti di rendering solo quando hanno una GIF da creare). Quindi una richiesta rifiutata per il suo token non consuma alcun gettone dell’endpoint, mentre una richiesta con un corpo non valido li consuma.

    Altre restrizioni, gestite dagli endpoint stessi:

    - **Accessi falliti su un nome di accesso** (nome utente o e-mail): da `AUTH_FAILURES_PER_ACCOUNT` (5) tentativi falliti in poi, ogni tentativo deve attendere il doppio del precedente (2 s, 4 s e così via, fino a 15 minuti): 429 `too_many_attempts` con `retryAfter`. Il contatore si azzera dopo un’ora senza tentativi falliti.
    - **Secondi fattori errati all’accesso:** la stessa regola dal 5° codice errato dell’account. Un singolo passaggio di accesso accetta al massimo 5 codici errati.
    - **Codici di secondo fattore di un account:** al massimo `AUTH_MFA_PER_ACCOUNT` (10) codici (dell’app di autenticazione o di recupero, corretti o errati) ogni 15 minuti, da qualsiasi indirizzo, all’accesso e nelle riautenticazioni; oltre questo numero, 429 `too_many_attempts` prima che il codice venga verificato, così un codice di recupero non viene consumato.
    - **Riautenticazioni fallite di un account** (password o codice errati): la stessa regola (429 `too_many_attempts`), condivisa da tutti gli endpoint che richiedono la riautenticazione.
    - **E-mail:** un’e-mail di conferma o di reimpostazione per indirizzo ogni 5 minuti (la risposta resta la stessa); un avviso «qualcuno ha cercato di usare il tuo indirizzo» per indirizzo all’ora.
    - **Segnalazioni:** `REPORTS_PER_DAY` (5) per giocatore in 24 ore: 429 `report_limit`.

    ## Proof of work

    `POST /auth/register` richiede sempre una proof of work quando `POW_REGISTER_BITS` è maggiore di 0 (18 per impostazione predefinita; `GET /info` lo indica come `pow.register`). `POST /auth/login` e `POST /auth/sso/google/link` (un unico tipo di challenge per entrambi) ne richiedono una solo per 5 minuti dopo che il server ha rilevato un’ondata di accessi falliti (`POW_LOGIN_TRIGGER_PER_MIN`, poi `POW_LOGIN_BITS`); il client lo scopre dalla risposta.

    1. La richiesta senza prova (o con una prova rifiutata) riceve la risposta 428 `pow_required` con `reason` e un challenge `pow` `{ challenge, bits, expiresAt }`.
    2. Trova un nonce: una stringa decimale di al massimo 20 cifre tale che `SHA-256(challenge + ":" + nonce)` inizi con `bits` bit a zero (a partire dal bit più significativo del primo byte). 18 bit richiedono in media circa 260.000 hash.
    3. Invia di nuovo la stessa richiesta con `"pow": { "challenge": "...", "nonce": "123456" }` nel corpo.

    Un challenge è valido per 2 minuti e una sola volta, per un endpoint e una rete client (un indirizzo IPv4 o un /64 IPv6). È firmato, quindi il server non ne conserva nulla finché non torna indietro. `reason` indica perché una prova è stata rifiutata: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` o `replayed`.

    ## Riautenticazione

    Le modifiche all’account chiedono di nuovo la password e, quando l’autenticazione a due fattori è attiva, un secondo fattore:

    - solo la password: `POST /account/password` e `POST /account/mfa/totp/setup`;
    - la password e un codice dell’app di autenticazione, mentre un codice di recupero viene rifiutato: `POST /account/mfa/recovery-codes`;
    - la password e un codice dell’app di autenticazione o un codice di recupero: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` e `POST /account/delete`.

    Nei corpi, `code` contiene un codice a 6 cifre dell’app di autenticazione e `recoveryCode` un codice di recupero (`xxxx-xxxx-xx`; maiuscole e minuscole, spazi e trattini non contano). Un codice di recupero può anche essere inviato in `code` dove i codici di recupero sono accettati. Ogni codice funziona una sola volta: un codice dell’app di autenticazione già usato viene rifiutato fino al successivo intervallo di 30 secondi, e un codice di recupero, una volta usato, non esiste più.

    | Stato | `error` | Quando |
    |---|---|---|
    | 403 | `invalid_password` | Password errata, oppure la password è stata reimpostata o cambiata mentre la richiesta era in esecuzione (non è stato modificato nulla). |
    | 403 | `mfa_code_required` | L’autenticazione a due fattori è attiva e non è stato inviato né `code` né `recoveryCode`. |
    | 403 | `invalid_code` | Codice errato o già usato. |
    | 400 | `password_not_set` | Un account solo Google non ha ancora una password («Password dimenticata?» ne imposta una). |
    | 429 | `too_many_attempts` | Troppi tentativi falliti, o troppi codici provati, su questo account. |
    | 503 / 429 | `server_busy` / `rate_limited` | La coda di hashing delle password è occupata. |

    Le password e i codici errati vengono conteggiati nel contatore dei tentativi falliti dell’account e registrati come eventi di sicurezza.

    ## Porta delle metriche

    Oltre alla porta dell’API, il server risponde in HTTP in chiaro sulla porta delle metriche (`METRICS_PORT`, 9464, associata a `METRICS_BIND`, 127.0.0.1 per impostazione predefinita; tienila privata). Non fa parte di questa API: `GET /healthz` risponde `ok`; `GET /readyz` risponde `ready` una volta completato l’avvio (ogni shard ha riprodotto il proprio journal e i listener sono in ascolto) e fino all’inizio dello spegnimento, altrimenti 503 `not ready`; `GET /metrics` fornisce le metriche Prometheus e, con `METRICS_TOKEN` impostato, richiede `Authorization: Bearer <token>` con esattamente quel 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: Codice sorgente del server Scacelith e sua documentazione di riferimento (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Il server ufficiale.
  - url: https://{host}:{port}/api/v1
    description: Qualsiasi server Scacelith, ad esempio un server della community.
    variables:
      host:
        default: caissa.scacelith.com
        description: Il nome host pubblico del server (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: La porta pubblica dell’API (`PUBLIC_API_PORT`, altrimenti `API_PORT`).
tags:
  - name: server-info
    x-displayName: Informazioni sul server
    description: Ciò che un client deve sapere prima di accedere o di connettersi.
  - name: health
    x-displayName: Controllo dello stato
    description: |-
      Vitalità (liveness) e prontezza (readiness) del server, per il monitoraggio. Rispondono sulla porta dell’API prima dell’autenticazione e di ogni limite degli endpoint, ma come ogni richiesta consumano un gettone del livello per indirizzo; un host di monitoraggio può essere elencato in `ABUSE_EXEMPT`. Per ogni endpoint funzionano entrambi i percorsi: nella radice del server e sotto `/api/v1`.

      La porta delle metriche ha propri endpoint di controllo dello stato, descritti nell’introduzione.
  - name: auth
    x-displayName: Registrazione e accesso
    description: |-
      Creazione di un account, accesso con una password (e un secondo fattore), e-mail di conferma e di reimpostazione della password.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` e `POST /auth/sso/complete` restituiscono una sessione `{ token, expiresAt, user }` (oppure, per i primi, un secondo passaggio).
  - name: google-sign-in
    x-displayName: Accesso con Google
    description: |-
      Disponibile quando `GET /info` indica `sso.google: true`; altrimenti ogni endpoint qui sotto risponde 404 `sso_disabled`. Un sito web effettua l’accesso allo stesso modo con un proprio client quando `sso.googleWeb` è true (sotto, **Su un sito web**). Il gioco effettua l’accesso tramite il browser di sistema con il flusso per app installate di RFC 8252 (un client «App desktop»): Google rimanda il browser a un listener del gioco su `127.0.0.1`, mai a questo server, e il gioco non vede mai una credenziale Google.

      1. Il client si mette in ascolto su `127.0.0.1:0` (il sistema sceglie la porta) e genera una coppia PKCE: un `codeVerifier` da 43 a 128 caratteri `[A-Za-z0-9._~-]` e `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, che ha 43 caratteri e nessun padding.
      2. `POST /auth/sso/google/start` con il challenge e la porta restituisce l’URL di Google e lo `state` del tentativo. Il client verifica l’URL (vedi sotto) e lo apre nel browser.
      3. Google manda il browser a `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Il client accetta solo lo `state` della risposta di avvio, e non invia nulla dopo un reindirizzamento con `error=`.
      4. `POST /auth/sso/google/finish` con l’id del tentativo, il `codeVerifier`, lo `state` e il `code` (e `iss` se Google lo ha inviato).
      5. La risposta è una sessione, un passaggio di autenticazione a due fattori (prosegui con `POST /auth/login/mfa`), `needsUsername` per un nuovo giocatore (prosegui con `POST /auth/sso/complete`), oppure `needsPassword` quando l’indirizzo è usato da un account con una password (prosegui con `POST /auth/sso/google/link`).

      **Il tag di origine.** L’URI di reindirizzamento contiene un tag del server che il giocatore ha aggiunto, così che un URL ottenuto da un altro server per i propri giocatori venga rifiutato dal gioco. L’origine è l’host in minuscolo (un letterale IPv6 tra parentesi quadre), `:` e la porta decimale dell’API, sempre scritta, 443 compresa: il server usa `SERVER_PUBLIC_HOST` e la sua porta pubblica dell’API, il gioco l’indirizzo a cui è connesso. Il tag è formato dai primi 22 caratteri del base64url (senza padding) di SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + origine). L’URI di reindirizzamento è `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: sempre il letterale IPv4, e la porta del listener del gioco (da 1024 a 65535). Il server non prende mai un URI, un host o un percorso dal client.

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

      Quindi l’accesso con Google funziona solo per i giocatori che hanno aggiunto il server esattamente con `SERVER_PUBLIC_HOST` e la sua porta pubblica dell’API.

      **Cosa verifica il gioco prima di aprire il browser.** L’`authUrl` inizia esattamente con `https://accounts.google.com/o/oauth2/v2/auth?` ed è in ASCII stampabile, con meno di 4096 caratteri, e la sua query contiene esattamente un `response_type=code`, esattamente un `redirect_uri` uguale all’URI che il gioco calcola dalla propria porta e dal proprio tag di origine, esattamente uno `state` uguale allo `state` della risposta, `code_challenge_method=S256` e un `code_challenge` di 43 caratteri. Altrimenti il gioco arresta il suo listener e non apre nulla. Invia `finish` e `link` solo al server che ha risposto a `start`.

      **Su un sito web.** Un sito web (quello ufficiale: <https://scacelith.com/>) effettua l’accesso con un client «Applicazione web» dello stesso progetto Google (`GOOGLE_WEB_CLIENT_ID`). Google rimanda il browser a una pagina del sito configurata dal gestore (`GOOGLE_WEB_REDIRECT_URI`, elencata tra gli «URI di reindirizzamento autorizzati» del client), e quella pagina invia il codice a `finish`; nemmeno qui il server prende un URI di reindirizzamento dal client.

      1. La pagina genera una coppia PKCE come sopra, conserva il verifier nella scheda (`sessionStorage`) e invia `POST /auth/sso/google/web/start` con il challenge.
      2. Verifica l’`authUrl` come fa il gioco, tranne che il suo `redirect_uri` deve essere una pagina della propria origine, poi vi naviga. Non viene caricato alcuno script di Google.
      3. Google manda il browser a `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. La pagina accetta solo lo `state` della risposta di avvio e non invia nulla dopo un reindirizzamento con `error=`.
      4. `finish`, `link` e `complete` seguono come per il gioco. Il tentativo ricorda di essere stato avviato da un sito web: il codice viene scambiato con il client e la pagina del sito web, e l’ID token deve indicare quel client (un codice o un ID token del client del gioco fallisce con 502 `sso_failed`, e viceversa).

      Un account Google raggiunge lo stesso account Scacelith dal gioco e dal sito web: il collegamento contiene il subject di Google (`sub`), che identifica l’account Google qualunque sia il client. L’origine del sito web deve essere in `CORS_ORIGINS` (altrimenti `check-config` emette un avviso).

      **Quale account raggiunge l’accesso con Google:** l’account già collegato a quell’account Google; altrimenti un account attivo con l’indirizzo confermato da Google (se ha una password, `finish` risponde `needsPassword` e il collegamento viene memorizzato solo quando la sua password, e poi il suo secondo fattore se è attivo, risultano corretti; senza password, 409 `sso_account_exists`); altrimenti un nuovo account (`needsUsername`). Un account Google non viene mai collegato a un account esistente in base al solo indirizzo. L’indirizzo dell’account riceve un’e-mail quando l’accesso con Google crea un account e quando viene aggiunto a un account esistente.
  - name: sessions
    x-displayName: Sessioni
    description: I dispositivi connessi dell’account e la disconnessione.
  - name: account
    x-displayName: Account
    description: La vista dell’account, le sue preferenze e la sua password.
  - name: two-step-verification
    x-displayName: Autenticazione a due fattori
    description: App di autenticazione (TOTP, RFC 6238), con SHA-1, 6 cifre, intervalli di 30 secondi e una tolleranza di un intervallo in entrambe le direzioni, più codici di recupero monouso.
  - name: email-change
    x-displayName: Cambio dell’indirizzo e-mail
    description: Cambio dell’indirizzo dell’account, confermato con un link inviato al nuovo indirizzo.
  - name: data-export
    x-displayName: Esportazione dei dati
    description: Tutto ciò che il server conserva sull’account, in un unico file JSON.
  - name: account-deletion
    x-displayName: Eliminazione dell’account
    description: Eliminazione definitiva dell’account.
  - name: game-history
    x-displayName: Storico delle partite
    description: Le partite del giocatore connesso, filtrate e suddivise in pagine.
  - name: games
    x-displayName: Partite e PGN
    description: |-
      Le registrazioni delle partite, con le loro mosse e i loro orologi, e i relativi file PGN.

      **Codici delle partite.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (vengono memorizzate solo le partite terminate). `result`: `1-0`, `0-1`, `1/2-1/2` o `*` (annullata). `reason`, con il suo nome (`termination`) e le parole che chiudono il testo delle mosse del file PGN (in inglese nel file, seguite qui dalla traduzione); i codici 7, 14 e 21 sono patte (il giocatore che ha esaurito il tempo, ha abbandonato o ha lasciato la partita aveva di fronte un avversario che non poteva dare matto: FIDE 6.9 e 5.1.2; `DESIGN.md` 6.3 riporta la regola), e il server non termina mai una partita con i codici 4 e 13 (appartengono all’elenco comune dei motivi):

      | Codice | `termination` | Parole nel PGN |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate — scacco matto |
      | 2 | `Resignation` | Resignation — abbandono |
      | 3 | `Timeout` | Loss on time — sconfitta per tempo |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) — seconda mossa illegale (partita persa) |
      | 5 | `Stalemate` | Stalemate — stallo |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) — posizione morta (materiale insufficiente) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate — bandierina caduta, ma l’avversario non può dare matto |
      | 8 | `FivefoldRepetition` | Fivefold repetition — quintuplice ripetizione |
      | 9 | `SeventyFiveMoves` | 75-move rule — regola delle 75 mosse |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) — triplice ripetizione (reclamata) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) — regola delle 50 mosse (reclamata) |
      | 12 | `Agreement` | Draw by agreement — patta per accordo |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate — seconda mossa illegale, ma l’avversario non può dare matto |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate — abbandono, ma l’avversario non può dare matto |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) — partita lasciata (disconnessione troppo lunga) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate — partita lasciata, ma l’avversario non può dare matto |
      | 22 | `Aborted` | Game aborted — partita annullata |
      | 23 | `NoShow` | Aborted: first move not played in time — annullata: prima mossa non giocata in tempo |
      | 24 | `Forfeit` | Forfeit (fair play violation) — sconfitta a tavolino (violazione del fair play) |
      | 25 | `ServerAborted` | Aborted by the server — annullata dal server |
      | 26 | `BothDisconnected` | Aborted: both players disconnected — annullata: entrambi i giocatori disconnessi |

      Il codice 14 è nuovo nella versione minore 1 del protocollo: un client scritto prima di essa dovrebbe mostrare un codice sconosciuto come una semplice fine della partita, con il suo `result`.
  - name: gifs
    x-displayName: GIF animate
    description: |-
      Una partita come GIF animata, da conservare o da condividere: la scacchiera vista dall’alto, un fotogramma per posizione dall’inizio alla posizione finale, i nomi e i punteggi dei giocatori sopra la scacchiera, l’ultima mossa sotto di essa e, nell’ultimo fotogramma, il risultato e il modo in cui la partita è terminata.

      **Costo, cache e quote.** Una GIF viene creata su un thread di rendering dedicato, mai sui thread che eseguono le partite, e con la priorità di CPU più bassa: `GIF_THREADS` thread (`WORKERS` per impostazione predefinita), avviati con la prima GIF e arrestati dopo un minuto senza GIF. Fino a `GIF_QUEUE_MAX` (4 × `WORKERS`) GIF attendono un thread, al massimo `GIF_QUEUE_TIMEOUT_MS` (10 s) ciascuna; un rendering può durare `GIF_RENDER_TIMEOUT_MS` (30 s). Il server conserva le GIF che ha creato in una cache di `GIF_CACHE_MB` MB (32 × `WORKERS`), da cui escono per prime quelle usate meno di recente; una GIF presa dalla cache, o una in corso di creazione per un’altra richiesta, non costa alcun rendering. La chiave della cache comprende tutto ciò che cambia l’immagine, nomi compresi.

      Ogni richiesta conta in `gif` (30 al minuto per giocatore per entrambi gli endpoint). Una GIF che deve essere creata conta anche nei limiti di rendering: per giocatore, `GIF_USER_RENDERS_PER_MIN` (4) al minuto e `GIF_USER_RENDERS_PER_HOUR` (30) all’ora; per client, tutti i suoi account insieme, `GIF_IP_RENDERS_PER_MIN` (12) al minuto e `GIF_IP_RENDERS_PER_HOUR` (120) all’ora, e 3 volte tanto per /48 IPv6. Un client dovrebbe conservare il file che ha scaricato anziché richiederlo di nuovo, e attendere `retryAfter` dopo un 429 o un 503.

      Dimensioni: `small` (case da 32 px: 284 × 350 pixel), `medium` (48 px: 424 × 515), `large` (72 px: 628 × 762); senza coordinate 268 × 342, 400 × 503 e 600 × 748. La posizione iniziale resta visibile per almeno 1 s (o per il ritardo, se è più lungo), la posizione finale per 3 s, e la GIF si ripete in loop; dopo il primo fotogramma viene memorizzata solo la parte dell’immagine che cambia. Una partita di 40 mosse occupa circa 135, 205 e 325 KiB (small, medium, large), una partita di 150 mosse 0,5 MiB, 0,8 MiB e 1,2 MiB.
  - name: players
    x-displayName: Giocatori
    description: Profili pubblici e partite recenti. Solo dati pubblici, mai un indirizzo e-mail, una sessione, una sanzione o un livello di integrità. Un account eliminato non ha alcun profilo.
  - name: leaderboard
    x-displayName: Classifica
    description: I migliori giocatori di ogni categoria ufficiale.
  - name: reports
    x-displayName: Segnalazioni
    description: Segnalazione dell’avversario di una partita recente ai moderatori.
  - name: pages
    x-displayName: Pagine HTML
    description: |-
      Pagine per un browser, aperte dai link delle e-mail. I loro link puntano a `https://<SERVER_PUBLIC_HOST>` (con `:<PUBLIC_API_PORT>` quando non è 443), fuori da `/api/v1`.

      - Le pagine non eseguono JavaScript e non caricano alcuna risorsa esterna. Sono servite con `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - Un `GET` mostra solo un pulsante o un modulo, così che uno scanner di posta che apre il link non lo consumi. La modifica avviene con il `POST`: un modulo inviato come `application/x-www-form-urlencoded` (è accettato anche un corpo JSON), con il token del link in un campo nascosto. Un campo fornito due volte viene rifiutato.
      - Anche gli errori (limiti di frequenza, campi non validi) sono pagine HTML, intitolate «Request refused» (richiesta rifiutata) o, da 500 in su, «Server error» (errore del server). Solo gli errori rilevati prima che la pagina venga individuata (una destinazione della richiesta troppo lunga o malformata, il livello per indirizzo) ricevono una risposta in JSON.
x-tagGroups:
  - name: server
    x-displayName: Server
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Account
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Partite
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Giocatori e segnalazioni
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Pagine per il browser
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Ottieni il nome, le versioni, le porte e le regole di registrazione del server
      description: |-
        Ciò di cui un client ha bisogno prima di accedere o di connettersi: il nome e l’id del server, le versioni del protocollo WebSocket e dove si trova il WebSocket, le regole di registrazione, le cadenze ufficiali e i limiti che un client può verificare prima di inviare un modulo.

        **Limiti:** solo il livello per indirizzo.
      security: []
      responses:
        '200':
          description: La descrizione del server.
          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`: il livello per indirizzo.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Crea un account
      description: |-
        Crea un account: una volta confermato il suo indirizzo e-mail con il link inviato a quell’indirizzo, oppure subito senza conferma via e-mail.

        **Con conferma via e-mail** (`REQUIRE_EMAIL_VERIFICATION`, il valore predefinito): 202 `verification_sent`. Non esiste ancora alcun account: la registrazione resta in attesa per 24 ore e nel frattempo riserva il suo nome utente. Un link valido per quelle 24 ore viene inviato all’indirizzo, al massimo uno per indirizzo ogni 5 minuti (`POST /auth/verify-email/resend` ne invia uno nuovo); l’account viene creato, con l’indirizzo confermato, quando il link viene usato (il pulsante della pagina `/verify-email`), e il giocatore può allora accedere. Fino ad allora un accesso con quel nome utente riceve `invalid_credentials`, come per un account sconosciuto, e il profilo pubblico non esiste. Una nuova registrazione con lo stesso indirizzo sostituisce quella in attesa. La risposta è la stessa quando un altro account usa già l’indirizzo: in tal caso non viene inviato alcun link, il suo proprietario riceve invece un avviso (al massimo uno all’ora), e il nome utente viene riservato allo stesso modo, così che nulla riveli se l’indirizzo ha un account. Una registrazione il cui link non è stato usato viene scartata dopo 24 ore, e il suo nome utente torna libero.

        **Senza conferma via e-mail** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, l’account viene creato subito e può essere usato per accedere.

        **Regole.** `username`: da `USERNAME_MIN` a `USERNAME_MAX` caratteri (da 3 a 20), lettere, cifre, `_` e `-`, con una lettera o una cifra all’inizio (`GET /info` li indica in `limits`); i nomi riservati (`admin`, `moderator`, `deleted`…) e alcuni prefissi vengono rifiutati; unico senza distinzione tra maiuscole e minuscole. `email`: ripulito dagli spazi iniziali e finali e memorizzato in minuscolo, in ASCII semplice, con un dominio che contiene un punto. `password`: almeno `PASSWORD_MIN_LENGTH` (10) caratteri e al massimo 256 byte in UTF-8; non deve contenere il nome utente né la parte locale dell’e-mail, e non deve essere una password comune.

        **Errori**, verificati in questo ordine: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; gli errori della coda di hashing delle password; 409 `email_taken` (solo senza conferma via e-mail).

        **Limiti:** `auth`, poi `auth_register` (10 registrazioni all’ora per client). **Proof of work:** sempre, quando `POW_REGISTER_BITS` è maggiore di 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Primo tentativo, senza proof of work
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Inviata di nuovo con la proof of work
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: L’account è stato creato e può essere usato per accedere (nessuna conferma via e-mail su questo 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: La registrazione attende il suo link di conferma (oppure l’indirizzo ha già un account; la risposta non lo rivela).
          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` (con `field`), `invalid_json`, `invalid_username`, `invalid_email`, o `weak_password` con `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` o `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: le registrazioni sono chiuse su questo server (`GET /info` indica `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: un account ha già il nome utente, oppure lo riserva una registrazione in attesa di un altro indirizzo. `email_taken`: un altro account usa l’indirizzo (solo senza conferma via 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`: il limite `auth` o `auth_register`, o la coda di hashing delle password.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la coda di hashing delle password, o il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Accedi con una password
      description: |-
        Effettua l’accesso con un nome utente o un indirizzo e-mail e una password. Le risposte possibili sono due, entrambe con stato 200: una sessione oppure, quando l’autenticazione a due fattori è attiva, un secondo passaggio da completare entro 5 minuti con `POST /auth/login/mfa`.

        Un account sconosciuto, una password errata e un account senza password ricevono la stessa risposta, dopo lo stesso tempo: 401 `invalid_credentials`. Le verifiche dell’account (`banned`, `email_unverified`) avvengono solo dopo una password corretta. A partire da `AUTH_FAILURES_PER_ACCOUNT` (5) tentativi falliti su un nome di accesso, ogni tentativo deve attendere (429 `too_many_attempts`).

        **Limite:** `auth`. **Proof of work:** solo durante un’ondata di accessi falliti (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: Una sessione, o il secondo passaggio di un accesso con autenticazione a due fattori.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Accesso effettuato
                  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: Autenticazione a due fattori attiva
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: account sconosciuto, password errata, o account senza password.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Solo dopo una password corretta: `banned`, con `until` (ms dall’epoca Unix, `null` per una sospensione permanente), o `email_unverified` (un account meno recente il cui indirizzo non è ancora confermato).'
        '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` (l’attesa dopo i tentativi falliti di questo nome di accesso), o `rate_limited` (il limite `auth`, la coda di hashing delle password).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la coda di hashing delle password, o il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Completa un accesso con un secondo fattore
      description: |-
        Il secondo passaggio di un accesso con autenticazione a due fattori, dopo che `POST /auth/login` o un accesso con Google (`POST /auth/sso/google/finish` o `POST /auth/sso/google/link`) ha risposto `mfaRequired`. Invia `code` (un codice a 6 cifre dell’app di autenticazione, o un codice di recupero) o `recoveryCode`. Un codice di recupero usato qui non esiste più.

        Un passaggio accetta al massimo 5 codici errati; il passaggio termina anche quando la password viene reimpostata o cambiata. Dopo il passaggio della password di un collegamento con Google, il collegamento viene memorizzato solo quando il codice viene accettato qui.

        **Limiti:** `auth`, e al massimo `AUTH_MFA_PER_ACCOUNT` (10) codici ogni 15 minuti per l’account, da qualsiasi indirizzo; oltre questo numero il codice non viene verificato, così un codice di recupero non viene consumato.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Un codice dell’app di autenticazione
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Un codice di recupero
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Accesso effettuato.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: non è stato inviato né `code` né `recoveryCode`, oppure il corpo viola lo schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: il passaggio è scaduto, è già stato usato o è terminato dopo 5 codici errati, oppure la password è stata reimpostata o cambiata dopo il primo passaggio (accedi di nuovo). `invalid_code`: un codice errato o già usato.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (con `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: solo dopo il passaggio della password di un collegamento con Google, nel frattempo l’account Google è stato collegato a un altro account.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: solo dopo il passaggio della password di un collegamento con Google, nel frattempo l’account è cambiato; ricomincia l’accesso.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: l’attesa dopo i tentativi falliti dell’account, oppure i suoi `AUTH_MFA_PER_ACCOUNT` codici degli ultimi 15 minuti sono esauriti. `rate_limited`: il limite `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` con `retryAfter: 1`: il database è rimasto bloccato (dopo un passaggio di collegamento con Google, non è stato collegato nulla: ricomincia l’accesso). `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Disconnetti questa sessione
      description: |-
        Revoca la sessione del token usato. Il WebSocket aperto con essa viene chiuso. Nessun corpo (o `{}`).

        **Limite:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Disconnessione effettuata.
          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 corpo diverso da `{}`; `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`: il limite `sessions` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Disconnetti tutte le sessioni
      description: |-
        Revoca tutte le sessioni dell’account, compresa questa. I WebSocket aperti con esse vengono chiusi. Nessun corpo (o `{}`).

        **Limite:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Tutte le sessioni sono state disconnesse.
          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 corpo diverso da `{}`; `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`: il limite `sessions` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Invia di nuovo il link di conferma
      description: |-
        Invia di nuovo il link di conferma dell’e-mail. La risposta è 202 `accepted` qualunque sia l’indirizzo, così da non rivelare mai se un account o una registrazione lo usa.

        Una richiesta ha effetto al massimo una volta ogni 5 minuti per indirizzo. Una registrazione in attesa con quell’indirizzo ottiene di nuovo le sue 24 ore, che un altro account usi o meno l’indirizzo, così che il suo nome utente resti riservato altrettanto a lungo in entrambi i casi. Un link viene inviato solo per quella registrazione quando l’indirizzo non ha alcun account (un nuovo link, valido 24 ore, sostituisce il precedente), oppure per un account attivo e non confermato con quell’indirizzo.

        **Limiti:** `auth`, poi `auth_mail` (10 all’ora per client).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Accettata (qualunque sia l’indirizzo).
          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`: il limite `auth` o `auth_mail` (qualunque sia l’indirizzo).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` con `retryAfter: 1`: il database è rimasto bloccato durante la ricerca dell’account o la memorizzazione del link di un account non confermato (il rinnovo di una registrazione in attesa è un tentativo senza garanzia e non fa mai fallire la richiesta); i 5 minuti dell’indirizzo partono comunque, quindi una nuova richiesta entro quel periodo non ha alcun effetto. `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Invia un link di reimpostazione della password
      description: |-
        Invia un link di reimpostazione della password, valido per un’ora, che apre la pagina `/reset-password`. La risposta è 202 `accepted` qualunque sia l’indirizzo. Un link viene inviato solo a un account attivo, al massimo una volta ogni 5 minuti per indirizzo. Un account solo Google imposta in questo modo la sua prima password.

        Il recupero della password ha i limiti più severi dell’API, tutti contati sull’intero server: 3 richieste all’ora (`AUTH_FORGOT_PER_HOUR`) e 10 ogni 24 ore (`AUTH_FORGOT_PER_DAY`) per client (un indirizzo IPv4 o un /64 IPv6), 3 volte tanto per /48 IPv6, oltre al limite `auth` (20 ogni 10 minuti) e all’unica e-mail per indirizzo ogni 5 minuti. Né il 202 né il 429 rivelano se un account usa l’indirizzo. L’impostazione della nuova password ha un proprio limite (`auth_reset`).

        **Limiti:** `auth`, `auth_forgot`, poi `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Accettata (qualunque sia l’indirizzo).
          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`: il limite `auth`, `auth_forgot` o `auth_forgot_day` (qualunque sia l’indirizzo).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` con `retryAfter: 1`: il database è rimasto bloccato durante la ricerca dell’account o la memorizzazione del link; i 5 minuti dell’indirizzo partono comunque, quindi una nuova richiesta entro quel periodo non invia alcun link. `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Imposta una nuova password con il token di un link di reimpostazione
      description: |-
        Imposta una nuova password con il token di un link di reimpostazione (la pagina `/reset-password` fa lo stesso). La nuova password segue le regole della registrazione.

        La reimpostazione ha questi effetti, in un’unica transazione (tutti o nessuno): revoca tutte le sessioni e annulla un cambio di e-mail in attesa; gli altri link di reimpostazione dell’account smettono di funzionare; l’indirizzo risulta confermato (il link lo ha dimostrato); il proprietario riceve un’e-mail. L’autenticazione a due fattori non viene toccata. Dopo un errore della coda di hashing delle password, un 503 o un 500, non è cambiato nulla: la vecchia password e le sessioni restano, e il link resta valido.

        **Limiti:** `auth`, poi `auth_reset` (10 all’ora per client, 30 per /48 IPv6, condiviso con la pagina: ogni tentativo calcola l’hash di una password).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: La password è stata cambiata e tutte le sessioni sono state disconnesse.
          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`: il link non è valido, è già stato usato o è scaduto, oppure è stato inviato a un indirizzo che l’account non ha più. `weak_password` (con `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`: il limite `auth` o `auth_reset`, o la coda di hashing delle password.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: la coda di hashing delle password, o il database è rimasto bloccato (`retryAfter: 1`, non è cambiato nulla). `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Avvia un accesso con Google
      description: |-
        Avvia un tentativo di accesso con Google per il challenge PKCE e la porta del listener del gioco su `127.0.0.1`. La risposta fornisce l’URL di Google da aprire nel browser di sistema e lo `state` del tentativo; il tentativo è valido per 10 minuti.

        `authUrl` contiene `client_id`, `redirect_uri` (costruito da `redirectPort` e dal tag di origine del server), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 del verifier del server stesso per Google) con `code_challenge_method=S256`, e `prompt=select_account`. Il gioco lo verifica prima di aprirlo (vedi la descrizione della sezione).

        **Limite:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: Il tentativo.
          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`: l’accesso con Google non è disponibile su questo 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`: il limite `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Avvia un accesso con Google su un sito web
      description: |-
        Avvia un tentativo di accesso con Google di un sito web per il challenge PKCE della sua pagina. La risposta è la stessa del gioco: l’URL di Google verso cui navigare e lo `state` del tentativo; il tentativo è valido per 10 minuti. Disponibile quando `GET /info` indica `sso.googleWeb: true`.

        `authUrl` contiene il `client_id` del sito web e `redirect_uri=GOOGLE_WEB_REDIRECT_URI`, con gli stessi altri parametri del gioco. La pagina lo verifica prima di navigarvi (vedi **Su un sito web** nella descrizione della sezione).

        **Limite:** `sso_start`, condiviso con l’avvio del gioco.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      responses:
        '200':
          description: Il tentativo.
          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` mancante o malformato, o qualsiasi altro campo (compreso `redirectPort`).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: l’accesso con Google non è disponibile su questo server, o non per i siti 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`: il limite `sso_start` (condiviso con l’avvio del gioco).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Consegna al server la risposta di Google
      description: |-
        Consegna al server il `code` e lo `state` che Google ha inviato al listener del gioco o alla pagina del sito web, con l’id del tentativo e il verifier PKCE. Un id di tentativo è inutile senza il verifier, e il codice è inutile senza il verifier PKCE e il client secret del server stesso.

        Il server verifica il tentativo (sconosciuto, già usato o scaduto: 410), poi il verifier (uno errato lascia il tentativo utilizzabile), poi consuma il tentativo, verifica `state` e `iss`, scambia il codice con Google e verifica l’ID token. La risposta (200) è una delle seguenti:

        - `{ token, expiresAt, user }`: accesso effettuato all’account collegato;
        - `{ mfaRequired, mfaToken, expiresIn }`: prosegui con `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: un nuovo account; prosegui con `POST /auth/sso/complete` entro 10 minuti. `suggestedUsername` deriva dal nome Google o dall’indirizzo, ed è `""` quando niente è adatto;
        - `{ needsPassword, linkTicket, username, expiresIn }`: l’account con quell’indirizzo ha una password; prosegui con `POST /auth/sso/google/link`. Non è ancora collegato nulla.

        **Limite:** `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: Una sessione, un secondo passaggio, o il passaggio successivo di un primo accesso con Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Accesso effettuato all’account collegato
                  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: Autenticazione a due fattori attiva
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Un nuovo giocatore
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Un account con una password usa l’indirizzo
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: il verifier non corrisponde al challenge del tentativo (il tentativo resta utilizzabile). `sso_email_unverified`: Google non ha confermato l’indirizzo. `registration_closed`. `account_disabled`. Solo per un account collegato: `banned` (con `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: l’accesso con Google non è disponibile su questo server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: un account attivo senza password usa l’indirizzo (l’account non viene indicato).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: un tentativo sconosciuto, già usato o scaduto.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: il limite `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Collega Google a un account esistente con la sua password
      description: |-
        Dopo che `finish` ha risposto `needsPassword`: collega Google all’account esistente con la password di quell’account, digitata nel gioco. La risposta (200) è una sessione (il collegamento viene memorizzato e il giocatore risulta connesso), oppure, quando l’autenticazione a due fattori è attiva, `{ mfaRequired, mfaToken, expiresIn }`: prosegui con `POST /auth/login/mfa`, e il collegamento viene memorizzato solo quando lì un codice viene accettato.

        Una password errata lascia il ticket utilizzabile, per 5 tentativi in tutto. Un tentativo viene consumato subito prima della verifica della password: un 429 `too_many_attempts` o un 428 `pow_required` non ne consuma alcuno, mentre un rifiuto della coda di hashing delle password (503 `server_busy`, 429 `rate_limited`) ne ha consumato uno e conta come tentativo fallito dell’account. L’attesa dopo i tentativi falliti usa lo stesso contatore di `POST /auth/login`.

        **Limite:** `auth` (con il suo conteggio per /48 IPv6). **Proof of work:** come per `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: Una sessione, o il secondo passaggio dell’accesso.
          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`: una password errata; il ticket resta valido, per 5 tentativi in tutto.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (con `until`), solo dopo una password corretta.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: l’accesso con Google non è disponibile su questo server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: nel frattempo l’account Google è stato collegato a un altro account.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: un ticket sconosciuto, già usato o scaduto, la 5ª password errata, un account il cui stato o indirizzo è cambiato dopo `finish`, o uno il cui stato, indirizzo, password o autenticazione a due fattori è cambiato mentre il collegamento veniva salvato. Ricomincia l’accesso.'
        '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` (l’attesa dopo i tentativi falliti dell’account), o `rate_limited` (il limite `auth`, la coda di hashing delle password).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: la coda di hashing delle password, o il database è rimasto bloccato (`retryAfter: 1`, non è stato collegato nulla). `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Crea l’account di un primo accesso con Google
      description: |-
        Dopo che `finish` ha risposto `needsUsername`: crea l’account con il nome utente scelto (si applicano le regole della registrazione) ed effettua l’accesso. L’account non ha una password: `hasPassword` è `false`, e «Password dimenticata?» (`POST /auth/password/forgot`) gliene assegna una.

        **Limite:** `auth` (con il suo conteggio per /48 IPv6).
      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: L’account è stato creato e l’accesso è stato effettuato.
          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`: l’accesso con Google non è disponibile su questo server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (un account ha già questo nome utente, o lo riserva una registrazione in attesa di un altro indirizzo), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: un ticket sconosciuto, già usato o scaduto.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: il limite `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Elenca i dispositivi connessi
      description: |-
        Le sessioni attive dell’account (dispositivi connessi), a partire da quella usata più di recente. `current` indica la sessione che fa la richiesta. `lastSeenAt` viene aggiornato al massimo ogni 5 minuti (più tardi mentre il server è molto in ritardo); `expiresAt` è la scadenza assoluta, e il limite di inattività può terminare la sessione prima.

        **Limite:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Le sessioni attive.
          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`: il limite `sessions` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Disconnetti un dispositivo
      description: |-
        Disconnette una sessione dell’account; si può disconnettere anche quella corrente. Il WebSocket aperto con essa viene chiuso. Nessun corpo (o `{}`).

        **Limite:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: La sessione è stata disconnessa.
          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 corpo diverso da `{}`, o un `id` che non è una codifica URL valida; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: nessuna sessione attiva con questo id su questo account.'
        '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`: il limite `sessions` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Ottieni l’account, i suoi punteggi e le sanzioni attive
      description: |-
        L’account come lo vede il suo giocatore: la vista dell’account (anche il campo `user` di ogni risposta di accesso), un record di punteggio per ogni categoria in cui il giocatore ha giocato partite classificate, le sanzioni attive e la sospensione. Il livello di integrità del sistema anti-cheat non viene mai mostrato.

        **Limite:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: L’account.
          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`, o `invalid_token` (anche quando l’account è stato eliminato).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: il limite `account` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Accetta o rifiuta le sfide dirette
      description: |-
        Stabilisce se gli altri giocatori possono sfidare questo giocatore per nome. Con `none`, le sfide dirette vengono rifiutate: lo sfidante viene informato che il giocatore non è disponibile. Nessuna riautenticazione.

        **Limite:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Le preferenze ora in vigore.
          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`: il limite `account` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Cambia la password
      description: |-
        Cambia la password. Serve la password attuale, ma nessun secondo fattore, anche con l’autenticazione a due fattori attiva. La nuova password segue le regole della registrazione (verificate dopo la password attuale).

        Il cambio ha questi effetti, in un’unica transazione (tutti o nessuno): revoca tutte le altre sessioni (questa resta connessa), annulla un cambio di e-mail in attesa, rende inutilizzabili i link di reimpostazione della password dell’account e invia un’e-mail al proprietario.

        **Riautenticazione:** solo la password. **Limiti:** `reauth`, poi `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: La password è stata cambiata.
          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 account solo Google), `weak_password` (con `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: una password attuale errata, o una reimpostazione o un cambio della password avvenuti mentre la richiesta veniva verificata.'
        '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` (riautenticazioni fallite dell’account), o `rate_limited` (il limite `reauth` o `reauth_user`, il budget dell’account, la coda di hashing delle password).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: la coda di hashing delle password, o il database è rimasto bloccato (`retryAfter: 1`, non è cambiato nulla). `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Inizia ad attivare l’autenticazione a due fattori
      description: |-
        Salva un nuovo segreto di autenticazione in attesa, che sostituisce un eventuale segreto in attesa precedente, e lo restituisce per l’app di autenticazione (come testo e come URI `otpauth://` da mostrare come codice QR). L’autenticazione a due fattori non è ancora attiva: `POST /account/mfa/totp/enable` la attiva con un codice di questo segreto.

        **Riautenticazione:** solo la password. **Limiti:** `reauth`, poi `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Il segreto in attesa.
          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 account solo 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` (verificato prima della password).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (riautenticazioni fallite dell’account), o `rate_limited` (il limite `reauth` o `reauth_user`, il budget dell’account, la coda di hashing delle password).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la coda di hashing delle password, o il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Completa l’attivazione dell’autenticazione a due fattori e ottieni i codici di recupero
      description: |-
        Attiva l’autenticazione a due fattori con un codice del segreto in attesa (esattamente 6 cifre). Qui la password non viene chiesta: è stata fornita durante la configurazione. La risposta contiene 10 codici di recupero, mostrati solo questa volta.

        **Limiti:** `reauth`, poi `reauth_user`; un codice errato conta tra le riautenticazioni fallite dell’account.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: L’autenticazione a due fattori è attiva.
          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` non è di esattamente 6 cifre, o il corpo viola lo schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: un codice errato (controlla l’ora del dispositivo).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: nessun segreto in attesa (chiama prima `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` (riautenticazioni fallite dell’account), o `rate_limited` (il limite `reauth` o `reauth_user`, il budget dell’account).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Disattiva l’autenticazione a due fattori
      description: |-
        Disattiva l’autenticazione a due fattori. Il segreto e i codici di recupero vengono eliminati, e il proprietario riceve un’e-mail.

        **Riautenticazione:** la password e un codice dell’app di autenticazione o un codice di recupero (è obbligatorio uno tra `code` e `recoveryCode`). **Limiti:** `reauth`, poi `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: L’autenticazione a due fattori è disattivata.
          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 account solo Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (né `code` né `recoveryCode`, verificato prima della password), `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` (riautenticazioni fallite, o troppi codici provati su questo account), o `rate_limited` (il limite `reauth` o `reauth_user`, il budget dell’account, la coda di hashing delle password).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la coda di hashing delle password, o il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Sostituisci i codici di recupero
      description: |-
        Sostituisce i codici di recupero con 10 codici nuovi; quelli vecchi smettono di funzionare.

        **Riautenticazione:** la password e un codice dell’app di autenticazione in `code` (un codice di recupero viene rifiutato con 403 `invalid_code`). **Limiti:** `reauth`, poi `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: I nuovi codici di recupero, mostrati solo questa volta.
          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 account solo Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (anche per un codice di recupero).'
        '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` (riautenticazioni fallite, o troppi codici provati su questo account), o `rate_limited` (il limite `reauth` o `reauth_user`, il budget dell’account, la coda di hashing delle password).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la coda di hashing delle password, o il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Cambia l’indirizzo e-mail
      description: |-
        Cambia l’indirizzo e-mail dell’account. `newEmail` viene privato degli spazi iniziali e finali e convertito in minuscolo, poi verificato come un indirizzo di registrazione.

        **Con conferma via e-mail** (l’impostazione predefinita): 202 `verification_sent`. Un link valido per 24 ore viene inviato al nuovo indirizzo; apre `/confirm-email-change`, e l’indirizzo cambia solo quando il giocatore preme il pulsante di quella pagina. Fino ad allora, `GET /account/me` mostra il nuovo indirizzo come `pendingEmail`. Una nuova richiesta sostituisce quella in attesa; un cambio o una reimpostazione della password la annulla. A un dato nuovo indirizzo viene inviato al massimo un link ogni 5 minuti, chiunque lo chieda (una richiesta per il cambio già in attesa mantiene il link inviato in precedenza, che resta valido); la risposta è la stessa. L’indirizzo attuale riceve un avviso che è stato richiesto un cambio verso un indirizzo mascherato (`a***@example.org`). La risposta e `pendingEmail` sono gli stessi quando un altro account usa già il nuovo indirizzo: in quel caso non viene inviato nessun link, quindi quel cambio non si completa mai, e il proprietario di quell’indirizzo riceve invece un avviso (al massimo uno all’ora).

        Quando il link viene confermato, l’indirizzo cambia e risulta confermato, i dispositivi restano connessi, i link inviati in precedenza (conferma, reimpostazione della password, altri cambi) smettono di funzionare, e il vecchio indirizzo viene informato, con il nuovo mascherato.

        **Senza conferma via e-mail** (`REQUIRE_EMAIL_VERIFICATION=false`): l’indirizzo cambia subito (200 `email_changed`), e il vecchio indirizzo viene informato. Se un altro account usa l’indirizzo, la risposta è 409 `email_taken`, e il proprietario di quell’indirizzo riceve l’avviso.

        **Riautenticazione:** la password e, con l’autenticazione a due fattori, un codice dell’app di autenticazione o un codice di recupero. `invalid_email` e `same_email` vengono verificati prima della password, quindi non contano come tentativo fallito. **Limiti:** `reauth`, poi `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: L’indirizzo è cambiato subito (nessuna conferma via e-mail su questo 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: Il nuovo indirizzo, come è stato salvato.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: È stato inviato un link di conferma al nuovo indirizzo (oppure l’indirizzo appartiene a un altro account; la risposta non lo dice).
          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` (l’indirizzo attuale dell’account), `password_not_set` (un account solo Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (anche quando un cambio o una reimpostazione della password è avvenuto durante la richiesta: non viene inviato nessun link), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: solo senza conferma via 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` (riautenticazioni fallite, o troppi codici provati su questo account), o `rate_limited` (il limite `reauth` o `reauth_user`, il budget dell’account, la coda di hashing delle password).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: la coda di hashing delle password, o il database è rimasto bloccato (`retryAfter: 1`: non è cambiato nulla, e la stessa richiesta può essere inviata di nuovo). `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Scarica i dati dell’account
      description: |-
        Tutto ciò che il server conserva sull’account, in un unico file JSON da salvare (`format` `scacelith-account-export`, `version` 1). L’esportazione registra un evento di sicurezza (`account_exported`). L’array `notes` del documento indica al giocatore, in inglese semplice, cosa è escluso.

        **Mai nell’esportazione:** l’hash della password, il segreto dell’autenticazione a due fattori e i codici di recupero; qualsiasi token di sessione o di link, o un suo hash; i dati del sistema anti-cheat (livello e punteggio di integrità, anomalie, l’analisi delle partite, il peso di una segnalazione); le segnalazioni fatte da altri giocatori sul giocatore; l’identità dei moderatori; i dati privati degli altri giocatori (gli avversari compaiono con il loro nome pubblico e il loro punteggio, nulla indica se un altro giocatore è stato sanzionato, e non è incluso nessun indirizzo IP che possa appartenere a un’altra persona).

        **Riautenticazione:** la password e, con l’autenticazione a due fattori, un codice dell’app di autenticazione o un codice di recupero. **Limiti:** `account_export` (5 all’ora per giocatore, per l’intero server; ogni tentativo conta, compresi quelli falliti; verificato per primo), poi `reauth` e `reauth_user`. **Tempo massimo:** 60 secondi.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Il documento di esportazione, come allegato.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. I caratteri del nome utente diversi da lettere, cifre, `_`, `.` e `-` diventano `_`.'
              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 account solo 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` (riautenticazioni fallite, o troppi codici provati su questo account), o `rate_limited` (il limite `account_export`, `reauth` o `reauth_user`, il budget dell’account, la coda di hashing delle password).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (il database è rimasto bloccato, con un header `Retry-After: 1`), `server_busy` (la coda di hashing delle password), `timeout` (60 secondi). Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Elimina l’account
      description: |-
        Elimina l’account; l’operazione è irreversibile.

        - Tutte le sessioni vengono revocate subito: da quel momento il token riceve 401 `invalid_token`.
        - Il nome utente diventa `deleted#<id>`, nell’account e in ogni registrazione di partita.
        - Vengono cancellati: l’indirizzo e-mail, l’hash della password, il segreto dell’autenticazione a due fattori e i codici di recupero, le sessioni e i token dei link, il collegamento con Google, il record di integrità del sistema anti-cheat e gli indirizzi IP salvati con gli eventi di sicurezza.
        - I punteggi e le partite vengono conservati. Le partite restano leggibili con il nome anonimo, e `GET /players/{username}` risponde 404 per il vecchio nome.

        **Riautenticazione:** la password e, con l’autenticazione a due fattori, un codice dell’app di autenticazione o un codice di recupero. **Limiti:** `reauth`, poi `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: L’account è stato eliminato.
          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 account solo 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` (riautenticazioni fallite, o troppi codici provati su questo account), o `rate_limited` (il limite `reauth` o `reauth_user`, il budget dell’account, la coda di hashing delle password).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la coda di hashing delle password, o il database è rimasto bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Elenca le partite del giocatore, filtrate e paginate
      description: |-
        Le partite del giocatore che ha effettuato l’accesso, dalla più recente, filtrate e paginate, con il numero di partite che corrispondono al filtro. Ogni parametro di query è facoltativo, e un valore vuoto equivale a un parametro assente.

        Paginazione: passa il `next` della pagina precedente come `before`; `next` è `null` nell’ultima pagina. `total` conta le partite che corrispondono al filtro, su tutte le pagine.

        **Limite:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: L’id di una categoria ufficiale (`3+2`, o `3%2B2`), o `custom` per tutte le partite con un’altra cadenza.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Solo le partite classificate (`true`) o amichevoli (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Solo le partite vinte, perse o patte, dal punto di vista del giocatore. Le partite annullate compaiono solo senza questo filtro.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Una pagina dello storico.
          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` non è un id di partita), `invalid_limit`, o `invalid_filter` (`category`, `rated` o `result`), ciascuno con `field` che indica il parametro.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: il limite `account_games` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (il database è rimasto bloccato; `retryAfter: 1` nel corpo, nessun header `Retry-After`), `server_busy` (la verifica della sessione ha trovato il database bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Ottieni la registrazione di una partita con le sue mosse e i suoi orologi
      description: |-
        La registrazione di una partita, con le sue mosse e i suoi orologi. Senza token, o con il token di un giocatore che non ha giocato la partita, la risposta è quella pubblica. Quando il giocatore del token ha giocato la partita, la risposta aggiunge `you` e `reportable`.

        **Limite:** `public_read` (per giocatore con un token, per client senza).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: La registrazione della partita.
          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`: non è un intero positivo di al massimo 16 cifre (inferiore a 2^53); `invalid_request`: non è una codifica URL valida.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: è stato inviato un token e non è valido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: la partita non esiste.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: il limite `public_read` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (il database è rimasto bloccato; `retryAfter: 1` nel corpo, nessun header `Retry-After`), `server_busy` (la verifica della sessione ha trovato il database bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Scarica una partita come file PGN
      description: |-
        La stessa partita come file PGN: una sola partita con fine riga `\n`, e il testo delle mosse in righe di meno di 80 colonne. La risposta è la stessa con o senza token.

        Tag, in questo ordine: `Event` (`<SERVER_NAME> rated <category>` o `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (la data UTC di inizio), `Round`, `White`, `Black`, `Result` (`*` per una partita annullata), `UTCDate` e `UTCTime` (l’inizio), `WhiteElo` e `BlackElo` (i punteggi all’inizio, o `-`), `WhiteRatingDiff` e `BlackRatingDiff` (le variazioni, come `+10` e `-10`, in ogni partita classificata, `+0` quando le regole del punteggio lasciano il punteggio invariato; una partita amichevole, a cadenza personalizzata o annullata non ha né l’uno né l’altro), `TimeControl` (in secondi), `Termination` (un valore standard del PGN: `normal`; `time forfeit`, una caduta della bandierina, anche quando finisce in patta; `abandoned`; `rules infraction`, una seconda mossa illegale o una sconfitta a forfait per violazione del fair play; `unterminated`, una partita annullata), `PlyCount`, `ScacelithGameId` (l’id decimale).

        Ogni mossa porta `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: l’orologio di chi ha mosso, dopo la mossa, e il tempo addebitato per la mossa, in decimi di secondo (troncati). Un valore viene omesso quando la registrazione non lo contiene. Dopo l’ultima mossa segue il motivo della fine a parole, poi il risultato.

        **Limite:** `public_read` (per giocatore con un token, per client senza).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Il file 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: 'Il testo PGN, in 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`: non è un intero positivo di al massimo 16 cifre (inferiore a 2^53); `invalid_request`: non è una codifica URL valida.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: è stato inviato un token e non è valido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: la partita non esiste.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: il limite `public_read` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: tra gli altri casi, le mosse salvate non si possono rigiocare fino alla conclusione salvata (il server lo registra nei log).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (il database è rimasto bloccato; `retryAfter: 1` nel corpo, nessun header `Retry-After`), `server_busy` (la verifica della sessione ha trovato il database bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Scarica una partita di questo server come GIF animata
      description: |-
        La partita come GIF animata. I nomi e i punteggi sono quelli della registrazione della partita (i punteggi all’inizio, un account eliminato come `deleted#<id>`), così come il risultato e la conclusione (`Resignation`, `Loss on time`…).

        I controlli avvengono in questo ordine: `gif_disabled`, le opzioni dell’immagine (`size`, `orientation`, `delay`, `coords`), l’id della partita, la partita, la sua lunghezza.

        **Sessione obbligatoria:** le quote si contano per account. **Limiti:** `gif` a ogni richiesta; `gif_user_min`, `gif_user_hour`, `gif_ip_min` e `gif_ip_hour` solo quando la GIF deve essere creata (vedi la descrizione della sezione). **Tempo massimo:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 secondi (45 secondi per impostazione predefinita).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: La dimensione dell’immagine, `small` (case da 32 px), `medium` (48 px) o `large` (72 px).
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Il colore in basso sulla scacchiera.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Millisecondi per mossa (da 1 a 6 cifre decimali).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Se le lettere delle colonne e i numeri delle traverse vengono disegnati intorno alla scacchiera (`1`) o no (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` con `field` (`size`, `orientation`, `delay` o `coords`): un valore non consentito. `invalid_game_id`. `invalid_request`: non è una codifica URL valida.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: la partita non esiste. `gif_disabled`: il server ha disattivato le GIF (`GIF_ENABLED=false`; il gettone `gif` viene restituito).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: il limite `gif`, un limite di rendering o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: non è stato possibile creare la GIF (il server ne registra il motivo nei log). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` con `retryAfter` (da 3 a 10 secondi) e `Retry-After`: la coda di rendering è piena, o la GIF ha atteso `GIF_QUEUE_TIMEOUT_MS` (10 secondi) un thread libero; tutti i gettoni dei limiti presi dalla richiesta vengono restituiti. `busy`: il database è rimasto bloccato (`retryAfter: 1` solo nel corpo). `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Crea una GIF animata di qualsiasi partita inviata come PGN
      description: |-
        La stessa immagine di `GET /games/{id}/gif` per qualsiasi partita inviata come testo PGN: una partita salvata dal gioco, un’esportazione di un altro sito, una partita scritta a mano. Viene usata solo la prima partita del testo.

        - Il lettore PGN accetta ciò che accetta il lettore del gioco stesso: ogni PGN scritto da questo server e le esportazioni abituali di altri siti (commenti, varianti, NAG e annotazioni dell’orologio vengono ignorati; i numeri delle mosse e la notazione SAN vengono letti con tolleranza; un tag `FEN` dà la posizione iniziale, a meno che `SetUp` sia `"0"`).
        - I nomi e i punteggi provengono dai tag `White`, `Black`, `WhiteElo` e `BlackElo`. Le lettere accentate perdono gli accenti, gli altri caratteri al di fuori dell’ASCII stampabile diventano `?`, e i nomi lunghi vengono troncati (48 caratteri). Il risultato proviene dal tag `Result`, altrimenti dalla fine del testo delle mosse. Il tag `Termination` viene mostrato a meno che sia `normal`: in quel caso è la posizione finale a raccontare com’è finita (scacco matto, stallo).
        - Questo endpoint verifica da sé il proprio corpo: un campo sconosciuto, o un `pgn` mancante o che non è una stringa, risponde 400 `invalid_request` con `field`; un’opzione errata risponde 400 `invalid_option` (`delayMs` deve essere un numero JSON, `coords` un booleano; `null` viene rifiutato).

        **Sessione obbligatoria:** le quote si contano per account. **Limiti:** come per `GET /games/{id}/gif`. **Dimensione massima del corpo:** 135.168 byte, qualunque sia `HTTP_BODY_LIMIT` (il PGN come stringa JSON, compresi i caratteri di escape). **Tempo massimo:** 45 secondi per impostazione predefinita.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Una breve partita digitata, senza coordinate
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Un file PGN con tutte le opzioni
                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` con `field` (un campo sconosciuto, o `pgn` mancante o non stringa; senza `field` quando il corpo non è un oggetto); `invalid_json`; `invalid_option` con `field` (`size`, `orientation`, `delayMs` o `coords`); `invalid_pgn` con `line` e `column` (a partire da 1, colonne in caratteri) e il `message` del lettore: una mossa illegale o ambigua, un tag malformato, una variante di gioco sconosciuta, più di 65.536 byte.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: il server ha disattivato le GIF (`GIF_ENABLED=false`; il gettone `gif` viene restituito).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: un corpo di oltre 135.168 byte.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: il limite `gif`, un limite di rendering o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: non è stato possibile creare la GIF (il server ne registra il motivo nei log). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` con `retryAfter` (da 3 a 10 secondi) e `Retry-After`: la coda di rendering è piena, o la GIF ha atteso troppo a lungo un thread libero; tutti i gettoni dei limiti presi dalla richiesta vengono restituiti. `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Ottieni il profilo pubblico di un giocatore
      description: |-
        Il profilo pubblico di un giocatore: i punteggi nelle categorie ufficiali (nell’ordine del server) e i conteggi delle partite. `games.total` conta tutte le partite salvate, comprese quelle amichevoli e annullate; `games.rated`, `wins`, `draws` e `losses` sono sommati sui record di punteggio, quindi contano solo le partite classificate. La risposta è la stessa con o senza token.

        **Limite:** `public_read` (per giocatore con un token, per client senza).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Il profilo.
          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`: non è composto da 2 a 24 caratteri di `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: è stato inviato un token e non è valido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: il giocatore non esiste, o l’account è stato eliminato.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: il limite `public_read` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (il database è rimasto bloccato; `retryAfter: 1` nel corpo, nessun header `Retry-After`), `server_busy` (la verifica della sessione ha trovato il database bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Elenca le partite recenti di un giocatore
      description: |-
        Le partite recenti del giocatore, dalla più recente, paginate come lo storico ma senza filtri né totale. `color` è il colore di questo giocatore. `next` è l’id dell’ultima partita ogni volta che la pagina è piena, quindi la pagina successiva può essere vuota. Il giocatore viene cercato per primo: un giocatore sconosciuto dà 404 qualunque cosa dica la query.

        **Limite:** `public_read` (per giocatore con un token, per client senza).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Una pagina delle partite del giocatore.
          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` non è un id di partita), `invalid_limit` (questi due senza `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: è stato inviato un token e non è valido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: il giocatore non esiste, o l’account è stato eliminato.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: il limite `public_read` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (il database è rimasto bloccato; `retryAfter: 1` nel corpo, nessun header `Retry-After`), `server_busy` (la verifica della sessione ha trovato il database bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Ottieni i migliori giocatori di una categoria
      description: |-
        I 100 migliori record di punteggio classificati di una categoria ufficiale con almeno `minGames` (`PROVISIONAL_GAMES`) partite conteggiate, esclusi gli account eliminati e i bari accertati. Il server ricalcola ogni classifica al massimo ogni 10 secondi; `updatedAt` indica quando. Per una categoria è in corso un solo calcolo alla volta: nel frattempo le altre richieste ricevono la lista precedente, o attendono il calcolo quando non ce n’è ancora una.

        **Limiti:** solo il livello per indirizzo.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: L’id di una categoria ufficiale (`3+2`, o `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Quanti giocatori, da 1 a 100. Un numero più grande di al massimo 3 cifre vale 100; un valore vuoto equivale a un parametro assente.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: La classifica.
          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` (mancante, o non è una categoria ufficiale), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: il livello per indirizzo.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (il database è rimasto bloccato; `retryAfter: 1` nel corpo, nessun header `Retry-After`), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Segnala l’avversario di una partita recente
      description: |-
        Segnala l’avversario di una delle partite del giocatore stesso terminata negli ultimi 7 giorni. Una segnalazione da sola non cambia mai un punteggio, una sanzione o un livello di integrità. Alza la priorità di revisione che vedono i moderatori e, tranne che per `abuse`, richiede l’analisi della partita con il motore.

        Una segnalazione dello stesso avversario per la stessa partita riceve la stessa risposta e non cambia nulla; la risposta non rivela mai nulla sull’account segnalato. `GET /games/{id}` indica in anticipo ai giocatori della partita se una segnalazione verrebbe accettata (`reportable`).

        Questo endpoint verifica da sé il proprio corpo, nell’ordine `gameId`, `reported`, `category`, `comment`: un errore risponde 400 `invalid_request` senza `field`. I campi che non conosce vengono ignorati.

        **Limiti:** `reports` (per giocatore), poi `REPORTS_PER_DAY` (5) segnalazioni per giocatore in 24 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: La segnalazione è stata ricevuta (o era già stata presentata).
          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` (senza `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: non è l’avversario di chi segnala in una partita terminata negli ultimi 7 giorni.'
        '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`, con un header `Retry-After`): `REPORTS_PER_DAY` segnalazioni nelle ultime 24 ore. `rate_limited`: il limite `reports` o il budget dell’account.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la verifica della sessione ha trovato il database bloccato), `timeout`. Anche `server_busy` con `retryAfter` da 2 a 5 quando il server è molto in ritardo (vedi l’introduzione): la richiesta non è stata eseguita.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Il server ufficiale (le pagine si trovano nella radice, fuori da `/api/v1`).
      - url: https://{host}:{port}
        description: Qualsiasi server Scacelith (le pagine si trovano nella radice, fuori da `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Il nome host pubblico del server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: La porta pubblica dell’API (`PUBLIC_API_PORT`, altrimenti `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Mostra la pagina di conferma dell’e-mail
      description: |-
        La pagina di un link di conferma dell’e-mail (registrazione, o un’e-mail di conferma inviata di nuovo). Mostra solo un pulsante «Confirm my e-mail address» (Conferma il mio indirizzo e-mail), così che uno scanner di posta che apre il link non lo consumi; il pulsante invia il modulo a `POST /verify-email`.

        **Limite:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: La pagina con il suo pulsante di conferma.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Il link non è valido o è scaduto (una pagina HTML).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Il limite `page` (una pagina HTML), o il livello per indirizzo (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Conferma l’indirizzo e-mail
      description: |-
        Il modulo della pagina di conferma. Conferma l’indirizzo; per una nuova registrazione, crea l’account in quel momento, e il giocatore può accedere.

        **Limite:** `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: L’indirizzo è confermato (e l’account di una nuova registrazione è stato creato).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Il link non è valido, è già stato usato o è scaduto, oppure il modulo non è valido (una pagina HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Il link di una nuova registrazione il cui nome utente o indirizzo è stato preso nel frattempo da un altro account; non viene creato nessun account (una pagina HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Il limite `auth` (una pagina HTML), o il livello per indirizzo (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Il database è rimasto bloccato (`Retry-After: 1`), o il server è molto in ritardo (`Retry-After` da 2 a 5, vedi l’introduzione): non è cambiato nulla e il link funziona ancora. Anche il timeout del gestore delle richieste.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Il server ufficiale (le pagine si trovano nella radice, fuori da `/api/v1`).
      - url: https://{host}:{port}
        description: Qualsiasi server Scacelith (le pagine si trovano nella radice, fuori da `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Il nome host pubblico del server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: La porta pubblica dell’API (`PUBLIC_API_PORT`, altrimenti `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Mostra il modulo di reimpostazione della password
      description: |-
        La pagina di un link di reimpostazione della password: il modulo della nuova password (la password due volte). Il modulo viene inviato a `POST /reset-password`.

        **Limite:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Il modulo della nuova password.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Il link non è valido (anche quando è stato inviato a un indirizzo che l’account non ha più).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Il limite `page` (una pagina HTML), o il livello per indirizzo (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Imposta una nuova password dal modulo di reimpostazione
      description: |-
        Il modulo della pagina di reimpostazione; fa ciò che fa `POST /auth/password/reset`, in un’unica transazione: tutti i dispositivi vengono disconnessi, un cambio di e-mail in attesa viene annullato, gli altri link di reimpostazione smettono di funzionare, l’indirizzo risulta confermato e il proprietario riceve un’e-mail.

        Viene verificato prima il link, poi che le due password siano uguali, poi le regole delle password. Quando il server è occupato, il modulo torna con `Retry-After` e il link resta valido.

        **Limiti:** `auth`, poi `auth_reset` (condiviso con `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: La password è stata cambiata, e tutti i dispositivi sono stati disconnessi.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Di nuovo il modulo con l’errore (le password sono diverse, una password debole), il link non è valido, oppure il modulo non è valido (una pagina 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: Il limite `auth` o `auth_reset` (una pagina HTML), di nuovo il modulo con `Retry-After` quando questo client ha troppi hash di password in attesa (il link resta valido), o il livello per indirizzo (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Di nuovo il modulo con `Retry-After` quando il server è occupato (la coda di hashing delle password, o il database è rimasto bloccato); il link resta valido. Quando il server è molto in ritardo (vedi l’introduzione), invece, una pagina «Server error» (errore del server) con `Retry-After` (da 2 a 5): non è stato eseguito nulla e il link resta valido. Anche il timeout del gestore delle richieste.'
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Il server ufficiale (le pagine si trovano nella radice, fuori da `/api/v1`).
      - url: https://{host}:{port}
        description: Qualsiasi server Scacelith (le pagine si trovano nella radice, fuori da `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Il nome host pubblico del server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: La porta pubblica dell’API (`PUBLIC_API_PORT`, altrimenti `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Mostra la pagina di conferma del cambio di e-mail
      description: |-
        La pagina di un link di cambio dell’e-mail: mostra il nuovo indirizzo e il nome dell’account, con un pulsante «Use this e-mail address» (Usa questo indirizzo e-mail) che invia il modulo a `POST /confirm-email-change`.

        **Limite:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: La pagina con il nuovo indirizzo e il suo pulsante di conferma.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Il link non è valido o è scaduto (anche quando l’indirizzo dell’account è cambiato dopo la richiesta).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Il limite `page` (una pagina HTML), o il livello per indirizzo (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Conferma il nuovo indirizzo e-mail
      description: |-
        Il modulo della pagina di cambio dell’e-mail. L’indirizzo cambia e risulta confermato; i dispositivi restano connessi; i link inviati in precedenza (conferma, reimpostazione della password, altri cambi) smettono di funzionare; il vecchio indirizzo viene informato, con il nuovo mascherato.

        **Limite:** `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: L’indirizzo è stato cambiato.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Il link non è valido, è già stato usato o è scaduto, oppure il modulo non è valido (una pagina HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Nel frattempo un altro account ha preso l’indirizzo (una pagina HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Il limite `auth` (una pagina HTML), o il livello per indirizzo (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Il database è rimasto bloccato (`Retry-After: 1`), o il server è molto in ritardo (`Retry-After` da 2 a 5, vedi l’introduzione): non è cambiato nulla e il link funziona ancora. Anche il timeout del gestore delle richieste.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Il server ufficiale, nella radice.
      - url: https://caissa.scacelith.com/api/v1
        description: Il server ufficiale, sotto `/api/v1`.
      - url: https://{host}:{port}
        description: Qualsiasi server Scacelith, nella radice.
        variables:
          host:
            default: caissa.scacelith.com
            description: Il nome host pubblico del server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: La porta pubblica dell’API (`PUBLIC_API_PORT`, altrimenti `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Qualsiasi server Scacelith, sotto `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Il nome host pubblico del server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: La porta pubblica dell’API (`PUBLIC_API_PORT`, altrimenti `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Verifica che il processo sia in esecuzione
      description: |-
        Risponde 200 `ok` finché il processo è in esecuzione. Funziona anche `HEAD`. Qualsiasi altro metodo risponde 405 `method_not_allowed` con `Allow: GET, HEAD` (compreso `OPTIONS`).

        **Limiti:** solo il livello per indirizzo.
      security: []
      responses:
        '200':
          description: Il processo è in esecuzione.
          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`: il livello per indirizzo.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Il server ufficiale, nella radice.
      - url: https://caissa.scacelith.com/api/v1
        description: Il server ufficiale, sotto `/api/v1`.
      - url: https://{host}:{port}
        description: Qualsiasi server Scacelith, nella radice.
        variables:
          host:
            default: caissa.scacelith.com
            description: Il nome host pubblico del server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: La porta pubblica dell’API (`PUBLIC_API_PORT`, altrimenti `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Qualsiasi server Scacelith, sotto `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Il nome host pubblico del server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: La porta pubblica dell’API (`PUBLIC_API_PORT`, altrimenti `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Verifica che il server accetti giocatori
      description: |-
        Risponde 200 `ready` quando il server accetta giocatori, e 503 `not_ready` mentre si avvia o si arresta. Funziona anche `HEAD`. Qualsiasi altro metodo risponde 405 `method_not_allowed` con `Allow: GET, HEAD` (compreso `OPTIONS`).

        **Limiti:** solo il livello per indirizzo.
      security: []
      responses:
        '200':
          description: Il server accetta giocatori.
          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`: il livello per indirizzo.'
        '503':
          description: Il server si sta avviando o arrestando.
          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 di sessione (`sct_` seguito da 43 caratteri base64url) ottenuto da una risposta di accesso, nell’header `Authorization`: `Authorization: Bearer <token>`. Il prefisso è esattamente `Bearer` (con distinzione tra maiuscole e minuscole) seguito da uno spazio; un valore che non lo ha, o un token che non è composto da 1 a 512 caratteri ASCII stampabili, risponde 401 `invalid_token` come qualsiasi altro token non valido. Un header vuoto equivale a nessun header.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: L’id della partita, un intero decimale positivo di al massimo 16 cifre senza zeri iniziali, inferiore a 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: L’id della sessione, come lo fornisce `GET /auth/sessions`, scritto senza zeri iniziali (`03` non è la sessione 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Il nome utente del giocatore, senza distinzione tra maiuscole e minuscole. Il server accetta qualsiasi nome composto da 2 a 24 caratteri di `[A-Za-z0-9_.-]` (la regola più ampia che abbia mai consentito).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Un id di partita; vengono elencate solo le partite più vecchie. Passa il `next` della pagina precedente. Un valore vuoto equivale a un parametro assente.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: La dimensione della pagina, da 1 a 50. Un numero più grande di al massimo 3 cifre vale 50; un valore vuoto equivale a un parametro assente.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Il token del link dell’e-mail (43 caratteri base64url). Un token mancante o errato mostra la pagina «link invalid or expired» (link non valido o scaduto).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Ogni risposta del server vieta la memorizzazione nella cache.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: I secondi da attendere prima di riprovare; lo stesso valore di `retryAfter` nel corpo.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Il challenge bearer, con `error="invalid_token"` per un token non valido.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: Il server chiude la connessione dopo questa risposta.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (con `field` quando l’errore riguarda un campo del corpo) o `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Un campo viola lo schema
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Il corpo non è JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Una password debole
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: Un PGN illeggibile
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (nessun header `Authorization`) o `invalid_token` (il token è malformato, scaduto, revocato, o appartiene a un account eliminato).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Nessuna sessione
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Un token non valido
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: La richiesta è rifiutata.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Una password errata alla riautenticazione
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Un account sospeso
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found`, o una funzionalità che questo server ha disattivato.'
      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`: il corpo non è arrivato entro 10 secondi. Il server chiude la connessione.'
      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: La richiesta è in conflitto con lo stato del server.
      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`: il tentativo di accesso con Google o il ticket è sconosciuto, già usato o scaduto; ricomincia l’accesso.'
      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`: il corpo supera `HTTP_BODY_LIMIT` (16.384 byte per impostazione predefinita). Il server chiude la connessione.'
      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`: la destinazione della richiesta supera 4096 caratteri.'
      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`: il corpo non è `application/json`, o il suo charset non è 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`: la partita ha più di `GIF_MAX_PLIES` (600) semimosse.'
      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`: risolvi il challenge `pow` e invia di nuovo la stessa richiesta con `pow: { challenge, nonce }`. `reason` indica il motivo: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` o `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` (un limite di frequenza, il budget dell’account, o la coda di hashing delle password) o `too_many_attempts` (una limitazione dell’account), con `retryAfter` e un header `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: Un limite di frequenza
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Troppi tentativi falliti su questo account
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: un errore imprevisto. Il server lo registra nei log.'
      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`: uno `state` o un `iss` errato, oppure Google ha rifiutato il codice o ha inviato un ID token che non supera la verifica (compreso un codice o un ID token dell’altro client, quello del gioco o quello del sito web). Non riporta mai il testo di 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` o `timeout`: riprova dopo `retryAfter`, quando è indicato.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Il server è occupato
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: Il database è rimasto bloccato (una lettura)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Il server ha impiegato troppo tempo
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: La GIF animata, come allegato.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` per una partita di questo server, `attachment; filename="scacelith-game.gif"` per un PGN inviato con `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: La dimensione del file in byte.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: Una pagina HTML.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: Una pagina HTML che spiega il rifiuto.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Il modulo non è arrivato entro 10 secondi (una pagina HTML). Il server chiude la connessione.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: Una pagina HTML che spiega il conflitto.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Il modulo supera `HTTP_BODY_LIMIT` (una pagina HTML). Il server chiude la connessione.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Il corpo non è né un modulo (`application/x-www-form-urlencoded`) né JSON, o il suo charset non è UTF-8 (una pagina HTML).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Un limite di frequenza (una pagina HTML), con `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: Un errore imprevisto (una pagina HTML intitolata «Server error», errore del server). Il server lo registra nei log.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Il server è occupato (il database è rimasto bloccato, o il server è molto in ritardo; con `Retry-After`) o ha impiegato troppo tempo (una pagina HTML intitolata «Server error», errore del 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: L’unica forma di ogni risposta di errore dell’API JSON.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: Il codice di errore in `snake_case`. È in base a questo che un client sceglie cosa mostrare.
          examples:
            - rate_limited
        message:
          type: string
          description: Una frase in inglese, destinata ai log e come ripiego.
        retryAfter:
          type: integer
          minimum: 1
          description: I secondi da attendere prima di riprovare; il campo è presente solo nei rifiuti che cessano con il passare del tempo. La risposta porta allora anche un header `Retry-After` con lo stesso valore, tranne il 503 `busy` delle letture dello storico, delle partite, dei giocatori e della classifica.
        field:
          type: string
          description: Il campo o il parametro di query che ha causato l’errore (`invalid_request`, `invalid_option`, e `invalid_cursor`, `invalid_limit` e `invalid_filter` di `GET /account/games`). Un campo annidato si scrive con il punto (`pow.nonce`).
        reason:
          type: string
          description: 'Il motivo: la regola che un `weak_password` viola, o perché una proof of work è stata richiesta o rifiutata (`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: 'Solo `banned`: la fine della sospensione (ms dall’epoca Unix), `null` per una sospensione permanente.'
        line:
          type: integer
          minimum: 1
          description: 'Solo `invalid_pgn`: la riga dell’errore, a partire da 1.'
        column:
          type: integer
          minimum: 1
          description: 'Solo `invalid_pgn`: la colonna dell’errore, a partire da 1, in caratteri.'
    PowChallenge:
      type: object
      description: Un challenge di proof of work (il `pow` di un 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: Il challenge firmato, da rinviare così com’è.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Il numero di bit zero iniziali che `SHA-256(challenge + ":" + nonce)` deve avere.
        expiresAt:
          type: integer
          format: int64
          description: Quando scade il challenge (ms dall’epoca Unix), 2 minuti dopo la sua emissione.
    PowAnswer:
      type: object
      description: La risposta a un challenge di proof of work.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: Il `challenge` della risposta 428, così come è stato fornito.
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Una stringa decimale di al massimo 20 cifre tale che `SHA-256(challenge + ":" + nonce)` inizi con `bits` bit zero.
    ServerInfo:
      type: object
      description: Ciò che serve a un client prima di accedere o di connettersi.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Il nome del server (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: Un UUID che il database riceve al primo avvio. Resta lo stesso tra un riavvio e l’altro. È `null` quando il database non può fornirlo.
        motd:
          type: string
          description: Il messaggio del giorno (`SERVER_MOTD`), eventualmente vuoto.
        protocol:
          type: object
          description: Il protocollo WebSocket (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: La versione del protocollo più vecchia che il server supporta.
            max:
              type: integer
              minimum: 1
              description: La versione del protocollo più recente che il server supporta.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 'L’impronta dello schema del protocollo (i primi 4 byte dello SHA-256 della sua forma canonica, come intero senza segno: `0xc649140f`, 3326678031, per la versione minore 2 del protocollo); solo informativa, mai confrontata.'
            subprotocol:
              type: string
              description: Il token del sottoprotocollo WebSocket (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: La porta WebSocket usata dai giocatori (`PUBLIC_WS_PORT`, altrimenti `WS_PORT`, altrimenti `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Il percorso del WebSocket.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Se è possibile creare nuovi account.
        emailVerification:
          type: boolean
          description: Se i nuovi account confermano il proprio indirizzo (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Se l’accesso con Google è offerto al gioco.
            googleWeb:
              type: boolean
              description: Se l’accesso con Google è offerto anche a un sito web (`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: L’autenticazione a due fattori è sempre disponibile.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: I bit di proof of work richiesti dalla registrazione (0 se nessuno).
        categories:
          type: array
          description: Le cadenze ufficiali, quelle classificate (`RATED_CATEGORIES`), nell’ordine del server. Qualsiasi altra cadenza è `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Le regole che un client può verificare prima di inviare un modulo.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: La lunghezza minima del nome utente (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: La lunghezza massima del nome utente (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: L’espressione regolare a cui deve corrispondere un nuovo nome utente.
            passwordMinLength:
              type: integer
              description: La lunghezza minima della password, in caratteri (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: La lunghezza massima della password, in byte UTF-8.
            customTimeControls:
              type: boolean
              description: Se le sfide e le partite private possono usare cadenze personalizzate.
            reportsPerDay:
              type: integer
              description: Quante segnalazioni un giocatore può presentare ogni 24 ore (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: La dimensione massima di un messaggio WebSocket che un client può inviare, in byte.
      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: Una cadenza ufficiale.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: L’id della categoria, minuti e secondi di incremento (`3+2`).
        baseSec:
          type: number
          minimum: 0
          description: Il tempo base, in secondi.
        incSec:
          type: number
          minimum: 0
          description: L’incremento per mossa, in secondi.
    AccountView:
      type: object
      description: L’account come lo vede il suo giocatore (il campo `user` delle risposte di accesso e di `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: L’id dell’account.
        username:
          type: string
          description: Il nome utente.
        email:
          type: string
          format: email
          description: L’indirizzo e-mail.
        emailVerified:
          type: boolean
          description: Se l’indirizzo è confermato.
        mfaEnabled:
          type: boolean
          description: Se l’autenticazione a due fattori è attiva.
        googleLinked:
          type: boolean
          description: Se è collegato un account Google.
        hasPassword:
          type: boolean
          description: '`false` per un account creato tramite Google che non ha ancora impostato una password.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Se le sfide dirette per nome vengono accettate (vedi `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Quando è stato creato l’account (ms dall’epoca Unix).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: L’ultimo accesso (ms dall’epoca Unix), o `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Il nuovo indirizzo di un cambio di e-mail in attesa del suo link, o `null`.
    SessionAnswer:
      type: object
      description: Una nuova sessione.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Il token di sessione, per l’header `Authorization` e il WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: La scadenza assoluta della sessione (ms dall’epoca Unix); il limite di inattività può terminarla prima.
        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: Il secondo passaggio di un accesso con autenticazione a due fattori; continua con `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: Il token del passaggio, per `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: I secondi rimasti per completare il passaggio.
    LoginAnswer:
      description: Una sessione, o il secondo passaggio di un accesso con autenticazione a due fattori.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Un primo accesso con Google; continua con `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: Il ticket per `POST /auth/sso/complete`, valido per 10 minuti.
        suggestedUsername:
          type: string
          description: Un nome utente ricavato dal nome Google o dall’indirizzo, o `""` quando nessuno è adatto.
    SsoNeedsPassword:
      type: object
      description: Un account con password usa l’indirizzo; continua con `POST /auth/sso/google/link`. Non è ancora stato collegato nulla.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Il ticket per `POST /auth/sso/google/link`.
        username:
          type: string
          description: Il nome utente di quell’account (lo vede solo chi ha dimostrato a Google di possedere l’indirizzo).
        expiresIn:
          type: integer
          const: 600
          description: I secondi per cui il ticket resta valido.
    SsoFinishAnswer:
      description: La risposta di `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: Un tentativo di accesso con Google.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Il tentativo, per `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: L’URL di Google da aprire nel browser di sistema, dopo averlo verificato.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Lo `state` che Google deve rinviare.
        expiresIn:
          type: integer
          const: 600
          description: I secondi per cui il tentativo resta valido.
    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: Il nome utente; poi si applicano le regole del server (vedi la descrizione).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: L’indirizzo e-mail. Privato degli spazi iniziali e finali e salvato in minuscolo.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La password; poi si applicano le regole delle password.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Il nome utente, o l’indirizzo e-mail (qualsiasi testo con `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Facoltativo. Mostrato nell’elenco dei dispositivi connessi (ad esempio `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: Il `mfaToken` della risposta di accesso (o di quella di un accesso con Google).
        code:
          type: string
          maxLength: 32
          description: Facoltativo. Un codice a 6 cifre dell’app di autenticazione, o un codice di recupero.
        recoveryCode:
          type: string
          maxLength: 32
          description: Facoltativo. Un codice di recupero. È necessario uno tra `code` e `recoveryCode`.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: L’indirizzo e-mail.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Il parametro `token` del link di reimpostazione.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: La nuova password; si applicano le regole della registrazione per le password.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Il challenge PKCE S256 del `codeVerifier` del client (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: La porta del listener del client su `127.0.0.1`.
    SsoWebStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Il challenge PKCE S256 del `codeVerifier` della pagina (`BASE64URL(SHA-256(codeVerifier))`).
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: L’`attemptId` di `POST /auth/sso/google/start`.
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: Il verifier PKCE; il suo SHA-256 deve corrispondere al challenge fornito a `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Lo `state` così come Google lo ha rinviato.
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: Il `code` così come Google lo ha rinviato (ASCII stampabile senza spazi).
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Facoltativo. L’`iss` così come Google lo ha rinviato, quando lo ha fatto.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: Il `linkTicket` di `finish`, valido per 10 minuti.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La password dell’account.
        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: Lo `ssoTicket` di `finish`, valido per 10 minuti.
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Il nome utente; si applicano le regole della registrazione.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Le sessioni attive, a partire da quella usata più di recente.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Una sessione attiva (un dispositivo connesso).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: L’id della sessione, per `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: L’accesso (ms dall’epoca Unix).
        lastSeenAt:
          type: integer
          format: int64
          description: L’ultimo utilizzo (ms dall’epoca Unix), aggiornato al massimo ogni 5 minuti (più tardi mentre il server è molto in ritardo).
        expiresAt:
          type: integer
          format: int64
          description: La scadenza assoluta (ms dall’epoca Unix); il limite di inattività può terminare la sessione prima.
        clientLabel:
          type:
            - string
            - 'null'
          description: Il `clientLabel` dell’accesso, o `null` quando non ne è stato inviato nessuno.
        current:
          type: boolean
          description: Se questa è la sessione che fa la richiesta.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Un record per ogni categoria in cui il giocatore ha giocato partite classificate.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Le sanzioni attive.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` durante una sospensione, altrimenti `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Il record di punteggio di una categoria.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: L’id della categoria.
        rating:
          type: integer
          description: Il punteggio.
        games:
          type: integer
          minimum: 0
          description: Le partite giocate nella categoria (conteggiate o no).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Il punteggio più alto raggiunto.
        provisional:
          type: boolean
          description: '`true` finché il punteggio è ancora non classificato o ha meno di `PROVISIONAL_GAMES` partite conteggiate (il gioco lo mostra come `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Il tipo di sanzione.
        reason:
          type:
            - string
            - 'null'
          description: Il motivo indicato, se presente.
        startsAt:
          type: integer
          format: int64
          description: L’inizio (ms dall’epoca Unix).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: La fine (ms dall’epoca Unix), `null` quando la sanzione è permanente.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: La fine della sospensione (ms dall’epoca Unix), `null` se è permanente.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` per accettare le sfide dirette per nome, `none` per rifiutarle.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: La password attuale.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: La nuova password; si applicano le regole della registrazione per le password.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La password dell’account.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Un codice del segreto in attesa, esattamente 6 cifre.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La password dell’account.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Facoltativo; necessario con l’autenticazione a due fattori (o `recoveryCode`). Un codice dell’app di autenticazione, o un codice di recupero.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Facoltativo. Un codice di recupero (`xxxx-xxxx-xx`; maiuscole e minuscole, spazi e trattini non contano).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La password dell’account.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Un codice dell’app di autenticazione (un codice di recupero viene rifiutato).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Il nuovo indirizzo; privato degli spazi iniziali e finali e convertito in minuscolo, poi verificato come un indirizzo di registrazione.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La password dell’account.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Facoltativo; necessario con l’autenticazione a due fattori (o `recoveryCode`). Un codice dell’app di autenticazione, o un codice di recupero.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Facoltativo. Un codice di recupero.
    TotpSetup:
      type: object
      description: Il segreto in attesa per l’app di autenticazione.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Il segreto in base32, da inserire nell’app.
        uri:
          type: string
          format: uri
          description: L’URI `otpauth://totp/...`, da mostrare come codice QR.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Secondi per intervallo.
    RecoveryCodeList:
      type: array
      description: I 10 codici di recupero, mostrati solo questa volta. Ognuno funziona una sola volta.
      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 delle due parti di una partita.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Il nome nella registrazione della partita (`deleted#<id>` per un account eliminato).
        rating:
          type:
            - integer
            - 'null'
          description: Il punteggio all’inizio, `null` se sconosciuto.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Il punteggio dopo la partita. Una partita classificata lo ha sempre; `null` solo per una partita che non conta per i punteggi (amichevole, a cadenza personalizzata, annullata).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: La variazione del punteggio, `0` quando le regole del punteggio lasciano il punteggio invariato (un risultato di zero punti secondo la regola FIDE, un avversario non classificato, le prime partite di un giocatore non classificato); `null` come `ratingAfter`.
    GameSummary:
      type: object
      description: Il riepilogo di una partita salvata.
      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: L’id della partita.
        category:
          type: string
          description: L’id della categoria ufficiale (`3+2`), o `custom`.
        rated:
          type: boolean
          description: Se la partita conta per i punteggi.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: La cadenza in secondi (`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` (vedi i codici delle partite).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Il codice del motivo della fine (vedi i codici delle partite).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Il risultato, `*` per una partita annullata.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Il numero di semimosse.
        startedAt:
          type: integer
          format: int64
          description: L’inizio (ms dall’epoca Unix).
        endedAt:
          type: integer
          format: int64
          description: La fine (ms dall’epoca Unix).
    Termination:
      type: string
      description: Il nome del motivo della fine (vedi i codici delle partite); `Unknown` per un codice che il protocollo non conosce.
      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: Il riepilogo di una partita dal punto di vista di un giocatore.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Il colore del giocatore.
    HistoryGameSummary:
      description: Una partita dello storico del giocatore che ha effettuato l’accesso.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Il tempo base in millisecondi.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: L’incremento in millisecondi.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: L’esito dal punto di vista del giocatore.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Le partite della pagina, dalla più recente.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: L’id da passare come `before` per la pagina successiva, o `null` nell’ultima pagina.
        total:
          type: integer
          minimum: 0
          description: Il numero di partite che corrispondono al filtro, su tutte le pagine.
    MoveRecord:
      type: object
      description: Una semimossa di una partita.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: La mossa in notazione UCI, con `n`, `b`, `r` o `q` aggiunto per una promozione.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Il tempo addebitato per la mossa (ms), `null` quando la registrazione non lo contiene.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: L’orologio di chi ha mosso, dopo la mossa (ms), `null` quando la registrazione non lo contiene.
    PgnTagsView:
      type: object
      description: I principali tag PGN, da mostrare. Qui `Termination` è il nome del motivo della fine; il file PGN usa i valori standard del PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` o `<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: La data UTC di inizio.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: Il punteggio all’inizio, o `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Il punteggio all’inizio, o `"-"`.
          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: La registrazione di una partita con le sue mosse e i suoi orologi. Ha i campi di un riepilogo di partita, senza `color` e `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Il tempo base in millisecondi.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: L’incremento in millisecondi.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: Il nome di `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: L’id della partita di cui questa è la rivincita, o `null`.
            moves:
              type: array
              description: Una voce per semimossa.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Solo quando il giocatore del token ha giocato questa partita; il suo colore.
            reportable:
              type: boolean
              description: Solo quando il giocatore del token ha giocato questa partita; `true` quando `POST /reports` accetterebbe ora una segnalazione dell’avversario per questa partita (la partita è terminata da non più di 7 giorni, la quota giornaliera non è esaurita, e l’avversario non è già stato segnalato per questa partita).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Il nome utente, come lo ha scritto il giocatore.
        createdAt:
          type: integer
          format: int64
          description: Quando è stato creato l’account (ms dall’epoca Unix).
        ratings:
          type: array
          description: Solo le categorie ufficiali, nell’ordine del server.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Tutte le partite salvate, comprese quelle amichevoli e annullate.
            rated:
              type: integer
              minimum: 0
              description: Le partite classificate, sommate sui record di punteggio.
            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: Il nome utente, come lo ha scritto il giocatore.
        games:
          type: array
          description: Le partite della pagina, dalla più recente.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: L’id dell’ultima partita ogni volta che la pagina è piena (la pagina successiva può allora essere vuota), altrimenti `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: L’id della categoria.
        minGames:
          type: integer
          minimum: 0
          description: Le partite conteggiate di cui un record ha bisogno per comparire nell’elenco (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Quando è stata calcolata la classifica (ms dall’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: Il testo PGN, al massimo 65.536 byte UTF-8. Viene usata solo la prima partita.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Facoltativo. La dimensione dell’immagine.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Facoltativo. Il colore in basso sulla scacchiera.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Facoltativo. Millisecondi per mossa, un numero JSON con valore intero.
        coords:
          type: boolean
          default: true
          description: Facoltativo. Se le lettere delle colonne e i numeri delle traverse vengono disegnati intorno alla scacchiera.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: La partita, come intero o come stringa da 1 a 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: Il nome utente dell’avversario, come nella registrazione della partita o com’è ora, senza distinzione tra maiuscole e minuscole. Non può essere vuoto.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: L’oggetto della segnalazione.
        comment:
          type:
            - string
            - 'null'
          description: Facoltativo. Al massimo 500 caratteri, dopo che sono stati rimossi i caratteri di controllo (tranne tabulazione e avanzamento riga) e gli spazi iniziali e finali del testo.
    AccountExport:
      type: object
      description: Tutto ciò che il server conserva sull’account (orari in ms dall’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: Quando è stata fatta l’esportazione.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Cosa contiene il file e cosa esclude, in inglese semplice per il giocatore.
          items:
            type: string
        account:
          description: La vista dell’account di `GET /account/me`, con `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: L’indirizzo dell’account Google collegato, o `null`.
        ratings:
          type: array
          description: I record di punteggio completi.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: I punti restituiti al punteggio dopo che un avversario è stato sorpreso a imbrogliare, sommati per giorno UTC e categoria, dal più recente. Non vengono indicati né le partite né i bari.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Tutte le sessioni salvate, dalla più recente, senza alcun token. La pulizia legata alla conservazione dei dati elimina una sessione scaduta, e una sessione disconnessa un giorno dopo la disconnessione.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Gli eventi di sicurezza, dal più recente, conservati per `RETENTION_SECURITY_DAYS` giorni.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Tutte le sanzioni, comprese quelle revocate. Il nome del moderatore non è mai incluso.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Gli eventi di condotta registrati per le partite del giocatore abbandonate, annullate e con mancata presentazione, conservati per 30 giorni.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Le segnalazioni fatte dal giocatore.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Il numero di partite.
            list:
              type: array
              description: Tutte le partite, dalla più recente, come riepiloghi di `GET /account/games`. Le mosse si trovano in `GET /games/{id}` e il PGN in `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Un record di punteggio completo.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Se il giocatore è uscito dalla fase non classificata.
            countedGames:
              type: integer
              minimum: 0
              description: Le partite entrate nel calcolo del punteggio.
            updatedAt:
              type: integer
              format: int64
              description: Quando il record è cambiato l’ultima volta.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: Le 00:00 UTC del giorno (ms dall’epoca Unix).
        category:
          type: string
        points:
          type: integer
          description: I punti restituiti quel giorno in quella categoria.
    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: Quando la sessione è stata disconnessa, o `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: L’indirizzo dell’accesso, cancellato dopo `RETENTION_IP_DAYS` giorni.
    SecurityEvent:
      type: object
      description: |-
        Un evento di sicurezza. `ip` è indicato solo per ciò che è stato fatto con l’accesso effettuato, con la password dell’account (e il secondo fattore) o con un link inviato al suo indirizzo: `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` e `account_exported`; ogni altro tipo ha `ip: null`, e `ip` viene cancellato dopo `RETENTION_IP_DAYS` giorni.

        `detail` conserva solo questi campi: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` e `method` (`password`, o `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` e `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. Un evento `moderator_action` conserva solo `{ action }`, per le azioni `ban`, `unban`, `reset_mfa`, `verify_email` e `revoke_sessions` (le altre azioni dei moderatori sono escluse). Ogni altro tipo ha `detail: null`. Gli eventi `rating_refund` sono esclusi: i loro punti si trovano in `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Il tipo di evento (`login`, `password_changed`…).
        at:
          type: integer
          format: int64
          description: Quando è avvenuto.
        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: Il nome pubblico attuale del giocatore segnalato.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Se la segnalazione è ancora aperta (non viene detto se il giocatore segnalato è stato sanzionato).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Il token del link dell’e-mail (il campo nascosto del modulo della pagina).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Il token del link di reimpostazione (il campo nascosto del modulo).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: La nuova password; si applicano le regole della registrazione per le password.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Di nuovo la nuova password; deve essere uguale.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: Una pagina HTML in UTF-8 (`text/html; charset=utf-8`), senza JavaScript né risorse esterne.
