openapi: 3.1.1
info:
  title: API HTTPS do servidor Scacelith
  version: "1.1.0"
  summary: 'A API HTTPS de todos os servidores Scacelith: o oficial e os da comunidade.'
  description: |-
    Todo servidor Scacelith, o oficial `caissa.scacelith.com` e qualquer servidor da comunidade, responde a esta API HTTPS. O jogo a usa para tudo o que fica fora da partida em si: cadastro e login (incluindo a autenticação de dois fatores e o Google), a página da conta, o histórico de partidas, os downloads em PGN e os GIFs animados das partidas, os dispositivos conectados, o download dos dados, a exclusão da conta e as denúncias.

    As partidas ao vivo (busca de adversários, desafios, lances, relógios) passam pelo WebSocket do mesmo servidor (`wss://<host>/ws`), aberto com um token de sessão desta API; isso é descrito em `PROTOCOL.md`, não aqui.

    Os valores padrão indicados abaixo são os de um servidor com a configuração inalterada; um servidor da comunidade pode alterá-los (`CONFIG.md` lista todas as configurações).

    ## URL base, porta e transporte

    - Servidor oficial: `https://caissa.scacelith.com/api/v1`, porta TCP 443.
    - Um servidor da comunidade: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` é 443 por padrão; atrás de um NAT ou de um proxy, a porta que os jogadores usam é `PUBLIC_API_PORT`.
    - O WebSocket do jogo fica na mesma porta por padrão (`WS_PORT` o muda de porta; `GET /info` informa ao cliente onde ele está).
    - HTTP/1.1 sobre TLS. Com `TLS_MODE=proxy`, um proxy reverso faz a terminação TLS na frente do servidor. `TLS_MODE=off` (HTTP sem criptografia) é apenas para desenvolvimento local.
    - As páginas HTML abertas pelos links dos e-mails (`/verify-email`, `/reset-password`, `/confirm-email-change`) ficam na raiz do servidor, fora de `/api/v1`. Os endpoints de integridade respondem tanto na raiz quanto em `/api/v1`. O login com o Google não tem nenhuma página no servidor: o Google devolve o navegador ao próprio jogo, em `127.0.0.1`, ou a uma página do site que iniciou o login (`GOOGLE_WEB_REDIRECT_URI`).
    - Uma barra final é ignorada (`/api/v1/info/` é `/api/v1/info`), e os parâmetros de caminho são decodificados como URL (um parâmetro que não seja uma codificação de URL válida retorna 400 `invalid_request`).

    ## Requisições

    - **Corpos JSON.** As requisições `POST`, `PUT` e `DELETE` levam um objeto JSON com `Content-Type: application/json`. Um `charset` diferente de UTF-8 é recusado, assim como qualquer outro tipo de conteúdo (415 `unsupported_media_type`). Um corpo vazio conta como `{}`, que é o que recebem os endpoints sem parâmetros (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Tamanho e tempo.** Um corpo é limitado a `HTTP_BODY_LIMIT` bytes (16.384 por padrão; `POST /gif` tem um limite próprio de 135.168 bytes): 413 `payload_too_large`. Ele deve chegar em até 10 segundos: 408 `request_timeout`. Depois de qualquer um desses dois erros, o servidor fecha a conexão.
    - **Esquemas estritos.** Um campo que o endpoint não conhece é recusado, um campo não marcado como opcional é obrigatório, e os tipos e comprimentos são verificados. Qualquer uma dessas falhas retorna 400 `invalid_request`, com `field` indicando o campo (com pontos para um campo aninhado, como `pow.nonce`). As strings não podem conter caracteres de controle. Os comprimentos contam unidades de código UTF-16, então um caractere fora do Plano Multilíngue Básico (um emoji) conta duas vezes. Um corpo que não é JSON retorna 400 `invalid_json`. `POST /gif` e `POST /reports` verificam os próprios corpos (veja cada endpoint).
    - **Query strings.** Vale a primeira ocorrência de um parâmetro, os parâmetros desconhecidos são ignorados, e um `+` é decodificado como espaço: os endpoints que recebem uma categoria de controle de tempo aceitam tanto `3%2B2` quanto `3+2`.
    - **Métodos.** `HEAD` é `GET` sem o corpo. `OPTIONS` em um caminho existente retorna 204 com um cabeçalho `Allow` (e os cabeçalhos de um preflight CORS, abaixo). Qualquer outro método que o caminho não tenha retorna 405 `method_not_allowed` com `Allow`.
    - **Destino da requisição.** Ele não pode passar de 4096 caracteres (414 `uri_too_long`). Uma requisição que não pode ser interpretada de forma alguma, ou cuja linha de requisição e cabeçalhos são grandes demais ou chegam devagar demais, recebe uma resposta 400, 431 ou 408 vazia, e a conexão é fechada.
    - **CORS: uma lista de permissões, desativada por padrão.** A API atende o jogo, que não envia `Origin`. Com `CORS_ORIGINS` vazio (o padrão), nunca é enviado nenhum cabeçalho `Access-Control-*`, então nenhuma página web consegue ler uma resposta. `CORS_ORIGINS` lista as origens web cujas páginas podem chamar a API a partir de um navegador, cada uma exatamente como os navegadores a enviam em `Origin`: `https://host` ou `https://host:port`, em minúsculas, sem caminho, sem barra final e sem a porta padrão; `http://` só para `localhost`, `127.0.0.1` e `[::1]` (desenvolvimento local); sem curinga. O servidor oficial define `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com` para o seu site. Então:
      - Toda resposta para um caminho em `/api/v1`, exceto os endpoints de integridade, tanto de sucesso quanto de erro (incluindo o 429 da camada por endereço), leva `Vary: Origin`. Quando o `Origin` da requisição é, byte a byte, uma das origens listadas, ela também leva `Access-Control-Allow-Origin: <that origin>` e, exceto na resposta a um preflight, `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, para que a página leia os códigos de erro, `Retry-After` e os nomes de arquivo dos downloads de PGN e GIF.
      - Um preflight (`OPTIONS` com `Access-Control-Request-Method`) de uma origem listada em um caminho existente retorna 204, sem corpo, com `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (os métodos de `Allow`), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600` e `Vary: Origin`. Ele não precisa de token e não conta para nenhum orçamento de conta nem para nenhum limite de endpoint; como qualquer requisição, ele consome uma ficha da camada por endereço. Um navegador reutiliza um preflight por 10 minutos, mas só para aquela URL exata, query string incluída. Uma requisição sem `Authorization` e sem corpo JSON, como `GET /info` ou `GET /leaderboard`, não precisa de preflight.
      - Qualquer outra resposta a um preflight é um preflight com falha, mesmo com os cabeçalhos CORS: um 404 para um caminho que não existe, ou o 429 da camada por endereço. O navegador então não envia a requisição, e a página vê um erro de rede, sem a resposta nem o seu `Retry-After`.
      - `Access-Control-Allow-Credentials` nunca é enviado, nem `Access-Control-Allow-Origin: *`: uma página envia o seu token de sessão em `Authorization: Bearer`, como o jogo faz, e a API não define nenhum cookie. As páginas HTML, os endpoints de integridade e as respostas vazias a requisições que não podem ser interpretadas não levam nenhum cabeçalho CORS. O WebSocket não usa CORS: `WS_ALLOWED_ORIGINS` lista as páginas que podem abri-lo.
      - Uma página de uma origem que não está listada não lê nada, e as suas escritas entre sites (cross-site) nunca são enviadas: corpos JSON e `Authorization` exigem um preflight, cuja resposta não tem `Access-Control-Allow-Origin` para essa origem. `Cross-Origin-Resource-Policy: same-origin` continua em todas as respostas: ele só se aplica a requisições `no-cors` (outro site incorporando uma resposta), nunca às requisições `cors` de uma página listada.

    ## Respostas e erros

    - As respostas são JSON em UTF-8, exceto o download de PGN (`application/x-chess-pgn`), os GIFs animados (`image/gif`) e as páginas HTML. Os instantes são expressos em milissegundos desde 1970-01-01 UTC. Os IDs são inteiros. Os IDs de partida têm até 16 dígitos, mas ficam abaixo de 2^53, então um número JSON (um double) os representa com exatidão.
    - Toda resposta da API leva `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` e `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (as páginas HTML têm uma política própria). Com `TLS_MODE=native`, ela também leva `Strict-Transport-Security: max-age=31536000`. As respostas que o próprio listener escreve, antes da API (o `GET` e o `HEAD` dos endpoints de integridade, o 429 da camada por endereço e um 500 `internal_error` quando a API falha por completo), levam os mesmos cabeçalhos, exceto `Cross-Origin-Resource-Policy`, e o seu `Content-Type` é `application/json` sem charset.
    - Uma resposta deve ser lida em até 60 segundos a partir do momento em que o servidor a tem pronta, o que só importa para as grandes (um GIF, a exportação de dados, um PGN longo). O tempo que o servidor leva para preparar uma resposta não conta nem para esse prazo nem para os 30 segundos sem nenhum byte de entrada ou de saída após os quais uma conexão é fechada.

    Os erros têm um único formato (o esquema `Error`):

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

    `message` serve para os logs e como alternativa; um cliente escolhe o que mostrar a partir de `error`. `retryAfter` (em segundos) só está presente nas recusas que expiram com o tempo, e a resposta então também leva um cabeçalho `Retry-After` com o mesmo valor; a única exceção é o 503 `busy` das leituras de histórico, partida, jogador e classificação, que tem apenas o campo. Alguns erros adicionam campos: `field` (entrada inválida), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` e `column` (`invalid_pgn`).

    Erros que qualquer endpoint pode retornar:

    | Status | `error` | Quando |
    |---|---|---|
    | 400 | `invalid_request` | O corpo viola o esquema do endpoint (`field` diz onde), o destino da requisição ou o `Content-Length` está malformado, um parâmetro de caminho não é uma codificação de URL válida, ou o corpo chegou cortado. |
    | 400 | `invalid_json` | O corpo não é JSON. |
    | 401 | `unauthorized` | Falta o cabeçalho `Authorization` em um endpoint que exige uma sessão. |
    | 401 | `invalid_token` | O token está malformado, expirou, foi revogado ou pertence a uma conta excluída; também nos endpoints em que a sessão é opcional. |
    | 404 | `not_found` | Endpoint inexistente. Alguns endpoints também o usam: partida, jogador ou sessão inexistente. |
    | 405 | `method_not_allowed` | O caminho existe para outros métodos (veja `Allow`). |
    | 408 | `request_timeout` | O corpo não chegou em até 10 segundos. |
    | 413 | `payload_too_large` | O corpo excede `HTTP_BODY_LIMIT` (135.168 bytes para `POST /gif`). |
    | 414 | `uri_too_long` | O destino da requisição excede 4096 caracteres. |
    | 415 | `unsupported_media_type` | O corpo não é `application/json`, ou o seu charset não é UTF-8. |
    | 429 | `rate_limited` | Um limite de requisições (veja abaixo): `retryAfter` mais um cabeçalho `Retry-After`. |
    | 500 | `internal_error` | Uma falha inesperada. O servidor a registra no log. |
    | 503 | `timeout` | O servidor não respondeu em até 30 segundos (60 segundos para a exportação, 45 segundos para os GIFs com as configurações padrão). |
    | 503 | `server_busy` | Uma consulta de sessão ou uma alteração de conta encontrou o banco de dados bloqueado (`retryAfter` 1), ou a fila de hash de senhas está cheia (veja abaixo). Também, com um `retryAfter` de 2 a 5, quando o servidor está muito atrasado: para um `POST`, `PUT`, `PATCH` ou `DELETE`, o banco de dados tem gravações demais em espera; para qualquer requisição, requisições anteriores demais ainda estão em execução além do seu tempo limite, ou o token de sessão não pôde ser verificado em até 5 segundos. A requisição não foi executada, e os seus limites de requisições não consumiram nada. |

    Os endpoints de leitura (histórico de partidas, partidas, jogadores, classificação) e a exportação retornam 503 `busy` com `retryAfter: 1` quando o banco de dados continuou bloqueado.

    Os endpoints que verificam uma senha ou calculam o seu hash também podem retornar um destes erros, ambos com um `retryAfter` aleatório de 5 a 15 segundos:

    - 503 `server_busy`: a fila de hash de senhas do servidor (`PASSWORD_HASH_QUEUE_MAX`) está cheia, ou o tempo de espera se esgotou.
    - 429 `rate_limited`: com a fila já pela metade, este cliente (um endereço IPv4 ou um prefixo IPv6 /48) já tem `PASSWORD_HASH_WAITERS_PER_SOURCE` hashes em espera. Este 429 devolve as fichas de limite de requisições que a requisição consumiu.

    Nada foi alterado e nenhuma tentativa com falha foi contada (exceto em `POST /auth/sso/google/link`, em que a tentativa do ticket e a falha da conta já tinham sido contadas antes do hash), e um link de redefinição continua válido.

    As páginas HTML retornam os seus erros como páginas HTML, com os mesmos códigos de status.

    ## Autenticação

    Os endpoints que exigem uma sessão recebem um token bearer no cabeçalho `Authorization` (o esquema `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Obter um token.** Um token (`sct_` seguido de 43 caracteres base64url) vem de `POST /auth/login` e depois de `POST /auth/login/mfa`, quando a autenticação de dois fatores está ativada; de `POST /auth/sso/google/finish` e `POST /auth/sso/google/link` (login com o Google); e de `POST /auth/sso/complete`. Todos eles retornam `{ token, expiresAt, user }`. O servidor armazena apenas um SHA-256 do token.
    - **Sessão obrigatória.** Um cabeçalho ausente retorna 401 `unauthorized` com `WWW-Authenticate: Bearer realm="scacelith"`. Um token inválido retorna 401 `invalid_token` com `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Um cliente deve esquecer um token que recebe `invalid_token` e fazer login novamente.
    - **Sessão opcional.** Em `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` e `GET /players/{username}/games`, a sessão é opcional. Sem o cabeçalho, eles retornam a visão pública; se um cabeçalho for enviado, o seu token deve ser válido.
    - **Armazenamento ocupado.** Quando o banco de dados continua bloqueado enquanto um token é verificado, um endpoint que exige ou aceita uma sessão retorna 503 `server_busy` com `retryAfter: 1` (e `Retry-After`); quando a verificação leva mais de 5 segundos, ele retorna 503 `server_busy` com um `retryAfter` de 2 a 5. Nos dois casos, o token não foi avaliado: guarde-o e envie a requisição novamente.
    - **Duração.** Uma sessão termina no primeiro destes prazos: `SESSION_MAX_DAYS` (90) dias após o login (esse é o `expiresAt`) ou `SESSION_IDLE_DAYS` (30) dias sem uso. Cada uso adia o limite de inatividade; o servidor grava o novo valor no máximo a cada 5 minutos, em segundo plano. Enquanto o seu banco de dados tem muitas gravações em espera, ele adia essa gravação para uma requisição posterior, exceto na última hora antes do limite de inatividade, para que uma sessão em uso não expire. Uma conta mantém no máximo `MAX_SESSIONS_PER_USER` (10) sessões: um novo login revoga as mais antigas além desse número.
    - **Revogação.** Sair (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) revoga sessões; o mesmo fazem uma alteração de senha (as outras sessões), uma redefinição de senha e a exclusão da conta (todas as sessões), e um administrador. Uma revogação pela API tem efeito imediato, e o WebSocket aberto com uma sessão revogada é fechado. Uma alteração ou redefinição de senha e a exclusão da conta revogam na mesma transação que a própria alteração: quando uma delas falha, nenhuma sessão foi revogada e nada mais mudou. O servidor mantém em cache as consultas de sessão por 30 segundos, então uma revogação pelo comando de administração, que é um processo separado, tem efeito em até 30 segundos.
    - **Escopo.** Um token pertence a um único servidor e também abre o WebSocket dele. Nunca o envie a outro servidor.

    ## Limites de requisições e outras restrições

    Os limites se aplicam a um de dois escopos. **Cliente:** um endereço IPv4, ou um prefixo IPv6 /64 (no modo proxy, o endereço vem de `X-Forwarded-For` enviado por um endereço de `TRUSTED_PROXIES`); alguns limites também contam cada prefixo IPv6 /48 como um todo, além de cada uma das suas redes /64. **Jogador:** a conta autenticada, seja qual for o seu endereço; um limite contado por jogador em um endpoint em que a sessão é opcional é contado por cliente para uma requisição sem token.

    Cada limite é um balde de fichas (token bucket) que comporta `limit` requisições e é reabastecido continuamente à razão de `limit / window`; `retryAfter` é o tempo até a próxima ficha. Os limites marcados como *compartilhados* também são contados em uma janela deslizante da mesma duração, para que nenhuma janela contenha muito mais do que `limit` requisições. Todo limite é contado para o servidor inteiro. Quando um dos limites de um endpoint recusa uma requisição, as fichas que os seus outros limites consumiram para essa requisição são devolvidas. Uma recusa retorna 429 `rate_limited` com `retryAfter` e `Retry-After`.

    - **Camada por endereço.** Toda requisição (qualquer caminho e método, incluindo os endpoints de integridade e os upgrades para WebSocket) consome primeiro uma ficha do orçamento do seu cliente: `HTTP_RATE_PER_IP` (600) por minuto, com uma rajada de meio minuto, e `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) para um prefixo IPv6 /48. Um cliente também pode ter no máximo `IP_MAX_INFLIGHT` (32 x `WORKERS`) requisições em andamento (`retryAfter` 1 além disso). Um cliente que insiste depois das suas recusas é bloqueado: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) recusas em um minuto o bloqueiam por 1 minuto, e depois por 4, 16 e 60 minutos a cada novo bloqueio em até 6 horas. Uma recusa dos limites `auth`, `auth_*` e `reauth` conta como 5; os limites contados por jogador nunca contam. Os endereços em `ABUSE_EXEMPT` pulam esta camada, mas não o orçamento da conta nem os limites de endpoint.
    - **Orçamento da conta.** Toda requisição que leva um token de sessão válido também conta para a sua conta: `USER_RATE_PER_MIN` (120) por minuto para todos os endpoints juntos, seja qual for o endereço, com uma rajada de meio minuto.
    - **Limites por endpoint:**

    | Limite | Padrão | Contado por | Endpoints |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, compartilhado | cliente, e `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) por prefixo 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, compartilhado | cliente; o triplo por prefixo IPv6 /48 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / hora, compartilhado | cliente; o triplo por prefixo IPv6 /48 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / hora, compartilhado | cliente; o triplo por prefixo IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 horas, compartilhado | cliente; o triplo por prefixo IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / hora, compartilhado | cliente; o triplo por prefixo IPv6 /48 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | os números de `auth`, em um balde próprio, compartilhado | cliente e prefixo IPv6 /48 | `POST /account/password`, `/account/mfa/totp/setup`, `/account/mfa/totp/enable`, `/account/mfa/totp/disable`, `/account/mfa/recovery-codes`, `/account/email`, `/account/export`, `/account/delete` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 min, compartilhado | jogador | os mesmos endpoints de `reauth` |
    | `account` | 60 / min | jogador | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | jogador | `GET /account/games` |
    | `account_export` | 5 / hora, compartilhado | jogador | `POST /account/export` (verificado antes de `reauth`; toda tentativa conta) |
    | `sessions` | 60 / min | jogador | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | jogador (cliente, se não houver token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (um balde para os quatro) |
    | `gif` | 30 / min | jogador | `GET /games/{id}/gif`, `POST /gif` (um balde para os dois) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min e `GIF_USER_RENDERS_PER_HOUR` (30) / hora, compartilhados | jogador | os mesmos dois, só quando o GIF precisa ser gerado (não vindo do cache) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min e `GIF_IP_RENDERS_PER_HOUR` (120) / hora, compartilhados | cliente (todas as suas contas juntas); o triplo por prefixo IPv6 /48 | os mesmos, como acima |
    | `reports` | 30 / hora | jogador | `POST /reports` |
    | `sso_start` | 30 / 10 min, compartilhado | cliente, e 90 por prefixo IPv6 /48 | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (juntos) |
    | `sso_finish` | 30 / min | cliente | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | cliente | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` e `GET /leaderboard` não têm limite próprio: só a camada por endereço. Um endpoint com vários limites os verifica na ordem dada na sua descrição.

    O servidor trata uma requisição nesta ordem: a camada por endereço, depois os endpoints de integridade; a identificação do endpoint; as verificações de que o servidor não está muito atrasado (503 `server_busy`): requisições anteriores demais ainda em execução além do seu tempo limite e, para um `POST`, `PUT`, `PATCH` ou `DELETE`, o acúmulo de gravações do banco de dados; a autenticação, em até 5 segundos (503 `server_busy` além disso); o orçamento da conta, quando a requisição leva uma sessão; os limites do endpoint; o corpo; o próprio endpoint (os endpoints de GIF só consomem os seus limites de renderização quando têm um GIF para gerar). Assim, uma requisição recusada por causa do seu token não gasta nenhuma das fichas do endpoint, e uma requisição com um corpo inválido as gasta.

    Outras restrições, aplicadas pelos próprios endpoints:

    - **Logins com falha em um mesmo nome de login** (nome de usuário ou e-mail): a partir de `AUTH_FAILURES_PER_ACCOUNT` (5) falhas, cada tentativa deve esperar o dobro da anterior (2 s, 4 s e assim por diante, até 15 minutos): 429 `too_many_attempts` com `retryAfter`. O contador é zerado após uma hora sem falhas.
    - **Segundos fatores com falha no login:** a mesma regra, a partir do 5º código errado da conta. Uma etapa de login aceita no máximo 5 códigos errados.
    - **Códigos de segundo fator de uma conta:** no máximo `AUTH_MFA_PER_ACCOUNT` (10) códigos (do autenticador ou de recuperação, certos ou errados) a cada 15 minutos, de qualquer endereço, no login e nas reautenticações; além disso, 429 `too_many_attempts` antes de o código ser verificado, então um código de recuperação não é gasto.
    - **Reautenticações com falha de uma conta** (senha ou código errado): a mesma regra (429 `too_many_attempts`), compartilhada por todos os endpoints que reautenticam.
    - **E-mails:** um e-mail de confirmação ou de redefinição por endereço a cada 5 minutos (a resposta continua a mesma); um aviso “alguém tentou usar o seu endereço” por endereço por hora.
    - **Denúncias:** `REPORTS_PER_DAY` (5) por jogador em 24 horas: 429 `report_limit`.

    ## Prova de trabalho

    `POST /auth/register` sempre exige uma prova de trabalho quando `POW_REGISTER_BITS` é maior que 0 (18 por padrão; `GET /info` o informa como `pow.register`). `POST /auth/login` e `POST /auth/sso/google/link` (um mesmo tipo de desafio para os dois) só a exigem durante 5 minutos depois que o servidor detecta uma onda de logins com falha (`POW_LOGIN_TRIGGER_PER_MIN`, e então `POW_LOGIN_BITS`); o cliente descobre isso pela resposta.

    1. A requisição sem prova (ou com uma prova recusada) retorna 428 `pow_required` com `reason` e um desafio `pow` `{ challenge, bits, expiresAt }`.
    2. Encontre um nonce: uma string decimal de no máximo 20 dígitos tal que `SHA-256(challenge + ":" + nonce)` comece com `bits` bits zero (começando pelo bit mais significativo do primeiro byte). 18 bits exigem cerca de 260.000 hashes em média.
    3. Envie a mesma requisição novamente com `"pow": { "challenge": "...", "nonce": "123456" }` no corpo.

    Um desafio é válido por 2 minutos e uma única vez, para um endpoint e uma rede de cliente (um endereço IPv4 ou um prefixo IPv6 /64). Ele é assinado, então o servidor não guarda nada sobre ele até que ele volte. `reason` diz por que uma prova foi recusada: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` ou `replayed`.

    ## Reautenticação

    As alterações na conta pedem a senha novamente e, quando a autenticação de dois fatores está ativada, um segundo fator:

    - só a senha: `POST /account/password` e `POST /account/mfa/totp/setup`;
    - a senha e um código do autenticador (um código de recuperação é recusado): `POST /account/mfa/recovery-codes`;
    - a senha e um código do autenticador ou um código de recuperação: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` e `POST /account/delete`.

    Nos corpos, `code` contém um código de 6 dígitos do aplicativo autenticador e `recoveryCode`, um código de recuperação (`xxxx-xxxx-xx`; maiúsculas e minúsculas, espaços e hifens não importam). Um código de recuperação também pode ser enviado em `code` onde os códigos de recuperação são aceitos. Cada código funciona uma vez: um código do autenticador já usado é recusado até o próximo intervalo de 30 segundos, e um código de recuperação deixa de existir depois de usado.

    | Status | `error` | Quando |
    |---|---|---|
    | 403 | `invalid_password` | Senha errada, ou a senha foi redefinida ou alterada enquanto a requisição era executada (nada foi alterado). |
    | 403 | `mfa_code_required` | A autenticação de dois fatores está ativada e nem `code` nem `recoveryCode` foi enviado. |
    | 403 | `invalid_code` | Código errado ou já usado. |
    | 400 | `password_not_set` | Uma conta só com Google ainda não tem senha (“Esqueceu a senha?” define uma). |
    | 429 | `too_many_attempts` | Falhas demais, ou códigos demais tentados, nesta conta. |
    | 503 / 429 | `server_busy` / `rate_limited` | A fila de hash de senhas está ocupada. |

    As senhas e os códigos com falha contam no contador de falhas da conta e são registrados como eventos de segurança.

    ## Porta de métricas

    Além da porta da API, o servidor responde em HTTP sem criptografia na porta de métricas (`METRICS_PORT`, 9464, vinculada a `METRICS_BIND`, 127.0.0.1 por padrão; mantenha-a privada). Ela não faz parte desta API: `GET /healthz` retorna `ok`; `GET /readyz` retorna `ready` assim que a inicialização termina (cada shard reproduziu o seu journal e os listeners estão vinculados) e até o desligamento começar, caso contrário 503 `not ready`; `GET /metrics` serve as métricas do Prometheus e, com `METRICS_TOKEN` definido, exige `Authorization: Bearer <token>` com esse token exato.
  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-fonte do servidor Scacelith e a sua documentação de referência (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: O servidor oficial.
  - url: https://{host}:{port}/api/v1
    description: Qualquer servidor Scacelith, como um servidor da comunidade.
    variables:
      host:
        default: caissa.scacelith.com
        description: O nome de host público do servidor (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: A porta pública da API (`PUBLIC_API_PORT`, ou então `API_PORT`).
tags:
  - name: server-info
    x-displayName: Informações do servidor
    description: O que um cliente precisa saber antes de fazer login ou de se conectar.
  - name: health
    x-displayName: Integridade
    description: |-
      Atividade (liveness) e prontidão (readiness) do servidor, para monitoramento. Esses endpoints respondem na porta da API antes da autenticação e de todos os limites de endpoint, mas, como qualquer requisição, consomem uma ficha da camada por endereço; um host de monitoramento pode ser listado em `ABUSE_EXEMPT`. Os dois caminhos funcionam para cada endpoint: na raiz do servidor e em `/api/v1`.

      A porta de métricas tem endpoints de integridade próprios, descritos na introdução.
  - name: auth
    x-displayName: Cadastro e login
    description: |-
      Criação de conta, login com senha (e um segundo fator), e-mails de confirmação e de redefinição de senha.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` e `POST /auth/sso/complete` retornam uma sessão `{ token, expiresAt, user }` (ou, nos primeiros, uma segunda etapa).
  - name: google-sign-in
    x-displayName: Login com o Google
    description: |-
      Oferecido quando `GET /info` informa `sso.google: true`; caso contrário, todos os endpoints abaixo retornam 404 `sso_disabled`. Um site faz login da mesma forma, com o seu próprio cliente, quando `sso.googleWeb` é true (abaixo, **Em um site**). O jogo faz login pelo navegador do sistema com o fluxo para aplicativos instalados da RFC 8252 (um cliente do tipo “App para computador” / “Desktop app”): o Google devolve o navegador a um listener do jogo em `127.0.0.1`, nunca a este servidor, e o jogo nunca vê uma credencial do Google.

      1. O cliente escuta em `127.0.0.1:0` (o sistema escolhe a porta) e gera um par PKCE: um `codeVerifier` de 43 a 128 caracteres `[A-Za-z0-9._~-]` e `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, que tem 43 caracteres e nenhum preenchimento.
      2. `POST /auth/sso/google/start` com o desafio e a porta retorna a URL do Google e o `state` da tentativa. O cliente verifica a URL (abaixo) e a abre no navegador.
      3. O Google envia o navegador para `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. O cliente só aceita o `state` da resposta de início e não envia nada após um redirecionamento com `error=`.
      4. `POST /auth/sso/google/finish` com o ID da tentativa, o `codeVerifier`, o `state` e o `code` (e `iss`, quando o Google o enviou).
      5. A resposta é uma sessão, uma etapa de autenticação de dois fatores (continue com `POST /auth/login/mfa`), `needsUsername` para um jogador novo (continue com `POST /auth/sso/complete`), ou `needsPassword` quando uma conta com senha usa o endereço (continue com `POST /auth/sso/google/link`).

      **A tag de origem.** A URI de redirecionamento leva uma tag do servidor que o jogador adicionou, para que uma URL que outro servidor obteve para os seus próprios jogadores seja recusada pelo jogo. A origem é o host em minúsculas (um literal IPv6 entre colchetes), `:` e a porta decimal da API, sempre escrita, inclusive 443: o servidor usa `SERVER_PUBLIC_HOST` e a sua porta pública da API; o jogo, o endereço ao qual está conectado. A tag são os primeiros 22 caracteres do base64url (sem preenchimento) de SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + origem). A URI de redirecionamento é `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: sempre o literal IPv4, e a porta do listener do jogo (de 1024 a 65535). O servidor nunca aceita do cliente uma URI, um host ou um caminho.

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

      Portanto, o login com o Google só funciona para os jogadores que adicionaram o servidor exatamente com `SERVER_PUBLIC_HOST` e a sua porta pública da API.

      **O que o jogo verifica antes de abrir o navegador.** A `authUrl` começa exatamente com `https://accounts.google.com/o/oauth2/v2/auth?` e é ASCII imprimível com menos de 4096 caracteres, e a sua query tem exatamente um `response_type=code`, exatamente um `redirect_uri` igual à URI que o jogo calcula a partir da sua porta e da sua tag de origem, exatamente um `state` igual ao `state` da resposta, `code_challenge_method=S256` e um `code_challenge` de 43 caracteres. Caso contrário, o jogo encerra o seu listener e não abre nada. Ele só envia `finish` e `link` ao servidor que respondeu a `start`.

      **Em um site.** Um site (o oficial: <https://scacelith.com/>) faz login com um cliente do tipo “Aplicativo da Web” / “Web application” do mesmo projeto do Google (`GOOGLE_WEB_CLIENT_ID`). O Google devolve o navegador a uma página do site que o operador configurou (`GOOGLE_WEB_REDIRECT_URI`, listada nos “URIs de redirecionamento autorizados” do cliente), e essa página envia o código para `finish`; aqui também o servidor não aceita nenhuma URI de redirecionamento do cliente.

      1. A página gera um par PKCE como acima, guarda o verificador na aba (`sessionStorage`) e envia `POST /auth/sso/google/web/start` com o desafio.
      2. Ela verifica a `authUrl` como o jogo faz, exceto que o seu `redirect_uri` deve ser uma página da sua própria origem, e depois navega até ela. Nenhum script do Google é carregado.
      3. O Google envia o navegador para `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. A página só aceita o `state` da resposta de início e não envia nada após um redirecionamento com `error=`.
      4. `finish`, `link` e `complete` seguem como para o jogo. A tentativa lembra que um site a iniciou: o código é trocado com o cliente e a página do site, e o token de ID deve nomear esse cliente (um código ou token de ID do cliente do jogo falha com 502 `sso_failed`, e vice-versa).

      Uma conta Google leva à mesma conta Scacelith a partir do jogo e do site: o vínculo guarda o identificador de subject do Google (`sub`), que identifica a conta Google seja qual for o cliente. A origem do site deve estar em `CORS_ORIGINS` (caso contrário, `check-config` emite um aviso).

      **Qual conta o login com o Google acessa:** a conta já vinculada a essa conta Google; senão, uma conta ativa com o endereço que o Google confirmou (com senha, `finish` retorna `needsPassword` e o vínculo só é armazenado depois que a senha dessa conta e, em seguida, o seu segundo fator, quando ativado, forem aprovados; sem senha, 409 `sso_account_exists`); senão, uma conta nova (`needsUsername`). Uma conta Google nunca é vinculada a uma conta existente apenas pelo endereço. O endereço da conta recebe um e-mail quando o login com o Google cria uma conta e quando ele é adicionado a uma conta existente.
  - name: sessions
    x-displayName: Sessões
    description: Os dispositivos conectados da conta e o logout.
  - name: account
    x-displayName: Conta
    description: Os dados da conta, as suas preferências e a sua senha.
  - name: two-step-verification
    x-displayName: Autenticação de dois fatores
    description: Aplicativos autenticadores (TOTP, RFC 6238), com SHA-1, 6 dígitos, intervalos de 30 segundos e um intervalo de tolerância para cada lado, além de códigos de recuperação de uso único.
  - name: email-change
    x-displayName: Alteração do endereço de e-mail
    description: Alteração do endereço da conta, confirmada com um link enviado para o novo endereço.
  - name: data-export
    x-displayName: Exportação de dados
    description: Tudo o que o servidor guarda sobre a conta, em um único arquivo JSON.
  - name: account-deletion
    x-displayName: Exclusão da conta
    description: Exclusão definitiva da conta.
  - name: game-history
    x-displayName: Histórico de partidas
    description: As partidas do próprio jogador autenticado, filtradas e paginadas.
  - name: games
    x-displayName: Partidas e PGN
    description: |-
      Os registros das partidas, com os seus lances e relógios, e os seus arquivos PGN.

      **Códigos das partidas.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (só as partidas terminadas são armazenadas). `result`: `1-0`, `0-1`, `1/2-1/2` ou `*` (cancelada). `reason`, com o seu nome (`termination`) e as palavras que encerram o texto dos lances do arquivo PGN; os códigos 7, 14 e 21 são empates (o jogador que ficou sem tempo, abandonou ou saiu da partida enfrentava um adversário que não podia dar mate: FIDE 6.9 e 5.1.2; `DESIGN.md` 6.3 dá a regra), e o servidor nunca encerra uma partida com os códigos 4 e 13 (eles pertencem à lista compartilhada de motivos):

      | Código | `termination` | Palavras no PGN (em inglês) — tradução |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate — xeque-mate |
      | 2 | `Resignation` | Resignation — abandono |
      | 3 | `Timeout` | Loss on time — derrota por tempo |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) — segundo lance ilegal (derrota) |
      | 5 | `Stalemate` | Stalemate — afogamento |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) — posição morta (material insuficiente) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate — tempo esgotado, mas o adversário não pode dar mate |
      | 8 | `FivefoldRepetition` | Fivefold repetition — repetição quíntupla |
      | 9 | `SeventyFiveMoves` | 75-move rule — regra dos 75 lances |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) — repetição tripla (reivindicada) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) — regra dos 50 lances (reivindicada) |
      | 12 | `Agreement` | Draw by agreement — empate por acordo |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate — segundo lance ilegal, mas o adversário não pode dar mate |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate — abandono, mas o adversário não pode dar mate |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) — saiu da partida (desconexão longa demais) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate — saiu da partida, mas o adversário não pode dar mate |
      | 22 | `Aborted` | Game aborted — partida cancelada |
      | 23 | `NoShow` | Aborted: first move not played in time — cancelada: primeiro lance não feito a tempo |
      | 24 | `Forfeit` | Forfeit (fair play violation) — derrota por infração (violação do fair play) |
      | 25 | `ServerAborted` | Aborted by the server — cancelada pelo servidor |
      | 26 | `BothDisconnected` | Aborted: both players disconnected — cancelada: os dois jogadores se desconectaram |

      O código 14 é novo na versão secundária (minor) 1 do protocolo: um cliente escrito antes dela deve mostrar um código desconhecido como um simples fim de partida, com o seu `result`.
  - name: gifs
    x-displayName: GIFs animados
    description: |-
      Uma partida como GIF animado, para guardar ou compartilhar: o tabuleiro visto de cima, um quadro por posição desde o início até a posição final, os nomes e ratings dos jogadores acima do tabuleiro, o último lance abaixo dele e, no último quadro, o resultado e como a partida terminou.

      **Custo, cache e cotas.** Um GIF é gerado em uma thread de renderização própria, nunca nas threads que executam as partidas, e com a menor prioridade de CPU: `GIF_THREADS` threads (`WORKERS` por padrão), iniciadas com o primeiro GIF e encerradas após um minuto sem nenhum. Até `GIF_QUEUE_MAX` (4 x `WORKERS`) GIFs esperam por uma thread, no máximo `GIF_QUEUE_TIMEOUT_MS` (10 s) cada um; uma renderização pode durar `GIF_RENDER_TIMEOUT_MS` (30 s). O servidor guarda os GIFs que gerou em um cache de `GIF_CACHE_MB` MB (32 x `WORKERS`), saindo primeiro os usados há mais tempo; um GIF vindo do cache, ou que já está sendo gerado para outra requisição, não custa nenhuma renderização. A chave do cache inclui tudo o que muda a imagem, inclusive os nomes.

      Toda requisição conta em `gif` (30 por minuto por jogador para os dois endpoints). Um GIF que precisa ser gerado também conta nos limites de renderização: por jogador, `GIF_USER_RENDERS_PER_MIN` (4) por minuto e `GIF_USER_RENDERS_PER_HOUR` (30) por hora; por cliente, todas as suas contas juntas, `GIF_IP_RENDERS_PER_MIN` (12) por minuto e `GIF_IP_RENDERS_PER_HOUR` (120) por hora, e o triplo disso por prefixo IPv6 /48. Um cliente deve guardar o arquivo que baixou em vez de pedi-lo de novo, e esperar `retryAfter` após um 429 ou um 503.

      Tamanhos: `small` (casas de 32 px: 284 x 350 pixels), `medium` (48 px: 424 x 515), `large` (72 px: 628 x 762); sem coordenadas, 268 x 342, 400 x 503 e 600 x 748. A posição inicial fica pelo menos 1 s (ou o intervalo, quando ele é maior), a posição final 3 s, e o GIF se repete em loop; depois do primeiro quadro, só a parte da imagem que muda é armazenada. Uma partida de 40 lances ocupa cerca de 135, 205 e 325 KiB (small, medium, large), e uma partida de 150 lances, 0,5; 0,8 e 1,2 MiB.
  - name: players
    x-displayName: Jogadores
    description: Perfis públicos e partidas recentes. Apenas dados públicos, nunca um endereço de e-mail, uma sessão, uma sanção ou um nível de integridade. Uma conta excluída não tem perfil.
  - name: leaderboard
    x-displayName: Classificação
    description: Os melhores jogadores de cada categoria oficial.
  - name: reports
    x-displayName: Denúncias
    description: Denúncia do adversário de uma partida recente aos moderadores.
  - name: pages
    x-displayName: Páginas HTML
    description: |-
      Páginas para um navegador, abertas pelos links dos e-mails. Os seus links apontam para `https://<SERVER_PUBLIC_HOST>` (com `:<PUBLIC_API_PORT>` quando ela não é 443), fora de `/api/v1`.

      - As páginas não executam JavaScript e não carregam nenhum recurso externo. Elas são servidas com `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - Um `GET` só mostra um botão ou um formulário, para que um scanner de e-mail que abra o link não o consuma. A alteração acontece no `POST`: um formulário enviado como `application/x-www-form-urlencoded` (um corpo JSON também é aceito), com o token do link em um campo oculto. Um campo enviado duas vezes é recusado.
      - Os erros (limites de requisições, campos inválidos) também são páginas HTML, com o título “Request refused” (requisição recusada) ou, a partir de 500, “Server error” (erro do servidor). Só as falhas encontradas antes de a página ser identificada (um destino de requisição longo demais ou malformado, a camada por endereço) respondem em JSON.
x-tagGroups:
  - name: server
    x-displayName: Servidor
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Contas
    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: Jogadores e denúncias
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Páginas para o navegador
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Obter o nome, as versões, as portas e as regras de cadastro do servidor
      description: |-
        O que um cliente precisa antes de fazer login ou de se conectar: o nome e o ID do servidor, as versões do protocolo WebSocket e onde fica o WebSocket, as regras de cadastro, os controles de tempo oficiais e os limites que um cliente pode verificar antes de enviar um formulário.

        **Limites:** só a camada por endereço.
      security: []
      responses:
        '200':
          description: A descrição do 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`: a camada por endereço.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Criar uma conta
      description: |-
        Cria uma conta: assim que o seu endereço de e-mail for confirmado com o link enviado para ele, ou imediatamente, sem confirmação de e-mail.

        **Com confirmação de e-mail** (`REQUIRE_EMAIL_VERIFICATION`, o padrão): 202 `verification_sent`. Ainda não existe nenhuma conta: o cadastro fica pendente por 24 horas e reserva o seu nome de usuário nesse meio-tempo. Um link válido por essas 24 horas é enviado para o endereço, no máximo um por endereço a cada 5 minutos (`POST /auth/verify-email/resend` envia um novo); a conta é criada, com o endereço confirmado, quando o link é usado (o botão da página `/verify-email`), e o jogador pode então fazer login. Até lá, um login com esse nome de usuário retorna `invalid_credentials`, como para uma conta desconhecida, e o perfil público não existe. Um novo cadastro com o mesmo endereço substitui o pendente. A resposta é a mesma quando outra conta já usa o endereço: nesse caso nenhum link é enviado, o dono dessa conta recebe um aviso no lugar (no máximo um por hora), e o nome de usuário fica reservado da mesma forma, para que nada revele se o endereço tem uma conta. Um cadastro cujo link não foi usado é descartado após 24 horas, e o seu nome de usuário fica livre de novo.

        **Sem confirmação de e-mail** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`; a conta é criada imediatamente e pode fazer login.

        **Regras.** `username`: de `USERNAME_MIN` a `USERNAME_MAX` caracteres (3 a 20), letras, dígitos, `_` e `-`, começando com uma letra ou um dígito (`GET /info` os informa como `limits`); nomes reservados (`admin`, `moderator`, `deleted`…) e alguns prefixos são recusados; único sem distinção entre maiúsculas e minúsculas. `email`: sem espaços no início e no fim e armazenado em minúsculas, ASCII simples com um domínio com ponto. `password`: pelo menos `PASSWORD_MIN_LENGTH` (10) caracteres e no máximo 256 bytes de UTF-8; não pode conter o nome de usuário nem a parte local do e-mail, e não pode ser uma senha comum.

        **Erros**, verificados nesta ordem: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; os erros da fila de hash de senhas; 409 `email_taken` (só sem confirmação de e-mail).

        **Limites:** `auth`, depois `auth_register` (10 cadastros por hora por cliente). **Prova de trabalho:** sempre, quando `POW_REGISTER_BITS` é maior que 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Primeira tentativa, sem prova de trabalho
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Enviada novamente com a prova de trabalho
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: A conta foi criada e pode fazer login (sem confirmação de e-mail neste servidor).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: O cadastro aguarda o seu link de confirmação (ou o endereço já tem uma conta; a resposta não 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` (com `field`), `invalid_json`, `invalid_username`, `invalid_email` ou `weak_password` com `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` ou `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: o cadastro está fechado neste servidor (`GET /info` informa `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: uma conta tem esse nome de usuário, ou um cadastro pendente de outro endereço o reserva. `email_taken`: outra conta usa o endereço (só sem confirmação de e-mail).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '428':
          $ref: '#/components/responses/PowRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: o limite `auth` ou `auth_register`, ou a fila de hash de senhas.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash de senhas, ou o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Fazer login com senha
      description: |-
        Faz login com um nome de usuário ou endereço de e-mail e uma senha. Há duas respostas possíveis, ambas com status 200: uma sessão ou, quando a autenticação de dois fatores está ativada, uma segunda etapa a concluir em até 5 minutos com `POST /auth/login/mfa`.

        Conta desconhecida, senha errada e conta sem senha recebem a mesma resposta, após o mesmo tempo: 401 `invalid_credentials`. As verificações da conta (`banned`, `email_unverified`) só vêm depois de uma senha correta. A partir de `AUTH_FAILURES_PER_ACCOUNT` (5) falhas em um nome de login, cada tentativa deve esperar (429 `too_many_attempts`).

        **Limite:** `auth`. **Prova de trabalho:** só durante uma onda de logins com falha (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: Uma sessão, ou a segunda etapa de um login com autenticação de dois fatores.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Login efetuado
                  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: A autenticação de dois fatores está ativada
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: conta desconhecida, senha errada ou uma conta sem senha.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Só após uma senha correta: `banned`, com `until` (epoch em ms, `null` para um banimento permanente), ou `email_unverified` (uma conta mais antiga cujo endereço ainda não foi confirmado).'
        '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` (o atraso por falhas deste nome de login), ou `rate_limited` (o limite `auth`, a fila de hash de senhas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash de senhas, ou o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Concluir um login com um segundo fator
      description: |-
        A segunda etapa de um login com autenticação de dois fatores, depois que `POST /auth/login` ou um login com o Google (`POST /auth/sso/google/finish` ou `POST /auth/sso/google/link`) retornou `mfaRequired`. Envie `code` (um código de 6 dígitos do autenticador, ou um código de recuperação) ou `recoveryCode`. Um código de recuperação usado aqui deixa de existir.

        Uma etapa aceita no máximo 5 códigos errados; a etapa também termina quando a senha é redefinida ou alterada. Depois da etapa de senha de um vínculo com o Google, o vínculo só é armazenado quando o código é aprovado aqui.

        **Limites:** `auth`, e no máximo `AUTH_MFA_PER_ACCOUNT` (10) códigos a cada 15 minutos para a conta, de qualquer endereço; além disso, o código não é verificado, então um código de recuperação não é gasto.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Um código do autenticador
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Um código de recuperação
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Login efetuado.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: nem `code` nem `recoveryCode` foi enviado, ou o corpo viola o esquema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: a etapa expirou, foi usada ou terminou após 5 códigos errados, ou a senha foi redefinida ou alterada desde a primeira etapa (faça login novamente). `invalid_code`: um código errado ou já usado.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (com `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: só após a etapa de senha de um vínculo com o Google, a conta Google foi vinculada a outra conta nesse meio-tempo.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: só após a etapa de senha de um vínculo com o Google, a conta mudou nesse meio-tempo; inicie o login novamente.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: o atraso por falhas da conta, ou os seus `AUTH_MFA_PER_ACCOUNT` códigos dos últimos 15 minutos se esgotaram. `rate_limited`: o limite `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` com `retryAfter: 1`: o banco de dados continuou bloqueado (depois de uma etapa de vínculo com o Google, nada foi vinculado: inicie o login novamente). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Sair desta sessão
      description: |-
        Revoga a sessão do token usado. O WebSocket aberto com ela é fechado. Sem corpo (ou `{}`).

        **Limite:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Sessão encerrada.
          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`: um corpo diferente 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`: o limite `sessions` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Sair de todas as sessões
      description: |-
        Revoga todas as sessões da conta, inclusive esta. Os WebSockets abertos com elas são fechados. Sem corpo (ou `{}`).

        **Limite:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Todas as sessões foram encerradas.
          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`: um corpo diferente 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`: o limite `sessions` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Enviar o link de confirmação novamente
      description: |-
        Envia novamente o link de confirmação de e-mail. A resposta é 202 `accepted` seja qual for o endereço, para nunca revelar se uma conta ou um cadastro o usa.

        Uma requisição tem efeito no máximo uma vez a cada 5 minutos por endereço. Um cadastro pendente com esse endereço ganha novamente as suas 24 horas, quer outra conta use o endereço ou não, para que o seu nome de usuário fique reservado pelo mesmo tempo nos dois casos. Um link só é enviado para esse cadastro quando o endereço não tem conta (um novo link, válido por 24 horas, substitui o anterior), ou para uma conta ativa e não confirmada com esse endereço.

        **Limites:** `auth`, depois `auth_mail` (10 por hora por cliente).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Requisição aceita (seja qual for o endereço).
          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`: o limite `auth` ou `auth_mail` (seja qual for o endereço).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` com `retryAfter: 1`: o banco de dados continuou bloqueado enquanto a conta era consultada ou o link de uma conta não confirmada era armazenado (a renovação de um cadastro pendente é feita em regime de melhor esforço e nunca faz a requisição falhar); os 5 minutos do endereço começam a contar mesmo assim, então uma nova requisição dentro deles não faz nada. `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Enviar um link de redefinição de senha
      description: |-
        Envia um link de redefinição de senha, válido por uma hora, que abre a página `/reset-password`. A resposta é 202 `accepted` seja qual for o endereço. Um link só vai para uma conta ativa, no máximo uma vez a cada 5 minutos por endereço. Uma conta só com Google define a sua primeira senha desta forma.

        A recuperação de senha tem os limites mais rígidos da API, todos contados para o servidor inteiro: 3 requisições por hora (`AUTH_FORGOT_PER_HOUR`) e 10 a cada 24 horas (`AUTH_FORGOT_PER_DAY`) por cliente (um endereço IPv4 ou um prefixo IPv6 /64), o triplo desses números por prefixo IPv6 /48, além do limite `auth` (20 a cada 10 minutos) e do único e-mail por endereço a cada 5 minutos. Nem o 202 nem o 429 revelam se uma conta usa o endereço. Definir a nova senha tem um limite próprio (`auth_reset`).

        **Limites:** `auth`, `auth_forgot`, depois `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Requisição aceita (seja qual for o endereço).
          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`: o limite `auth`, `auth_forgot` ou `auth_forgot_day` (seja qual for o endereço).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` com `retryAfter: 1`: o banco de dados continuou bloqueado enquanto a conta era consultada ou o link era armazenado; os 5 minutos do endereço começam a contar mesmo assim, então uma nova requisição dentro deles não envia nenhum link. `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Definir uma nova senha com o token de um link de redefinição
      description: |-
        Define uma nova senha com o token de um link de redefinição (a página `/reset-password` faz o mesmo). A nova senha segue as regras do cadastro.

        A redefinição tem estes efeitos, em uma única transação (todos ou nenhum): ela revoga todas as sessões e cancela uma alteração de e-mail pendente; os outros links de redefinição da conta deixam de funcionar; o endereço passa a contar como confirmado (o link o comprovou); o dono recebe um e-mail. A autenticação de dois fatores não é alterada. Após um erro da fila de hash de senhas, um 503 ou um 500, nada mudou: a senha antiga e as sessões continuam, e o link continua válido.

        **Limites:** `auth`, depois `auth_reset` (10 por hora por cliente, 30 por prefixo IPv6 /48, compartilhado com a página: cada tentativa calcula o hash de uma senha).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: A senha foi alterada e todas as sessões foram encerradas.
          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`: o link é inválido, já foi usado ou expirou, ou foi enviado para um endereço que a conta não tem mais. `weak_password` (com `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`: o limite `auth` ou `auth_reset`, ou a fila de hash de senhas.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: a fila de hash de senhas, ou o banco de dados continuou bloqueado (`retryAfter: 1`, nada mudou). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Iniciar um login com o Google
      description: |-
        Inicia uma tentativa de login com o Google para o desafio PKCE e a porta do listener do jogo em `127.0.0.1`. A resposta dá a URL do Google a abrir no navegador do sistema e o `state` da tentativa; a tentativa é válida por 10 minutos.

        `authUrl` contém `client_id`, `redirect_uri` (construído a partir de `redirectPort` e da tag de origem do servidor), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 do verificador próprio do servidor para o Google) com `code_challenge_method=S256`, e `prompt=select_account`. O jogo a verifica antes de abri-la (veja a descrição da tag).

        **Limite:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: A tentativa.
          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`: o login com o Google não é oferecido neste servidor.'
        '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`: o limite `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Iniciar um login com o Google em um site
      description: |-
        Inicia uma tentativa de login com o Google de um site para o desafio PKCE da sua página. A resposta é a mesma que para o jogo: a URL do Google para onde navegar e o `state` da tentativa; a tentativa é válida por 10 minutos. Oferecido quando `GET /info` informa `sso.googleWeb: true`.

        `authUrl` contém o `client_id` do site e `redirect_uri=GOOGLE_WEB_REDIRECT_URI`, com os mesmos outros parâmetros que para o jogo. A página a verifica antes de navegar até ela (veja **Em um site** na descrição da tag).

        **Limite:** `sso_start`, compartilhado com o início do jogo.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      responses:
        '200':
          description: A tentativa.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoStartAnswer'
              example:
                attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
                authUrl: https://accounts.google.com/o/oauth2/v2/auth?client_id=1234567890-web.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fscacelith.com%2Faccount%2Fgoogle%2F&response_type=code&scope=openid%20email%20profile&state=yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ&nonce=gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU&code_challenge=ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc&code_challenge_method=S256&prompt=select_account
                state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
                expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: um `codeChallenge` ausente ou malformado, ou qualquer outro campo (inclusive `redirectPort`).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: o login com o Google não é oferecido neste servidor, ou não para sites.'
        '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`: o limite `sso_start` (compartilhado com o início do jogo).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Entregar a resposta do Google ao servidor
      description: |-
        Entrega ao servidor o `code` e o `state` que o Google enviou ao listener do jogo ou à página do site, com o ID da tentativa e o verificador PKCE. Um ID de tentativa é inútil sem o verificador, e o código é inútil sem o verificador PKCE próprio do servidor e a chave secreta do cliente.

        O servidor verifica a tentativa (desconhecida, usada ou expirada: 410), depois o verificador (um verificador errado deixa a tentativa utilizável), depois usa a tentativa uma vez, verifica `state` e `iss`, troca o código com o Google e verifica o token de ID. A resposta (200) é uma destas:

        - `{ token, expiresAt, user }`: login efetuado na conta vinculada;
        - `{ mfaRequired, mfaToken, expiresIn }`: continue com `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: uma conta nova; continue com `POST /auth/sso/complete` em até 10 minutos. `suggestedUsername` vem do nome no Google ou do endereço, e é `""` quando nada serve;
        - `{ needsPassword, linkTicket, username, expiresIn }`: a conta com esse endereço tem senha; continue com `POST /auth/sso/google/link`. Nada foi vinculado ainda.

        **Limite:** `sso_finish`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoFinishRequest'
            example:
              attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
              codeVerifier: Sb5SaoX0ByHGjJ0XQkEqaaPaJYx2BId4ncTT8tLM6W8
              state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
              code: 4/0AVMBsJhR2x7cKq9vT1pLmN3oW8yZ5aB6dE7fG8hJ
              iss: https://accounts.google.com
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: Uma sessão, uma segunda etapa ou a próxima etapa de um primeiro login com o Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Login efetuado na conta 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: A autenticação de dois fatores está ativada
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Um jogador novo
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Uma conta com senha usa o endereço
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: o verificador não corresponde ao desafio da tentativa (a tentativa continua utilizável). `sso_email_unverified`: o Google não confirmou o endereço. `registration_closed`. `account_disabled`. Só para uma conta vinculada: `banned` (com `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: o login com o Google não é oferecido neste servidor.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: uma conta ativa sem senha usa o endereço (ela não é identificada).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: uma tentativa desconhecida, usada ou expirada.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: o limite `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Vincular o Google a uma conta existente com a senha dela
      description: |-
        Depois que `finish` retornou `needsPassword`: vincula o Google à conta existente com a senha dessa conta, digitada no jogo. A resposta (200) é uma sessão (o vínculo é armazenado e o login do jogador é efetuado) ou, quando a autenticação de dois fatores está ativada, `{ mfaRequired, mfaToken, expiresIn }`: continue com `POST /auth/login/mfa`, e o vínculo só é armazenado quando um código for aprovado lá.

        Uma senha errada deixa o ticket utilizável, por 5 tentativas no total. Uma tentativa é consumida logo antes de a senha ser verificada: um 429 `too_many_attempts` ou um 428 `pow_required` não consome nenhuma, enquanto uma recusa da fila de hash de senhas (503 `server_busy`, 429 `rate_limited`) já consumiu uma e conta como uma falha da conta. O atraso por falhas é o mesmo contador de `POST /auth/login`.

        **Limite:** `auth` (com a sua contagem por prefixo IPv6 /48). **Prova de trabalho:** como para `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: Uma sessão, ou a segunda etapa do login.
          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`: uma senha incorreta; o ticket continua valendo, para 5 tentativas no total.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (com `until`), somente após uma senha correta.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: o login com o Google não é oferecido neste servidor.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: a conta Google foi vinculada a outra conta nesse meio-tempo.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: um ticket desconhecido, já usado ou expirado, a 5ª senha incorreta, uma conta cujo status ou endereço mudou desde `finish`, ou uma conta cujo status, endereço, senha ou autenticação de dois fatores mudou enquanto o vínculo era gravado. Inicie o login novamente.'
        '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` (o tempo de espera por falhas da conta), ou `rate_limited` (o limite `auth`, a fila de hash de senhas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: a fila de hash de senhas, ou o banco de dados continuou bloqueado (`retryAfter: 1`, nada foi vinculado). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Criar a conta de um primeiro login com o Google
      description: |-
        Depois que `finish` respondeu `needsUsername`: cria a conta com o nome de usuário escolhido (valem as regras do cadastro) e faz o login nela. A conta não tem senha: `hasPassword` é `false`, e “Esqueceu a senha?” (`POST /auth/password/forgot`) dá uma senha a ela.

        **Limite:** `auth` (com sua contagem por 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: A conta foi criada e o login foi feito.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`, `invalid_request`, `invalid_json`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: o login com o Google não é oferecido neste servidor.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (uma conta tem esse nome de usuário, ou um cadastro pendente de outro endereço o reserva), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: um ticket desconhecido, já usado ou expirado.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: o limite `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Listar os dispositivos conectados
      description: |-
        As sessões ativas da conta (dispositivos conectados), começando pela usada mais recentemente. `current` marca a sessão que faz a requisição. `lastSeenAt` é atualizado no máximo a cada 5 minutos (mais tarde enquanto o servidor está muito atrasado); `expiresAt` é o fim absoluto, e o limite de inatividade pode encerrar a sessão antes.

        **Limite:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: As sessões ativas.
          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`: o limite `sessions` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Desconectar um dispositivo
      description: |-
        Encerra uma sessão da conta; a sessão atual também pode ser encerrada. O WebSocket aberto com ela é fechado. Sem corpo (ou `{}`).

        **Limite:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: A sessão foi encerrada.
          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`: um corpo diferente de `{}`, ou um `id` que não é uma codificação de URL válida; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: nenhuma sessão ativa com este id nesta conta.'
        '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`: o limite `sessions` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Obter a conta, seus ratings e as sanções ativas
      description: |-
        A conta como o próprio jogador a vê: a visão da conta (também o `user` de toda resposta de login), um registro de rating por categoria em que o jogador jogou partidas ranqueadas, as sanções ativas e o banimento. O nível de integridade do sistema antitrapaça nunca é mostrado.

        **Limite:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: A conta.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountMe'
              example:
                user:
                  id: 1
                  username: alice
                  email: alice@example.org
                  emailVerified: true
                  mfaEnabled: true
                  googleLinked: false
                  hasPassword: true
                  acceptChallenges: all
                  createdAt: 1790882871478
                  lastLoginAt: 1790882902200
                  pendingEmail: alice.new@example.org
                ratings:
                  - category: '3+2'
                    rating: 1510
                    games: 2
                    wins: 1
                    draws: 1
                    losses: 0
                    peak: 1510
                    provisional: true
                sanctions: []
                ban: null
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`unauthorized`, ou `invalid_token` (também quando a conta foi excluída).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: o limite `account` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Aceitar ou recusar desafios diretos
      description: |-
        Define se outros jogadores podem desafiar este jogador pelo nome. Com `none`, os desafios diretos são recusados: quem desafia é informado de que o jogador não está disponível. Sem reautenticação.

        **Limite:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: As preferências agora em 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`: o limite `account` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Alterar a senha
      description: |-
        Altera a senha. A senha atual é necessária, mas nenhum segundo fator, mesmo com a autenticação de dois fatores ativada. A nova senha segue as regras do cadastro (verificadas depois da senha atual).

        A alteração tem estes efeitos, em uma única transação (todos eles, ou nenhum): revoga todas as outras sessões (esta continua conectada), cancela uma alteração de e-mail pendente, faz os links de redefinição de senha da conta pararem de funcionar e envia um e-mail ao titular.

        **Reautenticação:** somente a senha. **Limites:** `reauth`, depois `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: A senha foi alterada.
          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` (uma conta que só entra com o Google), `weak_password` (com `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: uma senha atual incorreta, ou uma redefinição ou alteração de senha que ocorreu enquanto a requisição era verificada.'
        '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` (reautenticações da conta que falharam), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash de senhas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: a fila de hash de senhas, ou o banco de dados continuou bloqueado (`retryAfter: 1`, nada mudou). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Começar a ativar a autenticação de dois fatores
      description: |-
        Grava um novo segredo de autenticador pendente, que substitui qualquer segredo pendente anterior, e o retorna para o aplicativo autenticador (como texto e como um URI `otpauth://` para mostrar como QR code). A autenticação de dois fatores ainda não está ativada: `POST /account/mfa/totp/enable` a ativa com um código deste segredo.

        **Reautenticação:** somente a senha. **Limites:** `reauth`, depois `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: O segredo pendente.
          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` (uma conta que só entra com o 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` (verificado antes da senha).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautenticações da conta que falharam), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash de senhas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash de senhas, ou o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Concluir a ativação da autenticação de dois fatores e obter os códigos de recuperação
      description: |-
        Ativa a autenticação de dois fatores com um código do segredo pendente (exatamente 6 dígitos). Nenhuma senha é pedida aqui; ela foi informada na configuração. A resposta contém 10 códigos de recuperação, mostrados só desta vez.

        **Limites:** `reauth`, depois `reauth_user`; um código incorreto conta nas falhas de reautenticação da conta.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: A autenticação de dois fatores está ativada.
          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` não tem exatamente 6 dígitos, ou o corpo viola o schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: um código incorreto (verifique a hora do dispositivo).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: nenhum segredo pendente (chame `POST /account/mfa/totp/setup` primeiro).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautenticações da conta que falharam), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Desativar a autenticação de dois fatores
      description: |-
        Desativa a autenticação de dois fatores. O segredo e os códigos de recuperação são excluídos, e o titular recebe um e-mail.

        **Reautenticação:** a senha e um código do autenticador ou um código de recuperação (um de `code` ou `recoveryCode` é obrigatório). **Limites:** `reauth`, depois `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: A autenticação de dois fatores está desativada.
          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` (uma conta que só entra com o Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (nem `code` nem `recoveryCode`, verificado antes da senha), `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` (reautenticações que falharam, ou códigos demais tentados nesta conta), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash de senhas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash de senhas, ou o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Substituir os códigos de recuperação
      description: |-
        Substitui os códigos de recuperação por 10 novos; os antigos param de funcionar.

        **Reautenticação:** a senha e um código do autenticador em `code` (um código de recuperação é recusado com 403 `invalid_code`). **Limites:** `reauth`, depois `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: Os novos códigos de recuperação, mostrados só desta 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` (uma conta que só entra com o Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (também para um código de recuperação).'
        '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` (reautenticações que falharam, ou códigos demais tentados nesta conta), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash de senhas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash de senhas, ou o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Alterar o endereço de e-mail
      description: |-
        Altera o endereço de e-mail da conta. `newEmail` tem os espaços do início e do fim removidos e é convertido para minúsculas; depois é verificado como um endereço de cadastro.

        **Com confirmação por e-mail** (o padrão): 202 `verification_sent`. Um link válido por 24 horas vai para o novo endereço; ele abre `/confirm-email-change`, e o endereço só muda quando o jogador clica no botão dessa página. Até lá, `GET /account/me` mostra o novo endereço como `pendingEmail`. Uma nova requisição substitui a pendente; uma alteração ou redefinição de senha a cancela. No máximo um link vai para um mesmo novo endereço a cada 5 minutos, não importa quem peça (uma requisição para a alteração já pendente mantém o link enviado antes, que continua válido); a resposta é a mesma. O endereço atual recebe um aviso de que foi solicitada uma alteração para um endereço mascarado (`a***@example.org`). A resposta e `pendingEmail` são os mesmos quando outra conta já usa o novo endereço: nesse caso nenhum link é enviado, de modo que essa alteração nunca se conclui, e o titular desse endereço recebe um aviso (no máximo um por hora) em vez disso.

        Quando o link é confirmado, o endereço muda e passa a contar como confirmado, os dispositivos continuam conectados, os links enviados antes (confirmação, redefinição de senha, outras alterações) param de funcionar, e o endereço anterior é avisado, com o novo mascarado.

        **Sem confirmação por e-mail** (`REQUIRE_EMAIL_VERIFICATION=false`): o endereço muda imediatamente (200 `email_changed`), e o endereço anterior é avisado. Se outra conta usa o endereço, a resposta é 409 `email_taken`, e o titular desse endereço recebe o aviso.

        **Reautenticação:** a senha e, com a autenticação de dois fatores, um código do autenticador ou um código de recuperação. `invalid_email` e `same_email` são verificados antes da senha, então não contam como falha. **Limites:** `reauth`, depois `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: O endereço mudou imediatamente (sem confirmação por e-mail neste servidor).
          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: O novo endereço, como foi gravado.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Um link de confirmação foi enviado para o novo endereço (ou o endereço pertence a outra conta; a resposta não diz).
          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` (o endereço atual da conta), `password_not_set` (uma conta que só entra com o Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (também quando uma alteração ou redefinição de senha passou à frente da requisição: nenhum link é enviado), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: somente sem confirmação por e-mail.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautenticações que falharam, ou códigos demais tentados nesta conta), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash de senhas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: a fila de hash de senhas, ou o banco de dados continuou bloqueado (`retryAfter: 1`: nada mudou, e a mesma requisição pode ser enviada de novo). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Baixar os dados da conta
      description: |-
        Tudo o que o servidor guarda sobre a conta, em um único arquivo JSON para salvar (`format` `scacelith-account-export`, `version` 1). A exportação registra um evento de segurança (`account_exported`). O array `notes` do documento diz ao jogador, em inglês simples, o que fica de fora.

        **Nunca na exportação:** o hash da senha, o segredo da autenticação de dois fatores e os códigos de recuperação; qualquer token de sessão ou de link, ou um hash de um deles; os dados do sistema antitrapaça (nível e pontuação de integridade, anomalias, a análise das partidas, o peso de uma denúncia); as denúncias que outros jogadores fizeram sobre o jogador; as identidades dos moderadores; os dados privados de outros jogadores (os adversários aparecem pelo nome público e pelo rating, nada indica se outro jogador foi sancionado, e nenhum endereço IP que possa ser de outra pessoa é incluído).

        **Reautenticação:** a senha e, com a autenticação de dois fatores, um código do autenticador ou um código de recuperação. **Limites:** `account_export` (5 por hora por jogador, para o servidor inteiro; toda tentativa conta, inclusive as que falham; verificado primeiro), depois `reauth` e `reauth_user`. **Tempo limite:** 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: O documento de exportação, como anexo.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Os caracteres do nome de usuário que não são letras, dígitos, `_`, `.` e `-` viram `_`.'
              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` (uma conta que só entra com o Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautenticações que falharam, ou códigos demais tentados nesta conta), ou `rate_limited` (o limite `account_export`, `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash de senhas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (o banco de dados continuou bloqueado, com um cabeçalho `Retry-After: 1`), `server_busy` (a fila de hash de senhas), `timeout` (60 segundos). Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Excluir a conta
      description: |-
        Exclui a conta; isso não pode ser desfeito.

        - Todas as sessões são revogadas imediatamente: o token recebe 401 `invalid_token` a partir daí.
        - O nome de usuário vira `deleted#<id>`, na conta e em todos os registros de partida.
        - São apagados: o endereço de e-mail, o hash da senha, o segredo de dois fatores e os códigos de recuperação, as sessões e os tokens de link, o vínculo com o Google, o registro de integridade do sistema antitrapaça e os endereços IP gravados com os eventos de segurança.
        - Os ratings e as partidas são mantidos. As partidas continuam legíveis sob o nome anônimo, e `GET /players/{username}` responde 404 para o nome anterior.

        **Reautenticação:** a senha e, com a autenticação de dois fatores, um código do autenticador ou um código de recuperação. **Limites:** `reauth`, depois `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: A conta foi excluída.
          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` (uma conta que só entra com o Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautenticações que falharam, ou códigos demais tentados nesta conta), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash de senhas).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash de senhas, ou o banco de dados continuou bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Listar as partidas do jogador, filtradas e paginadas
      description: |-
        As partidas do jogador conectado, das mais recentes para as mais antigas, filtradas e paginadas, com o número de partidas que correspondem ao filtro. Todo parâmetro de consulta é opcional, e um valor vazio conta como ausente.

        Paginação: passe o `next` da página anterior como `before`; `next` é `null` na última página. `total` conta as partidas que correspondem ao filtro, em todas as páginas.

        **Limite:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: O id de uma categoria oficial (`3+2`, ou `3%2B2`), ou `custom` para todas as partidas com outro controle de tempo.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Somente partidas ranqueadas (`true`) ou casuais (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Somente as partidas ganhas, perdidas ou empatadas, do ponto de vista do jogador. As partidas canceladas só aparecem sem este filtro.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Uma página do histórico.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HistoryPage'
              example:
                games:
                  - id: 4100000000001
                    category: '3+2'
                    rated: true
                    timeControl: '180+2'
                    white:
                      name: alice
                      rating: 1500
                      ratingAfter: 1510
                      ratingDiff: 10
                    black:
                      name: bob
                      rating: 1520
                      ratingAfter: 1510
                      ratingDiff: -10
                    color: white
                    status: 1
                    reason: 2
                    result: 1-0
                    termination: Resignation
                    plies: 41
                    startedAt: 1790620205000
                    endedAt: 1790620611000
                    baseMs: 180000
                    incMs: 2000
                    outcome: win
                next: null
                total: 1
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_cursor` (`before` não é um id de partida), `invalid_limit`, ou `invalid_filter` (`category`, `rated` ou `result`), cada um com `field` indicando o parâmetro.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: o limite `account_games` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (o banco de dados continuou bloqueado; `retryAfter: 1` no corpo, sem cabeçalho `Retry-After`), `server_busy` (a consulta da sessão encontrou o banco de dados bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Obter o registro de uma partida com seus lances e relógios
      description: |-
        O registro de uma partida, com seus lances e relógios. Sem token, ou com o token de um jogador que não jogou a partida, a resposta é a pública. Quando o jogador do token jogou a partida, a resposta acrescenta `you` e `reportable`.

        **Limite:** `public_read` (por jogador com token, por cliente sem token).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: O registro da 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`: não é um inteiro positivo de no máximo 16 dígitos (abaixo de 2^53); `invalid_request`: não é uma codificação de URL válida.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: um token foi enviado e não é válido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: a partida não existe.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: o limite `public_read` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (o banco de dados continuou bloqueado; `retryAfter: 1` no corpo, sem cabeçalho `Retry-After`), `server_busy` (a consulta da sessão encontrou o banco de dados bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Baixar uma partida como arquivo PGN
      description: |-
        A mesma partida como arquivo PGN: uma única partida com finais de linha `\n` e o texto dos lances em linhas de menos de 80 colunas. A resposta é a mesma com ou sem token.

        Tags, nesta ordem: `Event` (`<SERVER_NAME> rated <category>` ou `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (a data de início, em UTC), `Round`, `White`, `Black`, `Result` (`*` para uma partida cancelada), `UTCDate` e `UTCTime` (o início), `WhiteElo` e `BlackElo` (os ratings no início, ou `-`), `WhiteRatingDiff` e `BlackRatingDiff` (as variações, como `+10` e `-10`, em toda partida ranqueada, `+0` quando as regras de rating deixam o rating onde estava; uma partida casual, personalizada ou cancelada não tem nenhuma das duas), `TimeControl` (em segundos), `Termination` (um valor do padrão PGN: `normal`; `time forfeit`, uma queda de bandeira, também quando termina em empate; `abandoned`; `rules infraction`, um segundo lance ilegal ou uma derrota por violação de fair play; `unterminated`, uma partida cancelada), `PlyCount`, `ScacelithGameId` (o id decimal).

        Cada lance traz `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: o relógio de quem jogou, depois do lance, e o tempo descontado por esse lance, em décimos de segundo (truncados). Um valor é omitido quando o registro não o tem. Depois do último lance vem o motivo do término por extenso, e depois o resultado.

        **Limite:** `public_read` (por jogador com token, por cliente sem token).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: O arquivo PGN.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-<id>.pgn"`.'
              schema:
                type: string
                pattern: '^attachment; filename="scacelith-[1-9][0-9]{0,15}\.pgn"$'
              example: attachment; filename="scacelith-4100000000001.pgn"
          content:
            application/x-chess-pgn:
              schema:
                type: string
                description: 'O texto PGN, em 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`: não é um inteiro positivo de no máximo 16 dígitos (abaixo de 2^53); `invalid_request`: não é uma codificação de URL válida.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: um token foi enviado e não é válido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: a partida não existe.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: o limite `public_read` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: entre outros casos, os lances gravados não podem ser reproduzidos até o final gravado (o servidor registra isso no log).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (o banco de dados continuou bloqueado; `retryAfter: 1` no corpo, sem cabeçalho `Retry-After`), `server_busy` (a consulta da sessão encontrou o banco de dados bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Baixar uma partida deste servidor como GIF animado
      description: |-
        A partida como GIF animado. Os nomes e os ratings são os do registro da partida (os ratings no início, uma conta excluída como `deleted#<id>`), assim como o resultado e o final (`Resignation`, `Loss on time`…).

        As verificações seguem esta ordem: `gif_disabled`, as opções da imagem (`size`, `orientation`, `delay`, `coords`), o id da partida, a partida, seu tamanho.

        **Sessão obrigatória:** as cotas contam por conta. **Limites:** `gif` em toda requisição; `gif_user_min`, `gif_user_hour`, `gif_ip_min` e `gif_ip_hour` somente quando o GIF precisa ser gerado (veja a descrição da seção). **Tempo limite:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 segundos (45 segundos por padrão).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: O tamanho da imagem, `small` (casas de 32 px), `medium` (48 px) ou `large` (72 px).
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: O lado que fica na parte de baixo do tabuleiro.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Milissegundos por lance (de 1 a 6 dígitos decimais).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Se as letras das colunas e os números das fileiras são desenhados em volta do tabuleiro (`1`) ou não (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` com `field` (`size`, `orientation`, `delay` ou `coords`): um valor fora dos permitidos. `invalid_game_id`. `invalid_request`: não é uma codificação de URL válida.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: a partida não existe. `gif_disabled`: o servidor desativou os GIFs (`GIF_ENABLED=false`; o token de `gif` é devolvido).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: o limite `gif`, um limite de renderização, ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: não foi possível gerar o GIF (o servidor registra o motivo no log). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` com `retryAfter` (de 3 a 10 segundos) e `Retry-After`: a fila de renderização está cheia, ou o GIF esperou `GIF_QUEUE_TIMEOUT_MS` (10 segundos) por uma thread livre; todo token de limite que a requisição consumiu é devolvido. `busy`: o banco de dados continuou bloqueado (`retryAfter: 1` só no corpo). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Gerar um GIF animado de qualquer partida enviada como PGN
      description: |-
        A mesma imagem que `GET /games/{id}/gif` para qualquer partida enviada como texto PGN: uma partida salva pelo jogo, uma exportação de outro site, uma partida digitada à mão. Só a primeira partida do texto é usada.

        - O leitor de PGN aceita o que o leitor do próprio jogo aceita: todo PGN que este servidor escreve e as exportações usuais de outros sites (comentários, variantes, NAGs e anotações de relógio são ignorados; números de lance e SAN são lidos com tolerância; uma tag `FEN` dá a posição inicial, a menos que `SetUp` seja `"0"`).
        - Os nomes e os ratings vêm das tags `White`, `Black`, `WhiteElo` e `BlackElo`. As letras acentuadas perdem os acentos, os outros caracteres fora do ASCII imprimível viram `?`, e os nomes longos são cortados (48 caracteres). O resultado vem da tag `Result`, ou então do fim do texto dos lances. A tag `Termination` é mostrada, a menos que seja `normal`: nesse caso, a posição final conta a história (xeque-mate, afogamento).
        - Este endpoint verifica o próprio corpo: um campo desconhecido ou um `pgn` ausente ou que não é string responde 400 `invalid_request` com `field`; uma opção errada responde 400 `invalid_option` (`delayMs` deve ser um número JSON, `coords` um booleano; `null` é recusado).

        **Sessão obrigatória:** as cotas contam por conta. **Limites:** os mesmos de `GET /games/{id}/gif`. **Limite do corpo:** 135.168 bytes, qualquer que seja `HTTP_BODY_LIMIT` (o PGN como string JSON, com seus escapes). **Tempo limite:** 45 segundos por padrão.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Uma partida curta digitada, sem coordenadas
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Um arquivo PGN com todas as opções
                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` com `field` (um campo desconhecido, ou `pgn` ausente ou que não é string; sem `field` quando o corpo não é um objeto); `invalid_json`; `invalid_option` com `field` (`size`, `orientation`, `delayMs` ou `coords`); `invalid_pgn` com `line` e `column` (a partir de 1, colunas em caracteres) e a `message` do leitor: um lance ilegal ou ambíguo, uma tag malformada, uma variante desconhecida, mais de 65.536 bytes.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: o servidor desativou os GIFs (`GIF_ENABLED=false`; o token de `gif` é devolvido).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: um corpo acima 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`: o limite `gif`, um limite de renderização, ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: não foi possível gerar o GIF (o servidor registra o motivo no log). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` com `retryAfter` (de 3 a 10 segundos) e `Retry-After`: a fila de renderização está cheia, ou o GIF esperou tempo demais por uma thread livre; todo token de limite que a requisição consumiu é devolvido. `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Obter o perfil público de um jogador
      description: |-
        O perfil público de um jogador: ratings nas categorias oficiais (na ordem do servidor) e contagens de partidas. `games.total` conta todas as partidas gravadas, inclusive as casuais e as canceladas; `games.rated`, `wins`, `draws` e `losses` são somados sobre os registros de rating, portanto só partidas ranqueadas. A resposta é a mesma com ou sem token.

        **Limite:** `public_read` (por jogador com token, por cliente sem token).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: O 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`: não tem de 2 a 24 caracteres de `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: um token foi enviado e não é válido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: o jogador não existe, ou a conta foi excluída.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: o limite `public_read` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (o banco de dados continuou bloqueado; `retryAfter: 1` no corpo, sem cabeçalho `Retry-After`), `server_busy` (a consulta da sessão encontrou o banco de dados bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Listar as partidas recentes de um jogador
      description: |-
        As partidas recentes do jogador, das mais recentes para as mais antigas, paginadas como o histórico, mas sem filtros nem total. `color` é o lado deste jogador. `next` é o id da última partida sempre que a página está cheia, então a página seguinte pode estar vazia. O jogador é buscado primeiro: um jogador desconhecido dá 404, seja qual for a consulta.

        **Limite:** `public_read` (por jogador com token, por cliente sem token).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Uma página das partidas do jogador.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerGamesPage'
              example:
                username: alice
                games:
                  - id: 4100000000001
                    category: '3+2'
                    rated: true
                    timeControl: '180+2'
                    white:
                      name: alice
                      rating: 1500
                      ratingAfter: 1510
                      ratingDiff: 10
                    black:
                      name: bob
                      rating: 1520
                      ratingAfter: 1510
                      ratingDiff: -10
                    color: white
                    status: 1
                    reason: 2
                    result: 1-0
                    termination: Resignation
                    plies: 41
                    startedAt: 1790620205000
                    endedAt: 1790620611000
                next: 4100000000001
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`, `invalid_cursor` (`before` não é um id de partida), `invalid_limit` (estes dois sem `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: um token foi enviado e não é válido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: o jogador não existe, ou a conta foi excluída.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: o limite `public_read` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (o banco de dados continuou bloqueado; `retryAfter: 1` no corpo, sem cabeçalho `Retry-After`), `server_busy` (a consulta da sessão encontrou o banco de dados bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Obter os melhores jogadores de uma categoria
      description: |-
        Os 100 melhores registros ranqueados de uma categoria oficial com pelo menos `minGames` (`PROVISIONAL_GAMES`) partidas contadas, deixando de fora as contas excluídas e os trapaceiros confirmados. O servidor recalcula cada classificação no máximo a cada 10 segundos; `updatedAt` diz quando. Um único cálculo por categoria é executado por vez: enquanto isso, as outras requisições recebem a lista anterior, ou esperam o cálculo quando ainda não há nenhuma.

        **Limites:** somente a camada por endereço.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: O id de uma categoria oficial (`3+2`, ou `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Quantos jogadores, de 1 a 100. Um número maior, de até 3 dígitos, conta como 100; um valor vazio conta como ausente.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: A classificação.
          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, ou não é uma categoria oficial), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: a camada por endereço.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (o banco de dados continuou bloqueado; `retryAfter: 1` no corpo, sem cabeçalho `Retry-After`), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Denunciar o adversário de uma partida recente
      description: |-
        Denuncia o adversário de uma das partidas do próprio jogador que terminou nos últimos 7 dias. Uma denúncia nunca altera, por si só, um rating, uma sanção ou um nível de integridade. Ela aumenta a prioridade de revisão que os moderadores veem e, exceto para `abuse`, pede a análise da partida pelo motor.

        Uma denúncia do mesmo adversário pela mesma partida recebe a mesma resposta e não muda nada; a resposta nunca diz nada sobre a conta denunciada. `GET /games/{id}` informa antes aos jogadores da partida se uma denúncia seria aceita (`reportable`).

        Este endpoint verifica o próprio corpo, na ordem `gameId`, `reported`, `category`, `comment`: uma falha responde 400 `invalid_request` sem `field`. Os campos que ele não conhece são ignorados.

        **Limites:** `reports` (por jogador), depois `REPORTS_PER_DAY` (5) denúncias por jogador em 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: A denúncia foi recebida (ou já tinha sido feita).
          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` (sem `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: não é o adversário do denunciante em uma partida que terminou nos últimos 7 dias.'
        '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`, com um cabeçalho `Retry-After`): `REPORTS_PER_DAY` denúncias nas últimas 24 horas. `rate_limited`: o limite `reports` ou o orçamento da conta.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a consulta da sessão encontrou o banco de dados bloqueado), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (veja a introdução): a requisição não foi executada.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: O servidor oficial (as páginas ficam na raiz, fora de `/api/v1`).
      - url: https://{host}:{port}
        description: Qualquer servidor Scacelith (as páginas ficam na raiz, fora de `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: O nome de host público do servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: A porta pública da API (`PUBLIC_API_PORT`, senão `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Mostrar a página de confirmação de e-mail
      description: |-
        A página de um link de confirmação de e-mail (cadastro, ou um e-mail de confirmação reenviado). Ela só mostra um botão “Confirm my e-mail address” (confirmar meu endereço de e-mail), para que um scanner de e-mail que abra o link não o consuma; o botão envia o formulário para `POST /verify-email`.

        **Limite:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: A página com seu botão de confirmação.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: O link é inválido ou expirou (uma página HTML).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: O limite `page` (uma página HTML), ou a camada por endereço (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Confirmar o endereço de e-mail
      description: |-
        O formulário da página de confirmação. Ele confirma o endereço; para um novo cadastro, cria a conta nesse momento, e o jogador pode fazer login.

        **Limite:** `auth`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
          application/json:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: O endereço foi confirmado (e a conta de um novo cadastro foi criada).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: O link é inválido, já foi usado ou expirou, ou o formulário é inválido (uma página HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: O link de um novo cadastro cujo nome de usuário ou endereço outra conta tomou nesse meio-tempo; nenhuma conta é criada (uma página HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: O limite `auth` (uma página HTML), ou a camada por endereço (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'O banco de dados continuou bloqueado (`Retry-After: 1`), ou o servidor está muito atrasado (`Retry-After` de 2 a 5, veja a introdução): nada mudou e o link continua funcionando. Também o tempo limite do handler.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: O servidor oficial (as páginas ficam na raiz, fora de `/api/v1`).
      - url: https://{host}:{port}
        description: Qualquer servidor Scacelith (as páginas ficam na raiz, fora de `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: O nome de host público do servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: A porta pública da API (`PUBLIC_API_PORT`, senão `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Mostrar o formulário de redefinição de senha
      description: |-
        A página de um link de redefinição de senha: o formulário de nova senha (a senha duas vezes). O formulário é enviado para `POST /reset-password`.

        **Limite:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: O formulário de nova senha.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: O link é inválido (também quando foi enviado para um endereço que a conta não tem mais).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: O limite `page` (uma página HTML), ou a camada por endereço (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Definir uma nova senha pelo formulário de redefinição
      description: |-
        O formulário da página de redefinição; ele faz o que `POST /auth/password/reset` faz, em uma única transação: todos os dispositivos são desconectados, uma alteração de e-mail pendente é cancelada, os outros links de redefinição param de funcionar, o endereço passa a contar como confirmado e o titular recebe um e-mail.

        Primeiro o link é verificado, depois se as duas senhas são iguais, depois as regras de senha. Quando o servidor está ocupado, o formulário volta com `Retry-After` e o link continua válido.

        **Limites:** `auth`, depois `auth_reset` (compartilhado com `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: A senha foi alterada, e todos os dispositivos foram desconectados.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: O formulário de novo com o erro (as senhas são diferentes, uma senha fraca), o link é inválido, ou o formulário é inválido (uma 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: O limite `auth` ou `auth_reset` (uma página HTML), o formulário de novo com `Retry-After` quando este cliente tem hashes de senha demais aguardando (o link continua válido), ou a camada por endereço (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'O formulário de novo com `Retry-After` quando o servidor está ocupado (a fila de hash de senhas, ou o banco de dados continuou bloqueado); o link continua válido. Quando o servidor está muito atrasado (veja a introdução), em vez disso, uma página “Server error” (erro do servidor) com `Retry-After` (de 2 a 5): nada foi executado e o link continua válido. Também o tempo limite do handler.'
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: O servidor oficial (as páginas ficam na raiz, fora de `/api/v1`).
      - url: https://{host}:{port}
        description: Qualquer servidor Scacelith (as páginas ficam na raiz, fora de `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: O nome de host público do servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: A porta pública da API (`PUBLIC_API_PORT`, senão `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Mostrar a página de confirmação da alteração de e-mail
      description: |-
        A página de um link de alteração de e-mail: ela mostra o novo endereço e o nome da conta, com um botão “Use this e-mail address” (usar este endereço de e-mail) que envia o formulário para `POST /confirm-email-change`.

        **Limite:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: A página com o novo endereço e seu botão de confirmação.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: O link é inválido ou expirou (também quando o endereço da conta mudou desde a requisição).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: O limite `page` (uma página HTML), ou a camada por endereço (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Confirmar o novo endereço de e-mail
      description: |-
        O formulário da página de alteração de e-mail. O endereço muda e passa a contar como confirmado; os dispositivos continuam conectados; os links enviados antes (confirmação, redefinição de senha, outras alterações) param de funcionar; o endereço anterior é avisado, com o novo mascarado.

        **Limite:** `auth`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: lZ50AvPxUwbGqQCwWPIO28ONray7SzW0Cl_sLImwF3Q
          application/json:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: lZ50AvPxUwbGqQCwWPIO28ONray7SzW0Cl_sLImwF3Q
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: O endereço foi alterado.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: O link é inválido, já foi usado ou expirou, ou o formulário é inválido (uma página HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Outra conta tomou o endereço nesse meio-tempo (uma página HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: O limite `auth` (uma página HTML), ou a camada por endereço (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'O banco de dados continuou bloqueado (`Retry-After: 1`), ou o servidor está muito atrasado (`Retry-After` de 2 a 5, veja a introdução): nada mudou e o link continua funcionando. Também o tempo limite do handler.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: O servidor oficial, na raiz.
      - url: https://caissa.scacelith.com/api/v1
        description: O servidor oficial, em `/api/v1`.
      - url: https://{host}:{port}
        description: Qualquer servidor Scacelith, na raiz.
        variables:
          host:
            default: caissa.scacelith.com
            description: O nome de host público do servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: A porta pública da API (`PUBLIC_API_PORT`, senão `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Qualquer servidor Scacelith, em `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: O nome de host público do servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: A porta pública da API (`PUBLIC_API_PORT`, senão `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Verificar se o processo está em execução
      description: |-
        Responde 200 `ok` enquanto o processo está em execução. `HEAD` também funciona. Qualquer outro método responde 405 `method_not_allowed` com `Allow: GET, HEAD` (inclusive `OPTIONS`).

        **Limites:** somente a camada por endereço.
      security: []
      responses:
        '200':
          description: O processo está em execução.
          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`: a camada por endereço.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: O servidor oficial, na raiz.
      - url: https://caissa.scacelith.com/api/v1
        description: O servidor oficial, em `/api/v1`.
      - url: https://{host}:{port}
        description: Qualquer servidor Scacelith, na raiz.
        variables:
          host:
            default: caissa.scacelith.com
            description: O nome de host público do servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: A porta pública da API (`PUBLIC_API_PORT`, senão `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Qualquer servidor Scacelith, em `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: O nome de host público do servidor (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: A porta pública da API (`PUBLIC_API_PORT`, senão `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Verificar se o servidor aceita jogadores
      description: |-
        Responde 200 `ready` quando o servidor aceita jogadores, e 503 `not_ready` enquanto ele inicia ou para. `HEAD` também funciona. Qualquer outro método responde 405 `method_not_allowed` com `Allow: GET, HEAD` (inclusive `OPTIONS`).

        **Limites:** somente a camada por endereço.
      security: []
      responses:
        '200':
          description: O servidor aceita jogadores.
          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`: a camada por endereço.'
        '503':
          description: O servidor está iniciando ou parando.
          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: |-
        Um token de sessão (`sct_` seguido de 43 caracteres base64url) vindo de uma resposta de login, no cabeçalho `Authorization`: `Authorization: Bearer <token>`. O prefixo é exatamente `Bearer` (diferencia maiúsculas de minúsculas) e um espaço; um valor que não o tem, ou um token que não tem de 1 a 512 caracteres ASCII imprimíveis, responde 401 `invalid_token` como qualquer outro token inválido. Um cabeçalho vazio conta como ausência de cabeçalho.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: O id da partida, um inteiro decimal positivo de no máximo 16 dígitos sem zeros à esquerda, abaixo de 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: O id da sessão, como `GET /auth/sessions` o fornece, escrito sem zeros à esquerda (`03` não é a sessão 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: O nome de usuário do jogador, sem diferenciar maiúsculas de minúsculas. O servidor aceita qualquer nome de 2 a 24 caracteres de `[A-Za-z0-9_.-]` (a regra mais ampla que ele já permitiu).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Um id de partida; só as partidas mais antigas são listadas. Passe o `next` da página anterior. Um valor vazio conta como ausente.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: O tamanho da página, de 1 a 50. Um número maior, de até 3 dígitos, conta como 50; um valor vazio conta como ausente.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: O token do link do e-mail (43 caracteres base64url). Um token ausente ou errado mostra a página “link invalid or expired” (link inválido ou expirado).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Toda resposta do servidor proíbe o cache.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Segundos a esperar antes de tentar de novo; o mesmo valor que `retryAfter` no corpo.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: O desafio bearer, com `error="invalid_token"` para um token inválido.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: O servidor fecha a conexão depois desta resposta.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (com `field` quando um campo do corpo está com problema) ou `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Um campo viola o schema
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: O corpo não é JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Uma senha fraca
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: Um PGN que não pode ser lido
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (sem cabeçalho `Authorization`) ou `invalid_token` (o token é malformado, expirou, foi revogado ou pertence a uma conta excluída).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Sem sessão
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Um token inválido
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: A requisição é recusada.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Uma senha incorreta na reautenticação
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Uma conta banida
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found`, ou um recurso que este servidor desativou.'
      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`: o corpo não chegou em 10 segundos. O servidor fecha a conexão.'
      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: A requisição entra em conflito com o estado do 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`: a tentativa ou o ticket de login com o Google é desconhecido, já foi usado ou expirou; inicie o login novamente.'
      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`: o corpo excede `HTTP_BODY_LIMIT` (16.384 bytes por padrão). O servidor fecha a conexão.'
      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`: o alvo da requisição excede 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`: o corpo não é `application/json`, ou seu charset não é 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`: a partida tem mais de `GIF_MAX_PLIES` (600) meios-lances.'
      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`: resolva o desafio `pow` e envie a mesma requisição de novo com `pow: { challenge, nonce }`. `reason` diz o motivo: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` ou `replayed`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: pow_required
            message: Proof of work required.
            reason: required
            pow:
              challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
              bits: 18
              expiresAt: 1790882991200
    TooManyRequests:
      description: '`rate_limited` (um limite de requisições, o orçamento da conta, ou a fila de hash de senhas) ou `too_many_attempts` (uma restrição da conta), com `retryAfter` e um cabeçalho `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: Um limite de requisições
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Falhas demais nesta conta
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: uma falha inesperada. O servidor a registra no log.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: internal_error
            message: Internal server error.
    BadGateway:
      description: '`sso_failed`: um `state` ou `iss` errado, ou o Google recusou o código ou enviou um token de ID que não passa na verificação (inclusive um código ou token de ID do outro cliente, o do jogo ou o do site). Nunca traz o texto do Google.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: sso_failed
            message: Google sign-in could not be completed.
    ServiceUnavailable:
      description: '`server_busy`, `busy` ou `timeout`: tente de novo depois de `retryAfter`, quando ele é informado.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: O servidor está ocupado
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: O banco de dados continuou bloqueado (uma leitura)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: O servidor demorou demais
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: O GIF animado, como anexo.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` para uma partida deste servidor, `attachment; filename="scacelith-game.gif"` para um PGN enviado com `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: O tamanho do arquivo em bytes.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: Uma página HTML.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: Uma página HTML que explica a recusa.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: O formulário não chegou em 10 segundos (uma página HTML). O servidor fecha a conexão.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: Uma página HTML que explica o conflito.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: O formulário excede `HTTP_BODY_LIMIT` (uma página HTML). O servidor fecha a conexão.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: O corpo não é nem um formulário (`application/x-www-form-urlencoded`) nem JSON, ou seu charset não é UTF-8 (uma página HTML).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Um limite de requisições (uma página HTML), com `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: Uma falha inesperada (uma página HTML intitulada “Server error”). O servidor a registra no log.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: O servidor está ocupado (o banco de dados continuou bloqueado, ou o servidor está muito atrasado; com `Retry-After`) ou demorou demais (uma página HTML intitulada “Server error”).
      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: O formato único de toda resposta de erro da API JSON.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: O código de erro em `snake_case`. O cliente escolhe o que mostrar a partir dele.
          examples:
            - rate_limited
        message:
          type: string
          description: Uma frase em inglês, destinada aos logs e a servir de alternativa.
        retryAfter:
          type: integer
          minimum: 1
          description: Segundos a esperar antes de tentar de novo, presente só nas recusas que passam com o tempo. A resposta então também traz um cabeçalho `Retry-After` com o mesmo valor, exceto no 503 `busy` das leituras do histórico, de partida, de jogador e da classificação.
        field:
          type: string
          description: O campo ou parâmetro de consulta com problema (`invalid_request`, `invalid_option`, e `invalid_cursor`, `invalid_limit` e `invalid_filter` de `GET /account/games`). Um campo aninhado é escrito com pontos (`pow.nonce`).
        reason:
          type: string
          description: 'O motivo: a regra que um `weak_password` viola, ou por que uma prova de trabalho foi pedida ou recusada (`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: 'Só em `banned`: o fim do banimento (ms desde a época Unix), `null` para um banimento permanente.'
        line:
          type: integer
          minimum: 1
          description: 'Só em `invalid_pgn`: a linha do erro, a partir de 1.'
        column:
          type: integer
          minimum: 1
          description: 'Só em `invalid_pgn`: a coluna do erro, a partir de 1, em caracteres.'
    PowChallenge:
      type: object
      description: Um desafio de prova de trabalho (o `pow` de um 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: O desafio assinado, a ser devolvido como está.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: O número de bits zero iniciais que `SHA-256(challenge + ":" + nonce)` deve ter.
        expiresAt:
          type: integer
          format: int64
          description: Quando o desafio expira (ms desde a época Unix), 2 minutos depois de emitido.
    PowAnswer:
      type: object
      description: A resposta a um desafio de prova de trabalho.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: O `challenge` da resposta 428, tal como foi fornecido.
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Uma string decimal de no máximo 20 dígitos tal que `SHA-256(challenge + ":" + nonce)` comece com `bits` bits zero.
    ServerInfo:
      type: object
      description: O que um cliente precisa antes de fazer login ou se conectar.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: O nome do servidor (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: Um UUID que o banco de dados recebe na primeira inicialização. Ele continua o mesmo entre reinicializações. É `null` quando o banco de dados não consegue fornecê-lo.
        motd:
          type: string
          description: A mensagem do dia (`SERVER_MOTD`), possivelmente vazia.
        protocol:
          type: object
          description: O protocolo WebSocket (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: A versão mais antiga do protocolo que o servidor fala.
            max:
              type: integer
              minimum: 1
              description: A versão mais recente do protocolo que o servidor fala.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 'A impressão digital do schema do protocolo (os 4 primeiros bytes do SHA-256 da sua forma canônica, como inteiro sem sinal: `0xc649140f`, 3326678031, para a versão secundária 2 do protocolo); informativa, nunca comparada.'
            subprotocol:
              type: string
              description: O token de subprotocolo do WebSocket (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: A porta do WebSocket que os jogadores usam (`PUBLIC_WS_PORT`, senão `WS_PORT`, senão `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: O caminho do WebSocket.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Se novas contas podem ser criadas.
        emailVerification:
          type: boolean
          description: Se as novas contas confirmam seu endereço (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Se o login com o Google é oferecido ao jogo.
            googleWeb:
              type: boolean
              description: Se o login com o Google também é oferecido a um site (`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: A autenticação de dois fatores está sempre disponível.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Os bits de prova de trabalho que o cadastro exige (0 para nenhum).
        categories:
          type: array
          description: Os controles de tempo oficiais, ranqueados (`RATED_CATEGORIES`), na ordem do servidor. Qualquer outro controle de tempo é `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: As regras que um cliente pode verificar antes de enviar um formulário.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: O nome de usuário mais curto (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: O nome de usuário mais longo (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: A expressão regular à qual um novo nome de usuário deve corresponder.
            passwordMinLength:
              type: integer
              description: A senha mais curta, em caracteres (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: A senha mais longa, em bytes de UTF-8.
            customTimeControls:
              type: boolean
              description: Se desafios e partidas privadas podem usar controles de tempo personalizados.
            reportsPerDay:
              type: integer
              description: Quantas denúncias um jogador pode fazer a cada 24 horas (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: A maior mensagem WebSocket que um cliente pode enviar, em bytes.
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 3326678031
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
          googleWeb: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: Um controle de tempo oficial.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: O id da categoria, minutos e segundos de incremento (`3+2`).
        baseSec:
          type: number
          minimum: 0
          description: O tempo base, em segundos.
        incSec:
          type: number
          minimum: 0
          description: O incremento por lance, em segundos.
    AccountView:
      type: object
      description: A conta como o próprio jogador a vê (`user` das respostas de login e de `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: O id da conta.
        username:
          type: string
          description: O nome de usuário.
        email:
          type: string
          format: email
          description: O endereço de e-mail.
        emailVerified:
          type: boolean
          description: Se o endereço está confirmado.
        mfaEnabled:
          type: boolean
          description: Se a autenticação de dois fatores está ativada.
        googleLinked:
          type: boolean
          description: Se uma conta Google está vinculada.
        hasPassword:
          type: boolean
          description: '`false` para uma conta criada pelo Google que ainda não definiu uma senha.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Se os desafios diretos pelo nome são aceitos (veja `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Quando a conta foi criada (ms desde a época Unix).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: O último login (ms desde a época Unix), ou `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: O novo endereço de uma alteração de e-mail que aguarda o seu link, ou `null`.
    SessionAnswer:
      type: object
      description: Uma nova sessão.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: O token de sessão, para o cabeçalho `Authorization` e o WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: O fim absoluto da sessão (ms desde a época Unix); o limite de inatividade pode encerrá-la 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: A segunda etapa de um login com autenticação de dois fatores; continue com `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: O token da etapa, para `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Segundos restantes para concluir a etapa.
    LoginAnswer:
      description: Uma sessão, ou a segunda etapa de um login com autenticação de dois fatores.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Um primeiro login com o Google; continue com `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: O ticket para `POST /auth/sso/complete`, válido por 10 minutos.
        suggestedUsername:
          type: string
          description: Um nome de usuário formado a partir do nome no Google ou do endereço, ou `""` quando nada serve.
    SsoNeedsPassword:
      type: object
      description: Uma conta com senha usa o endereço; continue com `POST /auth/sso/google/link`. Nada foi vinculado ainda.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: O ticket para `POST /auth/sso/google/link`.
        username:
          type: string
          description: O nome de usuário dessa conta (só quem comprovou o endereço ao Google o vê).
        expiresIn:
          type: integer
          const: 600
          description: Segundos durante os quais o ticket continua válido.
    SsoFinishAnswer:
      description: A resposta 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: Uma tentativa de login com o Google.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: A tentativa, para `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: A URL do Google a abrir no navegador do sistema, depois de verificada.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: O `state` que o Google deve devolver.
        expiresIn:
          type: integer
          const: 600
          description: Segundos durante os quais a tentativa continua válida.
    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: O nome de usuário; em seguida, as regras do servidor (veja a descrição).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: O endereço de e-mail. Os espaços do início e do fim são removidos, e ele é gravado em minúsculas.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A senha; em seguida, as regras de senha.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: O nome de usuário, ou o endereço de e-mail (qualquer texto com `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Opcional. Mostrado na lista de dispositivos conectados (como `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: O `mfaToken` da resposta de login (ou da resposta de um login com o Google).
        code:
          type: string
          maxLength: 32
          description: Opcional. Um código de autenticador de 6 dígitos, ou um código de recuperação.
        recoveryCode:
          type: string
          maxLength: 32
          description: Opcional. Um código de recuperação. É necessário um entre `code` e `recoveryCode`.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: O endereço de e-mail.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: O parâmetro `token` do link de redefinição.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: A nova senha; valem as regras de senha do cadastro.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: O desafio PKCE S256 do `codeVerifier` do cliente (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: A porta em que o cliente escuta em `127.0.0.1`.
    SsoWebStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: O desafio PKCE S256 do `codeVerifier` da página (`BASE64URL(SHA-256(codeVerifier))`).
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: O `attemptId` de `POST /auth/sso/google/start`.
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: O verificador PKCE; seu SHA-256 deve corresponder ao desafio enviado a `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: O `state` tal como o Google o devolveu.
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: O `code` tal como o Google o devolveu (ASCII imprimível sem espaços).
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Opcional. O `iss` tal como o Google o devolveu, quando devolveu.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: O `linkTicket` de `finish`, válido por 10 minutos.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A senha da conta.
        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: O `ssoTicket` de `finish`, válido por 10 minutos.
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: O nome de usuário; valem as regras do cadastro.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: As sessões ativas, começando pela usada mais recentemente.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Uma sessão ativa (um dispositivo conectado).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: O id da sessão, para `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: O login (ms desde a época Unix).
        lastSeenAt:
          type: integer
          format: int64
          description: O último uso (ms desde a época Unix), atualizado no máximo a cada 5 minutos (mais tarde enquanto o servidor está muito atrasado).
        expiresAt:
          type: integer
          format: int64
          description: O fim absoluto (ms desde a época Unix); o limite de inatividade pode encerrar a sessão antes.
        clientLabel:
          type:
            - string
            - 'null'
          description: O `clientLabel` do login, ou `null` quando nenhum foi enviado.
        current:
          type: boolean
          description: Se esta é a sessão que faz a requisição.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Um registro por categoria em que o jogador jogou partidas ranqueadas.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: As sanções ativas.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` enquanto a conta está banida, senão `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: O registro de rating de uma categoria.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: O id da categoria.
        rating:
          type: integer
          description: O rating.
        games:
          type: integer
          minimum: 0
          description: As partidas jogadas na categoria (contadas ou não).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: O rating mais alto alcançado.
        provisional:
          type: boolean
          description: '`true` enquanto o rating ainda não está estabelecido ou tem menos de `PROVISIONAL_GAMES` partidas contadas (o jogo o mostra como `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: O tipo de sanção.
        reason:
          type:
            - string
            - 'null'
          description: O motivo informado, se houver.
        startsAt:
          type: integer
          format: int64
          description: O início (ms desde a época Unix).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: O fim (ms desde a época Unix), `null` quando a sanção é permanente.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: O fim do banimento (ms desde a época Unix), `null` quando é permanente.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` para aceitar desafios diretos pelo nome, `none` para recusá-los.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: A senha atual.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: A nova senha; valem as regras de senha do cadastro.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A senha da conta.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Um código do segredo pendente, com exatamente 6 dígitos.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A senha da conta.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcional; necessário com a autenticação de dois fatores (ou `recoveryCode`). Um código do autenticador, ou um código de recuperação.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcional. Um código de recuperação (`xxxx-xxxx-xx`; maiúsculas e minúsculas, espaços e hifens não importam).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A senha da conta.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Um código do autenticador (um código de recuperação é recusado).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: O novo endereço; sem os espaços do início e do fim e convertido para minúsculas, depois verificado como um endereço de cadastro.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A senha da conta.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcional; necessário com a autenticação de dois fatores (ou `recoveryCode`). Um código do autenticador, ou um código de recuperação.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Opcional. Um código de recuperação.
    TotpSetup:
      type: object
      description: O segredo pendente para o aplicativo autenticador.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: O segredo em base32, para digitar no aplicativo.
        uri:
          type: string
          format: uri
          description: O URI `otpauth://totp/...`, para mostrar como QR code.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Segundos por intervalo.
    RecoveryCodeList:
      type: array
      description: Os 10 códigos de recuperação, mostrados só desta vez. Cada um funciona uma única 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: Um lado de uma partida.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: O nome no registro da partida (`deleted#<id>` para uma conta excluída).
        rating:
          type:
            - integer
            - 'null'
          description: O rating no início, `null` quando desconhecido.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: O rating depois da partida. Uma partida ranqueada sempre o tem; `null` só para uma partida que não conta para os ratings (casual, personalizada, cancelada).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: A variação de rating, `0` quando as regras de rating deixam o rating onde estava (a regra da FIDE do resultado zero, um adversário sem rating estabelecido, as primeiras partidas de um jogador sem rating estabelecido); `null` como em `ratingAfter`.
    GameSummary:
      type: object
      description: O resumo de uma partida gravada.
      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: O id da partida.
        category:
          type: string
          description: O id da categoria oficial (`3+2`), ou `custom`.
        rated:
          type: boolean
          description: Se a partida conta para os ratings.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: O controle de tempo em 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` (veja os códigos de partida).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: O código do motivo do término (veja os códigos de partida).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: O resultado, `*` para uma partida cancelada.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: O número de meios-lances.
        startedAt:
          type: integer
          format: int64
          description: O início (ms desde a época Unix).
        endedAt:
          type: integer
          format: int64
          description: O fim (ms desde a época Unix).
    Termination:
      type: string
      description: O nome do motivo do término (veja os códigos de partida); `Unknown` para um código que o protocolo não conhece.
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - ResignationVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: Um resumo de partida do ponto de vista de um jogador.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: O lado do jogador.
    HistoryGameSummary:
      description: Uma partida do histórico do jogador conectado.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: O tempo base em milissegundos.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: O incremento em milissegundos.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: O desfecho do ponto de vista do jogador.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: As partidas da página, das mais recentes para as mais antigas.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: O id a passar como `before` para a página seguinte, ou `null` na última página.
        total:
          type: integer
          minimum: 0
          description: O número de partidas que correspondem ao filtro, em todas as páginas.
    MoveRecord:
      type: object
      description: Um meio-lance de uma partida.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: O lance em notação UCI, com `n`, `b`, `r` ou `q` acrescentado em caso de promoção.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: O tempo descontado pelo lance (ms), `null` quando o registro não o tem.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: O relógio de quem jogou, depois do lance (ms), `null` quando o registro não o tem.
    PgnTagsView:
      type: object
      description: As principais tags PGN, para exibição. Aqui, `Termination` é o nome do motivo do término; o arquivo PGN usa os valores do padrão PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` ou `<SERVER_NAME> casual <category>`.'
        Site:
          type: string
          description: '`SERVER_PUBLIC_HOST`.'
        Date:
          type: string
          pattern: '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$'
          description: A data de início, em UTC.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: O rating no início, ou `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: O rating no início, ou `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        TimeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
        Termination:
          $ref: '#/components/schemas/Termination'
        PlyCount:
          type: integer
          minimum: 0
    GameRecord:
      description: O registro de uma partida com seus lances e relógios. Tem os campos de um resumo de partida, sem `color` e `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: O tempo base em milissegundos.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: O incremento em milissegundos.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: O nome de `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: O id da partida da qual esta é uma revanche, ou `null`.
            moves:
              type: array
              description: Uma entrada por meio-lance.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Só quando o jogador do token jogou esta partida; o lado desse jogador.
            reportable:
              type: boolean
              description: Só quando o jogador do token jogou esta partida; `true` quando `POST /reports` aceitaria agora uma denúncia do adversário por esta partida (a partida terminou há no máximo 7 dias, a cota diária não se esgotou, e o adversário ainda não foi denunciado por esta partida).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: O nome de usuário, como o jogador o escreveu.
        createdAt:
          type: integer
          format: int64
          description: Quando a conta foi criada (ms desde a época Unix).
        ratings:
          type: array
          description: Somente as categorias oficiais, na ordem do servidor.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Todas as partidas gravadas, inclusive as casuais e as canceladas.
            rated:
              type: integer
              minimum: 0
              description: As partidas ranqueadas, somadas sobre os registros de rating.
            wins:
              type: integer
              minimum: 0
            draws:
              type: integer
              minimum: 0
            losses:
              type: integer
              minimum: 0
    PlayerGamesPage:
      type: object
      required:
        - username
        - games
        - next
      properties:
        username:
          type: string
          description: O nome de usuário, como o jogador o escreveu.
        games:
          type: array
          description: As partidas da página, das mais recentes para as mais antigas.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: O id da última partida sempre que a página está cheia (a página seguinte pode então estar vazia), senão `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: O id da categoria.
        minGames:
          type: integer
          minimum: 0
          description: As partidas contadas de que um registro precisa para ser listado (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Quando a classificação foi calculada (ms desde a é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: O texto PGN, no máximo 65.536 bytes de UTF-8. Só a primeira partida é usada.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Opcional. O tamanho da imagem.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Opcional. O lado que fica na parte de baixo do tabuleiro.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Opcional. Milissegundos por lance, um número JSON com valor inteiro.
        coords:
          type: boolean
          default: true
          description: Opcional. Se as letras das colunas e os números das fileiras são desenhados em volta do tabuleiro.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: A partida, como um inteiro ou uma string de 1 a 16 dígitos.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: O nome de usuário do adversário, como no registro da partida ou como é agora, sem diferenciar maiúsculas de minúsculas. Não pode estar em branco.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: O assunto da denúncia.
        comment:
          type:
            - string
            - 'null'
          description: Opcional. No máximo 500 caracteres depois que os caracteres de controle (exceto tabulação e quebra de linha) são removidos e os espaços do início e do fim do texto são cortados.
    AccountExport:
      type: object
      description: Tudo o que o servidor guarda sobre a conta (horários em ms desde a é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: Quando a exportação foi feita.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: O que o arquivo contém e o que deixa de fora, em inglês simples para o jogador.
          items:
            type: string
        account:
          description: A visão da conta de `GET /account/me`, com `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: O endereço da conta Google vinculada, ou `null`.
        ratings:
          type: array
          description: Os registros de rating completos.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Pontos de rating devolvidos depois que um adversário foi pego trapaceando, somados por dia UTC e categoria, dos mais recentes para os mais antigos. Nem as partidas nem os trapaceiros são identificados.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Todas as sessões gravadas, das mais recentes para as mais antigas, sem nenhum token. A limpeza de retenção exclui uma sessão expirada, e uma sessão encerrada um dia depois do encerramento.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Os eventos de segurança, dos mais recentes para os mais antigos, guardados durante `RETENTION_SECURITY_DAYS`.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Todas as sanções, inclusive as já retiradas. O nome do moderador nunca é incluído.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Os eventos de conduta registrados para as partidas abandonadas, canceladas e de não comparecimento do jogador, guardados por 30 dias.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: As denúncias que o jogador fez.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: O número de partidas.
            list:
              type: array
              description: Todas as partidas, das mais recentes para as mais antigas, como resumos de `GET /account/games`. Os lances estão em `GET /games/{id}` e o PGN em `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Um registro de rating completo.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Se o jogador já saiu da fase sem rating estabelecido.
            countedGames:
              type: integer
              minimum: 0
              description: As partidas que entraram no rating.
            updatedAt:
              type: integer
              format: int64
              description: Quando o registro mudou pela última vez.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: 00:00 UTC do dia (ms desde a época Unix).
        category:
          type: string
        points:
          type: integer
          description: Os pontos devolvidos nesse dia nessa categoria.
    ExportSession:
      type: object
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - revokedAt
        - clientLabel
        - ip
      properties:
        id:
          type: integer
          format: int64
        createdAt:
          type: integer
          format: int64
        lastSeenAt:
          type: integer
          format: int64
        expiresAt:
          type: integer
          format: int64
        revokedAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Quando a sessão foi encerrada, ou `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: O endereço do login, apagado depois de `RETENTION_IP_DAYS`.
    SecurityEvent:
      type: object
      description: |-
        Um evento de segurança. `ip` só é informado para o que foi feito com a sessão iniciada, com a senha da conta (e o segundo fator) ou com um link enviado para o endereço dela: `register`, `email_verified`, `login`, `sso_login`, `sso_linked`, `sso_account_created`, `recovery_code_used`, `password_reset`, `password_changed`, `reauth_failed`, `mfa_setup_started`, `mfa_enabled`, `mfa_disabled`, `recovery_codes_regenerated`, `session_revoked`, `sessions_revoked_all`, `email_change_requested`, `email_changed`, `email_change_refused` e `account_exported`; todos os outros tipos têm `ip: null`, e `ip` é apagado depois de `RETENTION_IP_DAYS`.

        `detail` só guarda estes campos: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` e `method` (`password`, ou `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` e `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. Um evento `moderator_action` só guarda `{ action }`, para as ações `ban`, `unban`, `reset_mfa`, `verify_email` e `revoke_sessions` (as outras ações de moderador ficam de fora). Todos os outros tipos têm `detail: null`. Os eventos `rating_refund` ficam de fora: seus pontos estão em `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: O tipo de evento (`login`, `password_changed`…).
        at:
          type: integer
          format: int64
          description: Quando aconteceu.
        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: O nome público atual do jogador denunciado.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Se a denúncia ainda está aberta (não se informa se o jogador denunciado foi sancionado).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: O token do link do e-mail (o campo oculto do formulário da página).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: O token do link de redefinição (o campo oculto do formulário).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: A nova senha; valem as regras de senha do cadastro.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: A nova senha de novo; ela deve ser igual.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: Uma página HTML em UTF-8 (`text/html; charset=utf-8`), sem JavaScript nem recursos externos.
