openapi: 3.1.1
info:
  title: API HTTPS du serveur Scacelith
  version: "0.9.0"
  summary: L’API HTTPS de tout serveur Scacelith, le serveur officiel comme les serveurs communautaires.
  description: |-
    Tout serveur Scacelith, qu’il s’agisse du serveur officiel `caissa.scacelith.com` ou d’un serveur communautaire, répond à cette API HTTPS. Le jeu s’en sert pour tout ce qui se passe en dehors des parties elles-mêmes : l’inscription et la connexion (double authentification et connexion Google comprises), la page du compte, l’historique des parties, les téléchargements PGN et les GIF animés des parties, les appareils connectés, le téléchargement des données, la suppression du compte et les signalements.

    Le jeu en direct (recherche d’adversaire, défis, coups, pendules) passe par le WebSocket du même serveur (`wss://<host>/ws`), ouvert avec un jeton de session de cette API ; il est décrit dans `PROTOCOL.md`, pas ici.

    Les valeurs par défaut indiquées ci-dessous sont celles d’un serveur dont la configuration n’a pas été modifiée ; un serveur communautaire peut les changer (`CONFIG.md` décrit chaque réglage).

    ## URL de base, port et transport

    - Serveur officiel : `https://caissa.scacelith.com/api/v1`, port TCP 443.
    - Serveur communautaire : `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` vaut 443 par défaut ; derrière un NAT ou un proxy, le port qu’utilisent les joueurs est `PUBLIC_API_PORT`.
    - Le WebSocket du jeu est sur le même port par défaut (`WS_PORT` le déplace ; `GET /info` indique au client où il se trouve).
    - HTTP/1.1 sur TLS. Avec `TLS_MODE=proxy`, un proxy inverse placé devant le serveur se charge de TLS. `TLS_MODE=off` (HTTP en clair) est réservé au développement local.
    - Les pages HTML ouvertes depuis les liens des e-mails (`/verify-email`, `/reset-password`, `/confirm-email-change`) se trouvent à la racine du serveur, hors de `/api/v1`. Les points de terminaison de santé répondent à la fois à la racine et sous `/api/v1`. La connexion Google n’a aucune page sur le serveur : Google renvoie le navigateur vers le jeu lui-même, sur `127.0.0.1`.
    - Une barre oblique finale est ignorée (`/api/v1/info/` équivaut à `/api/v1/info`), et les paramètres de chemin sont décodés (un paramètre dont l’encodage URL n’est pas valide reçoit 400 `invalid_request`).

    ## Requêtes

    - **Corps JSON.** Les requêtes `POST`, `PUT` et `DELETE` portent un objet JSON avec `Content-Type: application/json`. Un `charset` autre que UTF-8 est refusé, de même que tout autre type de contenu (415 `unsupported_media_type`). Un corps vide vaut `{}`, ce qu’attendent les points de terminaison sans paramètres (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Taille et délai.** Un corps est limité à `HTTP_BODY_LIMIT` octets (16 384 par défaut ; `POST /gif` a sa propre limite de 135 168 octets) : 413 `payload_too_large`. Il doit arriver en moins de 10 secondes : 408 `request_timeout`. Après l’une ou l’autre de ces erreurs, le serveur ferme la connexion.
    - **Schémas stricts.** Un champ que le point de terminaison ne connaît pas est refusé, un champ qui n’est pas marqué facultatif est obligatoire, et les types et les longueurs sont vérifiés. Chacun de ces échecs donne 400 `invalid_request`, avec `field` qui nomme le champ (en notation pointée pour un champ imbriqué, comme `pow.nonce`). Les chaînes ne peuvent pas contenir de caractères de contrôle. Les longueurs se comptent en unités de code UTF-16 : un caractère hors du plan multilingue de base (un emoji) compte donc double. Un corps qui n’est pas du JSON donne 400 `invalid_json`. `POST /gif` et `POST /reports` vérifient eux-mêmes leur corps (voir chacun de ces points de terminaison).
    - **Chaînes de requête.** Seule la première occurrence d’un paramètre compte, les paramètres inconnus sont ignorés, et un `+` se décode en espace : les points de terminaison qui prennent une catégorie de cadence acceptent aussi bien `3%2B2` que `3+2`.
    - **Méthodes.** `HEAD` est un `GET` sans le corps. `OPTIONS` sur un chemin existant répond 204 avec un en-tête `Allow`. Toute autre méthode que le chemin ne prend pas reçoit 405 `method_not_allowed` avec `Allow`.
    - **Cible de la requête.** Elle ne peut pas dépasser 4096 caractères (414 `uri_too_long`). Une requête impossible à analyser, ou dont la ligne de requête et les en-têtes sont trop volumineux ou arrivent trop lentement, reçoit une réponse 400, 431 ou 408 vide, et la connexion est fermée.
    - **Pas de CORS.** L’API sert le jeu, pas des pages web. Aucun en-tête `Access-Control-*` n’est jamais envoyé, si bien qu’une page web ne peut pas lire une réponse ; comme seuls les corps JSON sont acceptés, une écriture intersite exigerait une requête de pré-vérification, et cette requête échoue.

    ## Réponses et erreurs

    - Les réponses sont en JSON encodé en UTF-8, sauf le téléchargement PGN (`application/x-chess-pgn`), les GIF animés (`image/gif`) et les pages HTML. Les instants sont exprimés en millisecondes depuis le 1970-01-01 UTC. Les identifiants sont des entiers. Les identifiants de partie ont jusqu’à 16 chiffres mais restent inférieurs à 2^53 : un nombre JSON (un double) les représente donc exactement.
    - Toute réponse de l’API porte `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` et `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (les pages HTML ont leur propre politique). Avec `TLS_MODE=native`, elle porte aussi `Strict-Transport-Security: max-age=31536000`.
    - Une réponse doit être lue dans les 60 secondes qui suivent le moment où le serveur l’a prête, ce qui ne compte que pour les grosses réponses (un GIF, l’export des données, un long PGN). Le temps que met le serveur à préparer une réponse ne compte ni dans ce délai, ni dans les 30 secondes sans aucun octet entrant ou sortant au bout desquelles une connexion est fermée.

    Les erreurs ont toutes la même forme (le schéma `Error`) :

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

    `message` est destiné aux journaux et sert de solution de repli ; un client choisit ce qu’il affiche d’après `error`. `retryAfter` (en secondes) n’est présent que sur les refus qui prennent fin avec le temps, et la réponse porte alors aussi un en-tête `Retry-After` de même valeur ; la seule exception est le 503 `busy` des lectures de l’historique, des parties, des joueurs et du classement général, qui n’a que le champ. Certaines erreurs ajoutent des champs : `field` (saisie invalide), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` et `column` (`invalid_pgn`).

    Erreurs que tout point de terminaison peut renvoyer :

    | Statut | `error` | Quand |
    |---|---|---|
    | 400 | `invalid_request` | Le corps enfreint le schéma du point de terminaison (`field` indique où), la cible de la requête ou `Content-Length` est mal formé, un paramètre de chemin n’est pas un encodage URL valide, ou le corps a été tronqué. |
    | 400 | `invalid_json` | Le corps n’est pas du JSON. |
    | 401 | `unauthorized` | Pas d’en-tête `Authorization` sur un point de terminaison qui exige une session. |
    | 401 | `invalid_token` | Le jeton est mal formé, expiré, révoqué ou appartient à un compte supprimé ; y compris sur les points de terminaison où la session est facultative. |
    | 404 | `not_found` | Point de terminaison inexistant. Certains points de terminaison l’emploient aussi : partie, joueur ou session inexistants. |
    | 405 | `method_not_allowed` | Le chemin existe pour d’autres méthodes (voir `Allow`). |
    | 408 | `request_timeout` | Le corps n’est pas arrivé dans les 10 secondes. |
    | 413 | `payload_too_large` | Le corps dépasse `HTTP_BODY_LIMIT` (135 168 octets pour `POST /gif`). |
    | 414 | `uri_too_long` | La cible de la requête dépasse 4096 caractères. |
    | 415 | `unsupported_media_type` | Le corps n’est pas en `application/json`, ou son jeu de caractères n’est pas UTF-8. |
    | 429 | `rate_limited` | Une limite de débit (voir plus bas) : `retryAfter` plus un en-tête `Retry-After`. |
    | 500 | `internal_error` | Une défaillance inattendue. Le serveur la journalise. |
    | 503 | `timeout` | Le serveur n’a pas répondu dans les 30 secondes (60 secondes pour l’export, 45 secondes pour les GIF avec les réglages par défaut). |
    | 503 | `server_busy` | Une recherche de session ou une modification du compte a trouvé la base de données verrouillée (`retryAfter` 1), ou la file de hachage des mots de passe est pleine (voir plus bas). |

    Les points de terminaison de lecture (historique des parties, parties, joueurs, classement général) et l’export répondent 503 `busy` avec `retryAfter: 1` quand la base de données est restée verrouillée.

    Les points de terminaison qui vérifient ou hachent un mot de passe peuvent aussi répondre par l’une de ces erreurs, toutes deux avec un `retryAfter` aléatoire de 5 à 15 secondes :

    - 503 `server_busy` : la file de hachage des mots de passe du serveur (`PASSWORD_HASH_QUEUE_MAX`) est pleine, ou l’attente a expiré.
    - 429 `rate_limited` : la file étant au moins à moitié pleine, ce client (une adresse IPv4 ou un /48 IPv6) a déjà `PASSWORD_HASH_WAITERS_PER_SOURCE` hachages en attente. Ce 429 restitue les jetons de limite de débit que la requête avait pris.

    Rien n’a été modifié et aucune tentative échouée n’a été comptée (sauf sur `POST /auth/sso/google/link`, dont l’essai du ticket et l’échec sur le compte ont été comptés avant le hachage), et un lien de réinitialisation reste valide.

    Les pages HTML renvoient leurs erreurs sous forme de pages HTML, avec les mêmes codes de statut.

    ## Authentification

    Les points de terminaison qui exigent une session prennent un jeton Bearer dans l’en-tête `Authorization` (le schéma `bearerAuth`) :

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Obtenir un jeton.** Un jeton (`sct_` suivi de 43 caractères base64url) est délivré par `POST /auth/login`, puis `POST /auth/login/mfa` quand la double authentification est activée, par `POST /auth/sso/google/finish` et `POST /auth/sso/google/link` (connexion Google), et par `POST /auth/sso/complete`. Chacun d’eux répond `{ token, expiresAt, user }`. Le serveur ne conserve qu’une empreinte SHA-256 du jeton.
    - **Session obligatoire.** Un en-tête absent donne 401 `unauthorized` avec `WWW-Authenticate: Bearer realm="scacelith"`. Un jeton invalide donne 401 `invalid_token` avec `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Un client doit oublier un jeton qui reçoit `invalid_token` et se reconnecter.
    - **Session facultative.** Sur `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` et `GET /players/{username}/games`, la session est facultative. Sans l’en-tête, ces points de terminaison renvoient la vue publique ; si un en-tête est envoyé, son jeton doit être valide.
    - **Durée de vie.** Une session prend fin au premier de ces deux termes : `SESSION_MAX_DAYS` (90) jours après la connexion (c’est `expiresAt`), ou `SESSION_IDLE_DAYS` (30) jours sans utilisation. Chaque utilisation repousse la limite d’inactivité ; le serveur enregistre la nouvelle valeur au plus toutes les 5 minutes. Un compte conserve au plus `MAX_SESSIONS_PER_USER` (10) sessions : au-delà, une nouvelle connexion révoque la plus ancienne.
    - **Révocation.** La déconnexion (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) révoque des sessions ; c’est aussi le cas d’un changement de mot de passe (les autres sessions), d’une réinitialisation du mot de passe et de la suppression du compte (toutes les sessions), ainsi que d’un administrateur. Une révocation faite par l’API prend effet immédiatement, et le WebSocket ouvert avec une session révoquée est fermé. Le serveur garde en cache les recherches de session pendant 30 secondes : une révocation par la commande d’administration, qui est un processus distinct, prend donc effet dans les 30 secondes.
    - **Portée.** Un jeton appartient à un seul serveur et ouvre aussi son WebSocket. Ne l’envoyez jamais à un autre serveur.

    ## Limites de débit et autres freins

    Les limites s’appliquent à l’une de deux portées. **Client :** une adresse IPv4, ou un /64 IPv6 (en mode proxy, l’adresse provient de `X-Forwarded-For` envoyé par une adresse de `TRUSTED_PROXIES`) ; certaines limites comptent aussi chaque /48 IPv6 dans son ensemble, en plus de chacun de ses réseaux /64. **Joueur :** le compte connecté, quelle que soit son adresse ; une limite comptée par joueur sur un point de terminaison où la session est facultative se compte par client pour une requête sans jeton.

    Chaque limite est un seau à jetons qui contient `limit` requêtes et se remplit en continu au rythme de `limit / window` ; `retryAfter` est le temps qui reste jusqu’au prochain jeton. Les limites marquées *partagées* sont aussi comptées sur une fenêtre glissante de même durée, de sorte qu’aucune fenêtre ne contienne beaucoup plus de `limit` requêtes. Chaque limite est comptée pour l’ensemble du serveur. Quand l’une des limites d’un point de terminaison refuse une requête, les jetons que ses autres limites avaient pris pour cette requête sont restitués. Un refus donne 429 `rate_limited` avec `retryAfter` et `Retry-After`.

    - **Couche par adresse.** Toute requête (quels que soient le chemin et la méthode, points de terminaison de santé et passages en WebSocket compris) prend d’abord un jeton du budget de son client : `HTTP_RATE_PER_IP` (600) par minute avec une rafale d’une demi-minute, et `HTTP_RATE_PER_PREFIX` (4 × `HTTP_RATE_PER_IP`) pour un /48 IPv6. Un client peut aussi avoir au plus `IP_MAX_INFLIGHT` (32 × `WORKERS`) requêtes en cours (`retryAfter` 1 au-delà). Un client qui insiste malgré ses refus est bloqué : `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) refus en une minute le bloquent pendant 1 minute, puis 4, 16 et 60 minutes à chaque nouveau blocage dans les 6 heures. Un refus des limites `auth`, `auth_*` et `reauth` compte pour 5 ; les refus des limites comptées par joueur ne comptent jamais. Les adresses de `ABUSE_EXEMPT` échappent à cette couche, mais pas au budget du compte ni aux limites des points de terminaison.
    - **Budget du compte.** Toute requête qui porte un jeton de session valide est aussi décomptée de son compte : `USER_RATE_PER_MIN` (120) par minute pour l’ensemble des points de terminaison, quelle que soit l’adresse, avec une rafale d’une demi-minute.
    - **Limites des points de terminaison :**

    | Limite | Par défaut | Comptée par | Points de terminaison |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, partagée | client, et `AUTH_RATE_PER_PREFIX` (5 × `AUTH_RATE_PER_IP`) par /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) / heure, partagée | client, 3 fois plus par /48 IPv6 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / heure, partagée | client, 3 fois plus par /48 IPv6 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / heure, partagée | client, 3 fois plus par /48 IPv6 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 heures, partagée | client, 3 fois plus par /48 IPv6 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / heure, partagée | client, 3 fois plus par /48 IPv6 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | les valeurs de `auth`, dans un seau distinct, partagée | client et /48 IPv6 | les modifications du compte qui demandent le mot de passe (voir Réauthentification), et `POST /account/export` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 min, partagée | joueur | les mêmes points de terminaison que `reauth` |
    | `account` | 60 / min | joueur | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | joueur | `GET /account/games` |
    | `account_export` | 5 / heure, partagée | joueur | `POST /account/export` (vérifiée avant `reauth` ; chaque tentative compte) |
    | `sessions` | 60 / min | joueur | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | joueur (client sans jeton) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (un seul seau pour les quatre) |
    | `gif` | 30 / min | joueur | `GET /games/{id}/gif`, `POST /gif` (un seul seau pour les deux) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min et `GIF_USER_RENDERS_PER_HOUR` (30) / heure, partagées | joueur | les deux mêmes, seulement quand le GIF doit être fabriqué (pas pris dans le cache) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min et `GIF_IP_RENDERS_PER_HOUR` (120) / heure, partagées | client (tous ses comptes ensemble), 3 fois plus par /48 IPv6 | les mêmes, dans les mêmes conditions |
    | `reports` | 30 / heure | joueur | `POST /reports` |
    | `sso_start` | 30 / 10 min, partagée | client, et 90 par /48 IPv6 | `POST /auth/sso/google/start` |
    | `sso_finish` | 30 / min | client | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | client | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` et `GET /leaderboard` n’ont pas de limite propre : seulement la couche par adresse. Un point de terminaison qui a plusieurs limites les vérifie dans l’ordre donné dans sa description.

    Le serveur traite une requête dans cet ordre : la couche par adresse, puis les points de terminaison de santé ; la recherche du point de terminaison ; l’authentification ; le budget du compte, quand la requête porte une session ; les limites du point de terminaison ; le corps ; le point de terminaison lui-même (les points de terminaison GIF ne prennent leurs limites de rendu que lorsqu’ils ont un GIF à fabriquer). Ainsi, une requête refusée à cause de son jeton ne dépense aucun jeton du point de terminaison, alors qu’une requête dont le corps est invalide les dépense.

    Autres freins, appliqués par les points de terminaison eux-mêmes :

    - **Connexions échouées sur un même identifiant** (nom d’utilisateur ou e-mail) : à partir de `AUTH_FAILURES_PER_ACCOUNT` (5) échecs, chaque tentative doit attendre deux fois plus longtemps que la précédente (2 s, 4 s, et ainsi de suite, jusqu’à 15 minutes) : 429 `too_many_attempts` avec `retryAfter`. Le compteur s’efface après une heure sans échec.
    - **Seconds facteurs échoués à la connexion :** la même règle, à partir du 5e code erroné du compte. Une étape de connexion accepte au plus 5 codes erronés.
    - **Codes de second facteur d’un même compte :** au plus `AUTH_MFA_PER_ACCOUNT` (10) codes (codes de l’application ou codes de récupération, justes ou erronés) par période de 15 minutes, depuis n’importe quelle adresse, à la connexion et lors des réauthentifications ; au-delà, 429 `too_many_attempts` avant que le code soit vérifié, si bien qu’un code de récupération n’est pas consommé.
    - **Réauthentifications échouées d’un compte** (mot de passe ou code erroné) : la même règle (429 `too_many_attempts`), commune à tous les points de terminaison qui réauthentifient.
    - **E-mails :** un e-mail de confirmation ou de réinitialisation par adresse toutes les 5 minutes (la réponse reste la même) ; un avis "someone tried to use your address" (quelqu’un a essayé d’utiliser votre adresse) par adresse et par heure.
    - **Signalements :** `REPORTS_PER_DAY` (5) par joueur sur 24 heures : 429 `report_limit`.

    ## Preuve de travail

    `POST /auth/register` exige toujours une preuve de travail quand `POW_REGISTER_BITS` est supérieur à 0 (18 par défaut ; `GET /info` le donne dans `pow.register`). `POST /auth/login` et `POST /auth/sso/google/link` (un même type de défi pour les deux) n’en exigent une que pendant les 5 minutes qui suivent une vague de connexions échouées constatée par le serveur (`POW_LOGIN_TRIGGER_PER_MIN`, puis `POW_LOGIN_BITS`) ; le client l’apprend par la réponse.

    1. La requête sans preuve (ou avec une preuve refusée) reçoit 428 `pow_required` avec `reason` et un défi `pow` `{ challenge, bits, expiresAt }`.
    2. Trouvez un nonce : une chaîne décimale d’au plus 20 chiffres telle que `SHA-256(challenge + ":" + nonce)` commence par `bits` bits à zéro (bit de poids fort du premier octet en premier). 18 bits demandent environ 260 000 hachages en moyenne.
    3. Renvoyez la même requête avec `"pow": { "challenge": "...", "nonce": "123456" }` dans le corps.

    Un défi est valable 2 minutes et une seule fois, pour un seul point de terminaison et un seul réseau client (une adresse IPv4 ou un /64 IPv6). Il est signé : le serveur n’en conserve rien tant qu’il ne revient pas. `reason` indique pourquoi une preuve a été refusée : `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` ou `replayed`.

    ## Réauthentification

    Les modifications du compte redemandent le mot de passe et, quand la double authentification est activée, un second facteur :

    - le mot de passe seul : `POST /account/password` et `POST /account/mfa/totp/setup` ;
    - le mot de passe et un code de l’application, un code de récupération étant refusé : `POST /account/mfa/recovery-codes` ;
    - le mot de passe et un code de l’application ou un code de récupération : `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` et `POST /account/delete`.

    Dans les corps, `code` contient un code à 6 chiffres de l’application d’authentification et `recoveryCode` un code de récupération (`xxxx-xxxx-xx` ; la casse, les espaces et les tirets ne comptent pas). Un code de récupération peut aussi être envoyé dans `code` là où les codes de récupération sont acceptés. Chaque code ne sert qu’une fois : un code de l’application déjà utilisé est refusé jusqu’au pas de 30 secondes suivant, et un code de récupération disparaît une fois utilisé.

    | Statut | `error` | Quand |
    |---|---|---|
    | 403 | `invalid_password` | Mot de passe incorrect. |
    | 403 | `mfa_code_required` | La double authentification est activée et ni `code` ni `recoveryCode` n’a été envoyé. |
    | 403 | `invalid_code` | Code erroné ou déjà utilisé. |
    | 400 | `password_not_set` | Un compte uniquement Google n’a pas encore de mot de passe (« Mot de passe oublié ? » permet d’en définir un). |
    | 429 | `too_many_attempts` | Trop d’échecs, ou trop de codes essayés, sur ce compte. |
    | 503 / 429 | `server_busy` / `rate_limited` | La file de hachage des mots de passe est saturée. |

    Les mots de passe et codes erronés comptent dans le compteur d’échecs du compte et sont enregistrés comme événements de sécurité.

    ## Port des métriques

    En plus du port de l’API, le serveur répond en HTTP simple sur le port des métriques (`METRICS_PORT`, 9464, lié à `METRICS_BIND`, 127.0.0.1 par défaut ; gardez-le privé). Il ne fait pas partie de cette API : `GET /healthz` répond `ok` ; `GET /readyz` répond `ready` une fois le démarrage terminé (chaque partition a rejoué son journal et les écouteurs sont ouverts) et jusqu’au début de l’arrêt, sinon 503 `not ready` ; `GET /metrics` sert les métriques Prometheus et, si `METRICS_TOKEN` est défini, exige `Authorization: Bearer <token>` avec exactement ce jeton.
  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: Code source du serveur Scacelith et sa documentation de référence (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Le serveur officiel.
  - url: https://{host}:{port}/api/v1
    description: Tout serveur Scacelith, par exemple un serveur communautaire.
    variables:
      host:
        default: caissa.scacelith.com
        description: Le nom d’hôte public du serveur (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: Le port public de l’API (`PUBLIC_API_PORT`, sinon `API_PORT`).
tags:
  - name: server-info
    x-displayName: Informations sur le serveur
    description: Ce qu’un client doit savoir avant de se connecter à un compte ou d’ouvrir le WebSocket.
  - name: health
    x-displayName: Santé
    description: |-
      Vivacité et disponibilité du serveur, pour la supervision. Ces points de terminaison répondent sur le port de l’API avant l’authentification et toute limite de point de terminaison, mais, comme toute requête, ils prennent un jeton de la couche par adresse ; un hôte de supervision peut être inscrit dans `ABUSE_EXEMPT`. Les deux chemins fonctionnent pour chacun d’eux : à la racine du serveur et sous `/api/v1`.

      Le port des métriques a ses propres points de terminaison de santé, décrits dans l’introduction.
  - name: auth
    x-displayName: Inscription et connexion
    description: |-
      Création d’un compte, connexion avec un mot de passe (et un second facteur), e-mails de confirmation et de réinitialisation du mot de passe.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` et `POST /auth/sso/complete` répondent par une session `{ token, expiresAt, user }` (ou, pour les premiers, par une seconde étape).
  - name: google-sign-in
    x-displayName: Connexion Google
    description: |-
      Proposée quand `GET /info` indique `sso.google: true` ; sinon, chacun des points de terminaison ci-dessous répond 404 `sso_disabled`. Le jeu se connecte par le navigateur du système selon le flux des applications installées de la RFC 8252 (un client de type « Application de bureau ») : Google renvoie le navigateur vers un écouteur du jeu sur `127.0.0.1`, jamais vers ce serveur, et le jeu ne voit jamais d’identifiants Google.

      1. Le client écoute sur `127.0.0.1:0` (le système choisit le port) et crée une paire PKCE : un `codeVerifier` de 43 à 128 caractères `[A-Za-z0-9._~-]` et `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, qui compte 43 caractères, sans remplissage.
      2. `POST /auth/sso/google/start`, avec le défi PKCE et le port, renvoie l’URL Google et le `state` de la tentative. Le client vérifie l’URL (voir plus bas) et l’ouvre dans le navigateur.
      3. Google envoie le navigateur vers `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Le client n’accepte que le `state` de la réponse de démarrage, et n’envoie rien après une redirection `error=`.
      4. `POST /auth/sso/google/finish` avec l’identifiant de la tentative, le `codeVerifier`, le `state` et le `code` (et `iss` quand Google l’a envoyé).
      5. La réponse est une session, une étape de double authentification (continuez avec `POST /auth/login/mfa`), `needsUsername` pour un nouveau joueur (continuez avec `POST /auth/sso/complete`), ou `needsPassword` quand un compte doté d’un mot de passe utilise l’adresse (continuez avec `POST /auth/sso/google/link`).

      **L’étiquette d’origine.** L’URI de redirection porte une étiquette du serveur que le joueur a ajouté, afin que le jeu refuse une URL qu’un autre serveur a obtenue pour ses propres joueurs. L’origine est l’hôte en minuscules (un littéral IPv6 entre crochets), `:` et le port décimal de l’API, toujours écrit, 443 compris : le serveur prend `SERVER_PUBLIC_HOST` et son port public d’API, le jeu l’adresse à laquelle il est connecté. L’étiquette est formée des 22 premiers caractères du base64url (sans remplissage) de SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + origine). L’URI de redirection est `"http://127.0.0.1:" + port + "/oauth2/google/" + tag` : toujours le littéral IPv4, et le port de l’écouteur du jeu (1024 à 65535). Le serveur ne prend jamais d’URI, d’hôte ni de chemin venant du client.

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

      La connexion Google ne fonctionne donc que pour les joueurs qui ont ajouté le serveur exactement sous `SERVER_PUBLIC_HOST` et son port public d’API.

      **Ce que le jeu vérifie avant d’ouvrir le navigateur.** L’`authUrl` commence exactement par `https://accounts.google.com/o/oauth2/v2/auth?`, ne contient que de l’ASCII imprimable et fait moins de 4096 caractères, et sa chaîne de requête contient exactement un `response_type=code`, exactement un `redirect_uri` égal à l’URI que le jeu calcule à partir de son port et de son étiquette d’origine, exactement un `state` égal au `state` de la réponse, `code_challenge_method=S256` et un `code_challenge` de 43 caractères. Sinon, le jeu arrête son écouteur et n’ouvre rien. Il n’envoie `finish` et `link` qu’au serveur qui a répondu à `start`.

      **Le compte auquel mène la connexion Google :** le compte déjà associé à ce compte Google ; sinon, un compte actif ayant l’adresse confirmée par Google (s’il a un mot de passe, `finish` répond `needsPassword` et l’association n’est enregistrée qu’une fois son mot de passe validé, puis son second facteur s’il est activé ; s’il n’en a pas, 409 `sso_account_exists`) ; sinon, un nouveau compte (`needsUsername`). Un compte Google n’est jamais associé à un compte existant sur la seule foi de son adresse. L’adresse du compte reçoit un e-mail quand la connexion Google crée un compte et quand elle est ajoutée à un compte existant.
  - name: sessions
    x-displayName: Sessions
    description: Les appareils connectés du compte, et la déconnexion.
  - name: account
    x-displayName: Compte
    description: La vue du compte, ses préférences et son mot de passe.
  - name: two-step-verification
    x-displayName: Double authentification
    description: Applications d’authentification (TOTP, RFC 6238), avec SHA-1, 6 chiffres, des pas de 30 secondes et une tolérance d’un pas dans chaque sens, plus des codes de récupération à usage unique.
  - name: email-change
    x-displayName: Changement d’adresse e-mail
    description: Changer l’adresse du compte, avec confirmation par un lien envoyé à la nouvelle adresse.
  - name: data-export
    x-displayName: Export des données
    description: Tout ce que le serveur conserve sur le compte, en un seul fichier JSON.
  - name: account-deletion
    x-displayName: Suppression du compte
    description: Supprimer le compte définitivement.
  - name: game-history
    x-displayName: Historique des parties
    description: Les parties du joueur connecté, filtrées et paginées.
  - name: games
    x-displayName: Parties et PGN
    description: |-
      Les parties enregistrées, avec leurs coups et leurs pendules, et leurs fichiers PGN.

      **Codes des parties.** `status` : 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (seules les parties terminées sont enregistrées). `result` : `1-0`, `0-1`, `1/2-1/2`, ou `*` (annulée). `reason`, avec son nom (`termination`) et les mots qui terminent le texte des coups du fichier PGN (en anglais dans le fichier, suivis ci-dessous de leur traduction) ; les codes 7 et 21 sont des nulles (le joueur dont le temps s’est écoulé ou qui a quitté la partie faisait face à un adversaire incapable de mater), et le serveur ne termine jamais une partie avec les codes 4 et 13 (ils appartiennent à la liste commune des motifs) :

      | Code | `termination` | Mots dans le PGN |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (échec et mat) |
      | 2 | `Resignation` | Resignation (abandon) |
      | 3 | `Timeout` | Loss on time (perte au temps) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (second coup illégal, partie perdue) |
      | 5 | `Stalemate` | Stalemate (pat) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (position morte, matériel insuffisant) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (drapeau tombé, mais l’adversaire ne peut pas mater) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (quintuple répétition) |
      | 9 | `SeventyFiveMoves` | 75-move rule (règle des 75 coups) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (triple répétition réclamée) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (règle des 50 coups réclamée) |
      | 12 | `Agreement` | Draw by agreement (nulle par accord mutuel) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (second coup illégal, mais l’adversaire ne peut pas mater) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (partie quittée, déconnexion trop longue) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (partie quittée, mais l’adversaire ne peut pas mater) |
      | 22 | `Aborted` | Game aborted (partie annulée) |
      | 23 | `NoShow` | Aborted: first move not played in time (annulée : premier coup non joué à temps) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (forfait pour manquement au fair-play) |
      | 25 | `ServerAborted` | Aborted by the server (annulée par le serveur) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (annulée : les deux joueurs se sont déconnectés) |
  - name: gifs
    x-displayName: GIF animés
    description: |-
      Une partie sous forme de GIF animé, à conserver ou à partager : l’échiquier vu de dessus, une image par position, de la position de départ à la position finale, les noms et classements des joueurs au-dessus de l’échiquier, le dernier coup en dessous, et sur la dernière image le résultat et la façon dont la partie s’est terminée.

      **Coût, cache et quotas.** Un GIF est fabriqué sur un fil d’exécution de rendu dédié, jamais sur ceux qui font tourner les parties, et avec la priorité CPU la plus basse : `GIF_THREADS` fils (`WORKERS` par défaut), démarrés avec le premier GIF et arrêtés après une minute sans GIF. Jusqu’à `GIF_QUEUE_MAX` (4 × `WORKERS`) GIF attendent un fil, au plus `GIF_QUEUE_TIMEOUT_MS` (10 s) chacun ; un rendu peut durer `GIF_RENDER_TIMEOUT_MS` (30 s). Le serveur garde les GIF qu’il a fabriqués dans un cache de `GIF_CACHE_MB` Mo (32 × `WORKERS`), les moins récemment utilisés partant en premier ; un GIF pris dans le cache, ou en cours de fabrication pour une autre requête, ne coûte aucun rendu. La clé du cache tient compte de tout ce qui change l’image, noms compris.

      Chaque requête compte dans `gif` (30 par minute et par joueur pour les deux points de terminaison). Un GIF qui doit être fabriqué compte aussi dans les limites de rendu : par joueur, `GIF_USER_RENDERS_PER_MIN` (4) par minute et `GIF_USER_RENDERS_PER_HOUR` (30) par heure ; par client, tous ses comptes ensemble, `GIF_IP_RENDERS_PER_MIN` (12) par minute et `GIF_IP_RENDERS_PER_HOUR` (120) par heure, et 3 fois plus par /48 IPv6. Un client doit conserver le fichier qu’il a téléchargé plutôt que de le redemander, et attendre `retryAfter` après un 429 ou un 503.

      Tailles : `small` (cases de 32 px : 284 × 350 pixels), `medium` (48 px : 424 × 515), `large` (72 px : 628 × 762) ; sans coordonnées, 268 × 342, 400 × 503 et 600 × 748. La position de départ reste affichée au moins 1 s (ou le délai, s’il est plus long), la position finale 3 s, et le GIF tourne en boucle ; après la première image, seule la partie de l’image qui change est stockée. Une partie de 40 coups pèse environ 135, 205 et 325 Kio (petit, moyen, grand format), une partie de 150 coups 0,5, 0,8 et 1,2 Mio.
  - name: players
    x-displayName: Joueurs
    description: 'Profils publics et parties récentes. Uniquement des données publiques : jamais d’adresse e-mail, de session, de sanction ni de niveau d’intégrité. Un compte supprimé n’a pas de profil.'
  - name: leaderboard
    x-displayName: Classement général
    description: Les meilleurs joueurs de chaque catégorie officielle.
  - name: reports
    x-displayName: Signalements
    description: Signaler aux modérateurs l’adversaire d’une partie récente.
  - name: pages
    x-displayName: Pages HTML
    description: |-
      Pages destinées à un navigateur, ouvertes depuis les liens des e-mails. Leurs liens pointent vers `https://<SERVER_PUBLIC_HOST>` (avec `:<PUBLIC_API_PORT>` quand il ne vaut pas 443), hors de `/api/v1`.

      - Les pages n’exécutent aucun JavaScript et ne chargent aucune ressource externe. Elles sont servies avec `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - Un `GET` ne fait qu’afficher un bouton ou un formulaire, pour qu’un analyseur de courrier qui ouvre le lien ne le consomme pas. La modification a lieu sur `POST` : un formulaire envoyé en `application/x-www-form-urlencoded` (un corps JSON est aussi accepté), avec le jeton du lien dans un champ caché. Un champ fourni deux fois est refusé.
      - Les erreurs (limites de débit, champs invalides) sont aussi des pages HTML, intitulées "Request refused" (requête refusée) ou, à partir de 500, "Server error" (erreur du serveur). Seuls les échecs constatés avant que la page soit identifiée (une cible de requête trop longue ou mal formée, la couche par adresse) reçoivent une réponse en JSON.
x-tagGroups:
  - name: server
    x-displayName: Serveur
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Comptes
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Parties
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Joueurs et signalements
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Pages pour navigateur
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Obtenir le nom, les versions, les ports et les règles d’inscription du serveur
      description: |-
        Ce dont un client a besoin avant de se connecter à un compte ou d’ouvrir le WebSocket : le nom et l’identifiant du serveur, les versions du protocole WebSocket et l’emplacement du WebSocket, les règles d’inscription, les cadences officielles et les limites qu’un client peut vérifier avant d’envoyer un formulaire.

        **Limites :** seulement la couche par adresse.
      security: []
      responses:
        '200':
          description: La description du serveur.
          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` : la couche par adresse.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Créer un compte
      description: |-
        Crée un compte : une fois son adresse e-mail confirmée par le lien qui lui est envoyé, ou immédiatement sans confirmation par e-mail.

        **Avec confirmation par e-mail** (`REQUIRE_EMAIL_VERIFICATION`, par défaut) : 202 `verification_sent`. Aucun compte n’existe encore : l’inscription attend pendant 24 heures et réserve son nom d’utilisateur entre-temps. Un lien valable ces 24 heures est envoyé à l’adresse, au plus un par adresse toutes les 5 minutes (`POST /auth/verify-email/resend` en envoie un nouveau) ; le compte est créé, avec son adresse confirmée, quand le lien est utilisé (le bouton de la page `/verify-email`), et le joueur peut alors se connecter. D’ici là, une connexion avec ce nom d’utilisateur reçoit `invalid_credentials`, comme pour un compte inconnu, et le profil public n’existe pas. Une nouvelle inscription avec la même adresse remplace celle qui attend. La réponse est la même quand un autre compte utilise déjà l’adresse : aucun lien n’est alors envoyé, son titulaire reçoit un avis à la place (au plus un par heure), et le nom d’utilisateur est réservé de la même façon, si bien que rien ne révèle si l’adresse a un compte. Une inscription dont le lien n’a pas été utilisé est abandonnée au bout de 24 heures, et son nom d’utilisateur redevient libre.

        **Sans confirmation par e-mail** (`REQUIRE_EMAIL_VERIFICATION=false`) : 201 `ready`, le compte est créé immédiatement et peut se connecter.

        **Règles.** `username` : de `USERNAME_MIN` à `USERNAME_MAX` caractères (3 à 20), lettres, chiffres, `_` et `-`, commençant par une lettre ou un chiffre (`GET /info` les donne dans `limits`) ; les noms réservés (`admin`, `moderator`, `deleted`…) et certains préfixes sont refusés ; unique sans tenir compte de la casse. `email` : débarrassé des espaces de début et de fin et stocké en minuscules, en ASCII simple avec un domaine contenant un point. `password` : au moins `PASSWORD_MIN_LENGTH` (10) caractères et au plus 256 octets en UTF-8 ; il ne doit contenir ni le nom d’utilisateur ni la partie locale de l’e-mail, et ne doit pas être un mot de passe courant.

        **Erreurs**, vérifiées dans cet ordre : 403 `registration_closed` ; 400 `invalid_username` ; 400 `invalid_email` ; 400 `weak_password` ; 409 `username_taken` ; 428 `pow_required` ; les erreurs de la file de hachage des mots de passe ; 409 `email_taken` (seulement sans confirmation par e-mail).

        **Limites :** `auth`, puis `auth_register` (10 inscriptions par heure et par client). **Preuve de travail :** toujours, quand `POW_REGISTER_BITS` est supérieur à 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Première tentative, sans preuve de travail
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Renvoyée avec la preuve de travail
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: Le compte est créé et peut se connecter (pas de confirmation par e-mail sur ce serveur).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: L’inscription attend son lien de confirmation (ou l’adresse a déjà un compte ; la réponse ne le dit pas).
          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` (avec `field`), `invalid_json`, `invalid_username`, `invalid_email`, ou `weak_password` avec `reason` : `too_short`, `too_long`, `contains_username`, `contains_email` ou `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed` : les inscriptions sont fermées sur ce serveur (`GET /info` indique `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` : un compte porte ce nom d’utilisateur, ou une inscription en attente avec une autre adresse le réserve. `email_taken` : un autre compte utilise l’adresse (seulement sans confirmation par 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` : la limite `auth` ou `auth_register`, ou la file de hachage des mots de passe.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la file de hachage des mots de passe, ou la base de données est restée verrouillée), `timeout`.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Se connecter avec un mot de passe
      description: |-
        Connecte avec un nom d’utilisateur ou une adresse e-mail et un mot de passe. Deux réponses sont possibles, toutes deux avec le statut 200 : une session, ou, quand la double authentification est activée, une seconde étape à terminer dans les 5 minutes avec `POST /auth/login/mfa`.

        Un compte inconnu, un mot de passe erroné et un compte sans mot de passe reçoivent la même réponse, au bout du même temps : 401 `invalid_credentials`. Les vérifications du compte (`banned`, `email_unverified`) n’interviennent qu’après un mot de passe correct. À partir de `AUTH_FAILURES_PER_ACCOUNT` (5) échecs sur un même identifiant, chaque tentative doit attendre (429 `too_many_attempts`).

        **Limite :** `auth`. **Preuve de travail :** seulement pendant une vague de connexions échouées (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: Une session, ou la seconde étape d’une connexion avec double authentification.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Connecté
                  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: La double authentification est activée
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials` : compte inconnu, mot de passe erroné, ou compte sans mot de passe.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Seulement après un mot de passe correct : `banned`, avec `until` (instant en ms, `null` pour un bannissement définitif), ou `email_unverified` (un compte plus ancien dont l’adresse n’est pas encore confirmée).'
        '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` (le délai d’attente après échecs de cet identifiant), ou `rate_limited` (la limite `auth`, la file de hachage des mots de passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la file de hachage des mots de passe, ou la base de données est restée verrouillée), `timeout`.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Terminer une connexion avec un second facteur
      description: |-
        La seconde étape d’une connexion avec double authentification, après que `POST /auth/login` ou une connexion Google (`POST /auth/sso/google/finish` ou `POST /auth/sso/google/link`) a répondu `mfaRequired`. Envoyez `code` (un code à 6 chiffres de l’application, ou un code de récupération) ou `recoveryCode`. Un code de récupération utilisé ici est consommé.

        Une étape accepte au plus 5 codes erronés ; l’étape prend fin aussi quand le mot de passe est réinitialisé ou changé. Après l’étape du mot de passe d’une association Google, l’association n’est enregistrée que si le code est validé ici.

        **Limites :** `auth`, et au plus `AUTH_MFA_PER_ACCOUNT` (10) codes par période de 15 minutes pour le compte, depuis n’importe quelle adresse ; au-delà, le code n’est pas vérifié, si bien qu’un code de récupération n’est pas consommé.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Un code de l’application
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Un code de récupération
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Connecté.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request` : ni `code` ni `recoveryCode` n’a été envoyé, ou le corps enfreint le schéma ; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token` : l’étape a expiré, a déjà servi ou a pris fin après 5 codes erronés, ou le mot de passe a été réinitialisé ou changé depuis la première étape (reconnectez-vous). `invalid_code` : un code erroné ou déjà utilisé.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (avec `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked` : seulement après l’étape du mot de passe d’une association Google ; le compte Google a été associé à un autre compte entre-temps.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired` : seulement après l’étape du mot de passe d’une association Google ; le compte a changé entre-temps, recommencez depuis le jeu.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` : le délai d’attente après échecs du compte, ou ses `AUTH_MFA_PER_ACCOUNT` codes des 15 dernières minutes sont épuisés. `rate_limited` : la limite `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` avec `retryAfter: 1` : la base de données est restée verrouillée (après une étape d’association Google, rien n’a été associé : recommencez depuis le jeu). `timeout`.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Déconnecter cette session
      description: |-
        Révoque la session du jeton utilisé. Le WebSocket ouvert avec elle est fermé. Pas de corps (ou `{}`).

        **Limite :** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Déconnecté.
          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 corps autre que `{}` ; `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` : la limite `sessions` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de données est restée verrouillée), `timeout`.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Déconnecter toutes les sessions
      description: |-
        Révoque toutes les sessions du compte, y compris celle-ci. Les WebSockets ouverts avec elles sont fermés. Pas de corps (ou `{}`).

        **Limite :** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Toutes les sessions sont déconnectées.
          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 corps autre que `{}` ; `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` : la limite `sessions` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de données est restée verrouillée), `timeout`.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Renvoyer le lien de confirmation
      description: |-
        Renvoie le lien de confirmation de l’adresse e-mail. La réponse est 202 `accepted` quelle que soit l’adresse, pour ne jamais révéler si un compte ou une inscription l’utilise.

        Une requête n’agit qu’au plus une fois toutes les 5 minutes par adresse. Une inscription en attente avec cette adresse retrouve ses 24 heures, qu’un autre compte utilise l’adresse ou non, pour que son nom d’utilisateur reste réservé aussi longtemps dans les deux cas. Un lien n’est envoyé que pour cette inscription quand l’adresse n’a pas de compte (un nouveau lien, valable 24 heures, remplace le précédent), ou pour un compte actif non confirmé ayant cette adresse.

        **Limites :** `auth`, puis `auth_mail` (10 par heure et par client).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Acceptée (quelle que soit l’adresse).
          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` : la limite `auth` ou `auth_mail` (quelle que soit l’adresse).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` avec `retryAfter: 1` : la base de données est restée verrouillée pendant la recherche du compte (le renouvellement d’une inscription en attente se fait au mieux et ne fait jamais échouer la requête). `timeout`.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Envoyer un lien de réinitialisation du mot de passe
      description: |-
        Envoie un lien de réinitialisation du mot de passe, valable une heure, qui ouvre la page `/reset-password`. La réponse est 202 `accepted` quelle que soit l’adresse. Un lien n’est envoyé qu’à un compte actif, au plus une fois toutes les 5 minutes par adresse. Un compte uniquement Google définit ainsi son premier mot de passe.

        La récupération du mot de passe a les limites les plus strictes de l’API, toutes comptées pour l’ensemble du serveur : 3 requêtes par heure (`AUTH_FORGOT_PER_HOUR`) et 10 par 24 heures (`AUTH_FORGOT_PER_DAY`) par client (une adresse IPv4 ou un /64 IPv6), 3 fois plus par /48 IPv6, en plus de la limite `auth` (20 par 10 minutes) et d’un seul e-mail par adresse toutes les 5 minutes. Ni le 202 ni le 429 ne révèlent si un compte utilise l’adresse. La définition du nouveau mot de passe a sa propre limite (`auth_reset`).

        **Limites :** `auth`, `auth_forgot`, puis `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Acceptée (quelle que soit l’adresse).
          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` : la limite `auth`, `auth_forgot` ou `auth_forgot_day` (quelle que soit l’adresse).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` avec `retryAfter: 1` : la base de données est restée verrouillée. `timeout`.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Définir un nouveau mot de passe avec le jeton d’un lien de réinitialisation
      description: |-
        Définit un nouveau mot de passe avec le jeton d’un lien de réinitialisation (la page `/reset-password` fait de même). Le nouveau mot de passe suit les règles de l’inscription.

        La réinitialisation révoque toutes les sessions et annule un changement d’adresse e-mail en attente ; les autres liens de réinitialisation du compte cessent de fonctionner ; l’adresse est considérée comme confirmée (le lien l’a prouvé) ; le titulaire reçoit un e-mail. La double authentification n’est pas modifiée. Après une erreur de la file de hachage des mots de passe ou un 503, le lien reste valide.

        **Limites :** `auth`, puis `auth_reset` (10 par heure et par client, 30 par /48 IPv6, partagée avec la page : chaque tentative hache un mot de passe).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Le mot de passe est changé et toutes les sessions sont déconnectées.
          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` : le lien est invalide, déjà utilisé ou expiré, ou a été envoyé à une adresse que le compte n’a plus. `weak_password` (avec `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` : la limite `auth` ou `auth_reset`, ou la file de hachage des mots de passe.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` : la file de hachage des mots de passe, ou la base de données est restée verrouillée (`retryAfter: 1`, rien n’a changé). `timeout`.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Démarrer une connexion Google
      description: |-
        Démarre une tentative de connexion Google pour le défi PKCE et le port de l’écouteur du jeu sur `127.0.0.1`. La réponse donne l’URL Google à ouvrir dans le navigateur du système et le `state` de la tentative ; la tentative est valable 10 minutes.

        `authUrl` contient `client_id`, `redirect_uri` (construit à partir de `redirectPort` et de l’étiquette d’origine du serveur), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 du vérificateur propre au serveur pour Google) avec `code_challenge_method=S256`, et `prompt=select_account`. Le jeu la vérifie avant de l’ouvrir (voir la description de la section).

        **Limite :** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: La tentative.
          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` : la connexion Google n’est pas proposée sur ce serveur.'
        '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` : la limite `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de données est restée verrouillée), `timeout`.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Transmettre la réponse de Google au serveur
      description: |-
        Transmet au serveur le `code` et le `state` que Google a envoyés à l’écouteur du jeu, avec l’identifiant de la tentative et le vérificateur PKCE. Un identifiant de tentative ne sert à rien sans le vérificateur, et le code ne sert à rien sans le vérificateur PKCE propre au serveur et son secret client.

        Le serveur vérifie la tentative (inconnue, déjà utilisée ou expirée : 410), puis le vérificateur (un vérificateur erroné laisse la tentative utilisable), puis consomme la tentative, vérifie `state` et `iss`, échange le code auprès de Google et vérifie le jeton d’identité. La réponse (200) est l’une des suivantes :

        - `{ token, expiresAt, user }` : connecté au compte associé ;
        - `{ mfaRequired, mfaToken, expiresIn }` : continuez avec `POST /auth/login/mfa` ;
        - `{ needsUsername, ssoTicket, suggestedUsername }` : un nouveau compte ; continuez avec `POST /auth/sso/complete` dans les 10 minutes. `suggestedUsername` est tiré du nom Google ou de l’adresse, et vaut `""` quand rien ne convient ;
        - `{ needsPassword, linkTicket, username, expiresIn }` : le compte qui a cette adresse a un mot de passe ; continuez avec `POST /auth/sso/google/link`. Rien n’est encore associé.

        **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: Une session, une seconde étape, ou l’étape suivante d’une première connexion Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Connecté au compte associé
                  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: La double authentification est activée
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Un nouveau joueur
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Un compte doté d’un mot de passe utilise l’adresse
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier` : le vérificateur ne correspond pas au défi de la tentative (la tentative reste utilisable). `sso_email_unverified` : Google n’a pas confirmé l’adresse. `registration_closed`. `account_disabled`. Pour un compte associé seulement : `banned` (avec `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled` : la connexion Google n’est pas proposée sur ce serveur.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists` : un compte actif sans mot de passe utilise l’adresse (il n’est pas nommé).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired` : une tentative inconnue, déjà utilisée ou expirée.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited` : la limite `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de données est restée verrouillée), `timeout`.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Associer Google à un compte existant avec son mot de passe
      description: |-
        Après que `finish` a répondu `needsPassword` : associe Google au compte existant avec le mot de passe de ce compte, saisi dans le jeu. La réponse (200) est une session (l’association est enregistrée et le joueur connecté) ou, quand la double authentification est activée, `{ mfaRequired, mfaToken, expiresIn }` : continuez avec `POST /auth/login/mfa`, et l’association n’est enregistrée que lorsqu’un code y est validé.

        Un mot de passe erroné laisse le ticket utilisable, pour 5 essais en tout. Un essai est décompté juste avant la vérification du mot de passe : un 429 `too_many_attempts` ou un 428 `pow_required` n’en décompte aucun, alors qu’un refus de la file de hachage des mots de passe (503 `server_busy`, 429 `rate_limited`) en a décompté un et compte comme un échec du compte. Le délai d’attente après échecs utilise le même compteur que `POST /auth/login`.

        **Limite :** `auth` (avec son décompte par /48 IPv6). **Preuve de travail :** comme pour `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: Une session, ou la seconde étape de la connexion.
          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` : un mot de passe erroné ; le ticket reste valable, pour 5 essais en tout.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (avec `until`), seulement après un mot de passe correct.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled` : la connexion Google n’est pas proposée sur ce serveur.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked` : le compte Google a été associé à un autre compte entre-temps.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired` : un ticket inconnu, déjà utilisé ou expiré, le 5e mot de passe erroné, un compte dont le statut ou l’adresse a changé depuis `finish`, ou dont le statut, l’adresse, le mot de passe ou la double authentification a changé pendant l’enregistrement de l’association. Recommencez depuis le jeu.'
        '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` (le délai d’attente après échecs du compte), ou `rate_limited` (la limite `auth`, la file de hachage des mots de passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` : la file de hachage des mots de passe, ou la base de données est restée verrouillée (`retryAfter: 1`, rien n’a été associé). `timeout`.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Créer le compte d’une première connexion Google
      description: |-
        Après que `finish` a répondu `needsUsername` : crée le compte avec le nom d’utilisateur choisi (les règles de l’inscription s’appliquent) et le connecte. Le compte n’a pas de mot de passe : `hasPassword` vaut `false`, et « Mot de passe oublié ? » (`POST /auth/password/forgot`) lui en donne un.

        **Limite :** `auth` (avec son décompte par /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: Le compte est créé et connecté.
          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` ou `invalid_json`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled` : la connexion Google n’est pas proposée sur ce serveur.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (un compte porte ce nom d’utilisateur, ou une inscription en attente avec une autre adresse le réserve), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired` : un ticket inconnu, déjà utilisé ou expiré.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited` : la limite `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de données est restée verrouillée), `timeout`.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Lister les appareils connectés
      description: |-
        Les sessions actives du compte (appareils connectés), de la plus récemment utilisée à la plus ancienne. `current` marque la session qui fait la requête. `lastSeenAt` est mis à jour au plus toutes les 5 minutes ; `expiresAt` est la fin absolue, et la limite d’inactivité peut mettre fin à la session plus tôt.

        **Limite :** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Les sessions actives.
          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` : la limite `sessions` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de données est restée verrouillée), `timeout`.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Déconnecter un appareil
      description: |-
        Déconnecte une session du compte ; la session courante peut aussi être déconnectée. Le WebSocket ouvert avec elle est fermé. Pas de corps (ou `{}`).

        **Limite :** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: La session est déconnectée.
          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 corps autre que `{}`, ou un `id` dont l’encodage URL n’est pas valide ; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found` : aucune session active avec cet identifiant sur ce compte.'
        '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` : la limite `sessions` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de données est restée verrouillée), `timeout`.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Obtenir le compte, ses classements et ses sanctions en cours
      description: |-
        Le compte tel que son joueur le voit : la vue du compte (aussi le `user` de toute réponse de connexion), une fiche de classement par catégorie dans laquelle le joueur a disputé des parties classées, les sanctions en cours et le bannissement. Le niveau d’intégrité de l’anti-triche n’est jamais montré.

        **Limite :** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Le compte.
          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`, ou `invalid_token` (y compris quand le compte a été supprimé).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited` : la limite `account` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de données est restée verrouillée), `timeout`.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Accepter ou refuser les défis directs
      description: |-
        Indique si les autres joueurs peuvent défier ce joueur par son nom. Avec `none`, les défis directs sont refusés : le joueur qui lance le défi apprend que ce joueur n’est pas disponible. Pas de réauthentification.

        **Limite :** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Les préférences désormais en vigueur.
          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` : la limite `account` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de données est restée verrouillée), `timeout`.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Changer le mot de passe
      description: |-
        Change le mot de passe. Le mot de passe actuel est nécessaire, mais pas de second facteur, même avec la double authentification activée. Le nouveau mot de passe suit les règles de l’inscription (vérifiées après le mot de passe actuel).

        Le changement révoque toutes les autres sessions (celle-ci reste connectée), annule un changement d’adresse e-mail en attente, rend inopérants les liens de réinitialisation du mot de passe du compte et envoie un e-mail au titulaire.

        **Réauthentification :** le mot de passe seul. **Limites :** `reauth`, puis `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: Le mot de passe est changé.
          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 compte uniquement Google), `weak_password` (avec `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` : un mot de passe actuel erroné, ou une réinitialisation ou un changement de mot de passe survenu pendant la vérification de la requête.'
        '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` (réauthentifications échouées du compte), ou `rate_limited` (la limite `reauth` ou `reauth_user`, le budget du compte, la file de hachage des mots de passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la file de hachage des mots de passe, ou la base de données est restée verrouillée), `timeout`.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Commencer l’activation de la double authentification
      description: |-
        Enregistre un nouveau secret d’authentification en attente, qui remplace un éventuel secret en attente antérieur, et le renvoie pour l’application d’authentification (en texte et sous forme d’URI `otpauth://` à afficher en QR code). La double authentification n’est pas encore activée : `POST /account/mfa/totp/enable` l’active avec un code issu de ce secret.

        **Réauthentification :** le mot de passe seul. **Limites :** `reauth`, puis `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Le secret en attente.
          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 compte uniquement 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` (vérifié avant le mot de passe).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (réauthentifications échouées du compte), ou `rate_limited` (la limite `reauth` ou `reauth_user`, le budget du compte, la file de hachage des mots de passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la file de hachage des mots de passe, ou la base de données est restée verrouillée), `timeout`.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Terminer l’activation de la double authentification et obtenir les codes de récupération
      description: |-
        Active la double authentification avec un code issu du secret en attente (exactement 6 chiffres). Aucun mot de passe n’est demandé ici ; il a été fourni lors de la mise en place. La réponse contient 10 codes de récupération, affichés cette seule fois.

        **Limites :** `reauth`, puis `reauth_user` ; un code erroné compte parmi les échecs de réauthentification du compte.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: La double authentification est activée.
          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` ne compte pas exactement 6 chiffres, ou le corps enfreint le schéma ; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code` : un code erroné (vérifiez l’heure de l’appareil).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled` ; `mfa_setup_required` : aucun secret en attente (appelez d’abord `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` (réauthentifications échouées du compte), ou `rate_limited` (la limite `reauth` ou `reauth_user`, le budget du compte).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de données est restée verrouillée), `timeout`.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Désactiver la double authentification
      description: |-
        Désactive la double authentification. Le secret et les codes de récupération sont supprimés, et le titulaire reçoit un e-mail.

        **Réauthentification :** le mot de passe et un code de l’application ou un code de récupération (`code` ou `recoveryCode` est obligatoire). **Limites :** `reauth`, puis `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: La double authentification est désactivée.
          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 compte uniquement Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (ni `code` ni `recoveryCode`, vérifié avant le mot de passe), `invalid_password` ou `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` (réauthentifications échouées, ou trop de codes essayés sur ce compte), ou `rate_limited` (la limite `reauth` ou `reauth_user`, le budget du compte, la file de hachage des mots de passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la file de hachage des mots de passe, ou la base de données est restée verrouillée), `timeout`.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Remplacer les codes de récupération
      description: |-
        Remplace les codes de récupération par 10 nouveaux ; les anciens cessent de fonctionner.

        **Réauthentification :** le mot de passe et un code de l’application dans `code` (un code de récupération est refusé avec 403 `invalid_code`). **Limites :** `reauth`, puis `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: Les nouveaux codes de récupération, affichés cette seule fois.
          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 compte uniquement Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required` ou `invalid_code` (y compris pour un code de récupération).'
        '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` (réauthentifications échouées, ou trop de codes essayés sur ce compte), ou `rate_limited` (la limite `reauth` ou `reauth_user`, le budget du compte, la file de hachage des mots de passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la file de hachage des mots de passe, ou la base de données est restée verrouillée), `timeout`.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Changer l’adresse e-mail
      description: |-
        Change l’adresse e-mail du compte. `newEmail` est débarrassé des espaces de début et de fin et mis en minuscules, puis vérifié comme une adresse d’inscription.

        **Avec confirmation par e-mail** (par défaut) : 202 `verification_sent`. Un lien valable 24 heures est envoyé à la nouvelle adresse ; il ouvre `/confirm-email-change`, et l’adresse ne change que lorsque le joueur appuie sur le bouton de cette page. D’ici là, `GET /account/me` montre la nouvelle adresse dans `pendingEmail`. Une nouvelle demande remplace celle en attente ; un changement ou une réinitialisation du mot de passe l’annule. Au plus un lien est envoyé à une même nouvelle adresse toutes les 5 minutes, quel que soit le demandeur (une demande portant sur le changement déjà en attente conserve le lien envoyé plus tôt, qui reste valide) ; la réponse est la même. L’adresse actuelle reçoit un avis indiquant qu’un changement vers une adresse masquée (`a***@example.org`) a été demandé. La réponse et `pendingEmail` sont les mêmes quand un autre compte utilise déjà la nouvelle adresse : aucun lien n’est alors envoyé, si bien que ce changement n’aboutit jamais, et le titulaire de cette adresse reçoit un avis à la place (au plus un par heure).

        Quand le lien est confirmé, l’adresse change et est considérée comme confirmée, les appareils restent connectés, les liens envoyés plus tôt (confirmation, réinitialisation du mot de passe, autres changements) cessent de fonctionner, et l’ancienne adresse en est informée, la nouvelle étant masquée.

        **Sans confirmation par e-mail** (`REQUIRE_EMAIL_VERIFICATION=false`) : l’adresse change immédiatement (200 `email_changed`), et l’ancienne adresse en est informée. Si un autre compte utilise l’adresse, la réponse est 409 `email_taken`, et le titulaire de cette adresse reçoit l’avis.

        **Réauthentification :** le mot de passe et, avec la double authentification, un code de l’application ou un code de récupération. `invalid_email` et `same_email` sont vérifiés avant le mot de passe : ils ne comptent donc aucun échec. **Limites :** `reauth`, puis `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’adresse a changé immédiatement (pas de confirmation par e-mail sur ce serveur).
          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: La nouvelle adresse, telle qu’elle est enregistrée.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Un lien de confirmation a été envoyé à la nouvelle adresse (ou l’adresse appartient à un autre compte ; la réponse ne le dit pas).
          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’adresse actuelle du compte), `password_not_set` (un compte uniquement Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (y compris quand un changement ou une réinitialisation du mot de passe a devancé la requête : aucun lien n’est envoyé), `mfa_code_required` ou `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken` : seulement sans confirmation par 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` (réauthentifications échouées, ou trop de codes essayés sur ce compte), ou `rate_limited` (la limite `reauth` ou `reauth_user`, le budget du compte, la file de hachage des mots de passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` : la file de hachage des mots de passe, ou la base de données est restée verrouillée (`retryAfter: 1` : rien n’a changé, et la même requête peut être renvoyée). `timeout`.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Télécharger les données du compte
      description: |-
        Tout ce que le serveur conserve sur le compte, en un seul fichier JSON à enregistrer (`format` `scacelith-account-export`, `version` 1). L’export enregistre un événement de sécurité (`account_exported`). Le tableau `notes` du document indique au joueur, en anglais courant, ce qui n’y figure pas.

        **Jamais dans l’export :** l’empreinte du mot de passe, le secret de la double authentification et les codes de récupération ; aucun jeton de session ou de lien, ni son empreinte ; les données de l’anti-triche (niveau et score d’intégrité, anomalies, analyse des parties, poids d’un signalement) ; les signalements faits par d’autres joueurs au sujet du joueur ; l’identité des modérateurs ; les données privées des autres joueurs (les adversaires apparaissent sous leur nom public et leur classement, rien n’indique si un autre joueur a été sanctionné, et aucune adresse IP susceptible d’appartenir à une autre personne n’est incluse).

        **Réauthentification :** le mot de passe et, avec la double authentification, un code de l’application ou un code de récupération. **Limites :** `account_export` (5 par heure et par joueur, pour l’ensemble du serveur ; chaque tentative compte, y compris celles qui échouent ; vérifiée en premier), puis `reauth` et `reauth_user`. **Délai maximal :** 60 secondes.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Le document d’export, en pièce jointe.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Dans le nom d’utilisateur, les caractères autres que les lettres, les chiffres, `_`, `.` et `-` deviennent `_`.'
              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 compte uniquement Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required` ou `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` (réauthentifications échouées, ou trop de codes essayés sur ce compte), ou `rate_limited` (la limite `account_export`, `reauth` ou `reauth_user`, le budget du compte, la file de hachage des mots de passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de données est restée verrouillée, avec un en-tête `Retry-After: 1`), `server_busy` (la file de hachage des mots de passe), `timeout` (60 secondes).'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Supprimer le compte
      description: |-
        Supprime le compte ; cette action est irréversible.

        - Toutes les sessions sont révoquées immédiatement : le jeton reçoit désormais 401 `invalid_token`.
        - Le nom d’utilisateur devient `deleted#<id>`, dans le compte et dans chaque partie enregistrée.
        - Sont effacés : l’adresse e-mail, l’empreinte du mot de passe, le secret de double authentification et les codes de récupération, les sessions et les jetons de lien, l’association Google, la fiche d’intégrité de l’anti-triche, et les adresses IP enregistrées avec les événements de sécurité.
        - Les classements et les parties sont conservés. Les parties restent lisibles sous le nom anonyme, et `GET /players/{username}` répond 404 pour l’ancien nom.

        **Réauthentification :** le mot de passe et, avec la double authentification, un code de l’application ou un code de récupération. **Limites :** `reauth`, puis `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: Le compte est supprimé.
          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 compte uniquement Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required` ou `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` (réauthentifications échouées, ou trop de codes essayés sur ce compte), ou `rate_limited` (la limite `reauth` ou `reauth_user`, le budget du compte, la file de hachage des mots de passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la file de hachage des mots de passe, ou la base de données est restée verrouillée), `timeout`.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Lister les parties du joueur, filtrées et paginées
      description: |-
        Les parties du joueur connecté, de la plus récente à la plus ancienne, filtrées et paginées, avec le nombre de parties qui correspondent au filtre. Tous les paramètres de requête sont facultatifs, et une valeur vide compte comme absente.

        Pagination : passez le `next` de la page précédente comme `before` ; `next` vaut `null` sur la dernière page. `total` compte les parties qui correspondent au filtre, toutes pages confondues.

        **Limite :** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: Un identifiant de catégorie officielle (`3+2`, ou `3%2B2`), ou `custom` pour toutes les parties jouées à une autre cadence.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Seulement les parties classées (`true`) ou amicales (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Seulement les parties gagnées, perdues ou nulles, du point de vue du joueur. Les parties annulées n’apparaissent que sans ce filtre.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Une page de l’historique.
          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` n’est pas un identifiant de partie), `invalid_limit`, ou `invalid_filter` (`category`, `rated` ou `result`), chacun avec `field` qui nomme le paramètre.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited` : la limite `account_games` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de données est restée verrouillée ; `retryAfter: 1` dans le corps, pas d’en-tête `Retry-After`), `server_busy` (la recherche de session a trouvé la base de données verrouillée), `timeout`.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Obtenir une partie enregistrée avec ses coups et ses pendules
      description: |-
        Une partie enregistrée, avec ses coups et ses pendules. Sans jeton, ou avec le jeton d’un joueur qui n’a pas joué la partie, la réponse est la réponse publique. Quand le joueur du jeton a joué la partie, la réponse ajoute `you` et `reportable`.

        **Limite :** `public_read` (par joueur avec un jeton, par client sans jeton).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: La partie enregistrée.
          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` : pas un entier positif d’au plus 16 chiffres (inférieur à 2^53) ; `invalid_request` : encodage URL non valide.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token` : un jeton a été envoyé et il n’est pas valide.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found` : partie inexistante.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited` : la limite `public_read` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de données est restée verrouillée ; `retryAfter: 1` dans le corps, pas d’en-tête `Retry-After`), `server_busy` (la recherche de session a trouvé la base de données verrouillée), `timeout`.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Télécharger une partie sous forme de fichier PGN
      description: |-
        La même partie sous forme de fichier PGN : une seule partie, avec des fins de ligne `\n` et le texte des coups en lignes de moins de 80 colonnes. La réponse est la même avec ou sans jeton.

        En-têtes PGN, dans cet ordre : `Event` (`<SERVER_NAME> rated <category>` ou `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (la date de début, en UTC), `Round`, `White`, `Black`, `Result` (`*` pour une partie annulée), `UTCDate` et `UTCTime` (le début), `WhiteElo` et `BlackElo` (les classements au début, ou `-`), `WhiteRatingDiff` et `BlackRatingDiff` (les variations, par exemple `+10` et `-10`, dans toute partie classée, `+0` quand les règles de classement laissent le classement inchangé ; une partie amicale, à cadence libre ou annulée n’en a pas), `TimeControl` (en secondes), `Termination` (une valeur standard du PGN : `normal` ; `time forfeit`, une chute du drapeau, y compris quand elle aboutit à une nulle ; `abandoned` ; `rules infraction`, un second coup illégal ou un forfait pour manquement au fair-play ; `unterminated`, une partie annulée), `PlyCount`, `ScacelithGameId` (l’identifiant décimal).

        Chaque coup porte `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}` : la pendule du joueur qui a joué, après le coup, et le temps décompté pour ce coup, en dixièmes de seconde (tronqués). Une valeur est omise quand la partie enregistrée ne l’a pas. Après le dernier coup viennent le motif de fin en toutes lettres, puis le résultat.

        **Limite :** `public_read` (par joueur avec un jeton, par client sans jeton).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Le fichier PGN.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: 'Valeur : `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: 'Le texte PGN, en 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` : pas un entier positif d’au plus 16 chiffres (inférieur à 2^53) ; `invalid_request` : encodage URL non valide.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token` : un jeton a été envoyé et il n’est pas valide.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found` : partie inexistante.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited` : la limite `public_read` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error` : entre autres, les coups enregistrés ne peuvent pas être rejoués jusqu’à la fin enregistrée (le serveur le journalise).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de données est restée verrouillée ; `retryAfter: 1` dans le corps, pas d’en-tête `Retry-After`), `server_busy` (la recherche de session a trouvé la base de données verrouillée), `timeout`.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Télécharger une partie de ce serveur sous forme de GIF animé
      description: |-
        La partie sous forme de GIF animé. Les noms et les classements sont ceux de la partie enregistrée (les classements au début, un compte supprimé sous la forme `deleted#<id>`), de même que le résultat et la fin de partie (`Resignation`, `Loss on time`…).

        Les vérifications se font dans cet ordre : `gif_disabled`, les options de l’image (`size`, `orientation`, `delay`, `coords`), l’identifiant de la partie, la partie, sa longueur.

        **Session obligatoire :** les quotas se comptent par compte. **Limites :** `gif` sur chaque requête ; `gif_user_min`, `gif_user_hour`, `gif_ip_min` et `gif_ip_hour` seulement quand le GIF doit être fabriqué (voir la description de la section). **Délai maximal :** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 secondes (45 secondes par défaut).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: La taille de l’image, `small` (cases de 32 px), `medium` (48 px) ou `large` (72 px).
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Le camp placé en bas de l’échiquier.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Millisecondes par coup (1 à 6 chiffres décimaux).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Indique si les lettres des colonnes et les numéros des rangées sont dessinés autour de l’échiquier (`1`) ou non (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` avec `field` (`size`, `orientation`, `delay` ou `coords`) : une valeur hors des valeurs permises. `invalid_game_id`. `invalid_request` : encodage URL non valide.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found` : partie inexistante. `gif_disabled` : le serveur a désactivé les GIF (`GIF_ENABLED=false` ; le jeton `gif` est restitué).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited` : la limite `gif`, une limite de rendu, ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed` : le GIF n’a pas pu être fabriqué (le serveur journalise la cause). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` avec `retryAfter` (3 à 10 secondes) et `Retry-After` : la file de rendu est pleine, ou le GIF a attendu `GIF_QUEUE_TIMEOUT_MS` (10 secondes) un fil d’exécution libre ; tous les jetons de limite pris par la requête sont restitués. `busy` : la base de données est restée verrouillée (`retryAfter: 1` dans le corps seulement). `timeout`.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Fabriquer un GIF animé de n’importe quelle partie envoyée en PGN
      description: |-
        La même image que `GET /games/{id}/gif` pour n’importe quelle partie envoyée sous forme de texte PGN : une partie sauvegardée par le jeu, un export d’un autre site, une partie saisie à la main. Seule la première partie du texte est utilisée.

        - Le lecteur PGN accepte ce qu’accepte le lecteur du jeu lui-même : tout PGN écrit par ce serveur et les exports habituels des autres sites (les commentaires, les variantes, les NAG et les annotations de pendule sont ignorés ; les numéros de coup et la notation SAN sont lus avec tolérance ; un en-tête `FEN` donne la position de départ, sauf si `SetUp` vaut `"0"`).
        - Les noms et les classements proviennent des en-têtes `White`, `Black`, `WhiteElo` et `BlackElo`. Les lettres accentuées perdent leurs accents, les autres caractères hors de l’ASCII imprimable deviennent `?`, et les noms longs sont tronqués (48 caractères). Le résultat provient de l’en-tête `Result`, sinon de la fin du texte des coups. L’en-tête `Termination` est affiché sauf s’il vaut `normal` : la position finale dit alors d’elle-même comment la partie s’est terminée (mat, pat).
        - Ce point de terminaison vérifie lui-même son corps : un champ inconnu, ou un `pgn` absent ou qui n’est pas une chaîne, donne 400 `invalid_request` avec `field` ; une option erronée donne 400 `invalid_option` (`delayMs` doit être un nombre JSON, `coords` un booléen ; `null` est refusé).

        **Session obligatoire :** les quotas se comptent par compte. **Limites :** comme pour `GET /games/{id}/gif`. **Taille maximale du corps :** 135 168 octets, quel que soit `HTTP_BODY_LIMIT` (le PGN sous forme de chaîne JSON, échappements compris). **Délai maximal :** 45 secondes par défaut.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Une courte partie saisie à la main, sans coordonnées
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Un fichier PGN avec toutes les options
                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` avec `field` (un champ inconnu, ou `pgn` absent ou qui n’est pas une chaîne ; sans `field` quand le corps n’est pas un objet) ; `invalid_json` ; `invalid_option` avec `field` (`size`, `orientation`, `delayMs` ou `coords`) ; `invalid_pgn` avec `line` et `column` (à partir de 1, colonnes comptées en caractères) et le `message` du lecteur : un coup illégal ou ambigu, un en-tête mal formé, une variante inconnue, plus de 65 536 octets.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled` : le serveur a désactivé les GIF (`GIF_ENABLED=false` ; le jeton `gif` est restitué).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large` : un corps de plus de 135 168 octets.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited` : la limite `gif`, une limite de rendu, ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed` : le GIF n’a pas pu être fabriqué (le serveur journalise la cause). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` avec `retryAfter` (3 à 10 secondes) et `Retry-After` : la file de rendu est pleine, ou le GIF a attendu trop longtemps un fil d’exécution libre ; tous les jetons de limite pris par la requête sont restitués. `timeout`.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Obtenir le profil public d’un joueur
      description: |-
        Le profil public d’un joueur : ses classements dans les catégories officielles (dans l’ordre du serveur) et ses nombres de parties. `games.total` compte toutes les parties enregistrées, amicales et annulées comprises ; `games.rated`, `wins`, `draws` et `losses` sont additionnés sur les fiches de classement, donc sur les seules parties classées. La réponse est la même avec ou sans jeton.

        **Limite :** `public_read` (par joueur avec un jeton, par client sans jeton).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Le profil.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerProfile'
              example:
                username: alice
                createdAt: 1790882839743
                ratings:
                  - category: '3+2'
                    rating: 1510
                    provisional: true
                    games: 2
                    wins: 1
                    draws: 1
                    losses: 0
                    peak: 1510
                games:
                  total: 3
                  rated: 2
                  wins: 1
                  draws: 1
                  losses: 0
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username` : pas de 2 à 24 caractères parmi `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token` : un jeton a été envoyé et il n’est pas valide.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found` : joueur inexistant, ou compte supprimé.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited` : la limite `public_read` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de données est restée verrouillée ; `retryAfter: 1` dans le corps, pas d’en-tête `Retry-After`), `server_busy` (la recherche de session a trouvé la base de données verrouillée), `timeout`.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Lister les parties récentes d’un joueur
      description: |-
        Les parties récentes du joueur, de la plus récente à la plus ancienne, paginées comme l’historique mais sans filtres ni total. `color` est le camp de ce joueur. `next` est l’identifiant de la dernière partie chaque fois que la page est pleine : la page suivante peut donc être vide. Le joueur est recherché en premier : un joueur inconnu donne un 404, quels que soient les paramètres de la requête.

        **Limite :** `public_read` (par joueur avec un jeton, par client sans jeton).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Une page des parties du joueur.
          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` n’est pas un identifiant de partie), `invalid_limit` (ces deux derniers sans `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token` : un jeton a été envoyé et il n’est pas valide.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found` : joueur inexistant, ou compte supprimé.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited` : la limite `public_read` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de données est restée verrouillée ; `retryAfter: 1` dans le corps, pas d’en-tête `Retry-After`), `server_busy` (la recherche de session a trouvé la base de données verrouillée), `timeout`.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Obtenir les meilleurs joueurs d’une catégorie
      description: |-
        Les 100 meilleures fiches de classement d’une catégorie officielle ayant au moins `minGames` (`PROVISIONAL_GAMES`) parties comptées, hors comptes supprimés et tricheurs avérés. Le serveur recalcule chaque tableau au plus toutes les 10 secondes ; `updatedAt` indique quand.

        **Limites :** seulement la couche par adresse.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Un identifiant de catégorie officielle (`3+2`, ou `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Le nombre de joueurs, de 1 à 100. Un nombre plus grand, de 3 chiffres au plus, compte pour 100 ; une valeur vide compte comme absente.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: Le tableau.
          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` (absente, ou pas une catégorie officielle), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited` : la couche par adresse.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de données est restée verrouillée ; `retryAfter: 1` dans le corps, pas d’en-tête `Retry-After`), `timeout`.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Signaler l’adversaire d’une partie récente
      description: |-
        Signale l’adversaire de l’une des parties du joueur terminée dans les 7 derniers jours. Un signalement ne modifie jamais à lui seul un classement, une sanction ou un niveau d’intégrité. Il relève la priorité d’examen que voient les modérateurs et, sauf pour `abuse`, il demande l’analyse de la partie par le moteur.

        Un signalement du même adversaire pour la même partie reçoit la même réponse et ne change rien ; la réponse ne révèle jamais rien sur le compte signalé. `GET /games/{id}` indique à l’avance aux joueurs de la partie si un signalement serait accepté (`reportable`).

        Ce point de terminaison vérifie lui-même son corps, dans l’ordre `gameId`, `reported`, `category`, `comment` : un échec donne 400 `invalid_request` sans `field`. Les champs qu’il ne connaît pas sont ignorés.

        **Limites :** `reports` (par joueur), puis `REPORTS_PER_DAY` (5) signalements par joueur sur 24 heures (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: Le signalement est reçu (ou avait déjà été déposé).
          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` (sans `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed` : ce joueur n’est pas l’adversaire de l’auteur du signalement dans une partie terminée dans les 7 derniers jours.'
        '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`, avec un en-tête `Retry-After`) : `REPORTS_PER_DAY` signalements au cours des dernières 24 heures. `rate_limited` : la limite `reports` ou le budget du compte.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la recherche de session a trouvé la base de données verrouillée), `timeout`.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Le serveur officiel (les pages se trouvent à la racine, hors de `/api/v1`).
      - url: https://{host}:{port}
        description: Tout serveur Scacelith (les pages se trouvent à la racine, hors de `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Le nom d’hôte public du serveur (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Le port public de l’API (`PUBLIC_API_PORT`, sinon `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Afficher la page de confirmation de l’adresse e-mail
      description: |-
        La page d’un lien de confirmation d’adresse e-mail (inscription, ou e-mail de confirmation renvoyé). Elle n’affiche qu’un bouton "Confirm my e-mail address" (confirmer mon adresse e-mail), pour qu’un analyseur de courrier qui ouvre le lien ne le consomme pas ; le bouton envoie le formulaire à `POST /verify-email`.

        **Limite :** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: La page avec son bouton de confirmation.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Le lien est invalide ou expiré (une page HTML).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: La limite `page` (une page HTML), ou la couche par adresse (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Confirmer l’adresse e-mail
      description: |-
        Le formulaire de la page de confirmation. Il confirme l’adresse ; pour une nouvelle inscription, il crée alors le compte, et le joueur peut se connecter.

        **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’adresse est confirmée (et le compte d’une nouvelle inscription créé).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Le lien est invalide, déjà utilisé ou expiré, ou le formulaire est invalide (une page HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Le lien d’une nouvelle inscription dont le nom d’utilisateur ou l’adresse a été pris entre-temps par un autre compte ; aucun compte n’est créé (une page HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: La limite `auth` (une page HTML), ou la couche par adresse (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'La base de données est restée verrouillée (`Retry-After: 1`) : rien n’a changé et le lien fonctionne toujours. Également en cas de dépassement du délai de traitement.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Le serveur officiel (les pages se trouvent à la racine, hors de `/api/v1`).
      - url: https://{host}:{port}
        description: Tout serveur Scacelith (les pages se trouvent à la racine, hors de `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Le nom d’hôte public du serveur (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Le port public de l’API (`PUBLIC_API_PORT`, sinon `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Afficher le formulaire de réinitialisation du mot de passe
      description: |-
        La page d’un lien de réinitialisation du mot de passe : le formulaire du nouveau mot de passe (le mot de passe deux fois). Le formulaire est envoyé à `POST /reset-password`.

        **Limite :** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Le formulaire du nouveau mot de passe.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Le lien est invalide (y compris quand il a été envoyé à une adresse que le compte n’a plus).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: La limite `page` (une page HTML), ou la couche par adresse (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Définir un nouveau mot de passe depuis le formulaire de réinitialisation
      description: |-
        Le formulaire de la page de réinitialisation ; il fait ce que fait `POST /auth/password/reset` : tous les appareils sont déconnectés, un changement d’adresse e-mail en attente est annulé, les autres liens de réinitialisation cessent de fonctionner, l’adresse est considérée comme confirmée et le titulaire reçoit un e-mail.

        Le lien est vérifié d’abord, puis l’égalité des deux mots de passe, puis les règles des mots de passe. Quand le serveur est occupé, le formulaire revient avec `Retry-After` et le lien reste valide.

        **Limites :** `auth`, puis `auth_reset` (partagée avec `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: Le mot de passe est changé, et tous les appareils sont déconnectés.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Le formulaire de nouveau, avec l’erreur (mots de passe différents, mot de passe trop faible), le lien est invalide, ou le formulaire est invalide (une page 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: La limite `auth` ou `auth_reset` (une page HTML), le formulaire de nouveau avec `Retry-After` quand ce client a trop de hachages de mot de passe en attente (le lien reste valide), ou la couche par adresse (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: Le formulaire de nouveau avec `Retry-After` quand le serveur est occupé (la file de hachage des mots de passe, ou la base de données est restée verrouillée) ; le lien reste valide. Également en cas de dépassement du délai de traitement.
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Le serveur officiel (les pages se trouvent à la racine, hors de `/api/v1`).
      - url: https://{host}:{port}
        description: Tout serveur Scacelith (les pages se trouvent à la racine, hors de `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Le nom d’hôte public du serveur (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Le port public de l’API (`PUBLIC_API_PORT`, sinon `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Afficher la page de confirmation du changement d’adresse e-mail
      description: |-
        La page d’un lien de changement d’adresse e-mail : elle montre la nouvelle adresse et le nom du compte, avec un bouton "Use this e-mail address" (utiliser cette adresse e-mail) qui envoie le formulaire à `POST /confirm-email-change`.

        **Limite :** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: La page avec la nouvelle adresse et son bouton de confirmation.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Le lien est invalide ou expiré (y compris quand l’adresse du compte a changé depuis la demande).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: La limite `page` (une page HTML), ou la couche par adresse (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Confirmer la nouvelle adresse e-mail
      description: |-
        Le formulaire de la page de changement d’adresse e-mail. L’adresse change et est considérée comme confirmée ; les appareils restent connectés ; les liens envoyés plus tôt (confirmation, réinitialisation du mot de passe, autres changements) cessent de fonctionner ; l’ancienne adresse en est informée, la nouvelle étant masquée.

        **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’adresse est changée.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Le lien est invalide, déjà utilisé ou expiré, ou le formulaire est invalide (une page HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Un autre compte a pris l’adresse entre-temps (une page HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: La limite `auth` (une page HTML), ou la couche par adresse (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'La base de données est restée verrouillée (`Retry-After: 1`) : rien n’a changé et le lien fonctionne toujours. Également en cas de dépassement du délai de traitement.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Le serveur officiel, à la racine.
      - url: https://caissa.scacelith.com/api/v1
        description: Le serveur officiel, sous `/api/v1`.
      - url: https://{host}:{port}
        description: Tout serveur Scacelith, à la racine.
        variables:
          host:
            default: caissa.scacelith.com
            description: Le nom d’hôte public du serveur (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Le port public de l’API (`PUBLIC_API_PORT`, sinon `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Tout serveur Scacelith, sous `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Le nom d’hôte public du serveur (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Le port public de l’API (`PUBLIC_API_PORT`, sinon `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Vérifier que le processus tourne
      description: |-
        Répond 200 `ok` tant que le processus tourne. `HEAD` fonctionne aussi. Toute autre méthode reçoit 405 `method_not_allowed` avec `Allow: GET, HEAD` (`OPTIONS` compris).

        **Limites :** seulement la couche par adresse.
      security: []
      responses:
        '200':
          description: Le processus tourne.
          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` : la couche par adresse.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Le serveur officiel, à la racine.
      - url: https://caissa.scacelith.com/api/v1
        description: Le serveur officiel, sous `/api/v1`.
      - url: https://{host}:{port}
        description: Tout serveur Scacelith, à la racine.
        variables:
          host:
            default: caissa.scacelith.com
            description: Le nom d’hôte public du serveur (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Le port public de l’API (`PUBLIC_API_PORT`, sinon `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Tout serveur Scacelith, sous `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Le nom d’hôte public du serveur (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Le port public de l’API (`PUBLIC_API_PORT`, sinon `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Vérifier que le serveur accepte des joueurs
      description: |-
        Répond 200 `ready` quand le serveur accepte des joueurs, et 503 `not_ready` pendant son démarrage ou son arrêt. `HEAD` fonctionne aussi. Toute autre méthode reçoit 405 `method_not_allowed` avec `Allow: GET, HEAD` (`OPTIONS` compris).

        **Limites :** seulement la couche par adresse.
      security: []
      responses:
        '200':
          description: Le serveur accepte des joueurs.
          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` : la couche par adresse.'
        '503':
          description: Le serveur démarre ou s’arrête.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: not_ready
              example:
                status: not_ready
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sct_[A-Za-z0-9_-]{43}
      description: |-
        Un jeton de session (`sct_` suivi de 43 caractères base64url) issu d’une réponse de connexion, dans l’en-tête `Authorization` : `Authorization: Bearer <token>`. Le préfixe est exactement `Bearer` (sensible à la casse) suivi d’une espace ; une valeur sans ce préfixe, ou un jeton qui ne compte pas de 1 à 512 caractères ASCII imprimables, reçoit 401 `invalid_token` comme tout autre jeton invalide. Un en-tête vide compte comme une absence d’en-tête.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: L’identifiant de la partie, un entier décimal positif d’au plus 16 chiffres sans zéros initiaux, inférieur à 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: L’identifiant de la session, tel que `GET /auth/sessions` le donne, écrit sans zéros initiaux (`03` n’est pas la session 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Le nom d’utilisateur du joueur, sans tenir compte de la casse. Le serveur accepte tout nom de 2 à 24 caractères parmi `[A-Za-z0-9_.-]` (la règle la plus large qu’il ait jamais admise).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Un identifiant de partie ; seules les parties plus anciennes sont listées. Passez le `next` de la page précédente. Une valeur vide compte comme absente.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: La taille de la page, de 1 à 50. Un nombre plus grand, de 3 chiffres au plus, compte pour 50 ; une valeur vide compte comme absente.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Le jeton du lien de l’e-mail (43 caractères base64url). Un jeton absent ou erroné affiche la page "link invalid or expired" (lien invalide ou expiré).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Toute réponse du serveur interdit la mise en cache.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Secondes à attendre avant de réessayer ; la même valeur que `retryAfter` dans le corps.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Le défi Bearer, avec `error="invalid_token"` pour un jeton invalide.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: Le serveur ferme la connexion après cette réponse.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (avec `field` quand un champ du corps est en cause) ou `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Un champ enfreint le schéma
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Le corps n’est pas du JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Un mot de passe trop faible
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: Un PGN illisible
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (pas d’en-tête `Authorization`) ou `invalid_token` (le jeton est mal formé, expiré, révoqué, ou appartient à un compte supprimé).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Pas de session
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Un jeton invalide
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: La requête est refusée.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Un mot de passe erroné lors d’une réauthentification
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Un compte banni
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found`, ou une fonctionnalité que ce serveur a désactivée.'
      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` : le corps n’est pas arrivé dans les 10 secondes. Le serveur ferme la connexion.'
      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 requête est en conflit avec l’état du serveur.
      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` : la tentative ou le ticket de connexion Google est inconnu, déjà utilisé ou expiré ; recommencez depuis le jeu.'
      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` : le corps dépasse `HTTP_BODY_LIMIT` (16 384 octets par défaut). Le serveur ferme la connexion.'
      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 cible de la requête dépasse 4096 caractères.'
      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` : le corps n’est pas en `application/json`, ou son jeu de caractères n’est pas 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 partie compte plus de `GIF_MAX_PLIES` (600) demi-coups.'
      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` : résolvez le défi `pow` et renvoyez la même requête avec `pow: { challenge, nonce }`. `reason` en indique la raison : `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` ou `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` (une limite de débit, le budget du compte, ou la file de hachage des mots de passe) ou `too_many_attempts` (un frein propre au compte), avec `retryAfter` et un en-tête `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: Une limite de débit
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Trop d’échecs sur ce compte
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error` : une défaillance inattendue. Le serveur la journalise.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: internal_error
            message: Internal server error.
    BadGateway:
      description: '`sso_failed` : un `state` ou un `iss` erroné, ou Google a refusé le code ou envoyé un jeton d’identité dont la vérification échoue. L’erreur ne reprend jamais le texte de 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` ou `timeout` : réessayez après `retryAfter` quand il est fourni.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Le serveur est occupé
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: La base de données est restée verrouillée (une lecture)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Le serveur a mis trop de temps
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: Le GIF animé, en pièce jointe.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` pour une partie de ce serveur, `attachment; filename="scacelith-game.gif"` pour un PGN envoyé avec `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 taille du fichier en octets.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: Une page HTML.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: Une page HTML qui explique le refus.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Le formulaire n’est pas arrivé dans les 10 secondes (une page HTML). Le serveur ferme la connexion.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: Une page HTML qui explique le conflit.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Le formulaire dépasse `HTTP_BODY_LIMIT` (une page HTML). Le serveur ferme la connexion.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Le corps n’est ni un formulaire (`application/x-www-form-urlencoded`) ni du JSON, ou son jeu de caractères n’est pas UTF-8 (une page HTML).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Une limite de débit (une page HTML), avec `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: Une défaillance inattendue, sous forme d’une page HTML intitulée "Server error" (erreur du serveur). Le serveur la journalise.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Le serveur est occupé (la base de données est restée verrouillée, avec `Retry-After`) ou a mis trop de temps ; la réponse est une page HTML intitulée "Server error" (erreur du serveur).
      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: La forme unique de toute réponse d’erreur de l’API JSON.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: Le code d’erreur, en `snake_case`. Un client choisit d’après lui ce qu’il affiche.
          examples:
            - rate_limited
        message:
          type: string
          description: Une phrase en anglais, destinée aux journaux et servant de solution de repli.
        retryAfter:
          type: integer
          minimum: 1
          description: Secondes à attendre avant de réessayer, présent seulement sur les refus qui prennent fin avec le temps. La réponse porte alors aussi un en-tête `Retry-After` de même valeur, sauf le 503 `busy` des lectures de l’historique, des parties, des joueurs et du classement général.
        field:
          type: string
          description: Le champ ou le paramètre de requête en cause (`invalid_request`, `invalid_option`, ainsi que `invalid_cursor`, `invalid_limit` et `invalid_filter` de `GET /account/games`). Un champ imbriqué est en notation pointée (`pow.nonce`).
        reason:
          type: string
          description: 'La raison : la règle qu’enfreint un `weak_password`, ou la raison pour laquelle une preuve de travail a été demandée ou refusée (`pow_required`).'
          enum:
            - too_short
            - too_long
            - contains_username
            - contains_email
            - too_common
            - required
            - malformed
            - signature
            - endpoint
            - network
            - expired
            - bits
            - work
            - replayed
        pow:
          $ref: '#/components/schemas/PowChallenge'
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: '`banned` seulement : la fin du bannissement (instant en ms), `null` pour un bannissement définitif.'
        line:
          type: integer
          minimum: 1
          description: '`invalid_pgn` seulement : la ligne de l’erreur, à partir de 1.'
        column:
          type: integer
          minimum: 1
          description: '`invalid_pgn` seulement : la colonne de l’erreur, à partir de 1, en caractères.'
    PowChallenge:
      type: object
      description: Un défi de preuve de travail (le `pow` d’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: Le défi signé, à renvoyer tel quel.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Le nombre de bits à zéro en tête que doit avoir `SHA-256(challenge + ":" + nonce)`.
        expiresAt:
          type: integer
          format: int64
          description: L’expiration du défi (instant en ms), 2 minutes après son émission.
    PowAnswer:
      type: object
      description: La réponse à un défi de preuve de travail.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: Le `challenge` de la réponse 428, tel qu’il a été donné.
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Une chaîne décimale d’au plus 20 chiffres telle que `SHA-256(challenge + ":" + nonce)` commence par `bits` bits à zéro.
    ServerInfo:
      type: object
      description: Ce dont un client a besoin avant de se connecter à un compte ou d’ouvrir le WebSocket.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Le nom du serveur (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: Un UUID attribué à la base de données à son premier démarrage. Il reste le même d’un redémarrage à l’autre. Il vaut `null` quand la base de données ne peut pas le fournir.
        motd:
          type: string
          description: Le message du jour (`SERVER_MOTD`), éventuellement vide.
        protocol:
          type: object
          description: Le protocole WebSocket (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: La plus ancienne version du protocole que parle le serveur.
            max:
              type: integer
              minimum: 1
              description: La plus récente version du protocole que parle le serveur.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: L’empreinte du schéma du protocole (les 4 premiers octets du SHA-256 de sa forme canonique, sous forme d’entier non signé) ; à titre indicatif.
            subprotocol:
              type: string
              description: Le jeton de sous-protocole WebSocket (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: Le port WebSocket qu’utilisent les joueurs (`PUBLIC_WS_PORT`, sinon `WS_PORT`, sinon `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Le chemin du WebSocket.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Indique si de nouveaux comptes peuvent être créés.
        emailVerification:
          type: boolean
          description: Indique si les nouveaux comptes confirment leur adresse (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
          properties:
            google:
              type: boolean
              description: Indique si la connexion Google est proposée.
        mfa:
          type: boolean
          const: true
          description: La double authentification est toujours disponible.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Les bits de preuve de travail qu’exige l’inscription (0 pour aucune).
        categories:
          type: array
          description: Les cadences officielles, celles des parties classées (`RATED_CATEGORIES`), dans l’ordre du serveur. Toute autre cadence est `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Les règles qu’un client peut vérifier avant d’envoyer un formulaire.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: La longueur minimale d’un nom d’utilisateur (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: La longueur maximale d’un nom d’utilisateur (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: L’expression régulière à laquelle doit correspondre un nouveau nom d’utilisateur.
            passwordMinLength:
              type: integer
              description: La longueur minimale d’un mot de passe, en caractères (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: La longueur maximale d’un mot de passe, en octets UTF-8.
            customTimeControls:
              type: boolean
              description: Indique si les défis et les parties privées peuvent utiliser des cadences libres.
            reportsPerDay:
              type: integer
              description: Le nombre de signalements qu’un joueur peut déposer par période de 24 heures (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: La taille maximale d’un message WebSocket qu’un client peut envoyer, en octets.
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 97842216
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: Une cadence officielle.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 'L’identifiant de la catégorie : les minutes et les secondes d’incrément (`3+2`).'
        baseSec:
          type: number
          minimum: 0
          description: Le temps de base, en secondes.
        incSec:
          type: number
          minimum: 0
          description: L’incrément par coup, en secondes.
    AccountView:
      type: object
      description: Le compte tel que son joueur le voit (`user` des réponses de connexion et de `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: L’identifiant du compte.
        username:
          type: string
          description: Le nom d’utilisateur.
        email:
          type: string
          format: email
          description: L’adresse e-mail.
        emailVerified:
          type: boolean
          description: Indique si l’adresse est confirmée.
        mfaEnabled:
          type: boolean
          description: Indique si la double authentification est activée.
        googleLinked:
          type: boolean
          description: Indique si un compte Google est associé.
        hasPassword:
          type: boolean
          description: '`false` pour un compte créé par Google qui n’a pas encore défini de mot de passe.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Indique si les défis directs par nom sont acceptés (voir `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: La date de création du compte (instant en ms).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: La dernière connexion (instant en ms), ou `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: La nouvelle adresse d’un changement d’adresse e-mail qui attend son lien, ou `null`.
    SessionAnswer:
      type: object
      description: Une nouvelle session.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Le jeton de session, pour l’en-tête `Authorization` et le WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: La fin absolue de la session (instant en ms) ; la limite d’inactivité peut y mettre fin plus tôt.
        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: La seconde étape d’une connexion avec double authentification ; continuez avec `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: Le jeton de l’étape, pour `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Les secondes restantes pour terminer l’étape.
    LoginAnswer:
      description: Une session, ou la seconde étape d’une connexion avec double authentification.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Une première connexion Google ; continuez avec `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: Le ticket pour `POST /auth/sso/complete`, valable 10 minutes.
        suggestedUsername:
          type: string
          description: Un nom d’utilisateur tiré du nom Google ou de l’adresse, ou `""` quand rien ne convient.
    SsoNeedsPassword:
      type: object
      description: Un compte doté d’un mot de passe utilise l’adresse ; continuez avec `POST /auth/sso/google/link`. Rien n’est encore associé.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Le ticket pour `POST /auth/sso/google/link`.
        username:
          type: string
          description: Le nom d’utilisateur de ce compte (seule une personne qui a prouvé à Google qu’elle détient l’adresse le voit).
        expiresIn:
          type: integer
          const: 600
          description: La durée de validité restante du ticket, en secondes.
    SsoFinishAnswer:
      description: La réponse de `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: Une tentative de connexion Google.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: La tentative, pour `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: L’URL Google à ouvrir dans le navigateur du système, une fois vérifiée.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Le `state` que Google doit renvoyer.
        expiresIn:
          type: integer
          const: 600
          description: La durée de validité restante de la tentative, en secondes.
    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: Le nom d’utilisateur ; les règles du serveur s’appliquent ensuite (voir la description).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: L’adresse e-mail. Débarrassée des espaces de début et de fin et stockée en minuscules.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Le mot de passe ; les règles des mots de passe s’appliquent ensuite.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Le nom d’utilisateur, ou l’adresse e-mail (tout texte contenant `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Facultatif. Affiché dans la liste des appareils connectés (par exemple `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: Le `mfaToken` de la réponse de connexion (ou de celle d’une connexion Google).
        code:
          type: string
          maxLength: 32
          description: Facultatif. Un code à 6 chiffres de l’application, ou un code de récupération.
        recoveryCode:
          type: string
          maxLength: 32
          description: Facultatif. Un code de récupération. `code` ou `recoveryCode` est nécessaire.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: L’adresse e-mail.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Le paramètre `token` du lien de réinitialisation.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Le nouveau mot de passe ; les règles de mot de passe de l’inscription s’appliquent.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Le défi PKCE S256 du `codeVerifier` du client (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: Le port de l’écouteur du client sur `127.0.0.1`.
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: L’`attemptId` de `POST /auth/sso/google/start`.
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: Le vérificateur PKCE ; son SHA-256 doit correspondre au défi donné à `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Le `state` tel que Google l’a renvoyé.
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: Le `code` tel que Google l’a renvoyé (ASCII imprimable sans espaces).
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Facultatif. L’`iss` tel que Google l’a renvoyé, s’il l’a fait.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: Le `linkTicket` de `finish`, valable 10 minutes.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Le mot de passe du compte.
        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: Le `ssoTicket` de `finish`, valable 10 minutes.
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Le nom d’utilisateur ; les règles de l’inscription s’appliquent.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Les sessions actives, de la plus récemment utilisée à la plus ancienne.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Une session active (un appareil connecté).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: L’identifiant de la session, pour `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: La connexion (instant en ms).
        lastSeenAt:
          type: integer
          format: int64
          description: La dernière utilisation (instant en ms), mise à jour au plus toutes les 5 minutes.
        expiresAt:
          type: integer
          format: int64
          description: La fin absolue (instant en ms) ; la limite d’inactivité peut mettre fin à la session plus tôt.
        clientLabel:
          type:
            - string
            - 'null'
          description: Le `clientLabel` de la connexion, ou `null` si elle n’en a pas envoyé.
        current:
          type: boolean
          description: Indique s’il s’agit de la session qui fait la requête.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Une fiche par catégorie dans laquelle le joueur a disputé des parties classées.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Les sanctions en cours.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` pendant un bannissement, sinon `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: La fiche de classement d’une catégorie.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: L’identifiant de la catégorie.
        rating:
          type: integer
          description: Le classement.
        games:
          type: integer
          minimum: 0
          description: Les parties jouées dans la catégorie (comptées ou non).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Le classement le plus élevé atteint.
        provisional:
          type: boolean
          description: '`true` tant que le joueur n’est pas encore classé ou compte moins de `PROVISIONAL_GAMES` parties comptées (le jeu l’affiche sous la forme `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Le type de sanction.
        reason:
          type:
            - string
            - 'null'
          description: Le motif indiqué, s’il y en a un.
        startsAt:
          type: integer
          format: int64
          description: Le début (instant en ms).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: La fin (instant en ms), `null` quand la sanction est définitive.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: La fin du bannissement (instant en ms), `null` s’il est définitif.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` pour accepter les défis directs par nom, `none` pour les refuser.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Le mot de passe actuel.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Le nouveau mot de passe ; les règles de mot de passe de l’inscription s’appliquent.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Le mot de passe du compte.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Un code issu du secret en attente, exactement 6 chiffres.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Le mot de passe du compte.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Facultatif ; nécessaire avec la double authentification (ou `recoveryCode`). Un code de l’application, ou un code de récupération.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Facultatif. Un code de récupération (`xxxx-xxxx-xx` ; la casse, les espaces et les tirets ne comptent pas).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Le mot de passe du compte.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Un code de l’application (un code de récupération est refusé).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: La nouvelle adresse ; débarrassée des espaces de début et de fin et mise en minuscules, puis vérifiée comme une adresse d’inscription.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Le mot de passe du compte.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Facultatif ; nécessaire avec la double authentification (ou `recoveryCode`). Un code de l’application, ou un code de récupération.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Facultatif. Un code de récupération.
    TotpSetup:
      type: object
      description: Le secret en attente, pour l’application d’authentification.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Le secret en base32, à saisir dans l’application.
        uri:
          type: string
          format: uri
          description: L’URI `otpauth://totp/...`, à afficher en QR code.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Secondes par pas.
    RecoveryCodeList:
      type: array
      description: Les 10 codes de récupération, affichés cette seule fois. Chacun ne sert qu’une fois.
      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: Un camp d’une partie.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Le nom dans la partie enregistrée (`deleted#<id>` pour un compte supprimé).
        rating:
          type:
            - integer
            - 'null'
          description: Le classement au début, `null` s’il est inconnu.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Le classement après la partie. Une partie classée l’a toujours ; `null` seulement pour une partie qui ne compte pas pour les classements (amicale, à cadence libre, annulée).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: La variation de classement, `0` quand les règles de classement laissent le classement inchangé (score de zéro point selon la FIDE, adversaire non classé, premières parties d’un joueur non classé) ; `null` dans les mêmes cas que `ratingAfter`.
    GameSummary:
      type: object
      description: Le résumé d’une partie enregistrée.
      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’identifiant de la partie.
        category:
          type: string
          description: L’identifiant de la catégorie officielle (`3+2`), ou `custom`.
        rated:
          type: boolean
          description: Indique si la partie compte pour les classements.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: La cadence en secondes (`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` (voir les codes des parties).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Le code du motif de fin (voir les codes des parties).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Le résultat, `*` pour une partie annulée.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Le nombre de demi-coups.
        startedAt:
          type: integer
          format: int64
          description: Le début (instant en ms).
        endedAt:
          type: integer
          format: int64
          description: La fin (instant en ms).
    Termination:
      type: string
      description: Le nom du motif de fin (voir les codes des parties) ; `Unknown` pour un code que le protocole ne connaît pas.
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: Le résumé d’une partie du point de vue d’un joueur.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Le camp du joueur.
    HistoryGameSummary:
      description: Une partie de l’historique du joueur connecté.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Le temps de base en millisecondes.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: L’incrément en millisecondes.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: L’issue du point de vue du joueur.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Les parties de la page, de la plus récente à la plus ancienne.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: L’identifiant à passer comme `before` pour la page suivante, ou `null` sur la dernière page.
        total:
          type: integer
          minimum: 0
          description: Le nombre de parties qui correspondent au filtre, toutes pages confondues.
    MoveRecord:
      type: object
      description: Un demi-coup d’une partie.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: Le coup en notation UCI, avec `n`, `b`, `r` ou `q` ajouté pour une promotion.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Le temps décompté pour le coup (ms), `null` quand la partie enregistrée ne l’a pas.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: La pendule du joueur qui a joué, après le coup (ms), `null` quand la partie enregistrée ne l’a pas.
    PgnTagsView:
      type: object
      description: Les principaux en-têtes PGN, pour l’affichage. `Termination` est ici le nom du motif de fin ; le fichier PGN utilise les valeurs standard du PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` ou `<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 date de début, en UTC.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: Le classement au début, ou `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Le classement au début, ou `"-"`.
          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: Une partie enregistrée avec ses coups et ses pendules. Elle a les champs d’un résumé de partie, sans `color` ni `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Le temps de base en millisecondes.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: L’incrément en millisecondes.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: Le nom de `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: L’identifiant de la partie dont celle-ci est la revanche, ou `null`.
            moves:
              type: array
              description: Une entrée par demi-coup.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Seulement quand le joueur du jeton a joué cette partie ; son camp.
            reportable:
              type: boolean
              description: Seulement quand le joueur du jeton a joué cette partie ; `true` quand `POST /reports` accepterait maintenant un signalement de l’adversaire pour cette partie (la partie s’est terminée il y a moins de 7 jours, le quota quotidien n’est pas épuisé, et l’adversaire n’est pas déjà signalé pour cette partie).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Le nom d’utilisateur, tel que le joueur l’a écrit.
        createdAt:
          type: integer
          format: int64
          description: La date de création du compte (instant en ms).
        ratings:
          type: array
          description: Les catégories officielles seulement, dans l’ordre du serveur.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Toutes les parties enregistrées, amicales et annulées comprises.
            rated:
              type: integer
              minimum: 0
              description: Les parties classées, additionnées sur les fiches de classement.
            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: Le nom d’utilisateur, tel que le joueur l’a écrit.
        games:
          type: array
          description: Les parties de la page, de la plus récente à la plus ancienne.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: L’identifiant de la dernière partie chaque fois que la page est pleine (la page suivante peut alors être vide), sinon `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: L’identifiant de la catégorie.
        minGames:
          type: integer
          minimum: 0
          description: Le nombre de parties comptées qu’il faut à une fiche pour figurer dans la liste (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Le moment où le tableau a été calculé (instant en ms).
        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: Le texte PGN, au plus 65 536 octets en UTF-8. Seule la première partie est utilisée.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Facultatif. La taille de l’image.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Facultatif. Le camp placé en bas de l’échiquier.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Facultatif. Millisecondes par coup, un nombre JSON de valeur entière.
        coords:
          type: boolean
          default: true
          description: Facultatif. Indique si les lettres des colonnes et les numéros des rangées sont dessinés autour de l’échiquier.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: La partie, sous forme d’entier ou de chaîne de 1 à 16 chiffres.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: Le nom d’utilisateur de l’adversaire, tel qu’il figure dans la partie enregistrée ou tel qu’il est aujourd’hui, sans tenir compte de la casse. Il ne peut pas être vide.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: L’objet du signalement.
        comment:
          type:
            - string
            - 'null'
          description: Facultatif. Au plus 500 caractères, une fois retirés les caractères de contrôle (autres que la tabulation et le saut de ligne) et les espaces de début et de fin.
    AccountExport:
      type: object
      description: Tout ce que le serveur conserve sur le compte (instants en ms).
      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: Le moment où l’export a été fait.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Ce que le fichier contient et ce qu’il omet, en anglais courant, pour le joueur.
          items:
            type: string
        account:
          description: La vue du compte de `GET /account/me`, avec `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: L’adresse du compte Google associé, ou `null`.
        ratings:
          type: array
          description: Les fiches de classement complètes.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Les points de classement restitués après qu’un adversaire a été reconnu coupable de triche, additionnés par jour UTC et par catégorie, du plus récent au plus ancien. Ni les parties ni les tricheurs ne sont nommés.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Toutes les sessions enregistrées, de la plus récente à la plus ancienne, sans aucun jeton. La purge de conservation supprime une session expirée, et une session déconnectée un jour après la déconnexion.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Les événements de sécurité, du plus récent au plus ancien, conservés `RETENTION_SECURITY_DAYS` jours.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Toutes les sanctions, levées comprises. Le nom du modérateur n’est jamais inclus.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Les événements de conduite enregistrés pour les parties du joueur quittées, annulées ou sans premier coup, conservés 30 jours.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Les signalements faits par le joueur.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Le nombre de parties.
            list:
              type: array
              description: Toutes les parties, de la plus récente à la plus ancienne, sous forme de résumés de `GET /account/games`. Les coups s’obtiennent par `GET /games/{id}` et le PGN par `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Une fiche de classement complète.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Indique si le joueur est sorti de la phase non classée.
            countedGames:
              type: integer
              minimum: 0
              description: Les parties prises en compte dans le classement.
            updatedAt:
              type: integer
              format: int64
              description: La dernière modification de la fiche.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: 00:00 UTC du jour (instant en ms).
        category:
          type: string
        points:
          type: integer
          description: Les points restitués ce jour-là dans cette catégorie.
    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: Le moment où la session a été déconnectée, ou `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: L’adresse de la connexion, effacée après `RETENTION_IP_DAYS` jours.
    SecurityEvent:
      type: object
      description: |-
        Un événement de sécurité. `ip` n’est fourni que pour ce qui a été fait en étant connecté, avec le mot de passe du compte (et le second facteur) ou avec un lien envoyé à son adresse : `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` et `account_exported` ; tout autre type a `ip: null`, et `ip` est effacé après `RETENTION_IP_DAYS` jours.

        `detail` ne conserve que ces champs : `login` : `method` ; `sso_login`, `sso_account_created` : `provider` ; `sso_linked` : `provider` et `method` (`password`, ou `password+totp`) ; `login_failed` : `failures` ; `login_lockout` : `retryAfterMs` ; `mfa_failed` : `attempts` ; `recovery_code_used` : `remaining` ; `reauth_failed` : `factor` ; `session_revoked` et `sessions_revoked_all` : `reason` ; `email_change_refused` : `reason` ; `sanction_auto` : `kind`, `gameId`, `until`. Un événement `moderator_action` ne conserve que `{ action }`, pour les actions `ban`, `unban`, `reset_mfa`, `verify_email` et `revoke_sessions` (les autres actions de modération sont omises). Tout autre type a `detail: null`. Les événements `rating_refund` sont omis : leurs points figurent dans `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Le type d’événement (`login`, `password_changed`…).
        at:
          type: integer
          format: int64
          description: Le moment où il s’est produit.
        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: Le nom public actuel du joueur signalé.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Indique si le signalement est encore ouvert (rien n’indique si le joueur signalé a été sanctionné).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Le jeton du lien de l’e-mail (le champ caché du formulaire de la page).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Le jeton du lien de réinitialisation (le champ caché du formulaire).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Le nouveau mot de passe ; les règles de mot de passe de l’inscription s’appliquent.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Le nouveau mot de passe, une seconde fois ; il doit être identique.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: Une page HTML en UTF-8 (`text/html; charset=utf-8`), sans JavaScript ni ressources externes.
