openapi: 3.1.1
info:
  title: API HTTPS do servidor Scacelith
  version: "1.1.0"
  summary: A API HTTPS de todos os servidores Scacelith, tanto do oficial como dos servidores da comunidade.
  description: |-
    Todos os servidores Scacelith, o oficial `caissa.scacelith.com` e qualquer servidor da comunidade, respondem a esta API HTTPS. O jogo usa-a para tudo o que fica fora de uma partida propriamente dita: a criação de conta e o início de sessão (incluindo a autenticação de dois fatores e o início de sessão com o Google), a página da conta, o histórico de partidas, as transferências de PGN e os GIF animados das partidas, os dispositivos com sessão iniciada, a transferência dos dados, a eliminação da conta e as denúncias.

    O jogo em direto (procura de adversário, desafios, lances, relógios) passa pelo WebSocket do mesmo servidor (`wss://<host>/ws`), aberto com um token de sessão desta API; está descrito em `PROTOCOL.md`, não aqui.

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

    ## URL de 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 predefinição; atrás de uma NAT ou de um proxy, a porta que os jogadores usam é `PUBLIC_API_PORT`.
    - O WebSocket do jogo está na mesma porta por predefinição (`WS_PORT` muda-o de porta; `GET /info` indica ao cliente onde se encontra).
    - HTTP/1.1 sobre TLS. Com `TLS_MODE=proxy`, um proxy inverso colocado à frente do servidor termina o TLS. `TLS_MODE=off` (HTTP simples) destina-se apenas ao desenvolvimento local.
    - As páginas HTML abertas a partir das ligações dos e-mails (`/verify-email`, `/reset-password`, `/confirm-email-change`) ficam na raiz do servidor, fora de `/api/v1`. Os endpoints de saúde respondem tanto na raiz como em `/api/v1`. O início de sessão com o Google não tem nenhuma página no servidor: o Google reencaminha o navegador para o próprio jogo, em `127.0.0.1`, ou para uma página do site que o iniciou (`GOOGLE_WEB_REDIRECT_URI`).
    - Uma barra final é ignorada (`/api/v1/info/` equivale a `/api/v1/info`) e os parâmetros de caminho são descodificados do formato URL (um parâmetro que não seja uma codificação URL válida recebe a resposta 400 `invalid_request`).

    ## Pedidos

    - **Corpos JSON.** Os pedidos `POST`, `PUT` e `DELETE` levam um objeto JSON com `Content-Type: application/json`. Um `charset` diferente de UTF-8 é recusado, tal 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 está limitado a `HTTP_BODY_LIMIT` bytes (16 384 por predefinição; `POST /gif` tem um limite próprio de 135 168 bytes): 413 `payload_too_large`. Tem de chegar em 10 segundos: 408 `request_timeout`. Depois de qualquer um destes erros, o servidor fecha a ligaçã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 os comprimentos são verificados. Qualquer uma destas falhas resulta em 400 `invalid_request`, com `field` a indicar o campo (com pontos, no caso de um campo aninhado, como `pow.nonce`). As cadeias de carateres não podem conter carateres de controlo. Os comprimentos contam unidades de código UTF-16, pelo que um caráter fora do Plano Multilingue Básico (um emoji) conta duas vezes. Um corpo que não seja JSON resulta em 400 `invalid_json`. `POST /gif` e `POST /reports` verificam eles próprios os seus corpos (ver cada endpoint).
    - **Cadeias de consulta.** Conta a primeira ocorrência de um parâmetro, os parâmetros desconhecidos são ignorados e um `+` é descodificado como espaço: os endpoints que recebem uma categoria de ritmo de jogo aceitam tanto `3%2B2` como `3+2`.
    - **Métodos.** `HEAD` é um `GET` sem o corpo. `OPTIONS` num caminho existente responde 204 com um cabeçalho `Allow` (e os cabeçalhos de uma verificação prévia CORS, ou preflight, descrita abaixo). Qualquer outro método que o caminho não tenha recebe a resposta 405 `method_not_allowed` com `Allow`.
    - **Destino do pedido.** Não pode exceder 4096 carateres (414 `uri_too_long`). Um pedido que não possa sequer ser analisado, ou cuja parte inicial (linha do pedido e cabeçalhos) seja demasiado grande ou chegue demasiado devagar, recebe uma resposta 400, 431 ou 408 vazia e a ligação é fechada.
    - **CORS: uma lista de permissões, desativada por predefinição.** A API serve o jogo, que não envia `Origin`. Com `CORS_ORIGINS` vazio (a predefinição), nunca é enviado nenhum cabeçalho `Access-Control-*`, pelo que nenhuma página web consegue ler uma resposta. `CORS_ORIGINS` enumera 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 predefinida; `http://` apenas para `localhost`, `127.0.0.1` e `[::1]` (desenvolvimento local); sem carateres universais. O servidor oficial define `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com` para o seu site. Nesse caso:
      - Todas as respostas para um caminho em `/api/v1`, exceto as dos endpoints de saúde, tanto de sucesso como de erro (incluindo o 429 da camada por endereço), levam `Vary: Origin`. Quando o `Origin` do pedido é, byte a byte, uma das origens enumeradas, levam também `Access-Control-Allow-Origin: <that origin>` e, exceto na resposta a uma verificação prévia, `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, para que a página consiga ler os códigos de erro, `Retry-After` e os nomes dos ficheiros das transferências de PGN e GIF.
      - Uma verificação prévia (`OPTIONS` com `Access-Control-Request-Method`) de uma origem enumerada num caminho existente recebe a resposta 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`. Não precisa de token e não conta para nenhum orçamento de conta nem para nenhum limite de endpoint; como qualquer pedido, consome um token da camada por endereço. Um navegador reutiliza uma verificação prévia durante 10 minutos, mas apenas para esse URL exato, incluindo a cadeia de consulta. Um pedido sem `Authorization` e sem corpo JSON, como `GET /info` ou `GET /leaderboard`, não precisa de verificação prévia.
      - Qualquer outra resposta a uma verificação prévia é uma verificação prévia falhada, 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 não envia então o pedido, e a página vê um erro de rede, sem a resposta nem o seu `Retry-After`.
      - Nunca é enviado `Access-Control-Allow-Credentials`, nem `Access-Control-Allow-Origin: *`: uma página envia o seu token de sessão em `Authorization: Bearer`, tal como o jogo, e a API não define nenhum cookie. As páginas HTML, os endpoints de saúde e as respostas vazias aos pedidos impossíveis de analisar não levam nenhum cabeçalho CORS. O WebSocket não usa CORS: `WS_ALLOWED_ORIGINS` enumera as páginas que o podem abrir.
      - Uma página de uma origem não enumerada não lê nada, e as suas escritas entre sites nunca são enviadas: os corpos JSON e `Authorization` exigem uma verificação prévia, cuja resposta não tem `Access-Control-Allow-Origin` para essa origem. `Cross-Origin-Resource-Policy: same-origin` mantém-se em todas as respostas: aplica-se apenas aos pedidos `no-cors` (outro site que incorpora uma resposta), nunca aos pedidos `cors` de uma página enumerada.

    ## Respostas e erros

    - As respostas são JSON em UTF-8, exceto a transferência de PGN (`application/x-chess-pgn`), os GIF animados (`image/gif`) e as páginas HTML. Os instantes são milissegundos desde 1970-01-01 UTC. Os IDs são números inteiros. Os IDs das partidas têm até 16 algarismos, mas ficam abaixo de 2^53, pelo que um número JSON (um double) os representa exatamente.
    - Todas as respostas da API levam `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`, levam também `Strict-Transport-Security: max-age=31536000`. As respostas que o serviço de escuta escreve por si próprio, antes da API (as respostas a `GET` e `HEAD` dos endpoints de saúde, 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 tem de ser lida nos 60 segundos seguintes ao momento em que o servidor a tem pronta, o que só importa para as respostas grandes (um GIF, a exportação de dados, um PGN longo). O tempo que o servidor demora a preparar uma resposta não conta para este prazo nem para os 30 segundos sem nenhum byte recebido ou enviado ao fim dos quais uma ligação é fechada.

    Os erros têm uma única forma (o esquema `Error`):

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

    `message` destina-se aos registos e serve de alternativa; um cliente escolhe o que mostrar a partir de `error`. `retryAfter` (em segundos) só está presente nas recusas que terminam com o passar do tempo, e a resposta leva então também um cabeçalho `Retry-After` com o mesmo valor; a única exceção é o 503 `busy` das leituras do histórico, das partidas, dos jogadores e da classificação, que tem apenas o campo. Alguns erros acrescentam campos: `field` (dados inválidos), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` e `column` (`invalid_pgn`).

    Erros que qualquer endpoint pode devolver:

    | Estado | `error` | Quando |
    |---|---|---|
    | 400 | `invalid_request` | O corpo viola o esquema do endpoint (`field` indica onde), o destino do pedido ou `Content-Length` está mal formado, um parâmetro de caminho não é uma codificação URL válida, ou o corpo foi truncado. |
    | 400 | `invalid_json` | O corpo não é JSON. |
    | 401 | `unauthorized` | Falta o cabeçalho `Authorization` num endpoint que exige uma sessão. |
    | 401 | `invalid_token` | O token está mal formado, expirou, foi revogado ou pertence a uma conta eliminada; também nos endpoints em que a sessão é opcional. |
    | 404 | `not_found` | O endpoint não existe. Alguns endpoints também o usam: a partida, o jogador ou a sessão não existe. |
    | 405 | `method_not_allowed` | O caminho existe para outros métodos (ver `Allow`). |
    | 408 | `request_timeout` | O corpo não chegou em 10 segundos. |
    | 413 | `payload_too_large` | O corpo excede `HTTP_BODY_LIMIT` (135 168 bytes para `POST /gif`). |
    | 414 | `uri_too_long` | O destino do pedido excede 4096 carateres. |
    | 415 | `unsupported_media_type` | O corpo não é `application/json`, ou o seu charset não é UTF-8. |
    | 429 | `rate_limited` | Um limite de pedidos (ver abaixo): `retryAfter` e um cabeçalho `Retry-After`. |
    | 500 | `internal_error` | Uma falha inesperada. O servidor regista-a. |
    | 503 | `timeout` | O servidor não respondeu em 30 segundos (60 segundos para a exportação, 45 segundos para os GIF com as definições predefinidas). |
    | 503 | `server_busy` | Uma consulta de sessão ou uma alteração da conta encontrou a base de dados bloqueada (`retryAfter` 1), ou a fila de hash das palavras-passe está cheia (ver abaixo). Também, com um `retryAfter` de 2 a 5, quando o servidor está muito atrasado: para um `POST`, `PUT`, `PATCH` ou `DELETE`, a base de dados tem demasiadas escritas em espera; para qualquer pedido, demasiados pedidos anteriores continuam em execução para além do seu tempo limite, ou o token de sessão não pôde ser verificado em 5 segundos. O pedido não foi executado e os seus limites de pedidos não consumiram nada. |

    Os endpoints de leitura (histórico de partidas, partidas, jogadores, classificação) e a exportação respondem 503 `busy` com `retryAfter: 1` quando a base de dados permaneceu bloqueada.

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

    - 503 `server_busy`: a fila de hash das palavras-passe do servidor (`PASSWORD_HASH_QUEUE_MAX`) está cheia, ou o tempo de espera esgotou-se.
    - 429 `rate_limited`: com a fila já meio cheia, este cliente (um endereço IPv4 ou um /48 IPv6) já tem `PASSWORD_HASH_WAITERS_PER_SOURCE` hashes em espera. Este 429 devolve os tokens de limite de pedidos que o pedido consumiu.

    Nada foi alterado e nenhuma tentativa falhada foi contada (exceto em `POST /auth/sso/google/link`, cuja tentativa do ticket e cuja falha da conta foram contadas antes do hash), e uma ligação de reposição continua válida.

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

    ## 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 carateres base64url) é devolvido por `POST /auth/login`, e depois por `POST /auth/login/mfa` quando a autenticação de dois fatores está ativada, por `POST /auth/sso/google/finish` e `POST /auth/sso/google/link` (início de sessão com o Google) e por `POST /auth/sso/complete`. Todos eles respondem `{ token, expiresAt, user }`. O servidor guarda apenas um hash SHA-256 do token.
    - **Sessão obrigatória.** A falta do cabeçalho resulta em 401 `unauthorized` com `WWW-Authenticate: Bearer realm="scacelith"`. Um token inválido resulta em 401 `invalid_token` com `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Um cliente deve esquecer um token que receba `invalid_token` e iniciar sessão 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, estes endpoints devolvem a vista pública; se for enviado um cabeçalho, o seu token tem de ser válido.
    - **Armazenamento ocupado.** Quando a base de dados permanece bloqueada durante a verificação de um token, um endpoint que exige ou aceita uma sessão responde 503 `server_busy` com `retryAfter: 1` (e `Retry-After`); quando a verificação demora mais de 5 segundos, responde 503 `server_busy` com um `retryAfter` de 2 a 5. Em ambos os casos, o token não foi avaliado: mantenha-o e envie novamente o pedido.
    - **Duração.** Uma sessão termina no primeiro destes prazos: `SESSION_MAX_DAYS` (90) dias após o início de sessão (é o `expiresAt`) ou `SESSION_IDLE_DAYS` (30) dias sem utilização. Cada utilização adia o limite de inatividade; o servidor escreve o novo valor no máximo a cada 5 minutos, em segundo plano. Enquanto a sua base de dados tem muitas escritas em espera, adia essa escrita para um pedido posterior, exceto na última hora antes do limite de inatividade, pelo que uma sessão em utilização não expira. Uma conta mantém no máximo `MAX_SESSIONS_PER_USER` (10) sessões: um novo início de sessão revoga as mais antigas além desse número.
    - **Revogação.** Terminar sessão (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) revoga sessões; o mesmo fazem uma alteração da palavra-passe (as outras sessões), uma reposição da palavra-passe e a eliminação da conta (todas as sessões), bem como um administrador. Uma revogação feita através da API produz efeito imediatamente, e o WebSocket aberto com uma sessão revogada é fechado. Uma alteração ou reposição da palavra-passe e a eliminação da conta revogam as sessões na mesma transação que a própria alteração: quando uma delas falha, nenhuma sessão foi revogada e também nada mais foi alterado. O servidor guarda em cache as consultas de sessão durante 30 segundos, pelo que uma revogação feita pelo comando de administração, um processo separado, produz efeito no prazo de 30 segundos.
    - **Âmbito.** Um token pertence a um único servidor e também abre o WebSocket desse servidor. Nunca o envie para outro servidor.

    ## Limites de pedidos e outras restrições

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

    Cada limite é um balde de tokens que comporta `limit` pedidos e se reabastece continuamente à taxa de `limit / window`; `retryAfter` é o tempo até ao próximo token. Os limites marcados como *partilhados* são também contados numa janela deslizante da mesma duração, para que nenhuma janela contenha muito mais de `limit` pedidos. Cada limite é contado para todo o servidor. Quando um dos limites de um endpoint recusa um pedido, os tokens que os seus outros limites consumiram para esse pedido são devolvidos. Uma recusa resulta em 429 `rate_limited` com `retryAfter` e `Retry-After`.

    - **Camada por endereço.** Todos os pedidos (qualquer caminho e método, incluindo os endpoints de saúde e as atualizações para WebSocket) consomem primeiro um token do orçamento do respetivo cliente: `HTTP_RATE_PER_IP` (600) por minuto com uma rajada de meio minuto, e `HTTP_RATE_PER_PREFIX` (4 × `HTTP_RATE_PER_IP`) para um /48 IPv6. Um cliente também pode ter no máximo `IP_MAX_INFLIGHT` (32 × `WORKERS`) pedidos em curso (acima desse número, `retryAfter` 1). Um cliente que insiste apesar das recusas é bloqueado: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) recusas num minuto bloqueiam-no durante 1 minuto, depois 4, 16 e 60 minutos a cada novo bloqueio no espaço de 6 horas. Uma recusa dos limites `auth`, `auth_*` e `reauth` conta 5; as recusas dos limites contados por jogador nunca contam. Os endereços indicados em `ABUSE_EXEMPT` estão isentos desta camada, mas não do orçamento da conta nem dos limites dos endpoints.
    - **Orçamento da conta.** Todos os pedidos que levam um token de sessão válido contam também para a respetiva conta: `USER_RATE_PER_MIN` (120) por minuto para todos os endpoints em conjunto, seja qual for o endereço, com uma rajada de meio minuto.
    - **Limites dos endpoints:**

    | Limite | Predefinição | Contado por | Endpoints |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, partilhado | cliente, e `AUTH_RATE_PER_PREFIX` (5 × `AUTH_RATE_PER_IP`) por /48 IPv6 | `POST /auth/register`, `/auth/login`, `/auth/login/mfa`, `/auth/verify-email/resend`, `/auth/password/forgot`, `/auth/password/reset`, `/auth/sso/google/link`, `/auth/sso/complete`; `POST /verify-email`, `/reset-password`, `/confirm-email-change` |
    | `auth_register` | `AUTH_REGISTER_PER_HOUR` (10) / hora, partilhado | cliente, o triplo por /48 IPv6 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / hora, partilhado | cliente, o triplo por /48 IPv6 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / hora, partilhado | cliente, o triplo por /48 IPv6 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 horas, partilhado | cliente, o triplo por /48 IPv6 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / hora, partilhado | cliente, o triplo por /48 IPv6 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | os valores de `auth`, num balde próprio, partilhado | cliente e /48 IPv6 | `POST /account/password`, `/account/mfa/totp/setup`, `/account/mfa/totp/enable`, `/account/mfa/totp/disable`, `/account/mfa/recovery-codes`, `/account/email`, `/account/export`, `/account/delete` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 min, partilhado | jogador | os mesmos endpoints que `reauth` |
    | `account` | 60 / min | jogador | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | jogador | `GET /account/games` |
    | `account_export` | 5 / hora, partilhado | jogador | `POST /account/export` (verificado antes de `reauth`; todas as tentativas contam) |
    | `sessions` | 60 / min | jogador | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | jogador (cliente sem token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (um único balde para os quatro) |
    | `gif` | 30 / min | jogador | `GET /games/{id}/gif`, `POST /gif` (um único 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, partilhados | jogador | os mesmos dois, apenas quando o GIF tem de ser gerado (não vem da cache) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min e `GIF_IP_RENDERS_PER_HOUR` (120) / hora, partilhados | cliente (todas as suas contas em conjunto), o triplo por /48 IPv6 | os mesmos, nas mesmas condições |
    | `reports` | 30 / hora | jogador | `POST /reports` |
    | `sso_start` | 30 / 10 min, partilhado | cliente, e 90 por /48 IPv6 | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (em conjunto) |
    | `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: apenas a camada por endereço. Um endpoint com vários limites verifica-os pela ordem indicada na sua descrição.

    O servidor trata um pedido por esta ordem: a camada por endereço, depois os endpoints de saúde; a correspondência do endpoint; as verificações de que o servidor não está muito atrasado (503 `server_busy`): demasiados pedidos anteriores ainda em execução para além do seu tempo limite e, para um `POST`, `PUT`, `PATCH` ou `DELETE`, as escritas pendentes da base de dados; a autenticação, em 5 segundos (503 `server_busy` se demorar mais); o orçamento da conta, quando o pedido 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, um pedido recusado por causa do seu token não gasta nenhum dos tokens do endpoint, e um pedido com um corpo inválido gasta-os.

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

    - **Inícios de sessão falhados num mesmo identificador** (nome de utilizador ou e-mail): a partir de `AUTH_FAILURES_PER_ACCOUNT` (5) falhas, cada tentativa tem de esperar o dobro da anterior (2 s, 4 s e assim sucessivamente, até 15 minutos): 429 `too_many_attempts` com `retryAfter`. O contador volta a zero após uma hora sem falhas.
    - **Segundos fatores falhados no início de sessão:** a mesma regra, a partir do 5.º código errado da conta. Um passo de início de sessão aceita no máximo 5 códigos errados.
    - **Códigos de segundo fator de uma mesma conta:** no máximo `AUTH_MFA_PER_ACCOUNT` (10) códigos (da aplicação de autenticação ou de recuperação, certos ou errados) a cada 15 minutos, a partir de qualquer endereço, no início de sessão e nas reautenticações; acima disso, 429 `too_many_attempts` antes de o código ser verificado, pelo que um código de recuperação não é gasto.
    - **Reautenticações falhadas de uma conta** (palavra-passe ou código errados): a mesma regra (429 `too_many_attempts`), partilhada por todos os endpoints que reautenticam.
    - **E-mails:** um e-mail de confirmação ou de reposição por endereço a cada 5 minutos (a resposta mantém-se igual); um aviso «alguém tentou usar o seu endereço» por endereço e por hora.
    - **Denúncias:** `REPORTS_PER_DAY` (5) por jogador em 24 horas: 429 `report_limit`.

    ## Prova de trabalho

    `POST /auth/register` exige sempre uma prova de trabalho quando `POW_REGISTER_BITS` é superior a 0 (18 por predefinição; `GET /info` indica-o em `pow.register`). `POST /auth/login` e `POST /auth/sso/google/link` (um mesmo tipo de desafio para ambos) só a exigem durante os 5 minutos seguintes a uma vaga de inícios de sessão falhados detetada pelo servidor (`POW_LOGIN_TRIGGER_PER_MIN`, depois `POW_LOGIN_BITS`); o cliente fica a sabê-lo pela resposta.

    1. O pedido sem prova (ou com uma prova recusada) recebe a resposta 428 `pow_required` com `reason` e um desafio `pow` `{ challenge, bits, expiresAt }`.
    2. Encontre um nonce: uma cadeia decimal de no máximo 20 algarismos tal que `SHA-256(challenge + ":" + nonce)` comece por `bits` bits a zero (o bit mais significativo do primeiro byte primeiro). 18 bits exigem cerca de 260 000 hashes em média.
    3. Envie novamente o mesmo pedido com `"pow": { "challenge": "...", "nonce": "123456" }` no corpo.

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

    ## Reautenticação

    As alterações da conta voltam a pedir a palavra-passe e, quando a autenticação de dois fatores está ativada, um segundo fator:

    - apenas a palavra-passe: `POST /account/password` e `POST /account/mfa/totp/setup`;
    - a palavra-passe e um código da aplicação de autenticação, sendo recusado um código de recuperação: `POST /account/mfa/recovery-codes`;
    - a palavra-passe e um código da aplicação de autenticação 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 algarismos da aplicação de autenticação e `recoveryCode` um código de recuperação (`xxxx-xxxx-xx`; maiúsculas e minúsculas, espaços e hífenes são indiferentes). Um código de recuperação também pode ser enviado em `code` onde os códigos de recuperação são aceites. Cada código funciona uma única vez: um código da aplicação já utilizado é recusado até ao intervalo de 30 segundos seguinte, e um código de recuperação deixa de existir depois de utilizado.

    | Estado | `error` | Quando |
    |---|---|---|
    | 403 | `invalid_password` | Palavra-passe errada, ou a palavra-passe foi reposta ou alterada enquanto o pedido estava em execução (nada foi alterado). |
    | 403 | `mfa_code_required` | A autenticação de dois fatores está ativada e não foi enviado `code` nem `recoveryCode`. |
    | 403 | `invalid_code` | Código errado ou já utilizado. |
    | 400 | `password_not_set` | Uma conta que só inicia sessão com o Google ainda não tem palavra-passe («Esqueceu-se da palavra-passe?» permite definir uma). |
    | 429 | `too_many_attempts` | Demasiadas falhas, ou demasiados códigos tentados, nesta conta. |
    | 503 / 429 | `server_busy` / `rate_limited` | A fila de hash das palavras-passe está ocupada. |

    As palavras-passe e os códigos falhados contam no contador de falhas da conta e são registados como eventos de segurança.

    ## Porta de métricas

    Além da porta da API, o servidor responde em HTTP simples na porta de métricas (`METRICS_PORT`, 9464, associada a `METRICS_BIND`, 127.0.0.1 por predefinição; mantenha-a privada). Não faz parte desta API: `GET /healthz` responde `ok`; `GET /readyz` responde `ready` depois de concluído o arranque (todas as partições voltaram a aplicar o seu diário e os serviços de escuta estão associados às respetivas portas) e até ao início do encerramento, caso contrário 503 `not ready`; `GET /metrics` fornece as métricas do Prometheus e, com `METRICS_TOKEN` definido, exige `Authorization: Bearer <token>` com exatamente esse token.
  contact:
    name: Scacelith
    url: https://github.com/DarkCenobyte/scacelith-chess-server
  license:
    name: GPL-3.0-or-later
    identifier: GPL-3.0-or-later
externalDocs:
  description: 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 anfitrião público do servidor (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: A porta pública da API (`PUBLIC_API_PORT` ou, na sua falta, `API_PORT`).
tags:
  - name: server-info
    x-displayName: Informações do servidor
    description: O que um cliente precisa de saber antes de iniciar sessão ou de se ligar.
  - name: health
    x-displayName: Saúde
    description: |-
      Atividade e prontidão do servidor, para monitorização. Respondem na porta da API antes da autenticação e de qualquer limite de endpoint, mas, como qualquer pedido, consomem um token da camada por endereço; um anfitrião de monitorização pode ser incluído em `ABUSE_EXEMPT`. Ambos os caminhos funcionam para cada endpoint: na raiz do servidor e em `/api/v1`.

      A porta de métricas tem endpoints de saúde próprios, descritos na introdução.
  - name: auth
    x-displayName: Registo e início de sessão
    description: |-
      Criação de uma conta, início de sessão com uma palavra-passe (e um segundo fator), e-mails de confirmação e de reposição da palavra-passe.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` e `POST /auth/sso/complete` respondem com uma sessão `{ token, expiresAt, user }` (ou, no caso dos primeiros, com um segundo passo).
  - name: google-sign-in
    x-displayName: Início de sessão com o Google
    description: |-
      Disponível quando `GET /info` indica `sso.google: true`; caso contrário, todos os endpoints abaixo respondem 404 `sso_disabled`. Um site inicia sessão da mesma forma com o seu próprio cliente quando `sso.googleWeb` é true (abaixo, **Num site**). O jogo inicia sessão através do navegador do sistema com o fluxo para aplicações instaladas do RFC 8252 (um cliente do tipo «Desktop app», aplicação para computador): o Google reencaminha o navegador para um serviço de escuta do jogo em `127.0.0.1`, nunca para este servidor, e o jogo nunca vê uma credencial Google.

      1. O cliente fica à escuta em `127.0.0.1:0` (o sistema escolhe a porta) e cria um par PKCE: um `codeVerifier` de 43 a 128 carateres `[A-Za-z0-9._~-]` e `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, que tem 43 carateres e não tem preenchimento.
      2. `POST /auth/sso/google/start`, com o desafio e a porta, devolve o URL do Google e o `state` da tentativa. O cliente verifica o URL (ver abaixo) e abre-o no navegador.
      3. O Google envia o navegador para `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. O cliente aceita apenas o `state` da resposta de início e não envia nada depois de 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, um passo de autenticação de dois fatores (continue com `POST /auth/login/mfa`), `needsUsername` para um novo jogador (continue com `POST /auth/sso/complete`) ou `needsPassword` quando uma conta com palavra-passe usa o endereço (continue com `POST /auth/sso/google/link`).

      **A etiqueta de origem.** O URI de redirecionamento leva uma etiqueta do servidor que o jogador adicionou, para que o jogo recuse um URL que outro servidor obteve para os seus próprios jogadores. A origem é o anfitrião em minúsculas (um literal IPv6 entre parênteses retos), `:` e a porta decimal da API, sempre escrita, incluindo 443: o servidor usa `SERVER_PUBLIC_HOST` e a sua porta pública da API; o jogo, o endereço a que está ligado. A etiqueta é formada pelos primeiros 22 carateres do base64url (sem preenchimento) de SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + origem). O URI de redirecionamento é `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: sempre o literal IPv4 e a porta do serviço de escuta do jogo (1024 a 65535). O servidor nunca aceita um URI, um anfitrião ou um caminho vindos do cliente.

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

      Assim, o início de sessão 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.** O `authUrl` começa exatamente por `https://accounts.google.com/o/oauth2/v2/auth?` e é ASCII imprimível com menos de 4096 carateres, e a sua cadeia de consulta tem exatamente um `response_type=code`, exatamente um `redirect_uri` igual ao URI que o jogo calcula a partir da sua porta e da sua etiqueta de origem, exatamente um `state` igual ao `state` da resposta, `code_challenge_method=S256` e um `code_challenge` de 43 carateres. Caso contrário, o jogo para o seu serviço de escuta e não abre nada. Só envia `finish` e `link` para o servidor que respondeu a `start`.

      **Num site.** Um site (o oficial: <https://scacelith.com/>) inicia sessão com um cliente do tipo «Web application» (aplicação Web) do mesmo projeto Google (`GOOGLE_WEB_CLIENT_ID`). O Google reencaminha o navegador para uma página do site configurada pelo administrador do servidor (`GOOGLE_WEB_REDIRECT_URI`, incluída nos URIs de redirecionamento autorizados do cliente, «Authorized redirect URIs»), e essa página envia o código para `finish`; também aqui o servidor não aceita nenhum URI de redirecionamento vindo do cliente.

      1. A página cria um par PKCE como acima, guarda o verificador no separador (`sessionStorage`) e envia `POST /auth/sso/google/web/start` com o desafio.
      2. Verifica o `authUrl` como o jogo faz, exceto que o seu `redirect_uri` tem de ser uma página da sua própria origem, e depois navega para ele. Nenhum script do Google é carregado.
      3. O Google envia o navegador para `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. A página aceita apenas o `state` da resposta de início e não envia nada depois de um redirecionamento com `error=`.
      4. `finish`, `link` e `complete` seguem-se como para o jogo. A tentativa memoriza que foi iniciada por um site: o código é trocado com o cliente e a página do site, e o token de ID tem de indicar esse cliente (um código ou um token de ID do cliente do jogo falha com 502 `sso_failed`, e vice-versa).

      Uma conta Google dá acesso à mesma conta Scacelith a partir do jogo e do site: a associação guarda o sujeito (subject) do Google (`sub`), que identifica a conta Google seja qual for o cliente. A origem do site tem de constar de `CORS_ORIGINS` (caso contrário, `check-config` emite um aviso).

      **A conta a que o início de sessão com o Google dá acesso:** a conta já associada a essa conta Google; caso contrário, uma conta ativa com o endereço que o Google confirmou (com palavra-passe, `finish` responde `needsPassword` e a associação só é guardada depois de passarem a palavra-passe e, a seguir, o segundo fator, quando está ativado; sem palavra-passe, 409 `sso_account_exists`); caso contrário, uma nova conta (`needsUsername`). Uma conta Google nunca é associada a uma conta existente apenas pelo seu endereço. O endereço da conta recebe um e-mail quando o início de sessão com o Google cria uma conta e quando é adicionado a uma conta existente.
  - name: sessions
    x-displayName: Sessões
    description: Os dispositivos com sessão iniciada na conta e o término de sessão.
  - name: account
    x-displayName: Conta
    description: A vista da conta, as suas preferências e a sua palavra-passe.
  - name: two-step-verification
    x-displayName: Autenticação de dois fatores
    description: Aplicações de autenticação (TOTP, RFC 6238), com SHA-1, 6 algarismos, intervalos de 30 segundos e uma tolerância de um intervalo em cada sentido, além de códigos de recuperação de utilização única.
  - name: email-change
    x-displayName: Alteração do endereço de e-mail
    description: Alterar o endereço da conta, com confirmação através de uma ligação enviada para o novo endereço.
  - name: data-export
    x-displayName: Exportação de dados
    description: Tudo o que o servidor guarda sobre a conta, num único ficheiro JSON.
  - name: account-deletion
    x-displayName: Eliminação da conta
    description: Eliminar a conta definitivamente.
  - name: game-history
    x-displayName: Histórico de partidas
    description: As partidas do próprio jogador com sessão iniciada, filtradas e paginadas.
  - name: games
    x-displayName: Partidas e PGN
    description: |-
      Os registos das partidas, com os seus lances e relógios, e os respetivos ficheiros PGN.

      **Códigos das partidas.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (só são guardadas as partidas terminadas). `result`: `1-0`, `0-1`, `1/2-1/2` ou `*` (anulada). `reason`, com o seu nome (`termination`) e as palavras que terminam o texto dos lances do ficheiro PGN (em inglês no ficheiro; a tradução segue entre parênteses); 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 indica a regra), e o servidor nunca termina uma partida com os códigos 4 e 13 (pertencem à lista comum de motivos):

      | Código | `termination` | Palavras no PGN |
      |---|---|---|
      | 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 (queda da bandeira, mas o adversário não pode dar mate) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (quíntupla repetição) |
      | 9 | `SeventyFiveMoves` | 75-move rule (regra dos 75 lances) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (tripla repetição reclamada) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (regra dos 50 lances reclamada) |
      | 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) (saída da partida: desconexão demasiado longa) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (saída da partida, mas o adversário não pode dar mate) |
      | 22 | `Aborted` | Game aborted (partida anulada) |
      | 23 | `NoShow` | Aborted: first move not played in time (anulada: primeiro lance não jogado a tempo) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (derrota por falta: violação do fair play) |
      | 25 | `ServerAborted` | Aborted by the server (anulada pelo servidor) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (anulada: ambos os jogadores se desligaram) |

      O código 14 é novo na versão secundária 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: GIF animados
    description: |-
      Uma partida em GIF animado, para guardar ou partilhar: o tabuleiro visto de cima, um fotograma por posição, da posição inicial à posição final, os nomes e as pontuações Elo dos jogadores acima do tabuleiro, o último lance abaixo dele e, no último fotograma, o resultado e a forma como a partida terminou.

      **Custo, cache e quotas.** Um GIF é gerado numa thread de renderização própria, nunca nas threads que executam as partidas, e com a prioridade de CPU mais baixa: `GIF_THREADS` threads (`WORKERS` por predefinição), iniciadas com o primeiro GIF e paradas após um minuto sem nenhum. Até `GIF_QUEUE_MAX` (4 × `WORKERS`) GIF aguardam 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 GIF que gerou numa cache de `GIF_CACHE_MB` MB (32 × `WORKERS`), da qual saem primeiro os utilizados há mais tempo; um GIF da cache, ou um que esteja a ser gerado para outro pedido, não custa nenhuma renderização. A chave da cache abrange tudo o que altera a imagem, incluindo os nomes.

      Cada pedido conta em `gif` (30 por minuto por jogador para os dois endpoints). Um GIF que tenha de ser gerado conta também 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 em conjunto, `GIF_IP_RENDERS_PER_MIN` (12) por minuto e `GIF_IP_RENDERS_PER_HOUR` (120) por hora, e o triplo por /48 IPv6. Um cliente deve guardar o ficheiro que transferiu em vez de o pedir novamente, e esperar `retryAfter` após um 429 ou um 503.

      Tamanhos: `small` (casas de 32 px: 284 × 350 píxeis), `medium` (48 px: 424 × 515), `large` (72 px: 628 × 762); sem coordenadas, 268 × 342, 400 × 503 e 600 × 748. A posição inicial fica pelo menos 1 s (o atraso, quando é mais longo), a posição final 3 s, e o GIF repete-se em ciclo; após o primeiro fotograma, só é guardada a parte da imagem que muda. Uma partida de 40 lances ocupa cerca de 135, 205 e 325 KiB (pequeno, médio, grande), uma 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 eliminada 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: Denunciar aos moderadores o adversário de uma partida recente.
  - name: pages
    x-displayName: Páginas HTML
    description: |-
      Páginas para um navegador, abertas a partir das ligações dos e-mails. As suas ligações apontam para `https://<SERVER_PUBLIC_HOST>` (com `:<PUBLIC_API_PORT>` quando não é 443), fora de `/api/v1`.

      - As páginas não executam JavaScript nem carregam nenhum recurso externo. 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` apenas mostra um botão ou um formulário, para que um analisador de correio que abra a ligação não a gaste. A alteração acontece no `POST`: um formulário enviado como `application/x-www-form-urlencoded` (também é aceite um corpo JSON), com o token da ligação num campo oculto. Um campo indicado duas vezes é recusado.
      - Os erros (limites de pedidos, campos inválidos) também são páginas HTML, com o título «Request refused» (pedido recusado) ou, a partir de 500, «Server error» (erro do servidor). Apenas as falhas detetadas antes de a página ser identificada (um destino do pedido demasiado longo ou mal formado, a camada por endereço) recebem resposta 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 registo do servidor
      description: |-
        Aquilo de que um cliente precisa antes de iniciar sessão ou de se ligar: o nome e o ID do servidor, as versões do protocolo WebSocket e onde se encontra o WebSocket, as regras de registo, os ritmos de jogo oficiais e os limites que um cliente pode verificar antes de enviar um formulário.

        **Limites:** apenas 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 (ver a introdução): o pedido não foi executado.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Criar uma conta
      description: |-
        Cria uma conta: depois de o seu endereço de e-mail ser confirmado com a ligação enviada para ele, ou de imediato, sem confirmação por e-mail.

        **Com confirmação por e-mail** (`REQUIRE_EMAIL_VERIFICATION`, a predefinição): 202 `verification_sent`. Ainda não existe nenhuma conta: o registo fica pendente durante 24 horas e reserva entretanto o seu nome de utilizador. É enviada para o endereço uma ligação válida durante essas 24 horas, no máximo uma por endereço a cada 5 minutos (`POST /auth/verify-email/resend` envia uma nova); a conta é criada, com o endereço confirmado, quando a ligação é utilizada (o botão da página `/verify-email`), e o jogador pode então iniciar sessão. Até lá, um início de sessão com esse nome de utilizador recebe `invalid_credentials`, como no caso de uma conta desconhecida, e o perfil público não existe. Um novo registo com o mesmo endereço substitui o pendente. A resposta é a mesma quando outra conta já usa o endereço: nesse caso não é enviada nenhuma ligação, o titular dessa conta recebe um aviso em vez disso (no máximo um por hora) e o nome de utilizador é reservado da mesma forma, para que nada revele se o endereço tem uma conta. Um registo cuja ligação não foi utilizada é descartado ao fim de 24 horas, e o seu nome de utilizador fica novamente livre.

        **Sem confirmação por e-mail** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, a conta é criada de imediato e pode iniciar sessão.

        **Regras.** `username`: de `USERNAME_MIN` a `USERNAME_MAX` carateres (3 a 20), letras, algarismos, `_` e `-`, a começar por uma letra ou um algarismo (`GET /info` indica-os em `limits`); os 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 guardado em minúsculas, ASCII simples com um domínio com ponto. `password`: pelo menos `PASSWORD_MIN_LENGTH` (10) carateres e no máximo 256 bytes de UTF-8; não pode conter o nome de utilizador nem a parte local do e-mail, nem ser uma palavra-passe comum.

        **Erros**, verificados por esta 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 das palavras-passe; 409 `email_taken` (apenas sem confirmação por e-mail).

        **Limites:** `auth`, depois `auth_register` (10 registos por hora por cliente). **Prova de trabalho:** sempre, quando `POW_REGISTER_BITS` é superior a 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: Enviado 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 é criada e pode iniciar sessão (sem confirmação por 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 registo aguarda a sua ligação de confirmação (ou o endereço já tem uma conta; a resposta não o indica).
          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 registo de novas contas está fechado neste servidor (`GET /info` indica `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: uma conta tem o nome de utilizador, ou um registo pendente de outro endereço reserva-o. `email_taken`: outra conta usa o endereço (apenas sem confirmação por 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 das palavras-passe.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash das palavras-passe, ou a base de dados permaneceu bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Iniciar sessão com uma palavra-passe
      description: |-
        Inicia sessão com um nome de utilizador ou endereço de e-mail e uma palavra-passe. Há duas respostas possíveis, ambas com o estado 200: uma sessão ou, quando a autenticação de dois fatores está ativada, um segundo passo a concluir em 5 minutos com `POST /auth/login/mfa`.

        Uma conta desconhecida, uma palavra-passe errada e uma conta sem palavra-passe recebem a mesma resposta, após o mesmo tempo: 401 `invalid_credentials`. As verificações da conta (`banned`, `email_unverified`) só ocorrem depois de uma palavra-passe correta. A partir de `AUTH_FAILURES_PER_ACCOUNT` (5) falhas num mesmo identificador de início de sessão, cada tentativa tem de esperar (429 `too_many_attempts`).

        **Limite:** `auth`. **Prova de trabalho:** apenas durante uma vaga de inícios de sessão falhados (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 o segundo passo de um início de sessão com autenticação de dois fatores.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Sessão iniciada
                  value:
                    token: sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
                    expiresAt: 1798658839708
                    user:
                      id: 1
                      username: alice
                      email: alice@example.org
                      emailVerified: true
                      mfaEnabled: false
                      googleLinked: false
                      hasPassword: true
                      acceptChallenges: all
                      createdAt: 1790882839743
                      lastLoginAt: 1790882839708
                      pendingEmail: null
                mfaRequired:
                  summary: 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, palavra-passe errada ou uma conta sem palavra-passe.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Apenas depois de uma palavra-passe correta: `banned`, com `until` (ms desde a época Unix, `null` para uma suspensão permanente), ou `email_unverified` (uma conta mais antiga cujo endereço ainda não está 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 identificador de início de sessão), ou `rate_limited` (o limite `auth`, a fila de hash das palavras-passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash das palavras-passe, ou a base de dados permaneceu bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Concluir um início de sessão com um segundo fator
      description: |-
        O segundo passo de um início de sessão com autenticação de dois fatores, depois de `POST /auth/login` ou de um início de sessão com o Google (`POST /auth/sso/google/finish` ou `POST /auth/sso/google/link`) ter respondido `mfaRequired`. Envie `code` (um código de 6 algarismos da aplicação de autenticação, ou um código de recuperação) ou `recoveryCode`. Um código de recuperação utilizado aqui deixa de existir.

        Um passo aceita no máximo 5 códigos errados; o passo também termina quando a palavra-passe é reposta ou alterada. Depois do passo da palavra-passe de uma associação Google, a associação só é guardada quando o código é aceite aqui.

        **Limites:** `auth` e, no máximo, `AUTH_MFA_PER_ACCOUNT` (10) códigos a cada 15 minutos para a conta, a partir de qualquer endereço; acima disso, o código não é verificado, pelo que 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 da aplicação de autenticação
                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: Sessão iniciada.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: não foi enviado `code` nem `recoveryCode`, ou o corpo viola o esquema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: o passo expirou, já foi utilizado ou terminou após 5 códigos errados, ou a palavra-passe foi reposta ou alterada desde o primeiro passo (inicie sessão novamente). `invalid_code`: um código errado ou já utilizado.'
        '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`: apenas após o passo da palavra-passe de uma associação Google, a conta Google foi entretanto associada a outra conta.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: apenas após o passo da palavra-passe de uma associação Google, a conta foi entretanto alterada; comece novamente o início de sessão.'
        '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 estão esgotados. `rate_limited`: o limite `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` com `retryAfter: 1`: a base de dados permaneceu bloqueada (após um passo de associação Google, nada foi associado: comece novamente o início de sessão). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Terminar esta sessão
      description: |-
        Revoga a sessão do token utilizado. O WebSocket aberto com ela é fechado. Sem corpo (ou `{}`).

        **Limite:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Sessão terminada.
          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` (a base de dados permaneceu bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Terminar todas as sessões
      description: |-
        Revoga todas as sessões da conta, incluindo esta. Os WebSockets abertos com elas são fechados. Sem corpo (ou `{}`).

        **Limite:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Todas as sessões foram terminadas.
          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` (a base de dados permaneceu bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Reenviar a ligação de confirmação
      description: |-
        Envia novamente a ligação de confirmação do e-mail. A resposta é 202 `accepted` seja qual for o endereço, para que nunca revele se uma conta ou um registo o usa.

        Um pedido produz efeito no máximo uma vez a cada 5 minutos por endereço. Um registo pendente com esse endereço recebe novamente as suas 24 horas, quer outra conta use o endereço quer não, para que o seu nome de utilizador fique reservado durante o mesmo tempo em ambos os casos. Só é enviada uma ligação para esse registo quando o endereço não tem conta (uma nova ligação, válida durante 24 horas, substitui a 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: Aceite (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`: a base de dados permaneceu bloqueada enquanto a conta era procurada ou a ligação de uma conta não confirmada era guardada (a renovação de um registo pendente é feita na medida do possível e nunca faz falhar o pedido); os 5 minutos do endereço começam a contar na mesma, pelo que um novo pedido dentro deles não faz nada. `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Enviar uma ligação de reposição da palavra-passe
      description: |-
        Envia uma ligação de reposição da palavra-passe, válida durante uma hora, que abre a página `/reset-password`. A resposta é 202 `accepted` seja qual for o endereço. Só é enviada uma ligação para uma conta ativa, no máximo uma vez a cada 5 minutos por endereço. É desta forma que uma conta que só inicia sessão com o Google define a sua primeira palavra-passe.

        A recuperação da palavra-passe tem os limites mais estritos da API, todos contados para todo o servidor: 3 pedidos por hora (`AUTH_FORGOT_PER_HOUR`) e 10 por 24 horas (`AUTH_FORGOT_PER_DAY`) por cliente (um endereço IPv4 ou um /64 IPv6), o triplo destes números por /48 IPv6, além do limite `auth` (20 a cada 10 minutos) e de um único e-mail por endereço a cada 5 minutos. Nem o 202 nem o 429 revelam se uma conta usa o endereço. A definição da nova palavra-passe 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: Aceite (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`: a base de dados permaneceu bloqueada enquanto a conta era procurada ou a ligação era guardada; os 5 minutos do endereço começam a contar na mesma, pelo que um novo pedido dentro deles não envia nenhuma ligação. `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Definir uma nova palavra-passe com o token de uma ligação de reposição
      description: |-
        Define uma nova palavra-passe com o token de uma ligação de reposição (a página `/reset-password` faz o mesmo). A nova palavra-passe segue as regras do registo.

        A reposição tem estes efeitos, numa única transação (todos ou nenhum): revoga todas as sessões e cancela uma alteração de e-mail pendente; as outras ligações de reposição da conta deixam de funcionar; o endereço passa a contar como confirmado (a ligação provou-o); o titular recebe um e-mail. A autenticação de dois fatores não é alterada. Após um erro da fila de hash das palavras-passe, um 503 ou um 500, nada mudou: a palavra-passe antiga e as sessões mantêm-se, e a ligação continua válida.

        **Limites:** `auth`, depois `auth_reset` (10 por hora por cliente, 30 por /48 IPv6, partilhado com a página: cada tentativa calcula o hash de uma palavra-passe).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: A palavra-passe foi alterada e todas as sessões foram terminadas.
          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`: a ligação é inválida, já foi utilizada ou expirou, ou foi enviada para um endereço que a conta já não tem. `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 das palavras-passe.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: a fila de hash das palavras-passe, ou a base de dados permaneceu bloqueada (`retryAfter: 1`, nada mudou). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Começar um início de sessão com o Google
      description: |-
        Começa uma tentativa de início de sessão com o Google para o desafio PKCE e a porta do serviço de escuta do jogo em `127.0.0.1`. A resposta indica o URL do Google a abrir no navegador do sistema e o `state` da tentativa; a tentativa é válida durante 10 minutos.

        `authUrl` contém `client_id`, `redirect_uri` (construído a partir de `redirectPort` e da etiqueta 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 verifica-o antes de o abrir (ver a descrição da secção).

        **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 início de sessão com o Google não está disponível 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` (a base de dados permaneceu bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Começar um início de sessão com o Google num site
      description: |-
        Começa uma tentativa de início de sessão com o Google de um site para o desafio PKCE da sua página. A resposta é a mesma que para o jogo: o URL do Google para onde navegar e o `state` da tentativa; a tentativa é válida durante 10 minutos. Disponível quando `GET /info` indica `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 verifica-o antes de navegar para ele (ver **Num site** na descrição da secção).

        **Limite:** `sso_start`, partilhado com o endpoint de início usado pelo 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` em falta ou mal formado, ou qualquer outro campo (incluindo `redirectPort`).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: o início de sessão com o Google não está disponível 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` (partilhado com o endpoint de início usado pelo jogo).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a base de dados permaneceu bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Entregar ao servidor a resposta do Google
      description: |-
        Entrega ao servidor o `code` e o `state` que o Google enviou para o serviço de escuta do jogo ou para a 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 o segredo do cliente.

        O servidor verifica a tentativa (desconhecida, já utilizada 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 das seguintes:

        - `{ token, expiresAt, user }`: sessão iniciada na conta associada;
        - `{ mfaRequired, mfaToken, expiresIn }`: continue com `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: uma nova conta; continue com `POST /auth/sso/complete` em 10 minutos. `suggestedUsername` vem do nome Google ou do endereço, e é `""` quando nada é adequado;
        - `{ needsPassword, linkTicket, username, expiresIn }`: a conta com esse endereço tem palavra-passe; continue com `POST /auth/sso/google/link`. Ainda nada está associado.

        **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, um segundo passo, ou o passo seguinte de um primeiro início de sessão com o Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Sessão iniciada na conta associada
                  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 novo jogador
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Uma conta com palavra-passe 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`. Apenas para uma conta associada: `banned` (com `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: o início de sessão com o Google não está disponível neste servidor.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: uma conta ativa sem palavra-passe usa o endereço (a conta não é indicada).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: uma tentativa desconhecida, já utilizada 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` (a base de dados permaneceu bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Associar o Google a uma conta existente com a respetiva palavra-passe
      description: |-
        Depois de `finish` ter respondido `needsPassword`: associa o Google à conta existente com a palavra-passe dessa conta, introduzida no jogo. A resposta (200) é uma sessão (a associação é guardada e o jogador inicia sessão) ou, quando a autenticação de dois fatores está ativada, `{ mfaRequired, mfaToken, expiresIn }`: continue com `POST /auth/login/mfa`, e a associação só é guardada quando um código for aceite aí.

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

        **Limite:** `auth` (com a sua contagem por /48 IPv6). **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 o segundo passo do início de sessão.
          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`: palavra-passe errada; o ticket continua válido, até 5 tentativas no total.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (com `until`), apenas depois de uma palavra-passe correta.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: o início de sessão com o Google não está disponível neste servidor.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: entretanto, a conta Google foi associada a outra conta.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: um ticket desconhecido, já usado ou expirado, a 5.ª palavra-passe errada, uma conta cujo estado ou endereço mudou desde `finish`, ou cujo estado, endereço, palavra-passe ou autenticação de dois fatores mudou enquanto a associação estava a ser guardada. Recomece o início de sessão.'
        '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 da conta após falhas), ou `rate_limited` (o limite `auth`, a fila de hash das palavras-passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: a fila de hash das palavras-passe, ou a base de dados continuou bloqueada (`retryAfter: 1`, nada foi associado). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Criar a conta de um primeiro início de sessão com o Google
      description: |-
        Depois de `finish` ter respondido `needsUsername`: cria a conta com o nome de utilizador escolhido (aplicam-se as regras do registo) e inicia sessão nela. A conta não tem palavra-passe: `hasPassword` é `false`, e «Esqueceu-se da palavra-passe?» (`POST /auth/password/forgot`) atribui-lhe uma.

        **Limite:** `auth` (com a sua contagem por /48 IPv6).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoCompleteRequest'
            example:
              ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
              username: alice
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: A conta foi criada e tem sessão iniciada.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`, `invalid_request`, `invalid_json`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: o início de sessão com o Google não está disponível neste servidor.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (uma conta tem esse nome de utilizador, ou um registo pendente de outro endereço reserva-o), `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` (a base de dados continuou bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Listar os dispositivos com sessão iniciada
      description: |-
        As sessões ativas da conta (dispositivos com sessão iniciada), a começar pela usada mais recentemente. `current` assinala a sessão que faz o pedido. `lastSeenAt` é atualizado no máximo a cada 5 minutos (mais tarde enquanto o servidor estiver muito atrasado); `expiresAt` é o fim absoluto, e o limite de inatividade pode terminar a sessão mais cedo.

        **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` (a base de dados continuou bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Terminar a sessão de um dispositivo
      description: |-
        Termina uma sessão da conta; a sessão atual também pode ser terminada. 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 terminada.
          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`: não há nenhuma sessão ativa com este identificador 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` (a base de dados continuou bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Obter a conta, as suas pontuações e as sanções ativas
      description: |-
        A conta tal como o seu jogador a vê: a vista da conta (também o `user` de todas as respostas de início de sessão), um registo de pontuação por cada categoria em que o jogador jogou partidas com Elo, as sanções ativas e a suspensão. O nível de integridade do sistema antibatota 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 eliminada).'
        '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` (a base de dados continuou bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Aceitar ou recusar desafios diretos
      description: |-
        Define se os outros jogadores podem desafiar este jogador pelo nome. Com `none`, os desafios diretos são recusados: o desafiante é 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` (a base de dados continuou bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Alterar a palavra-passe
      description: |-
        Altera a palavra-passe. É necessária a palavra-passe atual, mas nenhum segundo fator, mesmo com a autenticação de dois fatores ativada. A nova palavra-passe segue as regras do registo (verificadas depois da palavra-passe atual).

        A alteração tem estes efeitos, numa única transação (todos ou nenhum): revoga todas as outras sessões (esta sessão continua iniciada), cancela uma alteração de e-mail pendente, faz com que as ligações de reposição da palavra-passe da conta deixem de funcionar e envia um e-mail ao titular.

        **Reautenticação:** apenas a palavra-passe. **Limites:** `reauth` e 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 palavra-passe 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ó usa o Google), `weak_password` (com `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: a palavra-passe atual está errada, ou ocorreu uma reposição ou alteração da palavra-passe enquanto o pedido era verificado.'
        '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 falhadas da conta), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash das palavras-passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: a fila de hash das palavras-passe, ou a base de dados continuou bloqueada (`retryAfter: 1`, nada foi alterado). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Começar a ativar a autenticação de dois fatores
      description: |-
        Guarda um novo segredo de autenticação pendente, que substitui qualquer segredo pendente anterior, e devolve-o para a aplicação de autenticação (como texto e como URI `otpauth://` para mostrar como código QR). A autenticação de dois fatores ainda não fica ativada: `POST /account/mfa/totp/enable` ativa-a com um código deste segredo.

        **Reautenticação:** apenas a palavra-passe. **Limites:** `reauth` e 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ó usa 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 palavra-passe).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautenticações falhadas da conta), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash das palavras-passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash das palavras-passe, ou a base de dados continuou bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /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 algarismos). Aqui não é pedida a palavra-passe; foi fornecida na configuração. A resposta contém 10 códigos de recuperação, mostrados apenas desta vez.

        **Limites:** `reauth` e depois `reauth_user`; um código errado 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 algarismos, ou o corpo viola o esquema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: código errado (verifique a hora do dispositivo).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: não há nenhum segredo pendente (chame primeiro `POST /account/mfa/totp/setup`).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (reautenticações falhadas da conta), 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` (a base de dados continuou bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /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 eliminados, e o titular recebe um e-mail.

        **Reautenticação:** a palavra-passe e um código da aplicação de autenticação ou um código de recuperação (é obrigatório um dos dois, `code` ou `recoveryCode`). **Limites:** `reauth` e 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ó usa 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 palavra-passe), `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 falhadas, ou demasiados códigos tentados nesta conta), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash das palavras-passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash das palavras-passe, ou a base de dados continuou bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /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 deixam de funcionar.

        **Reautenticação:** a palavra-passe e um código da aplicação de autenticação em `code` (um código de recuperação é recusado com 403 `invalid_code`). **Limites:** `reauth` e 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 apenas 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ó usa 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 falhadas, ou demasiados códigos tentados nesta conta), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash das palavras-passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash das palavras-passe, ou a base de dados continuou bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /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. Os espaços no início e no fim de `newEmail` são removidos e o endereço é convertido em minúsculas; depois é verificado como um endereço de registo.

        **Com confirmação por e-mail** (a predefinição): 202 `verification_sent`. É enviada para o novo endereço uma ligação válida durante 24 horas; abre `/confirm-email-change`, e o endereço só muda quando o jogador carrega no botão dessa página. Até lá, `GET /account/me` mostra o novo endereço como `pendingEmail`. Um novo pedido substitui o pendente; uma alteração ou reposição da palavra-passe cancela-o. É enviada no máximo uma ligação para um mesmo novo endereço a cada 5 minutos, seja quem for que a peça (um pedido para a alteração já pendente mantém a ligação enviada antes, que continua válida); a resposta é a mesma. O endereço atual recebe um aviso de que foi pedida 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 não é enviada nenhuma ligação, pelo que essa alteração nunca é concluída, e o titular desse endereço recebe, em vez disso, um aviso (no máximo um por hora).

        Quando a ligação é confirmada, o endereço muda e passa a contar como confirmado, os dispositivos mantêm a sessão iniciada, as ligações enviadas antes (confirmação, reposição da palavra-passe, outras alterações) deixam 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 de imediato (200 `email_changed`) e o endereço anterior é avisado. Se outra conta usar o endereço, a resposta é 409 `email_taken`, e o titular desse endereço recebe o aviso.

        **Reautenticação:** a palavra-passe e, com a autenticação de dois fatores, um código da aplicação de autenticação ou um código de recuperação. `invalid_email` e `same_email` são verificados antes da palavra-passe, pelo que não contam como falha. **Limites:** `reauth` e 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 de imediato (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, tal como foi guardado.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Foi enviada uma ligação de confirmação para o novo endereço (ou o endereço pertence a outra conta; a resposta não 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_email`, `same_email` (o endereço atual da conta), `password_not_set` (uma conta que só usa 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 reposição da palavra-passe se antecipou ao pedido: não é enviada nenhuma ligação), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: apenas 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 falhadas, ou demasiados códigos tentados nesta conta), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash das palavras-passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: a fila de hash das palavras-passe, ou a base de dados continuou bloqueada (`retryAfter: 1`: nada foi alterado, e o mesmo pedido pode ser enviado de novo). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Transferir os dados da conta
      description: |-
        Tudo o que o servidor conserva sobre a conta, num único ficheiro JSON para guardar (`format` `scacelith-account-export`, `version` 1). A exportação regista um evento de segurança (`account_exported`). O array `notes` do documento indica ao jogador, em inglês simples, o que fica de fora.

        **Nunca na exportação:** o hash da palavra-passe, o segredo da autenticação de dois fatores e os códigos de recuperação; nenhum token de sessão ou de ligação, nem o seu hash; os dados do sistema antibatota (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 seu nome público e pela sua pontuação, nada indica se outro jogador foi sancionado, e não é incluído nenhum endereço IP que possa ser de outra pessoa).

        **Reautenticação:** a palavra-passe e, com a autenticação de dois fatores, um código da aplicação de autenticação ou um código de recuperação. **Limites:** `account_export` (5 por hora por jogador, para todo o servidor; todas as tentativas contam, incluindo as falhadas; verificado primeiro) e depois `reauth` e `reauth_user`. **Tempo máximo:** 60 segundos.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: O documento de exportação, como anexo.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Os carateres do nome de utilizador que não sejam letras, algarismos, `_`, `.` ou `-` passam a `_`.'
              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ó usa 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 falhadas, ou demasiados códigos tentados nesta conta), ou `rate_limited` (o limite `account_export`, `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash das palavras-passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (a base de dados continuou bloqueada, com um cabeçalho `Retry-After: 1`), `server_busy` (a fila de hash das palavras-passe), `timeout` (60 segundos). Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Eliminar a conta
      description: |-
        Elimina a conta; esta ação não pode ser anulada.

        - Todas as sessões são revogadas de imediato: a partir daí, o token recebe 401 `invalid_token`.
        - O nome de utilizador passa a ser `deleted#<id>`, na conta e em todos os registos de partidas.
        - São apagados: o endereço de e-mail, o hash da palavra-passe, o segredo de dois fatores e os códigos de recuperação, as sessões e os tokens de ligação, a associação ao Google, o registo de integridade do sistema antibatota e os endereços IP guardados com os eventos de segurança.
        - As pontuações e as partidas são conservadas. As partidas continuam legíveis com o nome anónimo, e `GET /players/{username}` responde com 404 para o nome anterior.

        **Reautenticação:** a palavra-passe e, com a autenticação de dois fatores, um código da aplicação de autenticação ou um código de recuperação. **Limites:** `reauth` e 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 eliminada.
          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ó usa 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 falhadas, ou demasiados códigos tentados nesta conta), ou `rate_limited` (o limite `reauth` ou `reauth_user`, o orçamento da conta, a fila de hash das palavras-passe).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (a fila de hash das palavras-passe, ou a base de dados continuou bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Listar as partidas do jogador, filtradas e paginadas
      description: |-
        As partidas do jogador com sessão iniciada, da mais recente para a mais antiga, filtradas e paginadas, com o número de partidas que correspondem ao filtro. Todos os parâmetros de consulta são opcionais, 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: Um identificador de categoria oficial (`3+2`, ou `3%2B2`), ou `custom` para todas as partidas com outro ritmo de jogo.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Apenas partidas com Elo (`true`) ou amigáveis (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Apenas as partidas ganhas, perdidas ou empatadas, do ponto de vista do jogador. As partidas anuladas 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 identificador de partida), `invalid_limit`, ou `invalid_filter` (`category`, `rated` ou `result`), cada um com `field` a indicar 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` (a base de dados continuou bloqueada; `retryAfter: 1` no corpo, sem cabeçalho `Retry-After`), `server_busy` (a procura da sessão encontrou a base de dados bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Obter o registo de uma partida com os lances e os relógios
      description: |-
        O registo de uma partida, com os 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 registo 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 número inteiro positivo de no máximo 16 algarismos (inferior a 2^53); `invalid_request`: codificação de URL inválida.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: foi enviado um token que 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` (a base de dados continuou bloqueada; `retryAfter: 1` no corpo, sem cabeçalho `Retry-After`), `server_busy` (a procura da sessão encontrou a base de dados bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Transferir uma partida como ficheiro PGN
      description: |-
        A mesma partida como ficheiro PGN: uma única partida com finais de linha `\n`, e o texto dos lances em linhas com menos de 80 colunas. A resposta é a mesma com ou sem token.

        Etiquetas, por esta ordem: `Event` (`<SERVER_NAME> rated <category>` ou `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (a data UTC do início), `Round`, `White`, `Black`, `Result` (`*` para uma partida anulada), `UTCDate` e `UTCTime` (o início), `WhiteElo` e `BlackElo` (as pontuações no início, ou `-`), `WhiteRatingDiff` e `BlackRatingDiff` (as variações, como `+10` e `-10`, em todas as partidas com Elo, `+0` quando as regras de pontuação deixam a pontuação onde estava; uma partida amigável, personalizada ou anulada não tem nenhuma delas), `TimeControl` (em segundos), `Termination` (um valor da norma 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 do jogo limpo; `unterminated`, uma partida anulada), `PlyCount`, `ScacelithGameId` (o identificador 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écimas de segundo (truncadas). Um valor é omitido quando o registo não o tem. Depois do último lance, vem o motivo do fim por extenso e, a seguir, o resultado.

        **Limite:** `public_read` (por jogador com token, por cliente sem token).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: O ficheiro 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 número inteiro positivo de no máximo 16 algarismos (inferior a 2^53); `invalid_request`: codificação de URL inválida.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: foi enviado um token que 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 guardados não podem ser reproduzidos até ao fim guardado (o servidor regista-o).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (a base de dados continuou bloqueada; `retryAfter: 1` no corpo, sem cabeçalho `Retry-After`), `server_busy` (a procura da sessão encontrou a base de dados bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Transferir uma partida deste servidor como GIF animado
      description: |-
        A partida como GIF animado. Os nomes e as pontuações são os do registo da partida (as pontuações no início, uma conta eliminada como `deleted#<id>`), tal como o resultado e o fim da partida (`Resignation`, `Loss on time`...).

        As verificações são feitas por esta ordem: `gif_disabled`, as opções da imagem (`size`, `orientation`, `delay`, `coords`), o identificador da partida, a partida, a sua extensão.

        **Sessão obrigatória:** as quotas contam por conta. **Limites:** `gif` em todos os pedidos; `gif_user_min`, `gif_user_hour`, `gif_ip_min` e `gif_ip_hour` apenas quando o GIF tem de ser criado (ver a descrição da secção). **Tempo máximo:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 segundos (45 segundos por predefiniçã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 algarismos 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 filas são desenhados à 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`: codificação de URL invá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 GIF (`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 criar o GIF (o servidor regista o motivo). `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; todos os tokens de limite que o pedido consumiu são devolvidos. `busy`: a base de dados continuou bloqueada (`retryAfter: 1` apenas no corpo). `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Criar um GIF animado de qualquer partida enviada em PGN
      description: |-
        A mesma imagem que `GET /games/{id}/gif` para qualquer partida enviada como texto PGN: uma partida guardada pelo jogo, uma exportação de outro site, uma partida escrita à mão. Só é usada a primeira partida do texto.

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

        **Sessão obrigatória:** as quotas contam por conta. **Limites:** como em `GET /games/{id}/gif`. **Tamanho máximo do corpo:** 135 168 bytes, seja qual for `HTTP_BODY_LIMIT` (o PGN como string JSON, incluindo os escapes). **Tempo máximo:** 45 segundos por predefinição.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Uma partida curta escrita à mão, sem coordenadas
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Um ficheiro 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` em falta ou que não é uma 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 carateres) e a `message` do leitor: um lance ilegal ou ambíguo, uma etiqueta danificada, 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 GIF (`GIF_ENABLED=false`; o token de `gif` é devolvido).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: um corpo com mais 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 criar o GIF (o servidor regista o motivo). `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 demasiado tempo por uma thread livre; todos os tokens de limite que o pedido consumiu são devolvidos. `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Obter o perfil público de um jogador
      description: |-
        O perfil público de um jogador: as pontuações nas categorias oficiais (pela ordem do servidor) e as contagens de partidas. `games.total` conta todas as partidas guardadas, incluindo as amigáveis e as anuladas; `games.rated`, `wins`, `draws` e `losses` são somados sobre os registos de pontuação, portanto só contam as partidas com Elo. 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 carateres de `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: foi enviado um token que não é válido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: o jogador não existe, ou a conta foi eliminada.'
        '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` (a base de dados continuou bloqueada; `retryAfter: 1` no corpo, sem cabeçalho `Retry-After`), `server_busy` (a procura da sessão encontrou a base de dados bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Listar as partidas recentes de um jogador
      description: |-
        As partidas recentes do jogador, da mais recente para a mais antiga, paginadas como o histórico, mas sem filtros nem total. `color` é o lado deste jogador. `next` é o identificador da última partida sempre que a página está cheia, pelo que a página seguinte pode estar vazia. O jogador é procurado 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 identificador de partida), `invalid_limit` (estes dois sem `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: foi enviado um token que não é válido.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: o jogador não existe, ou a conta foi eliminada.'
        '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` (a base de dados continuou bloqueada; `retryAfter: 1` no corpo, sem cabeçalho `Retry-After`), `server_busy` (a procura da sessão encontrou a base de dados bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Obter os melhores jogadores de uma categoria
      description: |-
        Os 100 melhores registos de pontuação de uma categoria oficial com pelo menos `minGames` (`PROVISIONAL_GAMES`) partidas contabilizadas, excluindo as contas eliminadas e os batoteiros confirmados. O servidor volta a calcular cada classificação no máximo a cada 10 segundos; `updatedAt` indica quando. Só é executado um cálculo de cada categoria de cada vez: entretanto, os outros pedidos recebem a lista anterior ou, quando ainda não há nenhuma, esperam pelo cálculo.

        **Limites:** apenas a camada por endereço.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Um identificador de 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 algarismos 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` (em falta, 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` (a base de dados continuou bloqueada; `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 (ver a introdução): o pedido não foi executado.'
  /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 tenha terminado nos últimos 7 dias. Uma denúncia nunca altera, por si só, uma pontuação, uma sanção ou um nível de integridade. 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 altera nada; a resposta nunca revela nada sobre a conta denunciada. `GET /games/{id}` indica antecipadamente aos jogadores da partida se uma denúncia seria aceite (`reportable`).

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

        **Limites:** `reports` (por jogador) e 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 apresentada).
          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 numa partida que tenha terminado 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 procura da sessão encontrou a base de dados bloqueada), `timeout`. Também `server_busy` com `retryAfter` de 2 a 5 quando o servidor está muito atrasado (ver a introdução): o pedido não foi executado.'
  /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 anfitrião 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 do e-mail
      description: |-
        A página de uma ligação de confirmação do e-mail (registo, ou um e-mail de confirmação reenviado). Só mostra um botão «Confirm my e-mail address» (confirmar o meu endereço de e-mail), para que um analisador de correio que abra a ligação não a 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 o botão de confirmação.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: A ligação é inválida 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 (`rate_limited` em JSON).
        '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. Confirma o endereço; no caso de um novo registo, é nesse momento que cria a conta, e o jogador pode iniciar sessão.

        **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 registo foi criada).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: A ligação é inválida, já foi usada ou expirou, ou o formulário é inválido (uma página HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: A ligação de um novo registo cujo nome de utilizador ou endereço foi entretanto ocupado por outra conta; não é criada nenhuma conta (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 (`rate_limited` em JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'A base de dados continuou bloqueada (`Retry-After: 1`), ou o servidor está muito atrasado (`Retry-After` de 2 a 5, ver a introdução): nada foi alterado e a ligação continua a funcionar. Também o esgotamento do tempo limite de processamento.'
  /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 anfitrião 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 reposição da palavra-passe
      description: |-
        A página de uma ligação de reposição da palavra-passe: o formulário da nova palavra-passe (a palavra-passe 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 da nova palavra-passe.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: A ligação é inválida (também quando foi enviada para um endereço que a conta já não tem).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: O limite `page` (uma página HTML), ou a camada por endereço (`rate_limited` em JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Definir uma nova palavra-passe a partir do formulário de reposição
      description: |-
        O formulário da página de reposição; faz o mesmo que `POST /auth/password/reset`, numa única transação: a sessão é terminada em todos os dispositivos, uma alteração de e-mail pendente é cancelada, as outras ligações de reposição deixam de funcionar, o endereço passa a contar como confirmado e o titular recebe um e-mail.

        Primeiro é verificada a ligação, depois se as duas palavras-passe são iguais e, por fim, as regras de palavra-passe. Quando o servidor está ocupado, o formulário volta com `Retry-After` e a ligação continua válida.

        **Limites:** `auth` e depois `auth_reset` (partilhado 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 palavra-passe foi alterada, e a sessão foi terminada em todos os dispositivos.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: O formulário de novo com o erro (as palavras-passe são diferentes, uma palavra-passe fraca), a ligação é inválida, 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 demasiados hashes de palavras-passe em espera (a ligação continua válida), ou a camada por endereço (`rate_limited` em JSON).
        '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 das palavras-passe, ou a base de dados continuou bloqueada); a ligação continua válida. Quando o servidor está muito atrasado (ver 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 a ligação continua válida. Também o esgotamento do tempo limite de processamento.'
  /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 anfitrião 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 uma ligação de alteração de e-mail: 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 o respetivo botão de confirmação.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: A ligação é inválida ou expirou (também quando o endereço da conta mudou desde o pedido).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: O limite `page` (uma página HTML), ou a camada por endereço (`rate_limited` em JSON).
        '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 mantêm a sessão iniciada; as ligações enviadas antes (confirmação, reposição da palavra-passe, outras alterações) deixam 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: A ligação é inválida, já foi usada ou expirou, ou o formulário é inválido (uma página HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Entretanto, outra conta ficou com o endereço (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 (`rate_limited` em JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'A base de dados continuou bloqueada (`Retry-After: 1`), ou o servidor está muito atrasado (`Retry-After` de 2 a 5, ver a introdução): nada foi alterado e a ligação continua a funcionar. Também o esgotamento do tempo limite de processamento.'
  /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 anfitrião 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 anfitrião 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 com 200 `ok` enquanto o processo estiver em execução. `HEAD` também funciona. Qualquer outro método responde com 405 `method_not_allowed` com `Allow: GET, HEAD` (incluindo `OPTIONS`).

        **Limites:** apenas 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 anfitrião 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 anfitrião 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 com 200 `ready` quando o servidor aceita jogadores, e com 503 `not_ready` enquanto está a arrancar ou a parar. `HEAD` também funciona. Qualquer outro método responde com 405 `method_not_allowed` com `Allow: GET, HEAD` (incluindo `OPTIONS`).

        **Limites:** apenas 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á a arrancar ou a parar.
          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 carateres base64url) obtido numa resposta de início de sessão, no cabeçalho `Authorization`: `Authorization: Bearer <token>`. O prefixo é exatamente `Bearer` (sensível a maiúsculas e minúsculas) seguido de um espaço; um valor que não o tenha, ou um token que não tenha de 1 a 512 carateres ASCII imprimíveis, responde com 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 identificador da partida, um número inteiro decimal positivo de no máximo 16 algarismos, sem zeros à esquerda, inferior a 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: O identificador da sessão, tal 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 utilizador do jogador, sem distinção entre maiúsculas e minúsculas. O servidor aceita qualquer nome com 2 a 24 carateres de `[A-Za-z0-9_.-]` (a regra mais ampla que alguma vez permitiu).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Um identificador de partida; só são listadas as partidas mais antigas. 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 algarismos 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 da ligação do e-mail (43 carateres base64url). Um token em falta ou errado mostra a página «link invalid or expired» (ligação inválida ou expirada).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Todas as respostas do servidor proíbem o armazenamento em cache.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Segundos a aguardar antes de tentar novamente; 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 ligação depois desta resposta.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (com `field` quando o problema está num campo do corpo) 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 esquema
              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 palavra-passe 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 está mal formado, expirou, foi revogado ou pertence a uma conta eliminada).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: 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: O pedido é recusado.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Uma palavra-passe errada na reautenticação
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Uma conta suspensa
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found`, ou uma funcionalidade 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 no prazo de 10 segundos. O servidor fecha a ligaçã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: O pedido 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 de início de sessão com o Google ou o ticket é desconhecido, já foi usado ou expirou; recomece o início de sessão.'
      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 predefinição). O servidor fecha a ligaçã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 destino do pedido excede 4096 carateres.'
      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 o 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 de novo o mesmo pedido com `pow: { challenge, nonce }`. `reason` indica 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 pedidos, o orçamento da conta ou a fila de hash das palavras-passe) ou `too_many_attempts` (uma restrição própria 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 pedidos
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Demasiadas falhas 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 regista-a.'
      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 um `iss` errado, ou o Google recusou o código ou enviou um token de ID que não passa na verificação (incluindo um código ou um token de ID do outro cliente, o do jogo ou o do site). Nunca inclui 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 novamente após `retryAfter`, quando for indicado.'
      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: A base de dados continuou bloqueada (uma leitura)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: O servidor demorou demasiado tempo
              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 ficheiro 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 no prazo de 10 segundos (uma página HTML). O servidor fecha a ligaçã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 ligaçã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 é um formulário (`application/x-www-form-urlencoded`) nem JSON, ou o 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 pedidos (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», erro do servidor). O servidor regista-a.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: O servidor está ocupado (a base de dados continuou bloqueada, ou o servidor está muito atrasado; com `Retry-After`) ou demorou demasiado tempo (uma página HTML intitulada «Server error», erro do servidor).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
  schemas:
    Error:
      type: object
      description: A forma única de todas as respostas 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`. É a partir dele que um cliente escolhe o que mostrar.
          examples:
            - rate_limited
        message:
          type: string
          description: Uma frase em inglês, destinada aos registos e a servir de alternativa.
        retryAfter:
          type: integer
          minimum: 1
          description: Segundos a aguardar antes de tentar novamente, presente apenas nas recusas que terminam com o tempo. A resposta traz então também um cabeçalho `Retry-After` com o mesmo valor, exceto o 503 `busy` das leituras do histórico, das partidas, dos jogadores e da classificação.
        field:
          type: string
          description: O campo ou parâmetro de consulta em causa (`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 a razão pela qual foi pedida ou recusada uma prova de trabalho (`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: 'Apenas `banned`: o fim da suspensão (ms desde a época Unix), `null` para uma suspensão permanente.'
        line:
          type: integer
          minimum: 1
          description: 'Apenas `invalid_pgn`: a linha do erro, a partir de 1.'
        column:
          type: integer
          minimum: 1
          description: 'Apenas `invalid_pgn`: a coluna do erro, a partir de 1, em carateres.'
    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 devolver tal como está.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: O número de bits iniciais a zero que `SHA-256(challenge + ":" + nonce)` tem de ter.
        expiresAt:
          type: integer
          format: int64
          description: Quando o desafio expira (ms desde a época Unix), 2 minutos depois de ter sido 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 algarismos tal que `SHA-256(challenge + ":" + nonce)` comece com `bits` bits a zero.
    ServerInfo:
      type: object
      description: Aquilo de que um cliente precisa antes de iniciar sessão ou de se ligar.
      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 a base de dados recebe no primeiro arranque. Mantém-se igual entre reinícios. É `null` quando a base de dados não o consegue fornecer.
        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 esquema do protocolo (os 4 primeiros bytes do SHA-256 da sua forma canónica, como inteiro sem sinal: `0xc649140f`, 3326678031, para a versão menor 2 do protocolo); informativa, nunca é comparada.'
            subprotocol:
              type: string
              description: O token do subprotocolo WebSocket (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: A porta 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 é possível criar novas contas.
        emailVerification:
          type: boolean
          description: Se as novas contas confirmam o seu endereço (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Se o início de sessão com o Google é oferecido ao jogo.
            googleWeb:
              type: boolean
              description: Se o início de sessão 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 registo exige (0 para nenhum).
        categories:
          type: array
          description: Os ritmos de jogo oficiais, com Elo (`RATED_CATEGORIES`), pela ordem do servidor. Qualquer outro ritmo de jogo é `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 comprimento mínimo de um nome de utilizador (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: O comprimento máximo de um nome de utilizador (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: A expressão regular a que um novo nome de utilizador tem de corresponder.
            passwordMinLength:
              type: integer
              description: O comprimento mínimo de uma palavra-passe, em carateres (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: O comprimento máximo de uma palavra-passe, em bytes de UTF-8.
            customTimeControls:
              type: boolean
              description: Se os desafios e as partidas privadas podem usar ritmos de jogo personalizados.
            reportsPerDay:
              type: integer
              description: Quantas denúncias um jogador pode apresentar por 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 ritmo de jogo oficial.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: O identificador 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 tal como o seu jogador a vê (`user` das respostas de início de sessão 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 identificador da conta.
        username:
          type: string
          description: O nome de utilizador.
        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 há uma conta Google associada.
        hasPassword:
          type: boolean
          description: '`false` para uma conta criada através do Google que ainda não definiu uma palavra-passe.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Se são aceites desafios diretos pelo nome (ver `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 início de sessão (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 à espera da respetiva ligação, 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 para o WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: O fim absoluto da sessão (ms desde a época Unix); o limite de inatividade pode terminá-la mais cedo.
        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: O segundo passo de um início de sessão 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 do passo, para `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Segundos restantes para concluir o passo.
    LoginAnswer:
      description: Uma sessão, ou o segundo passo de um início de sessão com autenticação de dois fatores.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Um primeiro início de sessão 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 durante 10 minutos.
        suggestedUsername:
          type: string
          description: Um nome de utilizador criado a partir do nome Google ou do endereço, ou `""` quando nada serve.
    SsoNeedsPassword:
      type: object
      description: Uma conta com palavra-passe usa o endereço; continue com `POST /auth/sso/google/link`. Ainda não foi associado nada.
      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 utilizador dessa conta (só o vê quem comprovou junto do Google que o endereço é seu).
        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 início de sessão 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: O URL do Google a abrir no navegador do sistema, depois de verificado.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: O `state` que o Google tem de 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 utilizador; aplicam-se depois as regras do servidor (ver a descrição).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: O endereço de e-mail. Os espaços no início e no fim são removidos e é guardado em minúsculas.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A palavra-passe; aplicam-se depois as regras de palavra-passe.
        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 utilizador, 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 com sessão iniciada (por exemplo, `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 início de sessão (ou da de um início de sessão com o Google).
        code:
          type: string
          maxLength: 32
          description: Opcional. Um código de 6 algarismos da aplicação de autenticação, ou um código de recuperação.
        recoveryCode:
          type: string
          maxLength: 32
          description: Opcional. Um código de recuperação. É necessário um dos dois, `code` ou `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` da ligação de reposição.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: A nova palavra-passe; aplicam-se as regras de palavra-passe do registo.
    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 está à 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; o seu SHA-256 tem de corresponder ao desafio dado 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 o 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 durante 10 minutos.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A palavra-passe 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 durante 10 minutos.
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: O nome de utilizador; aplicam-se as regras do registo.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: As sessões ativas, a começar pela usada mais recentemente.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Uma sessão ativa (um dispositivo com sessão iniciada).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: O identificador da sessão, para `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: O início de sessão (ms desde a época Unix).
        lastSeenAt:
          type: integer
          format: int64
          description: A última utilização (ms desde a época Unix), atualizada no máximo a cada 5 minutos (mais tarde enquanto o servidor estiver muito atrasado).
        expiresAt:
          type: integer
          format: int64
          description: O fim absoluto (ms desde a época Unix); o limite de inatividade pode terminar a sessão mais cedo.
        clientLabel:
          type:
            - string
            - 'null'
          description: O `clientLabel` do início de sessão, ou `null` quando não foi enviado nenhum.
        current:
          type: boolean
          description: Se esta é a sessão que faz o pedido.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Um registo por cada categoria em que o jogador jogou partidas com Elo.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: As sanções ativas.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` enquanto a conta estiver suspensa; caso contrário, `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: O registo de pontuação de uma categoria.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: O identificador da categoria.
        rating:
          type: integer
          description: A pontuação.
        games:
          type: integer
          minimum: 0
          description: As partidas jogadas na categoria (contabilizadas ou não).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: A pontuação mais alta alcançada.
        provisional:
          type: boolean
          description: '`true` enquanto a pontuação ainda não estiver estabelecida ou tiver menos de `PROVISIONAL_GAMES` partidas contabilizadas (o jogo mostra-a 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 indicado, se existir.
        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 da suspensão (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 os recusar.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: A palavra-passe atual.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: A nova palavra-passe; aplicam-se as regras de palavra-passe do registo.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A palavra-passe 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 algarismos.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A palavra-passe 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 da aplicação de autenticação 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`; as maiúsculas e minúsculas, os espaços e os hífenes não importam).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A palavra-passe da conta.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Um código da aplicação de autenticação (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; os espaços no início e no fim são removidos e é convertido em minúsculas, e depois é verificado como um endereço de registo.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: A palavra-passe 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 da aplicação de autenticação 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 a aplicação de autenticação.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: O segredo em base32, para introduzir na aplicação.
        uri:
          type: string
          format: uri
          description: O URI `otpauth://totp/...`, para mostrar como código QR.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Segundos por intervalo.
    RecoveryCodeList:
      type: array
      description: Os 10 códigos de recuperação, mostrados apenas 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 dos lados de uma partida.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: O nome no registo da partida (`deleted#<id>` para uma conta eliminada).
        rating:
          type:
            - integer
            - 'null'
          description: A pontuação no início, `null` quando é desconhecida.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: A pontuação depois da partida. Uma partida com Elo tem-na sempre; `null` apenas para uma partida que não conta para as pontuações (amigável, personalizada, anulada).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: A variação da pontuação, `0` quando as regras de pontuação deixam a pontuação onde estava (um resultado de zero pontos segundo a FIDE, um adversário sem pontuação estabelecida, as primeiras partidas de um jogador sem pontuação estabelecida); `null` nos mesmos casos que `ratingAfter`.
    GameSummary:
      type: object
      description: O resumo de uma partida guardada.
      required:
        - id
        - category
        - rated
        - timeControl
        - white
        - black
        - status
        - reason
        - result
        - termination
        - plies
        - startedAt
        - endedAt
      properties:
        id:
          type: integer
          format: int64
          minimum: 1
          maximum: 9007199254740991
          description: O identificador da partida.
        category:
          type: string
          description: O identificador da categoria oficial (`3+2`), ou `custom`.
        rated:
          type: boolean
          description: Se a partida conta para as pontuações.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: O ritmo de jogo 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` (ver os códigos das partidas).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: O código do motivo do fim (ver os códigos das partidas).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: O resultado, `*` para uma partida anulada.
        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 fim (ver os códigos das partidas); `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 com sessão iniciada.
      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, da mais recente para a mais antiga.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: O identificador 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 numa promoção.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: O tempo descontado pelo lance (ms), `null` quando o registo não o tem.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: O relógio de quem jogou, depois do lance (ms), `null` quando o registo não o tem.
    PgnTagsView:
      type: object
      description: As principais etiquetas PGN, para apresentação. Aqui, `Termination` é o nome do motivo do fim; o ficheiro PGN usa os valores da norma 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 UTC do início.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: A pontuação no início, ou `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: A pontuação 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 registo de uma partida com os seus lances e relógios. Tem os campos de um resumo de partida, sem `color` nem `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 identificador da partida de que esta é a desforra, 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: Apenas quando o jogador do token jogou esta partida; o seu lado.
            reportable:
              type: boolean
              description: Apenas 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á menos de 7 dias, a quota diária não está esgotada 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 utilizador, tal 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: Apenas as categorias oficiais, pela 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 guardadas, incluindo as amigáveis e as anuladas.
            rated:
              type: integer
              minimum: 0
              description: As partidas com Elo, somadas sobre os registos de pontuação.
            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 utilizador, tal como o jogador o escreveu.
        games:
          type: array
          description: As partidas da página, da mais recente para a mais antiga.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: O identificador da última partida sempre que a página está cheia (a página seguinte pode então estar vazia); caso contrário, `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: O identificador da categoria.
        minGames:
          type: integer
          minimum: 0
          description: As partidas contabilizadas de que um registo 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ó é usada a primeira partida.
        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 filas são desenhados à volta do tabuleiro.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: A partida, como número inteiro ou como string de 1 a 16 algarismos.
          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 utilizador do adversário, tal como no registo da partida ou tal como é agora, sem distinção entre maiúsculas e minúsculas. Não pode estar em branco.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: O motivo da denúncia.
        comment:
          type:
            - string
            - 'null'
          description: Opcional. No máximo 500 carateres, depois de removidos os carateres de controlo (exceto a tabulação e a mudança de linha) e os espaços no início e no fim do texto.
    AccountExport:
      type: object
      description: Tudo o que o servidor conserva sobre a conta (datas 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 ficheiro contém e o que deixa de fora, em inglês simples, para o jogador.
          items:
            type: string
        account:
          description: A vista 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 associada, ou `null`.
        ratings:
          type: array
          description: Os registos de pontuação completos.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Os pontos de Elo devolvidos depois de se ter apurado que um adversário fazia batota, somados por dia UTC e por categoria, do mais recente para o mais antigo. Nem as partidas nem os batoteiros são identificados.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Todas as sessões guardadas, da mais recente para a mais antiga, sem nenhum token. A limpeza de retenção elimina uma sessão expirada, e uma sessão terminada um dia depois do fim de sessão.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Os eventos de segurança, do mais recente para o mais antigo, conservados durante `RETENTION_SECURITY_DAYS` dias.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Todas as sanções, incluindo as levantadas. O nome do moderador nunca é incluído.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Os eventos de conduta registados para as partidas do jogador abandonadas, anuladas e sem primeiro lance a tempo, conservados durante 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, da mais recente para a mais antiga, 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 registo de pontuação 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 pontuação estabelecida.
            countedGames:
              type: integer
              minimum: 0
              description: As partidas que entraram no cálculo da pontuação.
            updatedAt:
              type: integer
              format: int64
              description: Quando o registo foi alterado pela última vez.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: As 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 terminada, ou `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: O endereço do início de sessão, apagado ao fim de `RETENTION_IP_DAYS` dias.
    SecurityEvent:
      type: object
      description: |-
        Um evento de segurança. `ip` só é indicado para o que foi feito com sessão iniciada, com a palavra-passe da conta (e o segundo fator) ou com uma ligação enviada para o seu endereço: `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 ao fim de `RETENTION_IP_DAYS` dias.

        `detail` só conserva 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ó conserva `{ action }`, para as ações `ban`, `unban`, `reset_mfa`, `verify_email` e `revoke_sessions` (as outras ações dos moderadores ficam de fora). Todos os outros tipos têm `detail: null`. Os eventos `rating_refund` ficam de fora: os 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 é indicado se o jogador denunciado foi sancionado).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: O token da ligação 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 da ligação de reposição (o campo oculto do formulário).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: A nova palavra-passe; aplicam-se as regras de palavra-passe do registo.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: A nova palavra-passe outra vez; tem de 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.
