openapi: 3.1.1
info:
  title: API HTTPS del servidor de Scacelith
  version: "0.9.0"
  summary: 'La API HTTPS de todos los servidores de Scacelith: el oficial y los de la comunidad.'
  description: |-
    Todos los servidores de Scacelith, el oficial `caissa.scacelith.com` y cualquier servidor de la comunidad, responden a esta API HTTPS. El juego la usa para todo lo que queda fuera de la partida propiamente dicha: el registro y el inicio de sesión (incluidos la autenticación de dos factores y Google), la página de la cuenta, el historial de partidas, las descargas PGN y los GIF animados de las partidas, los dispositivos con sesión iniciada, la descarga de los datos, la eliminación de la cuenta y las denuncias.

    El juego en directo (emparejamiento, retos, jugadas, relojes) pasa por el WebSocket del mismo servidor (`wss://<host>/ws`), que se abre con un token de sesión de esta API; se describe en `PROTOCOL.md`, no aquí.

    Los valores predeterminados que se indican a continuación son los de un servidor con la configuración sin modificar; un servidor de la comunidad puede cambiarlos (`CONFIG.md` enumera todos los ajustes).

    ## URL base, puerto y transporte

    - Servidor oficial: `https://caissa.scacelith.com/api/v1`, puerto TCP 443.
    - Un servidor de la comunidad: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` es 443 de forma predeterminada; detrás de un NAT o de un proxy, el puerto que usan los jugadores es `PUBLIC_API_PORT`.
    - El WebSocket del juego está en el mismo puerto de forma predeterminada (`WS_PORT` lo traslada; `GET /info` indica al cliente dónde está).
    - HTTP/1.1 sobre TLS. Con `TLS_MODE=proxy`, un proxy inverso situado delante del servidor termina TLS. `TLS_MODE=off` (HTTP sin cifrar) es solo para desarrollo local.
    - Las páginas HTML que se abren desde los enlaces de los correos (`/verify-email`, `/reset-password`, `/confirm-email-change`) están en la raíz del servidor, fuera de `/api/v1`. Los endpoints de estado responden tanto en la raíz como bajo `/api/v1`. El inicio de sesión con Google no tiene ninguna página en el servidor: Google devuelve el navegador al propio juego, en `127.0.0.1`.
    - Una barra final se ignora (`/api/v1/info/` equivale a `/api/v1/info`), y los parámetros de ruta se decodifican como URL (un parámetro que no es una codificación URL válida responde 400 `invalid_request`).

    ## Solicitudes

    - **Cuerpos JSON.** Las solicitudes `POST`, `PUT` y `DELETE` llevan un objeto JSON con `Content-Type: application/json`. Se rechaza un `charset` distinto de UTF-8, y también cualquier otro tipo de contenido (415 `unsupported_media_type`). Un cuerpo vacío cuenta como `{}`, que es lo que reciben los endpoints sin parámetros (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Tamaño y tiempo.** Un cuerpo está limitado a `HTTP_BODY_LIMIT` bytes (16 384 de forma predeterminada; `POST /gif` tiene su propio límite de 135 168 bytes): 413 `payload_too_large`. Debe llegar en un plazo de 10 segundos: 408 `request_timeout`. Tras cualquiera de estos dos errores, el servidor cierra la conexión.
    - **Esquemas estrictos.** Se rechaza cualquier campo que el endpoint no conozca, todo campo no marcado como opcional es obligatorio, y se comprueban los tipos y las longitudes. Cualquiera de estos fallos responde 400 `invalid_request`, con `field` indicando el campo (con puntos si está anidado, como `pow.nonce`). Las cadenas no pueden contener caracteres de control. Las longitudes cuentan unidades de código UTF-16, por lo que un carácter fuera del plano multilingüe básico (un emoji) cuenta doble. Un cuerpo que no es JSON responde 400 `invalid_json`. `POST /gif` y `POST /reports` comprueban sus cuerpos por sí mismos (véase cada endpoint).
    - **Cadenas de consulta.** Cuenta la primera aparición de un parámetro, los parámetros desconocidos se ignoran y un `+` se decodifica como un espacio: los endpoints que reciben una categoría de ritmo de juego aceptan tanto `3%2B2` como `3+2`.
    - **Métodos.** `HEAD` es `GET` sin el cuerpo. `OPTIONS` sobre una ruta existente responde 204 con un encabezado `Allow`. Cualquier otro método que la ruta no tenga responde 405 `method_not_allowed` con `Allow`.
    - **Destino de la solicitud.** No puede superar los 4096 caracteres (414 `uri_too_long`). Una solicitud que no se puede analizar en absoluto, o cuya línea de solicitud y encabezados son demasiado grandes o llegan demasiado despacio, recibe una respuesta 400, 431 o 408 vacía, y se cierra la conexión.
    - **Sin CORS.** La API está al servicio del juego, no de páginas web. Nunca se envía ningún encabezado `Access-Control-*`, de modo que una página web no puede leer una respuesta; como solo se aceptan cuerpos JSON, una escritura entre sitios necesitaría una solicitud preliminar (preflight), y esa solicitud preliminar falla.

    ## Respuestas y errores

    - Las respuestas son JSON en UTF-8, salvo la descarga PGN (`application/x-chess-pgn`), los GIF animados (`image/gif`) y las páginas HTML. Las marcas de tiempo son milisegundos desde el 1970-01-01 UTC. Los identificadores son enteros. Los identificadores de partida tienen hasta 16 cifras, pero se mantienen por debajo de 2^53, de modo que un número JSON (un double) los representa con exactitud.
    - Todas las respuestas de la API llevan `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` y `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (las páginas HTML tienen su propia política). Con `TLS_MODE=native` llevan también `Strict-Transport-Security: max-age=31536000`.
    - Una respuesta debe leerse en los 60 segundos siguientes al momento en que el servidor la tiene lista, lo que solo importa para las respuestas grandes (un GIF, la exportación de datos, un PGN largo). El tiempo que tarda el servidor en preparar una respuesta no cuenta ni para este plazo ni para los 30 segundos sin ningún byte de entrada ni de salida tras los cuales se cierra una conexión.

    Los errores tienen una única forma (el esquema `Error`):

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

    `message` está pensado para los registros y como último recurso; un cliente elige qué mostrar a partir de `error`. `retryAfter` (en segundos) solo está presente en los rechazos que expiran con el tiempo, y en ese caso la respuesta lleva también un encabezado `Retry-After` con el mismo valor; la única excepción es el 503 `busy` de las lecturas del historial, de las partidas, de los jugadores y de la clasificación, que solo tiene el campo. Algunos errores añaden campos: `field` (entrada no válida), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` y `column` (`invalid_pgn`).

    Errores que puede devolver cualquier endpoint:

    | Estado | `error` | Cuándo |
    |---|---|---|
    | 400 | `invalid_request` | El cuerpo incumple el esquema del endpoint (`field` indica dónde), el destino de la solicitud o `Content-Length` está mal formado, un parámetro de ruta no es una codificación URL válida, o el cuerpo llegó cortado. |
    | 400 | `invalid_json` | El cuerpo no es JSON. |
    | 401 | `unauthorized` | Falta el encabezado `Authorization` en un endpoint que necesita una sesión. |
    | 401 | `invalid_token` | El token está mal formado, ha caducado, ha sido revocado o pertenece a una cuenta eliminada; también en los endpoints donde la sesión es opcional. |
    | 404 | `not_found` | No existe ese endpoint. Algunos endpoints también lo usan: no existe esa partida, ese jugador o esa sesión. |
    | 405 | `method_not_allowed` | La ruta existe para otros métodos (véase `Allow`). |
    | 408 | `request_timeout` | El cuerpo no llegó en un plazo de 10 segundos. |
    | 413 | `payload_too_large` | El cuerpo supera `HTTP_BODY_LIMIT` (135 168 bytes para `POST /gif`). |
    | 414 | `uri_too_long` | El destino de la solicitud supera los 4096 caracteres. |
    | 415 | `unsupported_media_type` | El cuerpo no es `application/json`, o su charset no es UTF-8. |
    | 429 | `rate_limited` | Un límite de frecuencia (véase más abajo): `retryAfter` más un encabezado `Retry-After`. |
    | 500 | `internal_error` | Un fallo inesperado. El servidor lo registra. |
    | 503 | `timeout` | El servidor no respondió en 30 segundos (60 segundos para la exportación, 45 segundos para los GIF con los ajustes predeterminados). |
    | 503 | `server_busy` | Una búsqueda de sesión o un cambio en la cuenta encontró la base de datos bloqueada (`retryAfter` 1), o la cola de hash de contraseñas está llena (véase más abajo). |

    Los endpoints de lectura (historial de partidas, partidas, jugadores, clasificación) y la exportación responden 503 `busy` con `retryAfter: 1` cuando la base de datos ha seguido bloqueada.

    Los endpoints que comprueban una contraseña o calculan su hash también pueden responder uno de estos dos errores, ambos con un `retryAfter` aleatorio de 5 a 15 segundos:

    - 503 `server_busy`: la cola de hash de contraseñas del servidor (`PASSWORD_HASH_QUEUE_MAX`) está llena, o se agotó la espera.
    - 429 `rate_limited`: con la cola ya medio llena, este cliente (una dirección IPv4 o un prefijo IPv6 /48) ya tiene `PASSWORD_HASH_WAITERS_PER_SOURCE` hashes en espera. Este 429 devuelve las fichas de límite de frecuencia que había tomado la solicitud.

    No se ha cambiado nada ni se ha contado ningún intento fallido (salvo en `POST /auth/sso/google/link`, cuyo intento con el ticket y cuyo fallo de cuenta ya se habían contado antes del hash), y un enlace de restablecimiento sigue siendo válido.

    Las páginas HTML devuelven sus errores como páginas HTML, con los mismos códigos de estado.

    ## Autenticación

    Los endpoints que necesitan una sesión reciben un token Bearer en el encabezado `Authorization` (el esquema `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Obtener un token.** Un token (`sct_` seguido de 43 caracteres base64url) se obtiene de `POST /auth/login`, y después de `POST /auth/login/mfa` cuando la autenticación de dos factores está activada; de `POST /auth/sso/google/finish` y `POST /auth/sso/google/link` (inicio de sesión con Google), y de `POST /auth/sso/complete`. Todos ellos responden `{ token, expiresAt, user }`. El servidor solo guarda un SHA-256 del token.
    - **Sesión obligatoria.** Si falta el encabezado, la respuesta es 401 `unauthorized` con `WWW-Authenticate: Bearer realm="scacelith"`. Un token no válido recibe 401 `invalid_token` con `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Un cliente debe olvidar todo token que reciba `invalid_token` y volver a iniciar sesión.
    - **Sesión opcional.** En `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` y `GET /players/{username}/games` la sesión es opcional. Sin el encabezado, responden con la vista pública; si se envía el encabezado, su token debe ser válido.
    - **Duración.** Una sesión termina en el primero de estos dos plazos: `SESSION_MAX_DAYS` (90) días después del inicio de sesión (esto es `expiresAt`) o `SESSION_IDLE_DAYS` (30) días sin uso. Cada uso aplaza el límite de inactividad; el servidor escribe el nuevo valor como máximo cada 5 minutos. Una cuenta conserva como máximo `MAX_SESSIONS_PER_USER` (10) sesiones: un nuevo inicio de sesión revoca las más antiguas que superen ese número.
    - **Revocación.** Cerrar sesión (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) revoca sesiones; también lo hacen un cambio de contraseña (las demás sesiones), un restablecimiento de contraseña y la eliminación de la cuenta (todas las sesiones), así como un administrador. Una revocación desde la API surte efecto de inmediato, y el WebSocket abierto con una sesión revocada se cierra. El servidor guarda en caché las búsquedas de sesión durante 30 segundos, de modo que una revocación mediante el comando de administración, que es un proceso aparte, surte efecto en un plazo de 30 segundos.
    - **Alcance.** Un token pertenece a un único servidor y también abre su WebSocket. No lo envíe nunca a otro servidor.

    ## Límites de frecuencia y otras restricciones

    Los límites se aplican en uno de dos ámbitos. **Cliente:** una dirección IPv4, o un prefijo IPv6 /64 (en modo proxy, la dirección procede de `X-Forwarded-For` enviado por una dirección de `TRUSTED_PROXIES`); algunos límites cuentan además cada prefijo IPv6 /48 en su conjunto, aparte de cada una de sus redes /64. **Jugador:** la cuenta con la sesión iniciada, sea cual sea su dirección; un límite contado por jugador en un endpoint donde la sesión es opcional se cuenta por cliente para una solicitud sin token.

    Cada límite es un cubo de fichas (token bucket) con capacidad para `limit` solicitudes, que se rellena de forma continua a razón de `limit / window`; `retryAfter` es el tiempo que falta hasta la siguiente ficha. Los límites marcados como *compartidos* se cuentan además sobre una ventana deslizante de la misma duración, para que ninguna ventana contenga muchas más de `limit` solicitudes. Todos los límites se cuentan para el servidor entero. Cuando uno de los límites de un endpoint rechaza una solicitud, se devuelven las fichas que sus otros límites habían tomado para esa solicitud. Un rechazo responde 429 `rate_limited` con `retryAfter` y `Retry-After`.

    - **Capa por dirección.** Toda solicitud (cualquier ruta y método, incluidos los endpoints de estado y las actualizaciones a WebSocket) toma primero una ficha del presupuesto de su cliente: `HTTP_RATE_PER_IP` (600) por minuto con una ráfaga de medio minuto, y `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) para un prefijo IPv6 /48. Además, un cliente puede tener como máximo `IP_MAX_INFLIGHT` (32 x `WORKERS`) solicitudes en curso (`retryAfter` 1 por encima). Un cliente que insiste tras sus rechazos queda bloqueado: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) rechazos en un minuto lo bloquean durante 1 minuto, y después durante 4, 16 y 60 minutos con cada nuevo bloqueo en un plazo de 6 horas. Un rechazo de los límites `auth`, `auth_*` y `reauth` cuenta por 5; los límites contados por jugador nunca cuentan. Las direcciones de `ABUSE_EXEMPT` se saltan esta capa, pero no el presupuesto de la cuenta ni los límites de los endpoints.
    - **Presupuesto de la cuenta.** Toda solicitud que lleva un token de sesión válido cuenta también contra su cuenta: `USER_RATE_PER_MIN` (120) por minuto para todos los endpoints juntos, sea cual sea la dirección, con una ráfaga de medio minuto.
    - **Límites por endpoint:**

    | Límite | Valor predeterminado | Se cuenta por | Endpoints |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, compartido | cliente, y `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) por prefijo IPv6 /48 | `POST /auth/register`, `/auth/login`, `/auth/login/mfa`, `/auth/verify-email/resend`, `/auth/password/forgot`, `/auth/password/reset`, `/auth/sso/google/link`, `/auth/sso/complete`; `POST /verify-email`, `/reset-password`, `/confirm-email-change` |
    | `auth_register` | `AUTH_REGISTER_PER_HOUR` (10) / hora, compartido | cliente; el triple por prefijo IPv6 /48 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / hora, compartido | cliente; el triple por prefijo IPv6 /48 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / hora, compartido | cliente; el triple por prefijo IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 horas, compartido | cliente; el triple por prefijo IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / hora, compartido | cliente; el triple por prefijo IPv6 /48 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | las cifras de `auth`, en un cubo propio, compartido | cliente y prefijo IPv6 /48 | los cambios en la cuenta que piden la contraseña (véase Reautenticación), y `POST /account/export` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 min, compartido | jugador | los mismos endpoints que `reauth` |
    | `account` | 60 / min | jugador | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | jugador | `GET /account/games` |
    | `account_export` | 5 / hora, compartido | jugador | `POST /account/export` (se comprueba antes que `reauth`; todo intento cuenta) |
    | `sessions` | 60 / min | jugador | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | jugador (cliente si no hay token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (un solo cubo para los cuatro) |
    | `gif` | 30 / min | jugador | `GET /games/{id}/gif`, `POST /gif` (un solo cubo para los dos) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min y `GIF_USER_RENDERS_PER_HOUR` (30) / hora, compartidos | jugador | los mismos dos, solo cuando hay que crear el GIF (no cuando sale de la caché) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min y `GIF_IP_RENDERS_PER_HOUR` (120) / hora, compartidos | cliente (todas sus cuentas juntas); el triple por prefijo IPv6 /48 | los mismos, como en la fila anterior |
    | `reports` | 30 / hora | jugador | `POST /reports` |
    | `sso_start` | 30 / 10 min, compartido | cliente, y 90 por prefijo IPv6 /48 | `POST /auth/sso/google/start` |
    | `sso_finish` | 30 / min | cliente | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | cliente | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` y `GET /leaderboard` no tienen límite propio: solo la capa por dirección. Un endpoint con varios límites los comprueba en el orden indicado en su descripción.

    El servidor trata una solicitud en este orden: la capa por dirección y, a continuación, los endpoints de estado; la identificación del endpoint; la autenticación; el presupuesto de la cuenta, cuando la solicitud lleva una sesión; los límites del endpoint; el cuerpo; el propio endpoint (los endpoints de GIF solo toman sus límites de renderizado cuando tienen un GIF que crear). Así, una solicitud rechazada por su token no gasta ninguna ficha del endpoint, y una solicitud con un cuerpo no válido sí las gasta.

    Otras restricciones, que aplican los propios endpoints:

    - **Inicios de sesión fallidos con un mismo nombre de inicio de sesión** (nombre de usuario o correo electrónico): a partir de `AUTH_FAILURES_PER_ACCOUNT` (5) fallos, cada intento debe esperar el doble que el anterior (2 s, 4 s, y así sucesivamente, hasta 15 minutos): 429 `too_many_attempts` con `retryAfter`. El contador se reinicia tras una hora sin fallos.
    - **Segundos factores fallidos al iniciar sesión:** la misma regla a partir del 5.º código erróneo de la cuenta. Un mismo paso de inicio de sesión acepta como máximo 5 códigos erróneos.
    - **Códigos de segundo factor de una cuenta:** como máximo `AUTH_MFA_PER_ACCOUNT` (10) códigos (de la aplicación de autenticación o de recuperación, correctos o erróneos) cada 15 minutos, desde cualquier dirección, al iniciar sesión y en las reautenticaciones; por encima, 429 `too_many_attempts` antes de comprobar el código, de modo que no se gasta ningún código de recuperación.
    - **Reautenticaciones fallidas de una cuenta** (contraseña o código erróneos): la misma regla (429 `too_many_attempts`), compartida por todos los endpoints que piden reautenticación.
    - **Correos:** un correo de confirmación o de restablecimiento por dirección cada 5 minutos (la respuesta no cambia); un aviso «alguien ha intentado usar su dirección» por dirección y por hora.
    - **Denuncias:** `REPORTS_PER_DAY` (5) por jugador en 24 horas: 429 `report_limit`.

    ## Prueba de trabajo

    `POST /auth/register` exige siempre una prueba de trabajo cuando `POW_REGISTER_BITS` es mayor que 0 (18 de forma predeterminada; `GET /info` lo indica como `pow.register`). `POST /auth/login` y `POST /auth/sso/google/link` (un mismo tipo de desafío para ambos) solo la exigen durante los 5 minutos siguientes a una oleada de inicios de sesión fallidos detectada por el servidor (`POW_LOGIN_TRIGGER_PER_MIN`, y entonces `POW_LOGIN_BITS`); el cliente lo sabe por la respuesta.

    1. La solicitud sin prueba (o con una prueba rechazada) responde 428 `pow_required` con `reason` y un desafío `pow` `{ challenge, bits, expiresAt }`.
    2. Encuentre un nonce: una cadena decimal de 20 cifras como máximo tal que `SHA-256(challenge + ":" + nonce)` empiece por `bits` bits a cero (empezando por el bit más significativo del primer byte). 18 bits requieren unos 260 000 hashes de media.
    3. Envíe de nuevo la misma solicitud con `"pow": { "challenge": "...", "nonce": "123456" }` en el cuerpo.

    Un desafío es válido durante 2 minutos y una sola vez, para un endpoint y una red de cliente (una dirección IPv4 o un prefijo IPv6 /64). Está firmado, así que el servidor no guarda nada sobre él hasta que vuelve. `reason` indica por qué se rechazó una prueba: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` o `replayed`.

    ## Reautenticación

    Los cambios en la cuenta vuelven a pedir la contraseña y, cuando la autenticación de dos factores está activada, un segundo factor:

    - solo la contraseña: `POST /account/password` y `POST /account/mfa/totp/setup`;
    - la contraseña y un código de la aplicación de autenticación, sin admitir códigos de recuperación: `POST /account/mfa/recovery-codes`;
    - la contraseña y un código de la aplicación de autenticación o un código de recuperación: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` y `POST /account/delete`.

    En los cuerpos, `code` contiene un código de 6 cifras de la aplicación de autenticación y `recoveryCode`, un código de recuperación (`xxxx-xxxx-xx`; no importan las mayúsculas, los espacios ni los guiones). También se puede enviar un código de recuperación en `code` allí donde se admiten códigos de recuperación. Cada código sirve una sola vez: un código de la aplicación ya usado se rechaza hasta el siguiente intervalo de 30 segundos, y un código de recuperación deja de existir en cuanto se usa.

    | Estado | `error` | Cuándo |
    |---|---|---|
    | 403 | `invalid_password` | Contraseña incorrecta. |
    | 403 | `mfa_code_required` | La autenticación de dos factores está activada y no se envió ni `code` ni `recoveryCode`. |
    | 403 | `invalid_code` | Código erróneo o ya usado. |
    | 400 | `password_not_set` | Una cuenta que solo usa Google aún no tiene contraseña («¿Olvidó su contraseña?» permite crear una). |
    | 429 | `too_many_attempts` | Demasiados fallos, o demasiados códigos probados, en esta cuenta. |
    | 503 / 429 | `server_busy` / `rate_limited` | La cola de hash de contraseñas está saturada. |

    Las contraseñas y los códigos fallidos cuentan en el contador de fallos de la cuenta y se registran como eventos de seguridad.

    ## Puerto de métricas

    Además del puerto de la API, el servidor responde en HTTP sin cifrar en el puerto de métricas (`METRICS_PORT`, 9464, vinculado a `METRICS_BIND`, 127.0.0.1 de forma predeterminada; manténgalo privado). No forma parte de esta API: `GET /healthz` responde `ok`; `GET /readyz` responde `ready` una vez completado el arranque (cada partición ha vuelto a aplicar su diario y los puertos de escucha están vinculados) y hasta que empieza el apagado, y en caso contrario 503 `not ready`; `GET /metrics` sirve las métricas de Prometheus y, si se ha definido `METRICS_TOKEN`, exige `Authorization: Bearer <token>` con ese token exacto.
  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: Código fuente del servidor de Scacelith y su documentación de referencia (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: El servidor oficial.
  - url: https://{host}:{port}/api/v1
    description: Cualquier servidor de Scacelith, como un servidor de la comunidad.
    variables:
      host:
        default: caissa.scacelith.com
        description: El nombre de host público del servidor (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: El puerto público de la API (`PUBLIC_API_PORT` o, en su defecto, `API_PORT`).
tags:
  - name: server-info
    x-displayName: Información del servidor
    description: Lo que un cliente necesita saber antes de iniciar sesión o de conectarse.
  - name: health
    x-displayName: Estado del servicio
    description: |-
      Estado de actividad (liveness) y de disponibilidad (readiness) del servidor, para la supervisión. Estos endpoints responden en el puerto de la API antes de la autenticación y de cualquier límite de endpoint, pero, como cualquier solicitud, toman una ficha de la capa por dirección; un equipo de supervisión puede incluirse en `ABUSE_EXEMPT`. Las dos rutas funcionan para cada endpoint: en la raíz del servidor y bajo `/api/v1`.

      El puerto de métricas tiene sus propios endpoints de estado, descritos en la introducción.
  - name: auth
    x-displayName: Registro e inicio de sesión
    description: |-
      Creación de una cuenta, inicio de sesión con contraseña (y un segundo factor), correos de confirmación y de restablecimiento de contraseña.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` y `POST /auth/sso/complete` responden con una sesión `{ token, expiresAt, user }` (o, en el caso de los primeros, con un segundo paso).
  - name: google-sign-in
    x-displayName: Inicio de sesión con Google
    description: |-
      Disponible cuando `GET /info` indica `sso.google: true`; en caso contrario, todos los endpoints siguientes responden 404 `sso_disabled`. El juego inicia sesión a través del navegador del sistema con el flujo para aplicaciones instaladas de la RFC 8252 (un cliente de tipo «App de escritorio»): Google devuelve el navegador a un puerto de escucha del juego en `127.0.0.1`, nunca a este servidor, y el juego nunca ve ninguna credencial de Google.

      1. El cliente escucha en `127.0.0.1:0` (el sistema elige el puerto) y genera un par PKCE: un `codeVerifier` de 43 a 128 caracteres `[A-Za-z0-9._~-]` y `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, que tiene 43 caracteres y no lleva relleno.
      2. `POST /auth/sso/google/start`, con el desafío y el puerto, devuelve la URL de Google y el `state` del intento. El cliente comprueba la URL (véase más abajo) y la abre en el navegador.
      3. Google envía el navegador a `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. El cliente solo acepta el `state` de la respuesta de inicio, y no envía nada tras una redirección con `error=`.
      4. `POST /auth/sso/google/finish` con el identificador del intento, el `codeVerifier`, el `state` y el `code` (y `iss` si Google lo envió).
      5. La respuesta es una sesión, un paso de autenticación de dos factores (continúe con `POST /auth/login/mfa`), `needsUsername` para un jugador nuevo (continúe con `POST /auth/sso/complete`), o `needsPassword` cuando una cuenta con contraseña usa la dirección (continúe con `POST /auth/sso/google/link`).

      **La etiqueta de origen.** La URI de redirección lleva una etiqueta del servidor que añadió el jugador, de modo que el juego rechaza una URL que otro servidor haya obtenido para sus propios jugadores. El origen es el host en minúsculas (un literal IPv6 entre corchetes), `:` y el puerto decimal de la API, siempre escrito, 443 incluido: el servidor toma `SERVER_PUBLIC_HOST` y su puerto público de la API; el juego, la dirección a la que está conectado. La etiqueta son los 22 primeros caracteres del base64url (sin relleno) de SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + origen). La URI de redirección es `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: siempre el literal IPv4, y el puerto en el que escucha el juego (de 1024 a 65535). El servidor nunca toma del cliente ninguna URI, host ni ruta.

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

      Así pues, el inicio de sesión con Google solo funciona para los jugadores que añadieron el servidor exactamente con `SERVER_PUBLIC_HOST` y su puerto público de la API.

      **Lo que comprueba el juego antes de abrir el navegador.** La `authUrl` empieza exactamente por `https://accounts.google.com/o/oauth2/v2/auth?` y es ASCII imprimible de menos de 4096 caracteres, y su consulta tiene exactamente un `response_type=code`, exactamente un `redirect_uri` igual a la URI que el juego calcula a partir de su puerto y de su etiqueta de origen, exactamente un `state` igual al `state` de la respuesta, `code_challenge_method=S256` y un `code_challenge` de 43 caracteres. En caso contrario, el juego deja de escuchar y no abre nada. Solo envía `finish` y `link` al servidor que respondió a `start`.

      **A qué cuenta da acceso el inicio de sesión con Google:** a la cuenta ya vinculada a esa cuenta de Google; si no la hay, a una cuenta activa con la dirección que Google ha confirmado (con contraseña, `finish` responde `needsPassword` y el vínculo solo se guarda cuando se superan su contraseña y, después, su segundo factor si está activado; sin contraseña, 409 `sso_account_exists`); si tampoco la hay, a una cuenta nueva (`needsUsername`). Una cuenta de Google nunca se vincula a una cuenta existente solo por su dirección. La dirección de la cuenta recibe un correo cuando el inicio de sesión con Google crea una cuenta y cuando se añade a una cuenta existente.
  - name: sessions
    x-displayName: Sesiones
    description: Los dispositivos con sesión iniciada de la cuenta, y el cierre de sesión.
  - name: account
    x-displayName: Cuenta
    description: La vista de la cuenta, sus preferencias y su contraseña.
  - name: two-step-verification
    x-displayName: Autenticación de dos factores
    description: Aplicaciones de autenticación (TOTP, RFC 6238), con SHA-1, 6 cifras, intervalos de 30 segundos y un intervalo de tolerancia en cada sentido, además de códigos de recuperación de un solo uso.
  - name: email-change
    x-displayName: Cambio de dirección de correo
    description: Cambio de la dirección de la cuenta, confirmado con un enlace enviado a la nueva dirección.
  - name: data-export
    x-displayName: Exportación de datos
    description: Todo lo que el servidor guarda sobre la cuenta, en un único archivo JSON.
  - name: account-deletion
    x-displayName: Eliminación de la cuenta
    description: Eliminación definitiva de la cuenta.
  - name: game-history
    x-displayName: Historial de partidas
    description: Las partidas del propio jugador con la sesión iniciada, filtradas y paginadas.
  - name: games
    x-displayName: Partidas y PGN
    description: |-
      Los registros de las partidas, con sus jugadas y sus relojes, y sus archivos PGN.

      **Códigos de partida.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (solo se guardan las partidas terminadas). `result`: `1-0`, `0-1`, `1/2-1/2` o `*` (anulada). `reason`, con su nombre (`termination`) y las palabras que cierran el texto de jugadas del archivo PGN; los códigos 7 y 21 son tablas (el jugador que se quedó sin tiempo o que abandonó la partida tenía enfrente a un rival que no podía dar mate), y el servidor nunca termina una partida con los códigos 4 y 13 (pertenecen a la lista común de motivos):

      | Código | `termination` | Palabras en el PGN (en inglés) — traducción |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate — jaque mate |
      | 2 | `Resignation` | Resignation — abandono |
      | 3 | `Timeout` | Loss on time — derrota por tiempo |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) — segunda jugada ilegal (partida perdida) |
      | 5 | `Stalemate` | Stalemate — rey ahogado |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) — posición muerta (material insuficiente) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate — caída de bandera, pero el rival no puede dar mate |
      | 8 | `FivefoldRepetition` | Fivefold repetition — quíntuple repetición |
      | 9 | `SeventyFiveMoves` | 75-move rule — regla de los 75 movimientos |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) — triple repetición (reclamada) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) — regla de los 50 movimientos (reclamada) |
      | 12 | `Agreement` | Draw by agreement — tablas de mutuo acuerdo |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate — segunda jugada ilegal, pero el rival no puede dar mate |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) — partida abandonada (desconexión demasiado larga) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate — partida abandonada, pero el rival no puede dar mate |
      | 22 | `Aborted` | Game aborted — partida anulada |
      | 23 | `NoShow` | Aborted: first move not played in time — anulada: primera jugada no realizada a tiempo |
      | 24 | `Forfeit` | Forfeit (fair play violation) — partida perdida (infracción del juego limpio) |
      | 25 | `ServerAborted` | Aborted by the server — anulada por el servidor |
      | 26 | `BothDisconnected` | Aborted: both players disconnected — anulada: ambos jugadores se desconectaron |
  - name: gifs
    x-displayName: GIF animados
    description: |-
      Una partida en forma de GIF animado, para conservarla o compartirla: el tablero visto desde arriba, un fotograma por posición desde el inicio hasta la posición final, los nombres y las puntuaciones de los jugadores encima del tablero, la última jugada debajo y, en el último fotograma, el resultado y cómo terminó la partida.

      **Coste, caché y cuotas.** Un GIF se crea en un hilo de renderizado propio, nunca en los hilos que ejecutan las partidas, y con la prioridad de CPU más baja: `GIF_THREADS` hilos (`WORKERS` de forma predeterminada), que se inician con el primer GIF y se detienen tras un minuto sin ninguno. Hasta `GIF_QUEUE_MAX` (4 x `WORKERS`) GIF pueden esperar un hilo, como máximo `GIF_QUEUE_TIMEOUT_MS` (10 s) cada uno; un renderizado puede durar `GIF_RENDER_TIMEOUT_MS` (30 s). El servidor guarda los GIF que ha creado en una caché de `GIF_CACHE_MB` MB (32 x `WORKERS`), de la que salen primero los usados hace más tiempo; un GIF de la caché, o uno que se está creando para otra solicitud, no cuesta ningún renderizado. La clave de la caché incluye todo lo que cambia la imagen, nombres incluidos.

      Toda solicitud cuenta en `gif` (30 por minuto y por jugador para los dos endpoints). Un GIF que hay que crear cuenta además en los límites de renderizado: por jugador, `GIF_USER_RENDERS_PER_MIN` (4) por minuto y `GIF_USER_RENDERS_PER_HOUR` (30) por hora; por cliente, con todas sus cuentas juntas, `GIF_IP_RENDERS_PER_MIN` (12) por minuto y `GIF_IP_RENDERS_PER_HOUR` (120) por hora, y el triple por prefijo IPv6 /48. Un cliente debe conservar el archivo que ha descargado en lugar de volver a pedirlo, y esperar `retryAfter` tras un 429 o un 503.

      Tamaños: `small` (casillas de 32 px: 284 x 350 píxeles), `medium` (48 px: 424 x 515), `large` (72 px: 628 x 762); sin coordenadas, 268 x 342, 400 x 503 y 600 x 748. La posición inicial se muestra al menos 1 s (o el retardo indicado, si es mayor), la posición final 3 s, y el GIF se reproduce en bucle; a partir del segundo fotograma solo se guarda la parte de la imagen que cambia. Una partida de 40 jugadas ocupa unos 135, 205 y 325 KiB (small, medium, large), y una de 150 jugadas, 0,5; 0,8 y 1,2 MiB.
  - name: players
    x-displayName: Jugadores
    description: 'Perfiles públicos y partidas recientes. Solo datos públicos: nunca una dirección de correo, una sesión, una sanción ni un nivel de integridad. Una cuenta eliminada no tiene perfil.'
  - name: leaderboard
    x-displayName: Clasificación
    description: Los mejores jugadores de cada categoría oficial.
  - name: reports
    x-displayName: Denuncias
    description: Denuncia del rival de una partida reciente ante los moderadores.
  - name: pages
    x-displayName: Páginas HTML
    description: |-
      Páginas para un navegador, que se abren desde los enlaces de los correos. Sus enlaces apuntan a `https://<SERVER_PUBLIC_HOST>` (con `:<PUBLIC_API_PORT>` cuando no es 443), fuera de `/api/v1`.

      - Las páginas no ejecutan JavaScript ni cargan ningún recurso externo. Se sirven con `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - Un `GET` solo muestra un botón o un formulario, para que un analizador de correo que abra el enlace no lo consuma. El cambio se produce con `POST`: un formulario enviado como `application/x-www-form-urlencoded` (también se acepta un cuerpo JSON), con el token del enlace en un campo oculto. Se rechaza un campo que aparezca dos veces.
      - Los errores (límites de frecuencia, campos no válidos) también son páginas HTML, tituladas «Request refused» (solicitud rechazada) o, a partir de 500, «Server error» (error del servidor). Solo responden en JSON los fallos detectados antes de identificar la página (un destino de solicitud demasiado largo o mal formado, la capa por dirección).
x-tagGroups:
  - name: server
    x-displayName: Servidor
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Cuentas
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Partidas
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Jugadores y denuncias
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Páginas para el navegador
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Obtener el nombre, las versiones, los puertos y las reglas de registro del servidor
      description: |-
        Lo que un cliente necesita antes de iniciar sesión o de conectarse: el nombre y el identificador del servidor, las versiones del protocolo WebSocket y dónde está el WebSocket, las reglas de registro, los ritmos de juego oficiales y los límites que un cliente puede comprobar antes de enviar un formulario.

        **Límites:** solo la capa por dirección.
      security: []
      responses:
        '200':
          description: La descripción del servidor.
          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 capa por dirección.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Crear una cuenta
      description: |-
        Crea una cuenta: en cuanto se confirma su dirección de correo con el enlace que se le envía, o de inmediato si no se exige confirmación del correo.

        **Con confirmación del correo** (`REQUIRE_EMAIL_VERIFICATION`, el valor predeterminado): 202 `verification_sent`. Todavía no existe ninguna cuenta: el registro queda pendiente durante 24 horas y, entretanto, reserva su nombre de usuario. Se envía a la dirección un enlace válido durante esas 24 horas, como máximo uno por dirección cada 5 minutos (`POST /auth/verify-email/resend` envía uno nuevo); la cuenta se crea, con su dirección confirmada, cuando se usa el enlace (el botón de la página `/verify-email`), y entonces el jugador puede iniciar sesión. Hasta ese momento, un inicio de sesión con ese nombre de usuario responde `invalid_credentials`, como para una cuenta desconocida, y el perfil público no existe. Un nuevo registro con la misma dirección sustituye al pendiente. La respuesta es la misma cuando otra cuenta ya usa la dirección: en ese caso no se envía ningún enlace, sino que su titular recibe un aviso (como máximo uno por hora), y el nombre de usuario queda reservado de la misma manera, de modo que nada revela si la dirección tiene una cuenta. Un registro cuyo enlace no se ha usado se descarta al cabo de 24 horas, y su nombre de usuario vuelve a quedar libre.

        **Sin confirmación del correo** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`; la cuenta se crea de inmediato y puede iniciar sesión.

        **Reglas.** `username`: de `USERNAME_MIN` a `USERNAME_MAX` caracteres (de 3 a 20), letras, cifras, `_` y `-`, empezando por una letra o una cifra (`GET /info` los indica en `limits`); se rechazan los nombres reservados (`admin`, `moderator`, `deleted`...) y algunos prefijos; único sin distinguir mayúsculas de minúsculas. `email`: sin espacios en los extremos y guardado en minúsculas, en ASCII simple y con un dominio que contenga un punto. `password`: al menos `PASSWORD_MIN_LENGTH` (10) caracteres y como máximo 256 bytes en UTF-8; no debe contener el nombre de usuario ni la parte local del correo, ni ser una contraseña común.

        **Errores**, comprobados en este orden: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; los errores de la cola de hash de contraseñas; 409 `email_taken` (solo sin confirmación del correo).

        **Límites:** `auth` y después `auth_register` (10 registros por hora y por cliente). **Prueba de trabajo:** siempre, cuando `POW_REGISTER_BITS` es mayor que 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Primer intento, sin prueba de trabajo
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Nuevo envío con la prueba de trabajo
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: La cuenta se ha creado y puede iniciar sesión (este servidor no exige confirmación del correo).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: El registro espera a que se use su enlace de confirmación (o la dirección ya tiene una cuenta; la respuesta no lo revela).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationSentStatus'
              example:
                status: verification_sent
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request` (con `field`), `invalid_json`, `invalid_username`, `invalid_email`, o `weak_password` con `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` o `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: el registro está cerrado en este servidor (`GET /info` indica `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: una cuenta tiene ese nombre de usuario, o lo reserva un registro pendiente de otra dirección. `email_taken`: otra cuenta usa la dirección (solo sin confirmación del correo).'
        '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`: el límite `auth` o `auth_register`, o la cola de hash de contraseñas.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la cola de hash de contraseñas, o la base de datos ha seguido bloqueada), `timeout`.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Iniciar sesión con contraseña
      description: |-
        Inicia sesión con un nombre de usuario o una dirección de correo y una contraseña. Hay dos respuestas posibles, ambas con el estado 200: una sesión o, cuando la autenticación de dos factores está activada, un segundo paso que debe completarse en 5 minutos con `POST /auth/login/mfa`.

        Una cuenta desconocida, una contraseña incorrecta y una cuenta sin contraseña reciben la misma respuesta, tras el mismo tiempo: 401 `invalid_credentials`. Las comprobaciones de la cuenta (`banned`, `email_unverified`) solo se hacen tras una contraseña correcta. A partir de `AUTH_FAILURES_PER_ACCOUNT` (5) fallos con un mismo nombre de inicio de sesión, cada intento debe esperar (429 `too_many_attempts`).

        **Límite:** `auth`. **Prueba de trabajo:** solo durante una oleada de inicios de sesión fallidos (428 `pow_required`).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
            example:
              login: alice
              password: correct horse battery
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: Una sesión, o el segundo paso de un inicio de sesión con autenticación de dos factores.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Sesión iniciada
                  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 autenticación de dos factores está activada
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: cuenta desconocida, contraseña incorrecta o cuenta sin contraseña.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Solo tras una contraseña correcta: `banned`, con `until` (ms desde la época Unix; `null` para una suspensión permanente), o `email_unverified` (una cuenta antigua cuya dirección aún no está confirmada).'
        '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` (el retardo por fallos de este nombre de inicio de sesión), o `rate_limited` (el límite `auth`, la cola de hash de contraseñas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la cola de hash de contraseñas, o la base de datos ha seguido bloqueada), `timeout`.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Completar un inicio de sesión con un segundo factor
      description: |-
        El segundo paso de un inicio de sesión con autenticación de dos factores, después de que `POST /auth/login` o un inicio de sesión con Google (`POST /auth/sso/google/finish` o `POST /auth/sso/google/link`) haya respondido `mfaRequired`. Envíe `code` (un código de 6 cifras de la aplicación de autenticación, o un código de recuperación) o `recoveryCode`. Un código de recuperación usado aquí deja de existir.

        Un paso acepta como máximo 5 códigos erróneos; el paso también termina cuando se restablece o se cambia la contraseña. Tras el paso de la contraseña de una vinculación con Google, el vínculo solo se guarda cuando el código se acepta aquí.

        **Límites:** `auth`, y como máximo `AUTH_MFA_PER_ACCOUNT` (10) códigos cada 15 minutos para la cuenta, desde cualquier dirección; por encima, el código no se comprueba, de modo que no se gasta ningún código de recuperación.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Un código de la aplicación de autenticación
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Un código de recuperación
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Sesión iniciada.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: no se envió ni `code` ni `recoveryCode`, o el cuerpo incumple el esquema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: el paso ha caducado, ya se ha usado o terminó tras 5 códigos erróneos, o la contraseña se ha restablecido o cambiado desde el primer paso (vuelva a iniciar sesión). `invalid_code`: un código erróneo o ya usado.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (con `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: solo tras el paso de la contraseña de una vinculación con Google; entretanto, la cuenta de Google se ha vinculado a otra cuenta.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: solo tras el paso de la contraseña de una vinculación con Google; entretanto, la cuenta ha cambiado; vuelva a empezar desde el juego.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: el retardo por fallos de la cuenta, o se han agotado sus `AUTH_MFA_PER_ACCOUNT` códigos de los últimos 15 minutos. `rate_limited`: el límite `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` con `retryAfter: 1`: la base de datos ha seguido bloqueada (tras un paso de vinculación con Google, no se ha vinculado nada: vuelva a empezar desde el juego). `timeout`.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Cerrar esta sesión
      description: |-
        Revoca la sesión del token utilizado. El WebSocket abierto con ella se cierra. Sin cuerpo (o `{}`).

        **Límite:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Sesión cerrada.
          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 cuerpo distinto de `{}`; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `sessions` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de datos ha seguido bloqueada), `timeout`.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Cerrar todas las sesiones
      description: |-
        Revoca todas las sesiones de la cuenta, incluida esta. Los WebSockets abiertos con ellas se cierran. Sin cuerpo (o `{}`).

        **Límite:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Se han cerrado todas las sesiones.
          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 cuerpo distinto de `{}`; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `sessions` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de datos ha seguido bloqueada), `timeout`.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Reenviar el enlace de confirmación
      description: |-
        Vuelve a enviar el enlace de confirmación del correo. La respuesta es 202 `accepted` sea cual sea la dirección, de modo que nunca revela si una cuenta o un registro la usan.

        Una solicitud surte efecto como máximo una vez cada 5 minutos por dirección. Un registro pendiente con esa dirección recupera sus 24 horas, la use o no otra cuenta, para que su nombre de usuario siga reservado el mismo tiempo en ambos casos. Solo se envía un enlace para ese registro cuando la dirección no tiene cuenta (un nuevo enlace, válido durante 24 horas, sustituye al anterior), o para una cuenta activa y sin confirmar con esa dirección.

        **Límites:** `auth` y después `auth_mail` (10 por hora y por cliente).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Aceptada (sea cual sea la dirección).
          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`: el límite `auth` o `auth_mail` (sea cual sea la dirección).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` con `retryAfter: 1`: la base de datos ha seguido bloqueada mientras se buscaba la cuenta (la renovación de un registro pendiente se hace en la medida de lo posible y nunca hace fallar la solicitud). `timeout`.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Enviar un enlace de restablecimiento de contraseña
      description: |-
        Envía un enlace de restablecimiento de contraseña, válido durante una hora, que abre la página `/reset-password`. La respuesta es 202 `accepted` sea cual sea la dirección. Solo se envía un enlace a una cuenta activa, como máximo una vez cada 5 minutos por dirección. Una cuenta que solo usa Google define así su primera contraseña.

        La recuperación de la contraseña tiene los límites más estrictos de la API, todos contados para el servidor entero: 3 solicitudes por hora (`AUTH_FORGOT_PER_HOUR`) y 10 cada 24 horas (`AUTH_FORGOT_PER_DAY`) por cliente (una dirección IPv4 o un prefijo IPv6 /64), el triple de esas cifras por prefijo IPv6 /48, además del límite `auth` (20 cada 10 minutos) y de un único correo por dirección cada 5 minutos. Ni el 202 ni el 429 revelan si una cuenta usa la dirección. La definición de la nueva contraseña tiene su propio límite (`auth_reset`).

        **Límites:** `auth`, `auth_forgot` y después `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Aceptada (sea cual sea la dirección).
          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`: el límite `auth`, `auth_forgot` o `auth_forgot_day` (sea cual sea la dirección).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` con `retryAfter: 1`: la base de datos ha seguido bloqueada. `timeout`.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Definir una nueva contraseña con el token de un enlace de restablecimiento
      description: |-
        Define una nueva contraseña con el token de un enlace de restablecimiento (la página `/reset-password` hace lo mismo). La nueva contraseña sigue las reglas del registro.

        El restablecimiento revoca todas las sesiones y cancela un cambio de correo pendiente; los demás enlaces de restablecimiento de la cuenta dejan de funcionar; la dirección se considera confirmada (el enlace lo ha demostrado); el titular recibe un correo. La autenticación de dos factores no se modifica. Tras un error de la cola de hash de contraseñas o un 503, el enlace sigue siendo válido.

        **Límites:** `auth` y después `auth_reset` (10 por hora y por cliente, 30 por prefijo IPv6 /48, compartido con la página: cada intento calcula el hash de una contraseña).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: La contraseña se ha cambiado y se han cerrado todas las sesiones.
          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`: el enlace no es válido, ya se ha usado o ha caducado, o se envió a una dirección que la cuenta ya no tiene. `weak_password` (con `reason`). `invalid_request`, `invalid_json`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `auth` o `auth_reset`, o la cola de hash de contraseñas.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: la cola de hash de contraseñas, o la base de datos ha seguido bloqueada (`retryAfter: 1`, sin ningún cambio). `timeout`.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Comenzar un inicio de sesión con Google
      description: |-
        Comienza un intento de inicio de sesión con Google para el desafío PKCE y el puerto en el que escucha el juego en `127.0.0.1`. La respuesta da la URL de Google que se debe abrir en el navegador del sistema y el `state` del intento; el intento es válido durante 10 minutos.

        `authUrl` contiene `client_id`, `redirect_uri` (construida a partir de `redirectPort` y de la etiqueta de origen del servidor), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 del verificador propio del servidor ante Google) con `code_challenge_method=S256`, y `prompt=select_account`. El juego la comprueba antes de abrirla (véase la descripción de la etiqueta).

        **Límite:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: El intento.
          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`: este servidor no ofrece el inicio de sesión con Google.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de datos ha seguido bloqueada), `timeout`.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Transmitir al servidor la respuesta de Google
      description: |-
        Transmite al servidor el `code` y el `state` que Google envió al puerto de escucha del juego, junto con el identificador del intento y el verificador PKCE. Un identificador de intento no sirve de nada sin el verificador, y el código no sirve de nada sin el verificador PKCE propio del servidor y su secreto de cliente.

        El servidor comprueba el intento (desconocido, ya usado o caducado: 410), después el verificador (uno erróneo deja el intento utilizable), y luego consume el intento, comprueba `state` e `iss`, canjea el código con Google y verifica el token de ID. La respuesta (200) es una de estas:

        - `{ token, expiresAt, user }`: sesión iniciada en la cuenta vinculada;
        - `{ mfaRequired, mfaToken, expiresIn }`: continúe con `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: una cuenta nueva; continúe con `POST /auth/sso/complete` en un plazo de 10 minutos. `suggestedUsername` procede del nombre de Google o de la dirección, y es `""` cuando nada encaja;
        - `{ needsPassword, linkTicket, username, expiresIn }`: la cuenta con esa dirección tiene contraseña; continúe con `POST /auth/sso/google/link`. Todavía no se ha vinculado nada.

        **Límite:** `sso_finish`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoFinishRequest'
            example:
              attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
              codeVerifier: Sb5SaoX0ByHGjJ0XQkEqaaPaJYx2BId4ncTT8tLM6W8
              state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
              code: 4/0AVMBsJhR2x7cKq9vT1pLmN3oW8yZ5aB6dE7fG8hJ
              iss: https://accounts.google.com
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: Una sesión, un segundo paso o el paso siguiente de un primer inicio de sesión con Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Sesión iniciada en la cuenta vinculada
                  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 autenticación de dos factores está activada
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Un jugador nuevo
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Una cuenta con contraseña usa la dirección
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: el verificador no corresponde al desafío del intento (el intento sigue siendo utilizable). `sso_email_unverified`: Google no ha confirmado la dirección. `registration_closed`. `account_disabled`. Solo para una cuenta vinculada: `banned` (con `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: este servidor no ofrece el inicio de sesión con Google.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: una cuenta activa sin contraseña usa la dirección (no se indica cuál).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: un intento desconocido, ya usado o caducado.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de datos ha seguido bloqueada), `timeout`.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Vincular Google a una cuenta existente con su contraseña
      description: |-
        Después de que `finish` haya respondido `needsPassword`: vincula Google a la cuenta existente con la contraseña de esa cuenta, introducida en el juego. La respuesta (200) es una sesión (el vínculo se guarda y el jugador inicia sesión) o, cuando la autenticación de dos factores está activada, `{ mfaRequired, mfaToken, expiresIn }`: continúe con `POST /auth/login/mfa`; el vínculo solo se guarda cuando allí se acepta un código.

        Una contraseña incorrecta deja el ticket utilizable, hasta 5 intentos en total. Un intento se descuenta justo antes de comprobar la contraseña: un 429 `too_many_attempts` o un 428 `pow_required` no descuentan ninguno, mientras que un rechazo de la cola de hash de contraseñas (503 `server_busy`, 429 `rate_limited`) ya ha descontado uno y cuenta como un fallo de la cuenta. El retardo por fallos usa el mismo contador que `POST /auth/login`.

        **Límite:** `auth` (con su recuento por prefijo IPv6 /48). **Prueba de trabajo:** como en `POST /auth/login`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoLinkRequest'
            example:
              linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
              password: correct horse battery
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: Una sesión, o el segundo paso del inicio de sesión.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: una contraseña incorrecta; el ticket sigue siendo válido, hasta 5 intentos en total.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (con `until`), solo tras una contraseña correcta.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: este servidor no ofrece el inicio de sesión con Google.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: entretanto, la cuenta de Google se ha vinculado a otra cuenta.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: un ticket desconocido, ya usado o caducado, la 5.ª contraseña incorrecta, una cuenta cuyo estado o dirección ha cambiado desde `finish`, o cuyo estado, dirección, contraseña o autenticación de dos factores ha cambiado mientras se guardaba el vínculo. Vuelva a empezar desde el juego.'
        '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` (el retardo por fallos de la cuenta), o `rate_limited` (el límite `auth`, la cola de hash de contraseñas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: la cola de hash de contraseñas, o la base de datos ha seguido bloqueada (`retryAfter: 1`, no se ha vinculado nada). `timeout`.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Crear la cuenta de un primer inicio de sesión con Google
      description: |-
        Después de que `finish` haya respondido `needsUsername`: crea la cuenta con el nombre de usuario elegido (se aplican las reglas del registro) e inicia su sesión. La cuenta no tiene contraseña: `hasPassword` es `false`, y «¿Olvidó su contraseña?» (`POST /auth/password/forgot`) le asigna una.

        **Límite:** `auth` (con su recuento por prefijo IPv6 /48).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoCompleteRequest'
            example:
              ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
              username: alice
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: La cuenta se ha creado y tiene la sesión iniciada.
          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` o `invalid_json`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: este servidor no ofrece el inicio de sesión con Google.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (una cuenta tiene ese nombre de usuario, o lo reserva un registro pendiente de otra dirección), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: un ticket desconocido, ya usado o caducado.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de datos ha seguido bloqueada), `timeout`.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Listar los dispositivos con sesión iniciada
      description: |-
        Las sesiones activas de la cuenta (dispositivos con sesión iniciada), primero las usadas más recientemente. `current` marca la sesión que hace la solicitud. `lastSeenAt` se actualiza como máximo cada 5 minutos; `expiresAt` es el final absoluto, y el límite de inactividad puede terminar la sesión antes.

        **Límite:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Las sesiones activas.
          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`: el límite `sessions` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de datos ha seguido bloqueada), `timeout`.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Cerrar la sesión de un dispositivo
      description: |-
        Cierra una sesión de la cuenta; también se puede cerrar la sesión actual. El WebSocket abierto con ella se cierra. Sin cuerpo (o `{}`).

        **Límite:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: La sesión se ha cerrado.
          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 cuerpo distinto de `{}`, o un `id` que no es una codificación URL válida; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: ninguna sesión activa con este identificador en esta cuenta.'
        '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`: el límite `sessions` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de datos ha seguido bloqueada), `timeout`.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Obtener la cuenta, sus puntuaciones y sus sanciones activas
      description: |-
        La cuenta tal como la ve su jugador: la vista de la cuenta (también el `user` de todas las respuestas de inicio de sesión), un registro de puntuación por cada categoría en la que el jugador ha jugado partidas puntuables, las sanciones activas y la suspensión. El nivel de integridad del sistema antitrampas nunca se muestra.

        **Límite:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: La cuenta.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountMe'
              example:
                user:
                  id: 1
                  username: alice
                  email: alice@example.org
                  emailVerified: true
                  mfaEnabled: true
                  googleLinked: false
                  hasPassword: true
                  acceptChallenges: all
                  createdAt: 1790882871478
                  lastLoginAt: 1790882902200
                  pendingEmail: alice.new@example.org
                ratings:
                  - category: '3+2'
                    rating: 1510
                    games: 2
                    wins: 1
                    draws: 1
                    losses: 0
                    peak: 1510
                    provisional: true
                sanctions: []
                ban: null
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`unauthorized`, o `invalid_token` (también cuando la cuenta se ha eliminado).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `account` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de datos ha seguido bloqueada), `timeout`.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Aceptar o rechazar los retos directos
      description: |-
        Indica si otros jugadores pueden retar a este jugador por su nombre. Con `none`, los retos directos se rechazan: al retador se le indica que el jugador no está disponible. Sin reautenticación.

        **Límite:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Las preferencias ya en vigor.
          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`: el límite `account` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de datos ha seguido bloqueada), `timeout`.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Cambiar la contraseña
      description: |-
        Cambia la contraseña. Se necesita la contraseña actual, pero ningún segundo factor, aunque la autenticación de dos factores esté activada. La nueva contraseña sigue las reglas del registro (se comprueban después de la contraseña actual).

        El cambio revoca todas las demás sesiones (esta sigue iniciada), cancela un cambio de correo pendiente, invalida los enlaces de restablecimiento de contraseña de la cuenta y envía un correo al titular.

        **Reautenticación:** solo la contraseña. **Límites:** `reauth` y después `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordChangeRequest'
            example:
              currentPassword: correct horse battery
              newPassword: a much better passphrase
      responses:
        '200':
          description: La contraseña se ha cambiado.
          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` (una cuenta que solo usa Google), `weak_password` (con `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: una contraseña actual incorrecta, o un restablecimiento o cambio de contraseña que se produjo mientras se comprobaba la solicitud.'
        '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` (reautenticaciones fallidas de la cuenta), o `rate_limited` (el límite `reauth` o `reauth_user`, el presupuesto de la cuenta, la cola de hash de contraseñas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la cola de hash de contraseñas, o la base de datos ha seguido bloqueada), `timeout`.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Empezar a activar la autenticación de dos factores
      description: |-
        Guarda un nuevo secreto pendiente para la aplicación de autenticación, que sustituye a cualquier otro pendiente anterior, y lo devuelve para la aplicación de autenticación (como texto y como URI `otpauth://` para mostrar en forma de código QR). La autenticación de dos factores aún no está activada: `POST /account/mfa/totp/enable` la activa con un código de este secreto.

        **Reautenticación:** solo la contraseña. **Límites:** `reauth` y después `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: El secreto pendiente.
          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` (una cuenta que solo usa 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` (se comprueba antes que la contraseña).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautenticaciones fallidas de la cuenta), o `rate_limited` (el límite `reauth` o `reauth_user`, el presupuesto de la cuenta, la cola de hash de contraseñas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la cola de hash de contraseñas, o la base de datos ha seguido bloqueada), `timeout`.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Terminar de activar la autenticación de dos factores y obtener los códigos de recuperación
      description: |-
        Activa la autenticación de dos factores con un código del secreto pendiente (exactamente 6 cifras). Aquí no se pide la contraseña: ya se dio en la configuración. La respuesta contiene 10 códigos de recuperación, que solo se muestran esta vez.

        **Límites:** `reauth` y después `reauth_user`; un código erróneo cuenta en los fallos de reautenticación de la cuenta.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: La autenticación de dos factores está activada.
          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` no tiene exactamente 6 cifras, o el cuerpo incumple el esquema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: un código erróneo (compruebe la hora del dispositivo).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: no hay ningún secreto pendiente (llame primero a `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` (reautenticaciones fallidas de la cuenta), o `rate_limited` (el límite `reauth` o `reauth_user`, el presupuesto de la cuenta).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la base de datos ha seguido bloqueada), `timeout`.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Desactivar la autenticación de dos factores
      description: |-
        Desactiva la autenticación de dos factores. El secreto y los códigos de recuperación se eliminan, y el titular recibe un correo.

        **Reautenticación:** la contraseña y un código de la aplicación de autenticación o un código de recuperación (se requiere `code` o `recoveryCode`). **Límites:** `reauth` y después `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 autenticación de dos factores está desactivada.
          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` (una cuenta que solo usa Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (ni `code` ni `recoveryCode`; se comprueba antes que la contraseña), `invalid_password`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_not_enabled`.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautenticaciones fallidas, o demasiados códigos probados en esta cuenta), o `rate_limited` (el límite `reauth` o `reauth_user`, el presupuesto de la cuenta, la cola de hash de contraseñas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la cola de hash de contraseñas, o la base de datos ha seguido bloqueada), `timeout`.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Sustituir los códigos de recuperación
      description: |-
        Sustituye los códigos de recuperación por 10 nuevos; los anteriores dejan de funcionar.

        **Reautenticación:** la contraseña y un código de la aplicación de autenticación en `code` (un código de recuperación se rechaza con 403 `invalid_code`). **Límites:** `reauth` y después `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: Los nuevos códigos de recuperación, que solo se muestran esta vez.
          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` (una cuenta que solo usa Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (también para un código de recuperación).'
        '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` (reautenticaciones fallidas, o demasiados códigos probados en esta cuenta), o `rate_limited` (el límite `reauth` o `reauth_user`, el presupuesto de la cuenta, la cola de hash de contraseñas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la cola de hash de contraseñas, o la base de datos ha seguido bloqueada), `timeout`.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Cambiar la dirección de correo
      description: |-
        Cambia la dirección de correo de la cuenta. A `newEmail` se le quitan los espacios de los extremos y se pasa a minúsculas; después se comprueba como una dirección de registro.

        **Con confirmación del correo** (el valor predeterminado): 202 `verification_sent`. Se envía a la nueva dirección un enlace válido durante 24 horas; abre `/confirm-email-change`, y la dirección solo cambia cuando el jugador pulsa el botón de esa página. Hasta entonces, `GET /account/me` muestra la nueva dirección como `pendingEmail`. Una nueva solicitud sustituye a la pendiente; un cambio o restablecimiento de contraseña la cancela. Como máximo se envía un enlace a una misma dirección nueva cada 5 minutos, sea quien sea quien lo pida (una solicitud para el cambio que ya está pendiente conserva el enlace enviado antes, que sigue siendo válido); la respuesta es la misma. La dirección actual recibe un aviso de que se ha solicitado un cambio a una dirección enmascarada (`a***@example.org`). La respuesta y `pendingEmail` son los mismos cuando otra cuenta ya usa la nueva dirección: en ese caso no se envía ningún enlace, de modo que ese cambio nunca se completa, y el titular de esa dirección recibe en su lugar un aviso (como máximo uno por hora).

        Cuando se confirma el enlace, la dirección cambia y se considera confirmada, los dispositivos mantienen la sesión iniciada, los enlaces enviados anteriormente (confirmación, restablecimiento de contraseña, otros cambios) dejan de funcionar, y se avisa a la dirección anterior, con la nueva enmascarada.

        **Sin confirmación del correo** (`REQUIRE_EMAIL_VERIFICATION=false`): la dirección cambia de inmediato (200 `email_changed`) y se avisa a la dirección anterior. Si otra cuenta usa la dirección, la respuesta es 409 `email_taken`, y el titular de esa dirección recibe el aviso.

        **Reautenticación:** la contraseña y, con la autenticación de dos factores, un código de la aplicación de autenticación o un código de recuperación. `invalid_email` y `same_email` se comprueban antes que la contraseña, por lo que no cuentan como fallo. **Límites:** `reauth` y después `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: La dirección ha cambiado de inmediato (este servidor no exige confirmación del correo).
          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 nueva dirección, tal como se ha guardado.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Se ha enviado un enlace de confirmación a la nueva dirección (o la dirección pertenece a otra cuenta; la respuesta no lo revela).
          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` (la dirección actual de la cuenta), `password_not_set` (una cuenta que solo usa Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (también cuando un cambio o restablecimiento de contraseña se ha adelantado a la solicitud: no se envía ningún enlace), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: solo sin confirmación del correo.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautenticaciones fallidas, o demasiados códigos probados en esta cuenta), o `rate_limited` (el límite `reauth` o `reauth_user`, el presupuesto de la cuenta, la cola de hash de contraseñas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: la cola de hash de contraseñas, o la base de datos ha seguido bloqueada (`retryAfter: 1`: no ha cambiado nada y se puede volver a enviar la misma solicitud). `timeout`.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Descargar los datos de la cuenta
      description: |-
        Todo lo que el servidor guarda sobre la cuenta, en un único archivo JSON para guardar (`format` `scacelith-account-export`, `version` 1). La exportación registra un evento de seguridad (`account_exported`). La matriz `notes` del documento indica al jugador, en un inglés sencillo, lo que queda fuera.

        **Nunca figura en la exportación:** el hash de la contraseña, el secreto de la autenticación de dos factores ni los códigos de recuperación; ningún token de sesión o de enlace, ni su hash; los datos del sistema antitrampas (nivel y puntuación de integridad, anomalías, el análisis de las partidas, el peso de una denuncia); las denuncias que otros jugadores hayan presentado contra el jugador; la identidad de los moderadores; los datos privados de otros jugadores (los rivales aparecen con su nombre público y su puntuación, nada indica si otro jugador ha sido sancionado, y no se incluye ninguna dirección IP que pueda pertenecer a otra persona).

        **Reautenticación:** la contraseña y, con la autenticación de dos factores, un código de la aplicación de autenticación o un código de recuperación. **Límites:** `account_export` (5 por hora y por jugador, para el servidor entero; todo intento cuenta, incluidos los fallidos; se comprueba primero), y después `reauth` y `reauth_user`. **Tiempo máximo:** 60 segundos.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: El documento de exportación, como archivo adjunto.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Los caracteres del nombre de usuario que no sean letras, cifras, `_`, `.` ni `-` se convierten en `_`.'
              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` (una cuenta que solo usa Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required` o `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` (reautenticaciones fallidas, o demasiados códigos probados en esta cuenta), o `rate_limited` (el límite `account_export`, `reauth` o `reauth_user`, el presupuesto de la cuenta, la cola de hash de contraseñas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de datos ha seguido bloqueada, con un encabezado `Retry-After: 1`), `server_busy` (la cola de hash de contraseñas), `timeout` (60 segundos).'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Eliminar la cuenta
      description: |-
        Elimina la cuenta; esta acción no se puede deshacer.

        - Todas las sesiones se revocan de inmediato: a partir de ese momento, el token recibe 401 `invalid_token`.
        - El nombre de usuario pasa a ser `deleted#<id>`, en la cuenta y en todos los registros de partidas.
        - Se borran: la dirección de correo, el hash de la contraseña, el secreto de la autenticación de dos factores y los códigos de recuperación, las sesiones y los tokens de enlace, el vínculo con Google, el registro de integridad del sistema antitrampas y las direcciones IP guardadas con los eventos de seguridad.
        - Las puntuaciones y las partidas se conservan. Las partidas siguen pudiendo consultarse con el nombre anónimo, y `GET /players/{username}` responde 404 para el nombre anterior.

        **Reautenticación:** la contraseña y, con la autenticación de dos factores, un código de la aplicación de autenticación o un código de recuperación. **Límites:** `reauth` y después `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 cuenta se ha eliminado.
          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` (una cuenta que solo usa Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required` o `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` (reautenticaciones fallidas, o demasiados códigos probados en esta cuenta), o `rate_limited` (el límite `reauth` o `reauth_user`, el presupuesto de la cuenta, la cola de hash de contraseñas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la cola de hash de contraseñas, o la base de datos ha seguido bloqueada), `timeout`.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Listar las partidas del jugador, filtradas y paginadas
      description: |-
        Las partidas del jugador con la sesión iniciada, de la más reciente a la más antigua, filtradas y paginadas, con el número de partidas que cumplen el filtro. Todos los parámetros de consulta son opcionales, y un valor vacío equivale a su ausencia.

        Paginación: pase el `next` de la página anterior como `before`; `next` es `null` en la última página. `total` cuenta las partidas que cumplen el filtro, en todas las páginas.

        **Límite:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: Un identificador de categoría oficial (`3+2`, o `3%2B2`), o `custom` para todas las partidas con otro ritmo de juego.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Solo las partidas puntuables (`true`) o amistosas (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Solo las partidas ganadas, perdidas o en tablas, desde el punto de vista del jugador. Las partidas anuladas solo aparecen sin este filtro.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Una página del historial.
          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` no es un identificador de partida), `invalid_limit`, o `invalid_filter` (`category`, `rated` o `result`), cada uno con `field` indicando el parámetro.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `account_games` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de datos ha seguido bloqueada; `retryAfter: 1` en el cuerpo, sin encabezado `Retry-After`), `server_busy` (la búsqueda de la sesión encontró la base de datos bloqueada), `timeout`.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Obtener el registro de una partida con sus jugadas y sus relojes
      description: |-
        El registro de una partida, con sus jugadas y sus relojes. Sin token, o con el token de un jugador que no jugó la partida, la respuesta es la pública. Cuando el jugador del token jugó la partida, la respuesta añade `you` y `reportable`.

        **Límite:** `public_read` (por jugador con token, por cliente sin él).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: El registro de la partida.
          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`: no es un entero positivo de 16 cifras como máximo (por debajo de 2^53); `invalid_request`: no es una codificación URL válida.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: se envió un token y no es válido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: no existe esa partida.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `public_read` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de datos ha seguido bloqueada; `retryAfter: 1` en el cuerpo, sin encabezado `Retry-After`), `server_busy` (la búsqueda de la sesión encontró la base de datos bloqueada), `timeout`.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Descargar una partida como archivo PGN
      description: |-
        La misma partida como archivo PGN: una sola partida con finales de línea `\n`, y el texto de las jugadas en líneas de menos de 80 columnas. La respuesta es la misma con o sin token.

        Etiquetas, en este orden: `Event` (`<SERVER_NAME> rated <category>` o `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (la fecha UTC de inicio), `Round`, `White`, `Black`, `Result` (`*` para una partida anulada), `UTCDate` y `UTCTime` (el inicio), `WhiteElo` y `BlackElo` (las puntuaciones al inicio, o `-`), `WhiteRatingDiff` y `BlackRatingDiff` (las variaciones, como `+10` y `-10`, en todas las partidas puntuables, `+0` cuando las reglas de puntuación dejan la puntuación donde estaba; una partida amistosa, personalizada o anulada no tiene ninguna de las dos), `TimeControl` (en segundos), `Termination` (un valor del estándar PGN: `normal`; `time forfeit`, una caída de bandera, también cuando termina en tablas; `abandoned`; `rules infraction`, una segunda jugada ilegal o una pérdida por infracción del juego limpio; `unterminated`, una partida anulada), `PlyCount`, `ScacelithGameId` (el identificador decimal).

        Cada jugada lleva `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: el reloj del jugador que mueve después de la jugada y el tiempo que se le descontó por ella, en décimas de segundo (truncadas). Un valor se omite cuando el registro no lo tiene. Tras la última jugada figura el motivo del final, en palabras, y después el resultado.

        **Límite:** `public_read` (por jugador con token, por cliente sin él).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: El archivo PGN.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: 'Siempre `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: 'El texto 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`: no es un entero positivo de 16 cifras como máximo (por debajo de 2^53); `invalid_request`: no es una codificación URL válida.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: se envió un token y no es válido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: no existe esa partida.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `public_read` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: entre otros casos, las jugadas guardadas no se pueden reproducir hasta el final guardado (el servidor lo registra).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de datos ha seguido bloqueada; `retryAfter: 1` en el cuerpo, sin encabezado `Retry-After`), `server_busy` (la búsqueda de la sesión encontró la base de datos bloqueada), `timeout`.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Descargar una partida de este servidor como GIF animado
      description: |-
        La partida como GIF animado. Los nombres y las puntuaciones son los del registro de la partida (las puntuaciones al inicio, una cuenta eliminada como `deleted#<id>`), y lo mismo ocurre con el resultado y el final (`Resignation`, `Loss on time`...).

        Las comprobaciones se hacen en este orden: `gif_disabled`, las opciones de la imagen (`size`, `orientation`, `delay`, `coords`), el identificador de la partida, la partida, su longitud.

        **Sesión obligatoria:** las cuotas se cuentan por cuenta. **Límites:** `gif` en cada solicitud; `gif_user_min`, `gif_user_hour`, `gif_ip_min` y `gif_ip_hour` solo cuando hay que crear el GIF (véase la descripción de la etiqueta). **Tiempo máximo:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 segundos (45 segundos de forma predeterminada).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: 'El tamaño de la imagen: `small` (casillas de 32 px), `medium` (48 px) o `large` (72 px).'
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: El bando situado en la parte inferior del tablero.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Milisegundos por jugada (de 1 a 6 cifras decimales).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Si se dibujan alrededor del tablero las letras de las columnas y los números de las filas (`1`) o no (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` con `field` (`size`, `orientation`, `delay` o `coords`): un valor fuera de los permitidos. `invalid_game_id`. `invalid_request`: no es una codificación URL válida.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: no existe esa partida. `gif_disabled`: el servidor ha desactivado los GIF (`GIF_ENABLED=false`; se devuelve la ficha de `gif`).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `gif`, un límite de renderizado o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: no se ha podido crear el GIF (el servidor registra el motivo). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` con `retryAfter` (de 3 a 10 segundos) y `Retry-After`: la cola de renderizado está llena, o el GIF ha esperado `GIF_QUEUE_TIMEOUT_MS` (10 segundos) a que quedara libre un hilo; se devuelven todas las fichas de límite que había tomado la solicitud. `busy`: la base de datos ha seguido bloqueada (`retryAfter: 1` solo en el cuerpo). `timeout`.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Crear un GIF animado de cualquier partida enviada en PGN
      description: |-
        La misma imagen que `GET /games/{id}/gif` para cualquier partida enviada como texto PGN: una partida guardada por el juego, una exportación de otro sitio, una partida escrita a mano. Solo se usa la primera partida del texto.

        - El lector de PGN acepta lo mismo que el lector del propio juego: todo PGN que escribe este servidor y las exportaciones habituales de otros sitios (se omiten los comentarios, las variantes, los NAG y las anotaciones de reloj; los números de jugada y la notación SAN se leen con tolerancia; una etiqueta `FEN` indica la posición inicial salvo que `SetUp` sea `"0"`).
        - Los nombres y las puntuaciones proceden de las etiquetas `White`, `Black`, `WhiteElo` y `BlackElo`. Las letras acentuadas pierden el acento, los demás caracteres fuera del ASCII imprimible se convierten en `?`, y los nombres largos se recortan (48 caracteres). El resultado procede de la etiqueta `Result` o, en su defecto, del final del texto de las jugadas. La etiqueta `Termination` se muestra salvo que sea `normal`: en ese caso, la posición final lo dice todo (jaque mate, rey ahogado).
        - Este endpoint comprueba su cuerpo por sí mismo: un campo desconocido, o un `pgn` ausente o que no es una cadena, responde 400 `invalid_request` con `field`; una opción errónea responde 400 `invalid_option` (`delayMs` debe ser un número JSON y `coords` un booleano; `null` se rechaza).

        **Sesión obligatoria:** las cuotas se cuentan por cuenta. **Límites:** como en `GET /games/{id}/gif`. **Tamaño máximo del cuerpo:** 135 168 bytes, sea cual sea `HTTP_BODY_LIMIT` (el PGN como cadena JSON, con sus secuencias de escape). **Tiempo máximo:** 45 segundos de forma predeterminada.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Una partida corta escrita a mano, sin coordenadas
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Un archivo PGN con todas las opciones
                value:
                  pgn: |
                    [White "alice"]
                    [Black "bob"]
                    [WhiteElo "1500"]
                    [BlackElo "1520"]
                    [Result "1-0"]

                    1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0
                  size: small
                  orientation: black
                  delayMs: 800
                  coords: true
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request` con `field` (un campo desconocido, o `pgn` ausente o que no es una cadena; sin `field` cuando el cuerpo no es un objeto); `invalid_json`; `invalid_option` con `field` (`size`, `orientation`, `delayMs` o `coords`); `invalid_pgn` con `line` y `column` (desde 1, columnas en caracteres) y el `message` del lector: una jugada ilegal o ambigua, una etiqueta mal formada, una modalidad de ajedrez desconocida, más de 65 536 bytes.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: el servidor ha desactivado los GIF (`GIF_ENABLED=false`; se devuelve la ficha de `gif`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: un cuerpo de más de 135 168 bytes.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `gif`, un límite de renderizado o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: no se ha podido crear el GIF (el servidor registra el motivo). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` con `retryAfter` (de 3 a 10 segundos) y `Retry-After`: la cola de renderizado está llena, o el GIF ha esperado demasiado a que quedara libre un hilo; se devuelven todas las fichas de límite que había tomado la solicitud. `timeout`.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Obtener el perfil público de un jugador
      description: |-
        El perfil público de un jugador: las puntuaciones en las categorías oficiales (en el orden del servidor) y los recuentos de partidas. `games.total` cuenta todas las partidas guardadas, incluidas las amistosas y las anuladas; `games.rated`, `wins`, `draws` y `losses` se suman a partir de los registros de puntuación, por lo que solo cuentan las partidas puntuables. La respuesta es la misma con o sin token.

        **Límite:** `public_read` (por jugador con token, por cliente sin él).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: El perfil.
          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`: no tiene de 2 a 24 caracteres de `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: se envió un token y no es válido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: no existe ese jugador, o es una cuenta eliminada.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `public_read` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de datos ha seguido bloqueada; `retryAfter: 1` en el cuerpo, sin encabezado `Retry-After`), `server_busy` (la búsqueda de la sesión encontró la base de datos bloqueada), `timeout`.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Listar las partidas recientes de un jugador
      description: |-
        Las partidas recientes del jugador, de la más reciente a la más antigua, paginadas como el historial pero sin filtros ni total. `color` es el bando de este jugador. `next` es el identificador de la última partida siempre que la página está completa, por lo que la página siguiente puede estar vacía. Primero se busca al jugador: un jugador desconocido da un 404 diga lo que diga la consulta.

        **Límite:** `public_read` (por jugador con token, por cliente sin él).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Una página de las partidas del jugador.
          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` no es un identificador de partida), `invalid_limit` (estos dos últimos sin `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: se envió un token y no es válido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: no existe ese jugador, o es una cuenta eliminada.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: el límite `public_read` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de datos ha seguido bloqueada; `retryAfter: 1` en el cuerpo, sin encabezado `Retry-After`), `server_busy` (la búsqueda de la sesión encontró la base de datos bloqueada), `timeout`.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Obtener los mejores jugadores de una categoría
      description: |-
        Los 100 mejores registros de puntuación de una categoría oficial con al menos `minGames` (`PROVISIONAL_GAMES`) partidas contabilizadas, excluidas las cuentas eliminadas y los tramposos confirmados. El servidor vuelve a calcular cada clasificación como máximo cada 10 segundos; `updatedAt` indica cuándo.

        **Límites:** solo la capa por dirección.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Un identificador de categoría oficial (`3+2`, o `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Cuántos jugadores, de 1 a 100. Un número mayor de hasta 3 cifras cuenta como 100; un valor vacío equivale a su ausencia.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: La clasificación.
          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` (ausente, o no es una categoría oficial), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: la capa por dirección.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (la base de datos ha seguido bloqueada; `retryAfter: 1` en el cuerpo, sin encabezado `Retry-After`), `timeout`.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Denunciar al rival de una partida reciente
      description: |-
        Denuncia al rival de una de las partidas del propio jugador que haya terminado en los últimos 7 días. Una denuncia nunca cambia por sí sola una puntuación, una sanción ni un nivel de integridad. Aumenta la prioridad de revisión que ven los moderadores y, salvo para `abuse`, solicita el análisis de la partida con el motor.

        Una denuncia del mismo rival por la misma partida recibe la misma respuesta y no cambia nada; la respuesta nunca revela nada sobre la cuenta denunciada. `GET /games/{id}` indica de antemano a los jugadores de la partida si se aceptaría una denuncia (`reportable`).

        Este endpoint comprueba su cuerpo por sí mismo, en el orden `gameId`, `reported`, `category`, `comment`: un fallo responde 400 `invalid_request` sin `field`. Los campos que no conoce se ignoran.

        **Límites:** `reports` (por jugador) y después `REPORTS_PER_DAY` (5) denuncias por jugador en 24 horas (429 `report_limit`).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportRequest'
            example:
              gameId: 4100000000001
              reported: bob
              category: cheating
              comment: engine-like play
      responses:
        '202':
          description: La denuncia se ha recibido (o ya se había presentado).
          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` (sin `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: no es el rival del denunciante en una partida terminada en los últimos 7 días.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`report_limit` (`retryAfter: 3600`, con un encabezado `Retry-After`): `REPORTS_PER_DAY` denuncias en las últimas 24 horas. `rate_limited`: el límite `reports` o el presupuesto de la cuenta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (la búsqueda de la sesión encontró la base de datos bloqueada), `timeout`.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: El servidor oficial (las páginas están en la raíz, fuera de `/api/v1`).
      - url: https://{host}:{port}
        description: Cualquier servidor de Scacelith (las páginas están en la raíz, fuera de `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: El nombre de host público del servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: El puerto público de la API (`PUBLIC_API_PORT` o, en su defecto, `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Mostrar la página de confirmación del correo
      description: |-
        La página de un enlace de confirmación del correo (registro, o correo de confirmación reenviado). Solo muestra un botón «Confirm my e-mail address» (confirmar mi dirección de correo), para que un analizador de correo que abra el enlace no lo consuma; el botón envía el formulario a `POST /verify-email`.

        **Límite:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: La página con su botón de confirmación.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: El enlace no es válido o ha caducado (una página HTML).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: El límite `page` (una página HTML), o la capa por dirección (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Confirmar la dirección de correo
      description: |-
        El formulario de la página de confirmación. Confirma la dirección; en el caso de un registro nuevo, crea en ese momento la cuenta, y el jugador puede iniciar sesión.

        **Límite:** `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: La dirección se ha confirmado (y se ha creado la cuenta de un registro nuevo).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: El enlace no es válido, ya se ha usado o ha caducado, o el formulario no es válido (una página HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: El enlace de un registro nuevo cuyo nombre de usuario o dirección ha ocupado entretanto otra cuenta; no se crea ninguna cuenta (una página HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: El límite `auth` (una página HTML), o la capa por dirección (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'La base de datos ha seguido bloqueada (`Retry-After: 1`): no ha cambiado nada y el enlace sigue funcionando. También el tiempo máximo de tratamiento.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: El servidor oficial (las páginas están en la raíz, fuera de `/api/v1`).
      - url: https://{host}:{port}
        description: Cualquier servidor de Scacelith (las páginas están en la raíz, fuera de `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: El nombre de host público del servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: El puerto público de la API (`PUBLIC_API_PORT` o, en su defecto, `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Mostrar el formulario de restablecimiento de contraseña
      description: |-
        La página de un enlace de restablecimiento de contraseña: el formulario de nueva contraseña (la contraseña dos veces). El formulario se envía a `POST /reset-password`.

        **Límite:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: El formulario de nueva contraseña.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: El enlace no es válido (también cuando se envió a una dirección que la cuenta ya no tiene).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: El límite `page` (una página HTML), o la capa por dirección (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Definir una nueva contraseña desde el formulario de restablecimiento
      description: |-
        El formulario de la página de restablecimiento; hace lo mismo que `POST /auth/password/reset`: se cierra la sesión en todos los dispositivos, se cancela un cambio de correo pendiente, los demás enlaces de restablecimiento dejan de funcionar, la dirección se considera confirmada y el titular recibe un correo.

        Primero se comprueba el enlace, después que las dos contraseñas coinciden y, por último, las reglas de contraseñas. Cuando el servidor está ocupado, el formulario vuelve con `Retry-After` y el enlace sigue siendo válido.

        **Límites:** `auth` y después `auth_reset` (compartido con `POST /auth/password/reset`).
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/ResetPasswordForm'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
              confirmPassword: a much better passphrase
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordForm'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
              confirmPassword: a much better passphrase
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: La contraseña se ha cambiado y se ha cerrado la sesión en todos los dispositivos.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: El formulario de nuevo con el error (las contraseñas no coinciden, una contraseña débil), el enlace no es válido, o el formulario no es válido (una página 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: El límite `auth` o `auth_reset` (una página HTML), el formulario de nuevo con `Retry-After` cuando este cliente tiene demasiados hashes de contraseña en espera (el enlace sigue siendo válido), o la capa por dirección (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: El formulario de nuevo con `Retry-After` cuando el servidor está ocupado (la cola de hash de contraseñas, o la base de datos ha seguido bloqueada); el enlace sigue siendo válido. También el tiempo máximo de tratamiento.
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: El servidor oficial (las páginas están en la raíz, fuera de `/api/v1`).
      - url: https://{host}:{port}
        description: Cualquier servidor de Scacelith (las páginas están en la raíz, fuera de `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: El nombre de host público del servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: El puerto público de la API (`PUBLIC_API_PORT` o, en su defecto, `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Mostrar la página de confirmación del cambio de correo
      description: |-
        La página de un enlace de cambio de correo: muestra la nueva dirección y el nombre de la cuenta, con un botón «Use this e-mail address» (usar esta dirección de correo) que envía el formulario a `POST /confirm-email-change`.

        **Límite:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: La página con la nueva dirección y su botón de confirmación.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: El enlace no es válido o ha caducado (también cuando la dirección de la cuenta ha cambiado desde la solicitud).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: El límite `page` (una página HTML), o la capa por dirección (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Confirmar la nueva dirección de correo
      description: |-
        El formulario de la página de cambio de correo. La dirección cambia y se considera confirmada; los dispositivos mantienen la sesión iniciada; los enlaces enviados anteriormente (confirmación, restablecimiento de contraseña, otros cambios) dejan de funcionar; se avisa a la dirección anterior, con la nueva enmascarada.

        **Límite:** `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: La dirección se ha cambiado.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: El enlace no es válido, ya se ha usado o ha caducado, o el formulario no es válido (una página HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Entretanto, otra cuenta ha ocupado la dirección (una página HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: El límite `auth` (una página HTML), o la capa por dirección (`rate_limited` en JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'La base de datos ha seguido bloqueada (`Retry-After: 1`): no ha cambiado nada y el enlace sigue funcionando. También el tiempo máximo de tratamiento.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: El servidor oficial, en la raíz.
      - url: https://caissa.scacelith.com/api/v1
        description: El servidor oficial, bajo `/api/v1`.
      - url: https://{host}:{port}
        description: Cualquier servidor de Scacelith, en la raíz.
        variables:
          host:
            default: caissa.scacelith.com
            description: El nombre de host público del servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: El puerto público de la API (`PUBLIC_API_PORT` o, en su defecto, `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Cualquier servidor de Scacelith, bajo `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: El nombre de host público del servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: El puerto público de la API (`PUBLIC_API_PORT` o, en su defecto, `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Comprobar que el proceso está en ejecución
      description: |-
        Responde 200 `ok` mientras el proceso está en ejecución. `HEAD` también funciona. Cualquier otro método responde 405 `method_not_allowed` con `Allow: GET, HEAD` (`OPTIONS` incluido).

        **Límites:** solo la capa por dirección.
      security: []
      responses:
        '200':
          description: El proceso está en ejecución.
          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 capa por dirección.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: El servidor oficial, en la raíz.
      - url: https://caissa.scacelith.com/api/v1
        description: El servidor oficial, bajo `/api/v1`.
      - url: https://{host}:{port}
        description: Cualquier servidor de Scacelith, en la raíz.
        variables:
          host:
            default: caissa.scacelith.com
            description: El nombre de host público del servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: El puerto público de la API (`PUBLIC_API_PORT` o, en su defecto, `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Cualquier servidor de Scacelith, bajo `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: El nombre de host público del servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: El puerto público de la API (`PUBLIC_API_PORT` o, en su defecto, `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Comprobar que el servidor acepta jugadores
      description: |-
        Responde 200 `ready` cuando el servidor acepta jugadores, y 503 `not_ready` mientras arranca o se detiene. `HEAD` también funciona. Cualquier otro método responde 405 `method_not_allowed` con `Allow: GET, HEAD` (`OPTIONS` incluido).

        **Límites:** solo la capa por dirección.
      security: []
      responses:
        '200':
          description: El servidor acepta jugadores.
          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 capa por dirección.'
        '503':
          description: El servidor está arrancando o deteniéndose.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: not_ready
              example:
                status: not_ready
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sct_[A-Za-z0-9_-]{43}
      description: |-
        Un token de sesión (`sct_` seguido de 43 caracteres base64url) obtenido de una respuesta de inicio de sesión, en el encabezado `Authorization`: `Authorization: Bearer <token>`. El prefijo es exactamente `Bearer` (se distinguen mayúsculas y minúsculas) seguido de un espacio; un valor que no lo tenga, o un token que no tenga de 1 a 512 caracteres ASCII imprimibles, responde 401 `invalid_token`, como cualquier otro token no válido. Un encabezado vacío equivale a no enviar el encabezado.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: El identificador de la partida, un entero decimal positivo de 16 cifras como máximo, sin ceros a la izquierda, por debajo de 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: El identificador de la sesión, tal como lo da `GET /auth/sessions`, escrito sin ceros a la izquierda (`03` no es la sesión 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: El nombre de usuario del jugador, sin distinguir mayúsculas de minúsculas. El servidor acepta cualquier nombre de 2 a 24 caracteres de `[A-Za-z0-9_.-]` (la regla más amplia que ha permitido nunca).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Un identificador de partida; solo se listan las partidas anteriores. Pase el `next` de la página anterior. Un valor vacío equivale a su ausencia.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: El tamaño de la página, de 1 a 50. Un número mayor de hasta 3 cifras cuenta como 50; un valor vacío equivale a su ausencia.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: El token del enlace del correo (43 caracteres base64url). Si falta o es erróneo, se muestra la página «link invalid or expired» (enlace no válido o caducado).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Todas las respuestas del servidor prohíben el almacenamiento en caché.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Segundos que hay que esperar antes de volver a intentarlo; el mismo valor que `retryAfter` en el cuerpo.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: El desafío Bearer, con `error="invalid_token"` para un token no válido.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: El servidor cierra la conexión después de esta respuesta.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (con `field` cuando el problema está en un campo del cuerpo) o `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Un campo incumple el esquema
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: El cuerpo no es JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Una contraseña débil
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: Un PGN que no se puede leer
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (sin encabezado `Authorization`) o `invalid_token` (el token está mal formado, ha caducado, ha sido revocado o pertenece a una cuenta eliminada).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Sin sesión
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Un token no válido
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: La solicitud se ha rechazado.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Una contraseña incorrecta en la reautenticación
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Una cuenta suspendida
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found`, o una función que este servidor ha desactivado.'
      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`: el cuerpo no llegó en un plazo de 10 segundos. El servidor cierra la conexión.'
      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 solicitud entra en conflicto con el estado del servidor.
      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`: el intento o el ticket de inicio de sesión con Google es desconocido, ya se ha usado o ha caducado; vuelva a empezar desde el juego.'
      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`: el cuerpo supera `HTTP_BODY_LIMIT` (16 384 bytes de forma predeterminada). El servidor cierra la conexión.'
      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`: el destino de la solicitud supera los 4096 caracteres.'
      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`: el cuerpo no es `application/json`, o su charset no es 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 partida tiene más de `GIF_MAX_PLIES` (600) medias jugadas.'
      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`: resuelva el desafío `pow` y envíe de nuevo la misma solicitud con `pow: { challenge, nonce }`. `reason` indica el motivo: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` o `replayed`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: pow_required
            message: Proof of work required.
            reason: required
            pow:
              challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
              bits: 18
              expiresAt: 1790882991200
    TooManyRequests:
      description: '`rate_limited` (un límite de frecuencia, el presupuesto de la cuenta o la cola de hash de contraseñas) o `too_many_attempts` (una restricción de la cuenta), con `retryAfter` y un encabezado `Retry-After`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rateLimited:
              summary: Un límite de frecuencia
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Demasiados fallos en esta cuenta
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: un fallo inesperado. El servidor lo registra.'
      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` o un `iss` erróneo, o Google ha rechazado el código o ha enviado un token de ID que no supera la verificación. Nunca incluye el texto 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` o `timeout`: vuelva a intentarlo tras `retryAfter`, si se indica.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: El servidor está ocupado
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: La base de datos ha seguido bloqueada (una lectura)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: El servidor ha tardado demasiado
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: El GIF animado, como archivo adjunto.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` para una partida de este servidor, `attachment; filename="scacelith-game.gif"` para un PGN enviado con `POST /gif`.'
          schema:
            type: string
            pattern: '^attachment; filename="scacelith-([1-9][0-9]{0,15}|game)\.gif"$'
          example: attachment; filename="scacelith-4100000000001.gif"
        Content-Length:
          description: El tamaño del archivo en bytes.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: Una página HTML.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: Una página HTML que explica el rechazo.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: El formulario no llegó en un plazo de 10 segundos (una página HTML). El servidor cierra la conexión.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: Una página HTML que explica el conflicto.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: El formulario supera `HTTP_BODY_LIMIT` (una página HTML). El servidor cierra la conexión.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: El cuerpo no es ni un formulario (`application/x-www-form-urlencoded`) ni JSON, o su charset no es UTF-8 (una página HTML).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Un límite de frecuencia (una página HTML), con `Retry-After`.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlInternalError:
      description: Un fallo inesperado (una página HTML titulada «Server error», error del servidor). El servidor lo registra.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: El servidor está ocupado (la base de datos ha seguido bloqueada, con `Retry-After`) o ha tardado demasiado (una página HTML titulada «Server error», error del servidor).
      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 forma única de todas las respuestas de error de la API JSON.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: El código de error en `snake_case`. Un cliente elige qué mostrar a partir de él.
          examples:
            - rate_limited
        message:
          type: string
          description: Una frase en inglés, pensada para los registros y como último recurso.
        retryAfter:
          type: integer
          minimum: 1
          description: Segundos que hay que esperar antes de volver a intentarlo; solo está presente en los rechazos que expiran con el tiempo. En ese caso, la respuesta lleva también un encabezado `Retry-After` con el mismo valor, salvo el 503 `busy` de las lecturas del historial, de las partidas, de los jugadores y de la clasificación.
        field:
          type: string
          description: El campo o el parámetro de consulta erróneo (`invalid_request`, `invalid_option`, y `invalid_cursor`, `invalid_limit` e `invalid_filter` de `GET /account/games`). Un campo anidado se escribe con puntos (`pow.nonce`).
        reason:
          type: string
          description: 'El motivo: la regla que incumple una `weak_password`, o por qué se ha pedido o rechazado una prueba de trabajo (`pow_required`).'
          enum:
            - too_short
            - too_long
            - contains_username
            - contains_email
            - too_common
            - required
            - malformed
            - signature
            - endpoint
            - network
            - expired
            - bits
            - work
            - replayed
        pow:
          $ref: '#/components/schemas/PowChallenge'
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: 'Solo con `banned`: el final de la suspensión (ms desde la época Unix), `null` para una suspensión permanente.'
        line:
          type: integer
          minimum: 1
          description: 'Solo con `invalid_pgn`: la línea del error, desde 1.'
        column:
          type: integer
          minimum: 1
          description: 'Solo con `invalid_pgn`: la columna del error, desde 1, en caracteres.'
    PowChallenge:
      type: object
      description: Un desafío de prueba de trabajo (el `pow` de 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: El desafío firmado, que se debe devolver tal cual.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: El número de bits a cero iniciales que debe tener `SHA-256(challenge + ":" + nonce)`.
        expiresAt:
          type: integer
          format: int64
          description: Cuándo caduca el desafío (ms desde la época Unix), 2 minutos después de su emisión.
    PowAnswer:
      type: object
      description: La respuesta a un desafío de prueba de trabajo.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: El `challenge` de la respuesta 428, tal como se recibió.
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Una cadena decimal de 20 cifras como máximo tal que `SHA-256(challenge + ":" + nonce)` empiece por `bits` bits a cero.
    ServerInfo:
      type: object
      description: Lo que un cliente necesita antes de iniciar sesión o de conectarse.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: El nombre del servidor (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: Un UUID que la base de datos recibe en su primer arranque. Se mantiene igual entre reinicios. Es `null` cuando la base de datos no puede proporcionarlo.
        motd:
          type: string
          description: El mensaje del día (`SERVER_MOTD`), posiblemente vacío.
        protocol:
          type: object
          description: El protocolo WebSocket (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: La versión de protocolo más antigua que admite el servidor.
            max:
              type: integer
              minimum: 1
              description: La versión de protocolo más reciente que admite el servidor.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: La huella digital del esquema del protocolo (los 4 primeros bytes del SHA-256 de su forma canónica, como entero sin signo); a título informativo.
            subprotocol:
              type: string
              description: El nombre del subprotocolo WebSocket (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: El puerto del WebSocket que usan los jugadores (`PUBLIC_WS_PORT` o, en su defecto, `WS_PORT` o, en su defecto, `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: La ruta del WebSocket.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Si se pueden crear cuentas nuevas.
        emailVerification:
          type: boolean
          description: Si las cuentas nuevas deben confirmar su dirección (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
          properties:
            google:
              type: boolean
              description: Si se ofrece el inicio de sesión con Google.
        mfa:
          type: boolean
          const: true
          description: La autenticación de dos factores siempre está disponible.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Los bits de prueba de trabajo que exige el registro (0 si no se exige ninguna).
        categories:
          type: array
          description: Los ritmos de juego oficiales (puntuables) (`RATED_CATEGORIES`), en el orden del servidor. Cualquier otro ritmo de juego es `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Las reglas que un cliente puede comprobar antes de enviar un formulario.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: La longitud mínima del nombre de usuario (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: La longitud máxima del nombre de usuario (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: La expresión regular que debe cumplir un nuevo nombre de usuario.
            passwordMinLength:
              type: integer
              description: La longitud mínima de la contraseña, en caracteres (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: La longitud máxima de la contraseña, en bytes de UTF-8.
            customTimeControls:
              type: boolean
              description: Si los retos y las partidas privadas pueden usar ritmos de juego personalizados.
            reportsPerDay:
              type: integer
              description: Cuántas denuncias puede presentar un jugador cada 24 horas (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: El mayor mensaje WebSocket que puede enviar un cliente, en bytes.
      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: Un ritmo de juego oficial.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 'El identificador de la categoría: minutos e incremento en segundos (`3+2`).'
        baseSec:
          type: number
          minimum: 0
          description: El tiempo base, en segundos.
        incSec:
          type: number
          minimum: 0
          description: El incremento por jugada, en segundos.
    AccountView:
      type: object
      description: La cuenta tal como la ve su jugador (`user` de las respuestas de inicio de sesión y de `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: El identificador de la cuenta.
        username:
          type: string
          description: El nombre de usuario.
        email:
          type: string
          format: email
          description: La dirección de correo electrónico.
        emailVerified:
          type: boolean
          description: Si la dirección está confirmada.
        mfaEnabled:
          type: boolean
          description: Si la autenticación de dos factores está activada.
        googleLinked:
          type: boolean
          description: Si hay una cuenta de Google vinculada.
        hasPassword:
          type: boolean
          description: '`false` para una cuenta creada a través de Google que aún no ha definido una contraseña.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Si se aceptan los retos directos por nombre (véase `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Cuándo se creó la cuenta (ms desde la época Unix).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: El último inicio de sesión (ms desde la época Unix), o `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: La nueva dirección de un cambio de correo pendiente de su enlace, o `null`.
    SessionAnswer:
      type: object
      description: Una sesión nueva.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: El token de sesión, para el encabezado `Authorization` y el WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: El final absoluto de la sesión (ms desde la época Unix); el límite de inactividad puede terminarla antes.
        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: El segundo paso de un inicio de sesión con autenticación de dos factores; continúe con `POST /auth/login/mfa`.
      required:
        - mfaRequired
        - mfaToken
        - expiresIn
      properties:
        mfaRequired:
          type: boolean
          const: true
        mfaToken:
          type: string
          pattern: '^mfa_[A-Za-z0-9_-]{43}$'
          description: El token del paso, para `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Segundos que quedan para completar el paso.
    LoginAnswer:
      description: Una sesión, o el segundo paso de un inicio de sesión con autenticación de dos factores.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Un primer inicio de sesión con Google; continúe con `POST /auth/sso/complete`.
      required:
        - needsUsername
        - ssoTicket
        - suggestedUsername
      properties:
        needsUsername:
          type: boolean
          const: true
        ssoTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: El ticket para `POST /auth/sso/complete`, válido durante 10 minutos.
        suggestedUsername:
          type: string
          description: Un nombre de usuario formado a partir del nombre de Google o de la dirección, o `""` cuando nada encaja.
    SsoNeedsPassword:
      type: object
      description: Una cuenta con contraseña usa la dirección; continúe con `POST /auth/sso/google/link`. Todavía no se ha vinculado nada.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: El ticket para `POST /auth/sso/google/link`.
        username:
          type: string
          description: El nombre de usuario de esa cuenta (solo lo ve quien ha demostrado ante Google que la dirección es suya).
        expiresIn:
          type: integer
          const: 600
          description: Segundos durante los que el ticket sigue siendo válido.
    SsoFinishAnswer:
      description: La respuesta 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: Un intento de inicio de sesión con Google.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: El intento, para `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: La URL de Google que se debe abrir en el navegador del sistema, una vez comprobada.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: El `state` que Google debe devolver.
        expiresIn:
          type: integer
          const: 600
          description: Segundos durante los que el intento sigue siendo válido.
    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: El nombre de usuario; se aplican además las reglas del servidor (véase la descripción).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: La dirección de correo electrónico. Se le quitan los espacios de los extremos y se guarda en minúsculas.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La contraseña; se aplican además las reglas de contraseñas.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: El nombre de usuario o la dirección de correo electrónico (cualquier texto con `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Opcional. Se muestra en la lista de dispositivos con sesión iniciada (por ejemplo, `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: El `mfaToken` de la respuesta de inicio de sesión (o de la de un inicio de sesión con Google).
        code:
          type: string
          maxLength: 32
          description: Opcional. Un código de 6 cifras de la aplicación de autenticación, o un código de recuperación.
        recoveryCode:
          type: string
          maxLength: 32
          description: Opcional. Un código de recuperación. Se necesita `code` o `recoveryCode`.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: La dirección de correo electrónico.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: El parámetro `token` del enlace de restablecimiento.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: La nueva contraseña; se aplican las reglas de contraseñas del registro.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: El desafío PKCE S256 del `codeVerifier` del cliente (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: El puerto en el que escucha el cliente en `127.0.0.1`.
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: El `attemptId` de `POST /auth/sso/google/start`.
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: El verificador PKCE; su SHA-256 debe corresponder al desafío entregado a `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: El `state` tal como lo devolvió Google.
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: El `code` tal como lo devolvió Google (ASCII imprimible sin espacios).
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Opcional. El `iss` tal como lo devolvió Google, si lo hizo.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: El `linkTicket` de `finish`, válido durante 10 minutos.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La contraseña de la cuenta.
        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: El `ssoTicket` de `finish`, válido durante 10 minutos.
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: El nombre de usuario; se aplican las reglas del registro.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Las sesiones activas, primero las usadas más recientemente.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Una sesión activa (un dispositivo con sesión iniciada).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: El identificador de la sesión, para `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: El inicio de sesión (ms desde la época Unix).
        lastSeenAt:
          type: integer
          format: int64
          description: El último uso (ms desde la época Unix), actualizado como máximo cada 5 minutos.
        expiresAt:
          type: integer
          format: int64
          description: El final absoluto (ms desde la época Unix); el límite de inactividad puede terminar la sesión antes.
        clientLabel:
          type:
            - string
            - 'null'
          description: El `clientLabel` del inicio de sesión, o `null` si no se envió ninguno.
        current:
          type: boolean
          description: Si es la sesión que hace la solicitud.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Un registro por cada categoría en la que el jugador ha jugado partidas puntuables.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Las sanciones activas.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` mientras la cuenta está suspendida; si no, `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: El registro de puntuación de una categoría.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: El identificador de la categoría.
        rating:
          type: integer
          description: La puntuación.
        games:
          type: integer
          minimum: 0
          description: Las partidas jugadas en la categoría (contabilizadas o no).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: La puntuación más alta alcanzada.
        provisional:
          type: boolean
          description: '`true` mientras la puntuación aún no está establecida o tiene menos de `PROVISIONAL_GAMES` partidas contabilizadas (el juego la muestra como `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: El tipo de sanción.
        reason:
          type:
            - string
            - 'null'
          description: El motivo indicado, si lo hay.
        startsAt:
          type: integer
          format: int64
          description: El inicio (ms desde la época Unix).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: El final (ms desde la época Unix), `null` cuando la sanción es permanente.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: El final de la suspensión (ms desde la época Unix), `null` cuando es permanente.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` para aceptar los retos directos por nombre, `none` para rechazarlos.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: La contraseña actual.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: La nueva contraseña; se aplican las reglas de contraseñas del registro.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La contraseña de la cuenta.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Un código del secreto pendiente, de exactamente 6 cifras.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La contraseña de la cuenta.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcional; necesario con la autenticación de dos factores (o bien `recoveryCode`). Un código de la aplicación de autenticación, o un código de recuperación.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcional. Un código de recuperación (`xxxx-xxxx-xx`; no importan las mayúsculas, los espacios ni los guiones).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La contraseña de la cuenta.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Un código de la aplicación de autenticación (un código de recuperación se rechaza).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: La nueva dirección; se le quitan los espacios de los extremos, se pasa a minúsculas y después se comprueba como una dirección de registro.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: La contraseña de la cuenta.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcional; necesario con la autenticación de dos factores (o bien `recoveryCode`). Un código de la aplicación de autenticación, o un código de recuperación.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcional. Un código de recuperación.
    TotpSetup:
      type: object
      description: El secreto pendiente para la aplicación de autenticación.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: El secreto en base32, para escribirlo en la aplicación.
        uri:
          type: string
          format: uri
          description: La URI `otpauth://totp/...`, para mostrarla como código QR.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Segundos por intervalo.
    RecoveryCodeList:
      type: array
      description: Los 10 códigos de recuperación, que solo se muestran esta vez. Cada uno sirve una sola vez.
      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 bando de una partida.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: El nombre en el registro de la partida (`deleted#<id>` para una cuenta eliminada).
        rating:
          type:
            - integer
            - 'null'
          description: La puntuación al inicio, `null` si se desconoce.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: La puntuación después de la partida. Una partida puntuable siempre la tiene; `null` solo para una partida que no cuenta para las puntuaciones (amistosa, personalizada, anulada).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: La variación de la puntuación, `0` cuando las reglas de puntuación dejan la puntuación donde estaba (la regla de la FIDE sobre los resultados de cero puntos, un rival sin puntuación, las primeras partidas de un jugador sin puntuación); `null` en los mismos casos que `ratingAfter`.
    GameSummary:
      type: object
      description: El resumen de una partida guardada.
      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: El identificador de la partida.
        category:
          type: string
          description: El identificador de la categoría oficial (`3+2`), o `custom`.
        rated:
          type: boolean
          description: Si la partida cuenta para las puntuaciones.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: El ritmo de juego en segundos (`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` (véanse los códigos de partida).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: El código del motivo del final (véanse los códigos de partida).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: El resultado, `*` para una partida anulada.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: El número de medias jugadas.
        startedAt:
          type: integer
          format: int64
          description: El inicio (ms desde la época Unix).
        endedAt:
          type: integer
          format: int64
          description: El final (ms desde la época Unix).
    Termination:
      type: string
      description: El nombre del motivo del final (véanse los códigos de partida); `Unknown` para un código que el protocolo no conoce.
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: El resumen de una partida desde el punto de vista de uno de los jugadores.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: El bando del jugador.
    HistoryGameSummary:
      description: Una partida del historial del jugador con la sesión iniciada.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: El tiempo base en milisegundos.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: El incremento en milisegundos.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: El desenlace desde el punto de vista del jugador.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Las partidas de la página, de la más reciente a la más antigua.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: El identificador que se debe pasar como `before` para la página siguiente, o `null` en la última página.
        total:
          type: integer
          minimum: 0
          description: El número de partidas que cumplen el filtro, en todas las páginas.
    MoveRecord:
      type: object
      description: Una media jugada de una partida.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: La jugada en notación UCI, con `n`, `b`, `r` o `q` añadido en caso de promoción.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: El tiempo descontado por la jugada (ms), `null` cuando el registro no lo tiene.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: El reloj del jugador que mueve después de la jugada (ms), `null` cuando el registro no lo tiene.
    PgnTagsView:
      type: object
      description: Las principales etiquetas PGN, para mostrarlas. Aquí `Termination` es el nombre del motivo del final; el archivo PGN usa los valores del estándar PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` o `<SERVER_NAME> casual <category>`.'
        Site:
          type: string
          description: 'El valor de `SERVER_PUBLIC_HOST`.'
        Date:
          type: string
          pattern: '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$'
          description: La fecha UTC de inicio.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: La puntuación al inicio, o `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: La puntuación al inicio, o `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        TimeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
        Termination:
          $ref: '#/components/schemas/Termination'
        PlyCount:
          type: integer
          minimum: 0
    GameRecord:
      description: El registro de una partida con sus jugadas y sus relojes. Tiene los campos del resumen de una partida, sin `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: El tiempo base en milisegundos.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: El incremento en milisegundos.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: El nombre de `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: El identificador de la partida de la que esta es la revancha, o `null`.
            moves:
              type: array
              description: Una entrada por media jugada.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: 'Solo cuando el jugador del token ha jugado esta partida: su bando.'
            reportable:
              type: boolean
              description: 'Solo cuando el jugador del token ha jugado esta partida: `true` cuando `POST /reports` aceptaría ahora una denuncia del rival por esta partida (la partida terminó hace menos de 7 días, la cuota diaria no se ha agotado y el rival aún no ha sido denunciado por esta partida).'
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: El nombre de usuario, tal como lo escribió el jugador.
        createdAt:
          type: integer
          format: int64
          description: Cuándo se creó la cuenta (ms desde la época Unix).
        ratings:
          type: array
          description: Solo las categorías oficiales, en el orden del servidor.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Todas las partidas guardadas, incluidas las amistosas y las anuladas.
            rated:
              type: integer
              minimum: 0
              description: Las partidas puntuables, sumadas a partir de los registros de puntuación.
            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: El nombre de usuario, tal como lo escribió el jugador.
        games:
          type: array
          description: Las partidas de la página, de la más reciente a la más antigua.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: El identificador de la última partida siempre que la página está completa (en ese caso, la página siguiente puede estar vacía); si no, `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: El identificador de la categoría.
        minGames:
          type: integer
          minimum: 0
          description: Las partidas contabilizadas que necesita un registro para figurar en la clasificación (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Cuándo se calculó la clasificación (ms desde la época Unix).
        players:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LeaderboardEntry'
    LeaderboardEntry:
      type: object
      required:
        - rank
        - username
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
      properties:
        rank:
          type: integer
          minimum: 1
        username:
          type: string
        rating:
          type: integer
        games:
          type: integer
          minimum: 0
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
    GifRequest:
      type: object
      additionalProperties: false
      required:
        - pgn
      properties:
        pgn:
          type: string
          description: El texto PGN, como máximo 65 536 bytes en UTF-8. Solo se usa la primera partida.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Opcional. El tamaño de la imagen.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Opcional. El bando situado en la parte inferior del tablero.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Opcional. Milisegundos por jugada, un número JSON con valor entero.
        coords:
          type: boolean
          default: true
          description: Opcional. Si se dibujan alrededor del tablero las letras de las columnas y los números de las filas.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: La partida, como entero o como cadena de 1 a 16 cifras.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: El nombre de usuario del rival, tal como figura en el registro de la partida o tal como es ahora, sin distinguir mayúsculas de minúsculas. No puede estar en blanco.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: El motivo de la denuncia.
        comment:
          type:
            - string
            - 'null'
          description: Opcional. Como máximo 500 caracteres, una vez eliminados los caracteres de control (salvo la tabulación y el salto de línea) y los espacios de los extremos.
    AccountExport:
      type: object
      description: Todo lo que el servidor guarda sobre la cuenta (marcas de tiempo en ms desde la época Unix).
      required:
        - format
        - version
        - exportedAt
        - server
        - notes
        - account
        - ratings
        - ratingRefunds
        - sessions
        - securityEvents
        - sanctions
        - conduct
        - reportsFiled
        - games
      properties:
        format:
          type: string
          const: scacelith-account-export
        version:
          type: integer
          const: 1
        exportedAt:
          type: integer
          format: int64
          description: Cuándo se hizo la exportación.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: 'El valor de `SERVER_NAME`.'
            host:
              type: string
              description: 'El valor de `SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Lo que contiene el archivo y lo que deja fuera, explicado al jugador en un inglés sencillo.
          items:
            type: string
        account:
          description: La vista de la cuenta de `GET /account/me`, con `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: La dirección de la cuenta de Google vinculada, o `null`.
        ratings:
          type: array
          description: Los registros de puntuación completos.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Los puntos de puntuación restituidos tras descubrirse que un rival hacía trampas, sumados por día UTC y por categoría, del más reciente al más antiguo. No se nombran ni las partidas ni a los tramposos.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Todas las sesiones guardadas, de la más reciente a la más antigua, sin ningún token. La depuración por plazo de conservación elimina las sesiones caducadas, y las sesiones cerradas un día después del cierre de sesión.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Los eventos de seguridad, del más reciente al más antiguo, conservados durante `RETENTION_SECURITY_DAYS`.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Todas las sanciones, incluidas las levantadas. Nunca se incluye el nombre del moderador.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Los eventos de conducta registrados para las partidas del jugador abandonadas, anuladas o sin primera jugada a tiempo, conservados durante 30 días.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Las denuncias que ha presentado el jugador.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: El número de partidas.
            list:
              type: array
              description: Todas las partidas, de la más reciente a la más antigua, como los resúmenes de `GET /account/games`. Las jugadas están en `GET /games/{id}` y el PGN en `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Un registro de puntuación completo.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Si el jugador ha salido de la fase sin puntuación establecida.
            countedGames:
              type: integer
              minimum: 0
              description: Las partidas que han entrado en el cálculo de la puntuación.
            updatedAt:
              type: integer
              format: int64
              description: Cuándo cambió el registro por última vez.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: Las 00:00 UTC del día (ms desde la época Unix).
        category:
          type: string
        points:
          type: integer
          description: Los puntos restituidos ese día en esa categoría.
    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: Cuándo se cerró la sesión, o `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: La dirección del inicio de sesión, que se borra al cabo de `RETENTION_IP_DAYS`.
    SecurityEvent:
      type: object
      description: |-
        Un evento de seguridad. `ip` solo se indica para lo que se hizo con la sesión iniciada, con la contraseña de la cuenta (y el segundo factor) o con un enlace enviado a su dirección: `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` y `account_exported`; todos los demás tipos tienen `ip: null`, e `ip` se borra al cabo de `RETENTION_IP_DAYS`.

        `detail` solo conserva estos campos: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` y `method` (`password` o `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` y `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. Un evento `moderator_action` solo conserva `{ action }`, para las acciones `ban`, `unban`, `reset_mfa`, `verify_email` y `revoke_sessions` (las demás acciones de los moderadores quedan fuera). Todos los demás tipos tienen `detail: null`. Los eventos `rating_refund` quedan fuera: sus puntos figuran en `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: El tipo de evento (`login`, `password_changed`...).
        at:
          type: integer
          format: int64
          description: Cuándo se produjo.
        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: El nombre público actual del jugador denunciado.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Si la denuncia sigue abierta (no se indica si el jugador denunciado ha sido sancionado).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: El token del enlace del correo (el campo oculto del formulario de la página).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: El token del enlace de restablecimiento (el campo oculto del formulario).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: La nueva contraseña; se aplican las reglas de contraseñas del registro.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: De nuevo la nueva contraseña; debe coincidir.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: Una página HTML en UTF-8 (`text/html; charset=utf-8`), sin JavaScript ni recursos externos.
