openapi: 3.1.1
info:
  title: Scacelith server HTTPS API
  version: "0.9.0"
  summary: The HTTPS API of every Scacelith server, the official one and community servers.
  description: |-
    Every Scacelith server, the official `caissa.scacelith.com` and any community server, answers this HTTPS API. The game uses it for everything outside a game itself: sign-up and sign-in (two-step verification and Google included), the account page, game history, PGN downloads and animated GIFs of games, signed-in devices, the data download, the deletion of the account, and reports.

    Live play (matchmaking, challenges, moves, clocks) goes through the WebSocket of the same server (`wss://<host>/ws`), opened with a session token of this API; it is described in `PROTOCOL.md`, not here.

    Defaults below are those of a server with an untouched configuration; a community server may change them (`CONFIG.md` names every setting).

    ## Base URL, port and transport

    - Official server: `https://caissa.scacelith.com/api/v1`, TCP port 443.
    - A community server: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` is 443 by default; behind a NAT or a proxy, the port players use is `PUBLIC_API_PORT`.
    - The game's WebSocket is on the same port by default (`WS_PORT` moves it; `GET /info` tells the client where it is).
    - HTTP/1.1 over TLS. With `TLS_MODE=proxy`, a reverse proxy terminates TLS in front of the server. `TLS_MODE=off` (plain HTTP) is for local development only.
    - The HTML pages opened from the links of e-mails (`/verify-email`, `/reset-password`, `/confirm-email-change`) live at the root of the server, outside `/api/v1`. The health endpoints answer both at the root and under `/api/v1`. Google sign-in has no page on the server: Google sends the browser back to the game itself, on `127.0.0.1`.
    - A trailing slash is ignored (`/api/v1/info/` is `/api/v1/info`), and path parameters are URL-decoded (a parameter that is not valid URL encoding answers 400 `invalid_request`).

    ## Requests

    - **JSON bodies.** `POST`, `PUT` and `DELETE` requests carry a JSON object with `Content-Type: application/json`. A `charset` other than UTF-8 is refused, and so is any other content type (415 `unsupported_media_type`). An empty body counts as `{}`, which is what the endpoints without parameters take (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Size and time.** A body is limited to `HTTP_BODY_LIMIT` bytes (16,384 by default; `POST /gif` has its own limit of 135,168 bytes): 413 `payload_too_large`. It must arrive within 10 seconds: 408 `request_timeout`. After either error the server closes the connection.
    - **Strict schemas.** A field that the endpoint does not know is refused, a field not marked optional is required, and types and lengths are checked. Any of these failures answers 400 `invalid_request`, with `field` naming the field (dotted for a nested one, such as `pow.nonce`). Strings may not contain control characters. Lengths count UTF-16 code units, so a character outside the Basic Multilingual Plane (an emoji) counts twice. A body that is not JSON answers 400 `invalid_json`. `POST /gif` and `POST /reports` check their bodies themselves (see each endpoint).
    - **Query strings.** The first occurrence of a parameter counts, unknown parameters are ignored, and a `+` decodes to a space: the endpoints that take a time-control category accept both `3%2B2` and `3+2`.
    - **Methods.** `HEAD` is `GET` without the body. `OPTIONS` on an existing path answers 204 with an `Allow` header. Any other method that the path does not have answers 405 `method_not_allowed` with `Allow`.
    - **Request target.** It may not exceed 4096 characters (414 `uri_too_long`). A request that cannot be parsed at all, or whose head is too large or arrives too slowly, gets an empty 400, 431 or 408 answer and the connection is closed.
    - **No CORS.** The API serves the game, not web pages. No `Access-Control-*` header is ever sent, so a web page cannot read an answer; because only JSON bodies are taken, a cross-site write would need a preflight, and that preflight fails.

    ## Answers and errors

    - Answers are JSON in UTF-8, except the PGN download (`application/x-chess-pgn`), the animated GIFs (`image/gif`) and the HTML pages. Times are milliseconds since 1970-01-01 UTC. Ids are integers. Game ids have up to 16 digits but stay below 2^53, so a JSON number (a double) holds them exactly.
    - Every answer of the API carries `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` and `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (the HTML pages have a policy of their own). With `TLS_MODE=native` it also carries `Strict-Transport-Security: max-age=31536000`.
    - An answer must be read within 60 seconds of the moment the server has it ready, which only matters for the large ones (a GIF, the data export, a long PGN). The time the server takes to prepare an answer counts neither toward this nor toward the 30 seconds without a byte in or out after which a connection is closed.

    Errors have one shape (the `Error` schema):

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

    `message` is meant for logs and as a fallback; a client chooses what to show from `error`. `retryAfter` (seconds) is present only on refusals that end with time, and the answer then also carries a `Retry-After` header with the same value; the one exception is the 503 `busy` of the history, game, player and leaderboard reads, which has only the field. Some errors add fields: `field` (invalid input), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` and `column` (`invalid_pgn`).

    Errors that any endpoint can give:

    | Status | `error` | When |
    |---|---|---|
    | 400 | `invalid_request` | The body breaks the endpoint's schema (`field` says where), the request target or `Content-Length` is malformed, a path parameter is not valid URL encoding, or the body was cut off. |
    | 400 | `invalid_json` | The body is not JSON. |
    | 401 | `unauthorized` | No `Authorization` header on an endpoint that needs a session. |
    | 401 | `invalid_token` | The token is malformed, expired, revoked or belongs to a deleted account; also on endpoints where the session is optional. |
    | 404 | `not_found` | No such endpoint. Some endpoints also use it: no such game, player or session. |
    | 405 | `method_not_allowed` | The path exists for other methods (see `Allow`). |
    | 408 | `request_timeout` | The body did not arrive within 10 seconds. |
    | 413 | `payload_too_large` | The body exceeds `HTTP_BODY_LIMIT` (135,168 bytes for `POST /gif`). |
    | 414 | `uri_too_long` | The request target exceeds 4096 characters. |
    | 415 | `unsupported_media_type` | The body is not `application/json`, or its charset is not UTF-8. |
    | 429 | `rate_limited` | A rate limit (see below): `retryAfter` plus a `Retry-After` header. |
    | 500 | `internal_error` | An unexpected failure. The server logs it. |
    | 503 | `timeout` | The server did not answer within 30 seconds (60 seconds for the export, 45 seconds for the GIFs with the default settings). |
    | 503 | `server_busy` | A session lookup or an account change found the database locked (`retryAfter` 1), or the password hash queue is full (see below). |

    The read endpoints (game history, games, players, leaderboard) and the export answer 503 `busy` with `retryAfter: 1` when the database stayed locked.

    Endpoints that check or hash a password can also answer one of these, both with a random `retryAfter` of 5 to 15 seconds:

    - 503 `server_busy`: the server's password hash queue (`PASSWORD_HASH_QUEUE_MAX`) is full, or the wait ran out.
    - 429 `rate_limited`: once the queue is half full, this client (an IPv4 address or an IPv6 /48) already has `PASSWORD_HASH_WAITERS_PER_SOURCE` hashes waiting. This 429 gives back the rate-limit tokens that the request took.

    Nothing was changed and no failed attempt was counted (except on `POST /auth/sso/google/link`, whose ticket try and account failure were taken before the hash), and a reset link stays valid.

    The HTML pages answer their errors as HTML pages with the same status codes.

    ## Authentication

    The endpoints that need a session take a bearer token in the `Authorization` header (the `bearerAuth` scheme):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Getting a token.** A token (`sct_` followed by 43 base64url characters) comes from `POST /auth/login`, then `POST /auth/login/mfa` when two-step verification is on, from `POST /auth/sso/google/finish` and `POST /auth/sso/google/link` (Google sign-in), and from `POST /auth/sso/complete`. Every one of them answers `{ token, expiresAt, user }`. The server stores only a SHA-256 of the token.
    - **Required session.** A missing header answers 401 `unauthorized` with `WWW-Authenticate: Bearer realm="scacelith"`. An invalid token answers 401 `invalid_token` with `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. A client should forget a token that gets `invalid_token` and sign in again.
    - **Optional session.** On `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` and `GET /players/{username}/games` the session is optional. Without the header they answer the public view; if a header is sent, its token must be valid.
    - **Lifetime.** A session ends at the first of `SESSION_MAX_DAYS` (90) after the sign-in (this is `expiresAt`) and `SESSION_IDLE_DAYS` (30) without use. Each use pushes the idle limit back; the server writes the new value at most every 5 minutes. An account keeps at most `MAX_SESSIONS_PER_USER` (10) sessions: a new sign-in revokes the oldest beyond that number.
    - **Revocation.** Signing out (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) revokes sessions; so do a password change (the other sessions), a password reset and the deletion of the account (every session), and an administrator. A revocation from the API takes effect at once, and the WebSocket opened with a revoked session is closed. The server caches session lookups for 30 seconds, so a revocation by the admin command, a separate process, takes effect within 30 seconds.
    - **Scope.** A token belongs to one server and opens its WebSocket too. Never send it to another server.

    ## Rate limits and other throttles

    Limits apply to one of two scopes. **Client:** an IPv4 address, or an IPv6 /64 (in proxy mode, the address comes from `X-Forwarded-For` sent by a `TRUSTED_PROXIES` address); some limits also count each IPv6 /48 as a whole, on top of each of its /64 networks. **Player:** the signed-in account, whatever its address; a limit counted per player on an endpoint where the session is optional counts per client for a request without a token.

    Each limit is a token bucket that holds `limit` requests and refills continuously at `limit / window`; `retryAfter` is the time until the next token. Limits marked *shared* are also counted over a sliding window of the same length, so that no window holds much more than `limit` requests. Every limit is counted for the whole server. When one of an endpoint's limits refuses a request, the tokens that its other limits took for that request are given back. A refusal answers 429 `rate_limited` with `retryAfter` and `Retry-After`.

    - **Per-address layer.** Every request (any path and method, the health endpoints and WebSocket upgrades included) first takes one token of its client's budget: `HTTP_RATE_PER_IP` (600) per minute with a burst of half a minute, and `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) for an IPv6 /48. A client may also have at most `IP_MAX_INFLIGHT` (32 x `WORKERS`) requests in progress (`retryAfter` 1 beyond). A client that keeps going after its refusals is blocked: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) refusals in one minute block it for 1 minute, then 4, 16 and 60 minutes at each new block within 6 hours. A refusal of the `auth`, `auth_*` and `reauth` limits counts 5; limits counted per player never count. Addresses in `ABUSE_EXEMPT` skip this layer, but not the account budget or the endpoint limits.
    - **Account budget.** Every request that carries a valid session token also counts against its account: `USER_RATE_PER_MIN` (120) per minute for all endpoints together, whatever the address, with a burst of half a minute.
    - **Endpoint limits:**

    | Limit | Default | Counted per | Endpoints |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, shared | client, and `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) per IPv6 /48 | `POST /auth/register`, `/auth/login`, `/auth/login/mfa`, `/auth/verify-email/resend`, `/auth/password/forgot`, `/auth/password/reset`, `/auth/sso/google/link`, `/auth/sso/complete`; `POST /verify-email`, `/reset-password`, `/confirm-email-change` |
    | `auth_register` | `AUTH_REGISTER_PER_HOUR` (10) / hour, shared | client, 3 times that per IPv6 /48 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / hour, shared | client, 3 times that per IPv6 /48 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / hour, shared | client, 3 times that per IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 hours, shared | client, 3 times that per IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / hour, shared | client, 3 times that per IPv6 /48 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | the numbers of `auth`, a bucket of its own, shared | client and IPv6 /48 | the account changes that ask for the password (see Re-authentication), and `POST /account/export` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 min, shared | player | the same endpoints as `reauth` |
    | `account` | 60 / min | player | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | player | `GET /account/games` |
    | `account_export` | 5 / hour, shared | player | `POST /account/export` (checked before `reauth`; every attempt counts) |
    | `sessions` | 60 / min | player | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | player (client without a token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (one bucket for the four) |
    | `gif` | 30 / min | player | `GET /games/{id}/gif`, `POST /gif` (one bucket for the two) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min and `GIF_USER_RENDERS_PER_HOUR` (30) / hour, shared | player | the same two, only when the GIF has to be made (not from the cache) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min and `GIF_IP_RENDERS_PER_HOUR` (120) / hour, shared | client (all its accounts together), 3 times that per IPv6 /48 | the same, as above |
    | `reports` | 30 / hour | player | `POST /reports` |
    | `sso_start` | 30 / 10 min, shared | client, and 90 per IPv6 /48 | `POST /auth/sso/google/start` |
    | `sso_finish` | 30 / min | client | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | client | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` and `GET /leaderboard` have no limit of their own: only the per-address layer. An endpoint with several limits checks them in the order given in its description.

    The server handles a request in this order: the per-address layer, then the health endpoints; endpoint match; authentication; the account budget, when the request carries a session; the endpoint's limits; the body; the endpoint itself (the GIF endpoints take their render limits only when they have a GIF to make). So a request refused for its token spends none of the endpoint's tokens, and a request with an invalid body does spend them.

    Other throttles, answered by the endpoints themselves:

    - **Failed sign-ins on one login name** (user name or e-mail): from `AUTH_FAILURES_PER_ACCOUNT` (5) failures on, each attempt must wait twice as long as the one before (2 s, 4 s, and so on, up to 15 minutes): 429 `too_many_attempts` with `retryAfter`. The counter forgets after an hour without failures.
    - **Failed second factors at sign-in:** the same rule from the 5th wrong code of the account. One sign-in step accepts at most 5 wrong codes.
    - **Second-factor codes of one account:** at most `AUTH_MFA_PER_ACCOUNT` (10) codes (authenticator or recovery codes, right or wrong) per 15 minutes, from any address, at sign-in and in re-authentications; beyond it 429 `too_many_attempts` before the code is checked, so a recovery code is not used up.
    - **Failed re-authentications of an account** (wrong password or code): the same rule (429 `too_many_attempts`), shared by every endpoint that re-authenticates.
    - **E-mails:** one confirmation or reset mail per address every 5 minutes (the answer stays the same); one "someone tried to use your address" notice per address per hour.
    - **Reports:** `REPORTS_PER_DAY` (5) per player in 24 hours: 429 `report_limit`.

    ## Proof of work

    `POST /auth/register` always needs a proof of work when `POW_REGISTER_BITS` is above 0 (18 by default; `GET /info` gives it as `pow.register`). `POST /auth/login` and `POST /auth/sso/google/link` (one kind of challenge for both) need one only for 5 minutes after the server sees a wave of failed sign-ins (`POW_LOGIN_TRIGGER_PER_MIN`, then `POW_LOGIN_BITS`); a client finds out from the answer.

    1. The request without a proof (or with a refused one) answers 428 `pow_required` with `reason` and a `pow` challenge `{ challenge, bits, expiresAt }`.
    2. Find a nonce: a decimal string of at most 20 digits such that `SHA-256(challenge + ":" + nonce)` starts with `bits` zero bits (most significant bit of the first byte first). 18 bits take about 260,000 hashes on average.
    3. Send the same request again with `"pow": { "challenge": "...", "nonce": "123456" }` in the body.

    A challenge is valid for 2 minutes and only once, for one endpoint and one client network (an IPv4 address or IPv6 /64). It is signed, so the server keeps nothing about it until it comes back. `reason` says why a proof was refused: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` or `replayed`.

    ## Re-authentication

    Account changes ask for the password again and, when two-step verification is on, for a second factor:

    - the password only: `POST /account/password` and `POST /account/mfa/totp/setup`;
    - the password and an authenticator code, a recovery code being refused: `POST /account/mfa/recovery-codes`;
    - the password and an authenticator code or a recovery code: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` and `POST /account/delete`.

    In bodies, `code` holds a 6-digit authenticator code and `recoveryCode` a recovery code (`xxxx-xxxx-xx`; case, spaces and dashes do not matter). A recovery code may also be sent in `code` where recovery codes are accepted. Each code works once: a used authenticator code is refused until the next 30-second step, and a recovery code is gone once it is used.

    | Status | `error` | When |
    |---|---|---|
    | 403 | `invalid_password` | Wrong password. |
    | 403 | `mfa_code_required` | Two-step verification is on and neither `code` nor `recoveryCode` was sent. |
    | 403 | `invalid_code` | Wrong or already used code. |
    | 400 | `password_not_set` | A Google-only account has no password yet ("Forgot password" sets one). |
    | 429 | `too_many_attempts` | Too many failures, or too many codes tried, on this account. |
    | 503 / 429 | `server_busy` / `rate_limited` | The password hash queue is busy. |

    Failed passwords and codes count in the account's failure counter and are recorded as security events.

    ## Metrics port

    Besides the API port, the server answers plain HTTP on the metrics port (`METRICS_PORT`, 9464, bound to `METRICS_BIND`, 127.0.0.1 by default; keep it private). It is not part of this API: `GET /healthz` answers `ok`; `GET /readyz` answers `ready` once the start-up is complete (every shard has replayed its journal and the listeners are bound) and until the shutdown begins, else 503 `not ready`; `GET /metrics` serves the Prometheus metrics and, with `METRICS_TOKEN` set, needs `Authorization: Bearer <token>` with that exact 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: Source code of the Scacelith server and its reference documentation (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: The official server.
  - url: https://{host}:{port}/api/v1
    description: Any Scacelith server, such as a community server.
    variables:
      host:
        default: caissa.scacelith.com
        description: The server's public host name (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: The public API port (`PUBLIC_API_PORT`, else `API_PORT`).
tags:
  - name: server-info
    x-displayName: Server info
    description: What a client needs to know before it signs in or connects.
  - name: health
    x-displayName: Health
    description: |-
      Liveness and readiness of the server, for monitoring. They answer on the API port before authentication and every endpoint limit, but like any request they take a token of the per-address layer; a monitoring host can be listed in `ABUSE_EXEMPT`. Both paths work for each endpoint: at the root of the server and under `/api/v1`.

      The metrics port has health endpoints of its own, described in the introduction.
  - name: auth
    x-displayName: Registration and sign-in
    description: |-
      Creating an account, signing in with a password (and a second factor), confirmation and password reset e-mails.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` and `POST /auth/sso/complete` answer a session `{ token, expiresAt, user }` (or, for the first ones, a second step).
  - name: google-sign-in
    x-displayName: Google sign-in
    description: |-
      Offered when `GET /info` says `sso.google: true`; otherwise every endpoint below answers 404 `sso_disabled`. The game signs in through the system browser with the installed-app flow of RFC 8252 (a "Desktop app" client): Google sends the browser back to a listener of the game on `127.0.0.1`, never to this server, and the game never sees a Google credential.

      1. The client listens on `127.0.0.1:0` (the system picks the port) and makes a PKCE pair: a `codeVerifier` of 43 to 128 characters `[A-Za-z0-9._~-]` and `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, which has 43 characters and no padding.
      2. `POST /auth/sso/google/start` with the challenge and the port returns the Google URL and the attempt's `state`. The client checks the URL (below) and opens it in the browser.
      3. Google sends the browser to `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. The client accepts only the `state` of the start answer, and sends nothing after an `error=` redirect.
      4. `POST /auth/sso/google/finish` with the attempt id, the `codeVerifier`, the `state` and the `code` (and `iss` when Google sent it).
      5. The answer is a session, a two-step verification step (continue with `POST /auth/login/mfa`), `needsUsername` for a new player (continue with `POST /auth/sso/complete`), or `needsPassword` when an account with a password uses the address (continue with `POST /auth/sso/google/link`).

      **The origin tag.** The redirect URI carries a tag of the server the player added, so that a URL another server got for its own players is refused by the game. The origin is the lower-case host (an IPv6 literal in brackets), `:` and the decimal API port, always written, 443 included: the server takes `SERVER_PUBLIC_HOST` and its public API port, the game the address it is connected to. The tag is the first 22 characters of the base64url (no padding) of SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + origin). The redirect URI is `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: always the IPv4 literal, and the port of the game's listener (1024 to 65535). The server never takes a URI, host or path from the client.

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

      So Google sign-in works only for players who added the server under exactly `SERVER_PUBLIC_HOST` and its public API port.

      **What the game checks before it opens the browser.** The `authUrl` starts with exactly `https://accounts.google.com/o/oauth2/v2/auth?` and is printable ASCII under 4096 characters, and its query has exactly one `response_type=code`, exactly one `redirect_uri` equal to the URI the game computes from its port and its origin tag, exactly one `state` equal to the answer's `state`, `code_challenge_method=S256` and a 43-character `code_challenge`. Otherwise the game stops its listener and opens nothing. It posts `finish` and `link` only to the server that answered `start`.

      **Which account the Google sign-in reaches:** the account already linked to that Google account; otherwise an active account with the address that Google confirmed (with a password, `finish` answers `needsPassword` and the link is stored only once its password, then its second factor when it is on, pass; without a password, 409 `sso_account_exists`); otherwise a new account (`needsUsername`). A Google account is never linked to an existing account by its address alone. The account's address gets a mail when Google sign-in creates an account and when it is added to an existing one.
  - name: sessions
    x-displayName: Sessions
    description: The signed-in devices of the account, and signing out.
  - name: account
    x-displayName: Account
    description: The account view, its preferences and its password.
  - name: two-step-verification
    x-displayName: Two-step verification
    description: Authenticator apps (TOTP, RFC 6238), with SHA-1, 6 digits, 30-second steps and one step of tolerance either way, plus single-use recovery codes.
  - name: email-change
    x-displayName: E-mail address change
    description: Changing the address of the account, confirmed with a link sent to the new address.
  - name: data-export
    x-displayName: Data export
    description: Everything the server keeps about the account, as one JSON file.
  - name: account-deletion
    x-displayName: Account deletion
    description: Deleting the account for good.
  - name: game-history
    x-displayName: Game history
    description: The signed-in player's own games, filtered and paged.
  - name: games
    x-displayName: Games and PGN
    description: |-
      Game records with their moves and clocks, and their PGN files.

      **Game codes.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (only finished games are stored). `result`: `1-0`, `0-1`, `1/2-1/2`, or `*` (aborted). `reason`, with its name (`termination`) and the words that end the PGN file's move text; codes 7 and 21 are draws (the player who ran out of time or abandoned faced an opponent who could not checkmate), and the server never ends a game with codes 4 and 13 (they belong to the shared list of reasons):

      | Code | `termination` | Words in the PGN |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate |
      | 2 | `Resignation` | Resignation |
      | 3 | `Timeout` | Loss on time |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) |
      | 5 | `Stalemate` | Stalemate |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate |
      | 8 | `FivefoldRepetition` | Fivefold repetition |
      | 9 | `SeventyFiveMoves` | 75-move rule |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) |
      | 12 | `Agreement` | Draw by agreement |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate |
      | 22 | `Aborted` | Game aborted |
      | 23 | `NoShow` | Aborted: first move not played in time |
      | 24 | `Forfeit` | Forfeit (fair play violation) |
      | 25 | `ServerAborted` | Aborted by the server |
      | 26 | `BothDisconnected` | Aborted: both players disconnected |
  - name: gifs
    x-displayName: Animated GIFs
    description: |-
      A game as an animated GIF, to keep or to share: the board seen from above, one frame per position from the start to the final position, the players' names and ratings above the board, the last move below it, and on the last frame the result and how the game ended.

      **Cost, cache and quotas.** A GIF is made on a rendering thread of its own, never on the threads that run the games, and at the lowest CPU priority: `GIF_THREADS` threads (`WORKERS` by default), started with the first GIF and stopped after a minute without one. Up to `GIF_QUEUE_MAX` (4 x `WORKERS`) GIFs wait for a thread, at most `GIF_QUEUE_TIMEOUT_MS` (10 s) each; a render may last `GIF_RENDER_TIMEOUT_MS` (30 s). The server keeps the GIFs it made in a cache of `GIF_CACHE_MB` MB (32 x `WORKERS`), the least recently used going first; a GIF from the cache, or one being made for another request, costs no render. The cache keys on everything that changes the picture, names included.

      Every request counts in `gif` (30 per minute per player for both endpoints). A GIF that has to be made also counts in the render limits: per player `GIF_USER_RENDERS_PER_MIN` (4) per minute and `GIF_USER_RENDERS_PER_HOUR` (30) per hour; per client, all its accounts together, `GIF_IP_RENDERS_PER_MIN` (12) per minute and `GIF_IP_RENDERS_PER_HOUR` (120) per hour, and 3 times that per IPv6 /48. A client should keep the file it downloaded rather than ask for it again, and wait `retryAfter` after a 429 or a 503.

      Sizes: `small` (32 px squares: 284 x 350 pixels), `medium` (48 px: 424 x 515), `large` (72 px: 628 x 762); without coordinates 268 x 342, 400 x 503 and 600 x 748. The start position stays at least 1 s (the delay when it is longer), the final position 3 s, and the GIF loops; after the first frame, only the part of the picture that changes is stored. A 40-move game takes about 135, 205 and 325 KiB (small, medium, large), a 150-move game 0.5, 0.8 and 1.2 MiB.
  - name: players
    x-displayName: Players
    description: Public profiles and recent games. Public data only, never an e-mail address, a session, a sanction or an integrity level. A deleted account has no profile.
  - name: leaderboard
    x-displayName: Leaderboard
    description: The top players of each official category.
  - name: reports
    x-displayName: Reports
    description: Reporting the opponent of a recent game to the moderators.
  - name: pages
    x-displayName: HTML pages
    description: |-
      Pages for a browser, opened from the links of e-mails. Their links point to `https://<SERVER_PUBLIC_HOST>` (with `:<PUBLIC_API_PORT>` when it is not 443), outside `/api/v1`.

      - The pages run no JavaScript and load no external resource. They are served with `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - A `GET` only shows a button or a form, so that a mail scanner opening the link does not use it up. The change happens on `POST`: a form sent as `application/x-www-form-urlencoded` (a JSON body is accepted too), with the link's token in a hidden field. A field given twice is refused.
      - Errors (rate limits, invalid fields) are HTML pages too, titled "Request refused" or, from 500 on, "Server error". Only the failures found before the page is matched (a request target that is too long or malformed, the per-address layer) answer in JSON.
x-tagGroups:
  - name: server
    x-displayName: Server
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Accounts
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Games
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Players and reports
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Browser pages
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Get the server's name, versions, ports and sign-up rules
      description: |-
        What a client needs before it signs in or connects: the server's name and id, the WebSocket protocol versions and where the WebSocket is, the sign-up rules, the official time controls and the limits a client can check before it sends a form.

        **Limits:** only the per-address layer.
      security: []
      responses:
        '200':
          description: The server's description.
          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`: the per-address layer.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Create an account
      description: |-
        Creates an account: once its e-mail address is confirmed with the link sent to it, or at once without e-mail confirmation.

        **With e-mail confirmation** (`REQUIRE_EMAIL_VERIFICATION`, the default): 202 `verification_sent`. No account exists yet: the signup waits for 24 hours and holds its username meanwhile. A link valid for those 24 hours goes to the address, at most one per address every 5 minutes (`POST /auth/verify-email/resend` sends a new one); the account is created, its address confirmed, when the link is used (the button of the `/verify-email` page), and the player can then sign in. Until then a sign-in with that username answers `invalid_credentials`, as for an unknown account, and the public profile does not exist. A new signup with the same address replaces the waiting one. The answer is the same when another account already uses the address: no link is sent then, its owner gets a notice instead (at most one per hour), and the username is held in the same way, so that nothing tells whether the address has an account. A signup whose link was not used is dropped after 24 hours, and its username is free again.

        **Without e-mail confirmation** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, the account is created at once and can sign in.

        **Rules.** `username`: `USERNAME_MIN` to `USERNAME_MAX` characters (3 to 20), letters, digits, `_` and `-`, starting with a letter or a digit (`GET /info` gives them as `limits`); reserved names (`admin`, `moderator`, `deleted`...) and some prefixes are refused; unique without regard to case. `email`: trimmed and stored in lower case, plain ASCII with a dotted domain. `password`: at least `PASSWORD_MIN_LENGTH` (10) characters and at most 256 bytes of UTF-8; it must not contain the user name or the e-mail's local part, and must not be a common password.

        **Errors**, checked in this order: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; the password hash queue errors; 409 `email_taken` (only without e-mail confirmation).

        **Limits:** `auth`, then `auth_register` (10 registrations per hour per client). **Proof of work:** always, when `POW_REGISTER_BITS` is above 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: First attempt, without a proof of work
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Sent again with the proof of work
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: The account is created and can sign in (no e-mail confirmation on this server).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: The signup waits for its confirmation link (or the address already has an account; the answer does not tell).
          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` (with `field`), `invalid_json`, `invalid_username`, `invalid_email`, or `weak_password` with `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` or `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: registration is closed on this server (`GET /info` says `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: an account has the username, or a waiting signup of another address holds it. `email_taken`: another account uses the address (only without e-mail confirmation).'
        '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`: the `auth` or `auth_register` limit, or the password hash queue.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the password hash queue, or the database stayed locked), `timeout`.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Sign in with a password
      description: |-
        Signs in with a user name or e-mail address and a password. There are two possible answers, both with status 200: a session, or, when two-step verification is on, a second step to complete within 5 minutes with `POST /auth/login/mfa`.

        Unknown account, wrong password and account without a password get the same answer, after the same time: 401 `invalid_credentials`. The account checks (`banned`, `email_unverified`) come only after a correct password. From `AUTH_FAILURES_PER_ACCOUNT` (5) failures on one login name, each attempt must wait (429 `too_many_attempts`).

        **Limit:** `auth`. **Proof of work:** only during a wave of failed sign-ins (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: A session, or the second step of a sign-in with two-step verification.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Signed in
                  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: Two-step verification is on
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: unknown account, wrong password, or an account without a password.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'After a correct password only: `banned`, with `until` (epoch ms, `null` for a permanent ban), or `email_unverified` (an older account whose address is not confirmed yet).'
        '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` (the failure delay of this login name), or `rate_limited` (the `auth` limit, the password hash queue).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the password hash queue, or the database stayed locked), `timeout`.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Complete a sign-in with a second factor
      description: |-
        The second step of a sign-in with two-step verification, after `POST /auth/login` or a Google sign-in (`POST /auth/sso/google/finish` or `POST /auth/sso/google/link`) answered `mfaRequired`. Send `code` (a 6-digit authenticator code, or a recovery code) or `recoveryCode`. A recovery code used here is gone.

        One step accepts at most 5 wrong codes; the step also ends when the password is reset or changed. After the password step of a Google link, the link is stored only when the code passes here.

        **Limits:** `auth`, and at most `AUTH_MFA_PER_ACCOUNT` (10) codes per 15 minutes for the account, from any address; beyond it the code is not checked, so a recovery code is not spent.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: An authenticator code
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: A recovery code
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Signed in.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: neither `code` nor `recoveryCode` was sent, or the body breaks the schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: the step expired, was used, or ended after 5 wrong codes, or the password was reset or changed since the first step (sign in again). `invalid_code`: a wrong or already used code.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (with `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: after the password step of a Google link only, the Google account was linked to another account meanwhile.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: after the password step of a Google link only, the account changed meanwhile; start again from the game.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: the account''s failure delay, or its `AUTH_MFA_PER_ACCOUNT` codes of the last 15 minutes are used up. `rate_limited`: the `auth` limit.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` with `retryAfter: 1`: the database stayed locked (after a Google link step, nothing was linked: start again from the game). `timeout`.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Sign out this session
      description: |-
        Revokes the session of the token used. The WebSocket opened with it is closed. No body (or `{}`).

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Signed out.
          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`: a body other than `{}`; `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`: the `sessions` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the database stayed locked), `timeout`.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Sign out every session
      description: |-
        Revokes every session of the account, this one included. The WebSockets opened with them are closed. No body (or `{}`).

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Every session is signed out.
          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`: a body other than `{}`; `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`: the `sessions` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the database stayed locked), `timeout`.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Send the confirmation link again
      description: |-
        Sends the e-mail confirmation link again. The answer is 202 `accepted` whatever the address, so that it never tells whether an account or a signup uses it.

        A request acts at most once every 5 minutes per address. A signup waiting with that address gets its 24 hours again, whether or not another account uses the address, so that its username stays held as long in both cases. A link is sent only for that signup when the address has no account (a new link, valid 24 hours, replaces the previous one), or for an active, unconfirmed account with that address.

        **Limits:** `auth`, then `auth_mail` (10 per hour per client).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Accepted (whatever the address).
          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`: the `auth` or `auth_mail` limit (whatever the address).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` with `retryAfter: 1`: the database stayed locked while the account was looked up (the renewal of a waiting signup is best effort and never fails the request). `timeout`.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Send a password reset link
      description: |-
        Sends a password reset link, valid for one hour, which opens the page `/reset-password`. The answer is 202 `accepted` whatever the address. A link goes only to an active account, at most once every 5 minutes per address. A Google-only account sets its first password this way.

        Password recovery has the strictest limits of the API, all counted for the whole server: 3 requests per hour (`AUTH_FORGOT_PER_HOUR`) and 10 per 24 hours (`AUTH_FORGOT_PER_DAY`) per client (an IPv4 address or an IPv6 /64), 3 times those numbers per IPv6 /48, on top of the `auth` limit (20 per 10 minutes) and of the one mail per address every 5 minutes. Neither the 202 nor the 429 tells whether an account uses the address. Setting the new password has its own limit (`auth_reset`).

        **Limits:** `auth`, `auth_forgot`, then `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Accepted (whatever the address).
          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`: the `auth`, `auth_forgot` or `auth_forgot_day` limit (whatever the address).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` with `retryAfter: 1`: the database stayed locked. `timeout`.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Set a new password with a reset link's token
      description: |-
        Sets a new password with the token of a reset link (the page `/reset-password` does the same). The new password follows the rules of registration.

        The reset revokes every session and cancels a pending e-mail change; the other reset links of the account stop working; the address counts as confirmed (the link proved it); the owner gets a mail. Two-step verification is not touched. After a password hash queue error or a 503, the link stays valid.

        **Limits:** `auth`, then `auth_reset` (10 per hour per client, 30 per IPv6 /48, shared with the page: each attempt hashes a password).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: The password is changed and every session is signed out.
          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`: the link is invalid, used or expired, or was mailed to an address the account no longer has. `weak_password` (with `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`: the `auth` or `auth_reset` limit, or the password hash queue.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: the password hash queue, or the database stayed locked (`retryAfter: 1`, nothing changed). `timeout`.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Start a Google sign-in
      description: |-
        Starts a Google sign-in attempt for the PKCE challenge and the port of the game's listener on `127.0.0.1`. The answer gives the Google URL to open in the system browser and the attempt's `state`; the attempt is valid for 10 minutes.

        `authUrl` holds `client_id`, `redirect_uri` (built from `redirectPort` and the server's origin tag), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 of the server's own verifier for Google) with `code_challenge_method=S256`, and `prompt=select_account`. The game checks it before it opens it (see the tag description).

        **Limit:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: The attempt.
          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`: Google sign-in is not offered on this server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the `sso_start` limit.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the database stayed locked), `timeout`.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Hand Google's answer to the server
      description: |-
        Hands the `code` and `state` that Google sent to the game's listener to the server, with the attempt id and the PKCE verifier. An attempt id is useless without the verifier, and the code is useless without the server's own PKCE verifier and client secret.

        The server checks the attempt (unknown, used or expired: 410), then the verifier (a wrong one leaves the attempt usable), then uses the attempt once, checks `state` and `iss`, exchanges the code with Google and verifies the ID token. The answer (200) is one of:

        - `{ token, expiresAt, user }`: signed in to the linked account;
        - `{ mfaRequired, mfaToken, expiresIn }`: continue with `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: a new account; continue with `POST /auth/sso/complete` within 10 minutes. `suggestedUsername` comes from the Google name or the address, and is `""` when nothing fits;
        - `{ needsPassword, linkTicket, username, expiresIn }`: the account with that address has a password; continue with `POST /auth/sso/google/link`. Nothing is linked yet.

        **Limit:** `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: A session, a second step, or the next step of a first Google sign-in.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Signed in to the linked account
                  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: Two-step verification is on
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: A new player
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: An account with a password uses the address
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: the verifier does not match the attempt''s challenge (the attempt stays usable). `sso_email_unverified`: Google has not confirmed the address. `registration_closed`. `account_disabled`. For a linked account only: `banned` (with `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: Google sign-in is not offered on this server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: an active account without a password uses the address (it is not named).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: an unknown, used or expired attempt.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the `sso_finish` limit.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the database stayed locked), `timeout`.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Link Google to an existing account with its password
      description: |-
        After `finish` answered `needsPassword`: links Google to the existing account with that account's password, typed in the game. The answer (200) is a session (the link is stored and the player signed in), or, when two-step verification is on, `{ mfaRequired, mfaToken, expiresIn }`: continue with `POST /auth/login/mfa`, and the link is stored only when a code passes there.

        A wrong password leaves the ticket usable, for 5 tries in all. A try is taken just before the password is checked: a 429 `too_many_attempts` or a 428 `pow_required` takes none, while a refusal of the password hash queue (503 `server_busy`, 429 `rate_limited`) has taken one and counts as a failure of the account. The failure delay is the same counter as `POST /auth/login`.

        **Limit:** `auth` (with its IPv6 /48 count). **Proof of work:** as for `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: A session, or the second step of the sign-in.
          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`: a wrong password; the ticket stays, for 5 tries in all.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (with `until`), only after a correct password.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: Google sign-in is not offered on this server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: the Google account was linked to another account meanwhile.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: an unknown, used or expired ticket, the 5th wrong password, an account whose status or address changed since `finish`, or one whose status, address, password or two-step verification changed while the link was being stored. Start again from the game.'
        '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` (the account''s failure delay), or `rate_limited` (the `auth` limit, the password hash queue).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: the password hash queue, or the database stayed locked (`retryAfter: 1`, nothing was linked). `timeout`.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Create the account of a first Google sign-in
      description: |-
        After `finish` answered `needsUsername`: creates the account with the chosen username (the rules of registration apply) and signs it in. The account has no password: `hasPassword` is `false`, and "Forgot password" (`POST /auth/password/forgot`) gives it one.

        **Limit:** `auth` (with its IPv6 /48 count).
      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: The account is created and signed in.
          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`: Google sign-in is not offered on this server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (an account has the username, or a waiting signup of another address holds it), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: an unknown, used or expired ticket.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the `auth` limit.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the database stayed locked), `timeout`.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: List the signed-in devices
      description: |-
        The account's active sessions (signed-in devices), the most recently used first. `current` marks the session making the request. `lastSeenAt` is updated at most every 5 minutes; `expiresAt` is the absolute end, and the idle limit may end the session sooner.

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: The active sessions.
          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`: the `sessions` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the database stayed locked), `timeout`.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Sign out one device
      description: |-
        Signs out one session of the account; the current one may be signed out too. The WebSocket opened with it is closed. No body (or `{}`).

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: The session is signed out.
          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`: a body other than `{}`, or an `id` that is not valid URL encoding; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: no active session with this id on this account.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the `sessions` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the database stayed locked), `timeout`.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Get the account, its ratings and active sanctions
      description: |-
        The account as its player sees it: the account view (also the `user` of every sign-in answer), one rating record per category the player has played rated games in, the active sanctions and the ban. The anti-cheat's integrity level is never shown.

        **Limit:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: The account.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountMe'
              example:
                user:
                  id: 1
                  username: alice
                  email: alice@example.org
                  emailVerified: true
                  mfaEnabled: true
                  googleLinked: false
                  hasPassword: true
                  acceptChallenges: all
                  createdAt: 1790882871478
                  lastLoginAt: 1790882902200
                  pendingEmail: alice.new@example.org
                ratings:
                  - category: '3+2'
                    rating: 1510
                    games: 2
                    wins: 1
                    draws: 1
                    losses: 0
                    peak: 1510
                    provisional: true
                sanctions: []
                ban: null
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`unauthorized`, or `invalid_token` (also when the account was deleted).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the `account` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the database stayed locked), `timeout`.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Accept or refuse direct challenges
      description: |-
        Sets whether other players may challenge this player by name. With `none`, direct challenges are refused: the challenger is told that the player is unavailable. No re-authentication.

        **Limit:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: The preferences now in force.
          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`: the `account` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the database stayed locked), `timeout`.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Change the password
      description: |-
        Changes the password. The current password is needed, but no second factor, even with two-step verification on. The new password follows the rules of registration (checked after the current password).

        The change revokes every other session (this one stays signed in), cancels a pending e-mail change, makes the account's password reset links stop working, and sends the owner a mail.

        **Re-authentication:** the password only. **Limits:** `reauth`, then `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: The password is changed.
          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` (a Google-only account), `weak_password` (with `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: a wrong current password, or a password reset or change that landed while the request was checked.'
        '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` (failed re-authentications of the account), or `rate_limited` (the `reauth` or `reauth_user` limit, the account budget, the password hash queue).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the password hash queue, or the database stayed locked), `timeout`.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Start enabling two-step verification
      description: |-
        Stores a new pending authenticator secret, which replaces any earlier pending one, and returns it for the authenticator app (as text and as an `otpauth://` URI to show as a QR code). Two-step verification is not on yet: `POST /account/mfa/totp/enable` turns it on with a code of this secret.

        **Re-authentication:** the password only. **Limits:** `reauth`, then `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: The pending secret.
          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` (a Google-only account), `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` (checked before the password).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (failed re-authentications of the account), or `rate_limited` (the `reauth` or `reauth_user` limit, the account budget, the password hash queue).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the password hash queue, or the database stayed locked), `timeout`.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Finish enabling two-step verification and get recovery codes
      description: |-
        Turns two-step verification on with a code of the pending secret (exactly 6 digits). No password is asked here; it was given at setup. The answer holds 10 recovery codes, shown this once.

        **Limits:** `reauth`, then `reauth_user`; a wrong code counts in the account's re-authentication failures.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Two-step verification is on.
          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` is not exactly 6 digits, or the body breaks the schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: a wrong code (check the time of the device).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: no pending secret (call `POST /account/mfa/totp/setup` first).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (failed re-authentications of the account), or `rate_limited` (the `reauth` or `reauth_user` limit, the account budget).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the database stayed locked), `timeout`.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Turn two-step verification off
      description: |-
        Turns two-step verification off. The secret and the recovery codes are deleted, and the owner gets a mail.

        **Re-authentication:** the password and an authenticator code or a recovery code (one of `code` or `recoveryCode` is required). **Limits:** `reauth`, then `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: Two-step verification is off.
          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` (a Google-only account), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (neither `code` nor `recoveryCode`, checked before the password), `invalid_password`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_not_enabled`.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (failed re-authentications, or too many codes tried on this account), or `rate_limited` (the `reauth` or `reauth_user` limit, the account budget, the password hash queue).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the password hash queue, or the database stayed locked), `timeout`.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Replace the recovery codes
      description: |-
        Replaces the recovery codes with 10 new ones; the old ones stop working.

        **Re-authentication:** the password and an authenticator code in `code` (a recovery code is refused with 403 `invalid_code`). **Limits:** `reauth`, then `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: The new recovery codes, shown this once.
          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` (a Google-only account), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (also for a recovery 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` (failed re-authentications, or too many codes tried on this account), or `rate_limited` (the `reauth` or `reauth_user` limit, the account budget, the password hash queue).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the password hash queue, or the database stayed locked), `timeout`.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Change the e-mail address
      description: |-
        Changes the account's e-mail address. `newEmail` is trimmed and lower-cased, then checked like a registration address.

        **With e-mail confirmation** (the default): 202 `verification_sent`. A link valid for 24 hours goes to the new address; it opens `/confirm-email-change`, and the address changes only when the player presses that page's button. Until then, `GET /account/me` shows the new address as `pendingEmail`. A new request replaces the pending one; a password change or reset cancels it. At most one link goes to a given new address every 5 minutes, whoever asks (a request for the change already pending keeps the link sent earlier, which stays valid); the answer is the same. The current address gets a notice that a change to a masked address (`a***@example.org`) was requested. The answer and `pendingEmail` are the same when another account already uses the new address: no link is sent then, so that change never completes, and the owner of that address gets a notice (at most one per hour) instead.

        When the link is confirmed, the address changes and counts as confirmed, the devices stay signed in, the links sent earlier (confirmation, password reset, other changes) stop working, and the former address is told, with the new one masked.

        **Without e-mail confirmation** (`REQUIRE_EMAIL_VERIFICATION=false`): the address changes at once (200 `email_changed`), and the former address is told. If another account uses the address, the answer is 409 `email_taken`, and the owner of that address gets the notice.

        **Re-authentication:** the password and, with two-step verification, an authenticator code or a recovery code. `invalid_email` and `same_email` are checked before the password, so they count no failure. **Limits:** `reauth`, then `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: The address changed at once (no e-mail confirmation on this server).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - email
                properties:
                  status:
                    const: email_changed
                  email:
                    type: string
                    format: email
                    description: The new address, as stored.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: A confirmation link was sent to the new address (or the address belongs to another account; the answer does not tell).
          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` (the account''s current address), `password_not_set` (a Google-only account), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (also when a password change or reset overtook the request: no link is sent), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: only without e-mail confirmation.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (failed re-authentications, or too many codes tried on this account), or `rate_limited` (the `reauth` or `reauth_user` limit, the account budget, the password hash queue).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: the password hash queue, or the database stayed locked (`retryAfter: 1`: nothing changed, and the same request can be sent again). `timeout`.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Download the account's data
      description: |-
        Everything the server keeps about the account, as one JSON file to save (`format` `scacelith-account-export`, `version` 1). The export records a security event (`account_exported`). The `notes` array of the document tells the player in plain English what is left out.

        **Never in the export:** the password hash, the two-step verification secret and the recovery codes; any session or link token, or a hash of one; the anti-cheat's data (integrity level and score, anomalies, the analysis of the games, the weight of a report); the reports other players made about the player; the identities of moderators; other players' private data (opponents appear by their public name and rating, nothing tells whether another player was sanctioned, and no IP address that may be another person's is included).

        **Re-authentication:** the password and, with two-step verification, an authenticator code or a recovery code. **Limits:** `account_export` (5 per hour per player, for the whole server; every attempt counts, failed ones included; checked first), then `reauth` and `reauth_user`. **Time limit:** 60 seconds.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: The export document, as an attachment.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Characters other than letters, digits, `_`, `.` and `-` in the user name become `_`.'
              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` (a Google-only account), `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` (failed re-authentications, or too many codes tried on this account), or `rate_limited` (the `account_export`, `reauth` or `reauth_user` limit, the account budget, the password hash queue).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (the database stayed locked, with a `Retry-After: 1` header), `server_busy` (the password hash queue), `timeout` (60 seconds).'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Delete the account
      description: |-
        Deletes the account; this cannot be undone.

        - Every session is revoked at once: the token gets 401 `invalid_token` from then on.
        - The user name becomes `deleted#<id>`, in the account and in every game record.
        - These are erased: the e-mail address, the password hash, the two-step secret and recovery codes, the sessions and link tokens, the Google link, the anti-cheat's integrity record, and the IP addresses stored with security events.
        - Ratings and games are kept. Games stay readable under the anonymous name, and `GET /players/{username}` answers 404 for the former name.

        **Re-authentication:** the password and, with two-step verification, an authenticator code or a recovery code. **Limits:** `reauth`, then `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: The account is deleted.
          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` (a Google-only account), `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` (failed re-authentications, or too many codes tried on this account), or `rate_limited` (the `reauth` or `reauth_user` limit, the account budget, the password hash queue).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the password hash queue, or the database stayed locked), `timeout`.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: List the player's games, filtered and paged
      description: |-
        The signed-in player's games, newest first, filtered and paged, with the number of games matching the filter. Every query parameter is optional, and an empty value counts as absent.

        Paging: pass the previous page's `next` as `before`; `next` is `null` on the last page. `total` counts the games matching the filter, across all pages.

        **Limit:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: An official category id (`3+2`, or `3%2B2`), or `custom` for every game with another time control.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Only rated (`true`) or casual (`false`) games.
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Only the games won, lost or drawn, from the player's side. Aborted games appear only without this filter.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: One page of the history.
          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` is not a game id), `invalid_limit`, or `invalid_filter` (`category`, `rated` or `result`), each with `field` naming the parameter.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the `account_games` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (the database stayed locked; `retryAfter: 1` in the body, no `Retry-After` header), `server_busy` (the session lookup found the database locked), `timeout`.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Get a game record with its moves and clocks
      description: |-
        One game record, with its moves and clocks. Without a token, or with the token of a player who did not play the game, the answer is the public one. When the token's player played the game, the answer adds `you` and `reportable`.

        **Limit:** `public_read` (per player with a token, per client without).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: The game record.
          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`: not a positive integer of at most 16 digits (below 2^53); `invalid_request`: not valid URL encoding.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: a token was sent and it is not valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: no such game.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the `public_read` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (the database stayed locked; `retryAfter: 1` in the body, no `Retry-After` header), `server_busy` (the session lookup found the database locked), `timeout`.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Download a game as a PGN file
      description: |-
        The same game as a PGN file: one game with `\n` line endings, and move text in lines under 80 columns. The answer is the same with or without a token.

        Tags, in this order: `Event` (`<SERVER_NAME> rated <category>` or `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (the UTC start date), `Round`, `White`, `Black`, `Result` (`*` for an aborted game), `UTCDate` and `UTCTime` (the start), `WhiteElo` and `BlackElo` (the ratings at the start, or `-`), `WhiteRatingDiff` and `BlackRatingDiff` (the changes, such as `+10` and `-10`, in every rated game, `+0` when the rating rules leave the rating where it was; a casual, custom or aborted game has neither), `TimeControl` (in seconds), `Termination` (a PGN standard value: `normal`; `time forfeit`, a flag fall, also when it ends in a draw; `abandoned`; `rules infraction`, a second illegal move or a fair-play forfeit; `unterminated`, an aborted game), `PlyCount`, `ScacelithGameId` (the decimal id).

        Each move carries `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: the mover's clock after the move and the time charged for it, in tenths of a second (truncated). A value is left out when the record lacks it. After the last move, the end reason follows in words, then the result.

        **Limit:** `public_read` (per player with a token, per client without).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: The PGN file.
          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: 'The PGN text, in UTF-8 (`Content-Type: application/x-chess-pgn; charset=utf-8`).'
              example: |
                [Event "Scacelith rated 3+2"]
                [Site "caissa.scacelith.com"]
                [Date "2026.09.28"]
                [Round "-"]
                [White "alice"]
                [Black "bob"]
                [Result "1-0"]
                [UTCDate "2026.09.28"]
                [UTCTime "18:30:05"]
                [WhiteElo "1500"]
                [BlackElo "1520"]
                [WhiteRatingDiff "+10"]
                [BlackRatingDiff "-10"]
                [TimeControl "180+2"]
                [Termination "normal"]
                [PlyCount "2"]
                [ScacelithGameId "4100000000001"]

                1. e4 {[%clk 0:03:00.0] [%emt 0:00:00.0]} 1... e5 {[%clk 0:03:00.3]
                [%emt 0:00:01.7]} {Resignation} 1-0
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_game_id`: not a positive integer of at most 16 digits (below 2^53); `invalid_request`: not valid URL encoding.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: a token was sent and it is not valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: no such game.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the `public_read` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: among others, the stored moves cannot be replayed to the stored ending (the server logs it).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (the database stayed locked; `retryAfter: 1` in the body, no `Retry-After` header), `server_busy` (the session lookup found the database locked), `timeout`.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Download a game of this server as an animated GIF
      description: |-
        The game as an animated GIF. The names and ratings are those of the game record (the ratings at the start, a deleted account as `deleted#<id>`), and so are the result and the ending (`Resignation`, `Loss on time`...).

        The checks come in this order: `gif_disabled`, the picture options (`size`, `orientation`, `delay`, `coords`), the game id, the game, its length.

        **Session required:** the quotas count per account. **Limits:** `gif` on every request; `gif_user_min`, `gif_user_hour`, `gif_ip_min` and `gif_ip_hour` only when the GIF has to be made (see the tag description). **Time limit:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 seconds (45 seconds by default).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: The picture size, `small` (32 px squares), `medium` (48 px) or `large` (72 px).
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: The side at the bottom of the board.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Milliseconds per move (1 to 6 decimal digits).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Whether the file letters and rank numbers are drawn around the board (`1`) or not (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` with `field` (`size`, `orientation`, `delay` or `coords`): a value outside the allowed ones. `invalid_game_id`. `invalid_request`: not valid URL encoding.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: no such game. `gif_disabled`: the server turned GIFs off (`GIF_ENABLED=false`; the `gif` token is given back).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the `gif` limit, a render limit, or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: the GIF could not be made (the server logs why). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` with `retryAfter` (3 to 10 seconds) and `Retry-After`: the rendering queue is full, or the GIF waited `GIF_QUEUE_TIMEOUT_MS` (10 seconds) for a free thread; every limit token the request took is given back. `busy`: the database stayed locked (`retryAfter: 1` in the body only). `timeout`.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Make an animated GIF of any game sent as PGN
      description: |-
        The same picture as `GET /games/{id}/gif` for any game sent as PGN text: a game saved by the game, an export of another site, a game typed by hand. Only the first game of the text is used.

        - The PGN reader takes what the game's own reader takes: every PGN that this server writes and the usual exports of other sites (comments, variations, NAGs and clock annotations are skipped; move numbers and SAN read leniently; a `FEN` tag gives the start position unless `SetUp` is `"0"`).
        - The names and ratings come from the `White`, `Black`, `WhiteElo` and `BlackElo` tags. Accented letters lose their accents, other characters outside printable ASCII become `?`, and long names are cut (48 characters). The result comes from the `Result` tag, else from the end of the move text. The `Termination` tag is shown unless it is `normal`: the final position then tells the story (checkmate, stalemate).
        - This endpoint checks its body itself: an unknown field or a missing or non-string `pgn` answers 400 `invalid_request` with `field`; a wrong option answers 400 `invalid_option` (`delayMs` must be a JSON number, `coords` a boolean; `null` is refused).

        **Session required:** the quotas count per account. **Limits:** as for `GET /games/{id}/gif`. **Body limit:** 135,168 bytes, whatever `HTTP_BODY_LIMIT` (the PGN as a JSON string, its escapes included). **Time limit:** 45 seconds by default.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: A short game typed in, without coordinates
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: A PGN file with every option
                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` with `field` (an unknown field, or `pgn` missing or not a string; without `field` when the body is not an object); `invalid_json`; `invalid_option` with `field` (`size`, `orientation`, `delayMs` or `coords`); `invalid_pgn` with `line` and `column` (from 1, columns in characters) and the reader''s `message`: an illegal or ambiguous move, a broken tag, an unknown variant, more than 65,536 bytes.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: the server turned GIFs off (`GIF_ENABLED=false`; the `gif` token is given back).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: a body above 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`: the `gif` limit, a render limit, or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: the GIF could not be made (the server logs why). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` with `retryAfter` (3 to 10 seconds) and `Retry-After`: the rendering queue is full, or the GIF waited too long for a free thread; every limit token the request took is given back. `timeout`.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Get a player's public profile
      description: |-
        A player's public profile: ratings in the official categories (in the server's order) and game counts. `games.total` counts every stored game, casual and aborted ones included; `games.rated`, `wins`, `draws` and `losses` are summed over the rating records, so rated games only. The answer is the same with or without a token.

        **Limit:** `public_read` (per player with a token, per client without).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: The profile.
          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`: not 2 to 24 characters of `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: a token was sent and it is not valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: no such player, or a deleted account.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the `public_read` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (the database stayed locked; `retryAfter: 1` in the body, no `Retry-After` header), `server_busy` (the session lookup found the database locked), `timeout`.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: List a player's recent games
      description: |-
        The player's recent games, newest first, paged like the history but without filters or a total. `color` is the side of this player. `next` is the last game's id whenever the page is full, so the next page may be empty. The player is looked up first: an unknown player is a 404 whatever the query says.

        **Limit:** `public_read` (per player with a token, per client without).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: One page of the player's games.
          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` is not a game id), `invalid_limit` (these two without `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: a token was sent and it is not valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: no such player, or a deleted account.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the `public_read` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (the database stayed locked; `retryAfter: 1` in the body, no `Retry-After` header), `server_busy` (the session lookup found the database locked), `timeout`.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Get the top players of a category
      description: |-
        The top 100 rated records of an official category with at least `minGames` (`PROVISIONAL_GAMES`) counted games, deleted accounts and confirmed cheaters left out. The server computes each board again at most every 10 seconds; `updatedAt` says when.

        **Limits:** only the per-address layer.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: An official category id (`3+2`, or `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: How many players, 1 to 100. A larger number of up to 3 digits counts as 100; an empty value counts as absent.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: The board.
          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` (missing, or not an official category), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: the per-address layer.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (the database stayed locked; `retryAfter: 1` in the body, no `Retry-After` header), `timeout`.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Report the opponent of a recent game
      description: |-
        Reports the opponent of one of the player's own games that ended within the last 7 days. A report never changes a rating, a sanction or an integrity level by itself. It raises the review priority that moderators see, and, except for `abuse`, it asks for the engine analysis of the game.

        A report of the same opponent for the same game gets the same answer and changes nothing; the answer never tells anything about the reported account. `GET /games/{id}` tells the game's players beforehand whether a report would be taken (`reportable`).

        This endpoint checks its body itself, in the order `gameId`, `reported`, `category`, `comment`: a failure answers 400 `invalid_request` without `field`. Fields it does not know are ignored.

        **Limits:** `reports` (per player), then `REPORTS_PER_DAY` (5) reports per player in 24 hours (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: The report is received (or was already filed).
          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` (without `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: not the opponent of the reporter in a game that ended within the last 7 days.'
        '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`, with a `Retry-After` header): `REPORTS_PER_DAY` reports in the last 24 hours. `rate_limited`: the `reports` limit or the account budget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (the session lookup found the database locked), `timeout`.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: The official server (pages live at the root, outside `/api/v1`).
      - url: https://{host}:{port}
        description: Any Scacelith server (pages live at the root, outside `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: The server's public host name (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: The public API port (`PUBLIC_API_PORT`, else `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Show the e-mail confirmation page
      description: |-
        The page of an e-mail confirmation link (registration, or a confirmation e-mail sent again). It only shows a "Confirm my e-mail address" button, so that a mail scanner opening the link does not use it up; the button posts the form to `POST /verify-email`.

        **Limit:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: The page with its confirmation button.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: The link is invalid or expired (an HTML page).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: The `page` limit (an HTML page), or the per-address layer (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Confirm the e-mail address
      description: |-
        The form of the confirmation page. It confirms the address; for a new signup, it creates the account then, and the player can sign in.

        **Limit:** `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: The address is confirmed (and the account of a new signup created).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: The link is invalid, used or expired, or the form is invalid (an HTML page).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: The link of a new signup whose username or address another account took in the meantime; no account is created (an HTML page).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: The `auth` limit (an HTML page), or the per-address layer (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'The database stayed locked (`Retry-After: 1`): nothing changed and the link still works. Also the handler timeout.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: The official server (pages live at the root, outside `/api/v1`).
      - url: https://{host}:{port}
        description: Any Scacelith server (pages live at the root, outside `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: The server's public host name (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: The public API port (`PUBLIC_API_PORT`, else `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Show the password reset form
      description: |-
        The page of a password reset link: the new password form (the password twice). The form posts to `POST /reset-password`.

        **Limit:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: The new password form.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: The link is invalid (also when it was mailed to an address the account no longer has).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: The `page` limit (an HTML page), or the per-address layer (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Set a new password from the reset form
      description: |-
        The form of the reset page; it does what `POST /auth/password/reset` does: every device is signed out, a pending e-mail change is cancelled, the other reset links stop working, the address counts as confirmed and the owner gets a mail.

        The link is checked first, then that both passwords are the same, then the password rules. When the server is busy, the form comes back with `Retry-After` and the link stays valid.

        **Limits:** `auth`, then `auth_reset` (shared with `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: The password is changed, and every device signed out.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: The form again with the error (the passwords differ, a weak password), the link is invalid, or the form is invalid (an HTML page).
        '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: The `auth` or `auth_reset` limit (an HTML page), the form again with `Retry-After` when this client has too many password hashes waiting (the link stays valid), or the per-address layer (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: The form again with `Retry-After` when the server is busy (the password hash queue, or the database stayed locked); the link stays valid. Also the handler timeout.
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: The official server (pages live at the root, outside `/api/v1`).
      - url: https://{host}:{port}
        description: Any Scacelith server (pages live at the root, outside `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: The server's public host name (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: The public API port (`PUBLIC_API_PORT`, else `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Show the e-mail change confirmation page
      description: |-
        The page of an e-mail change link: it shows the new address and the account's name, with a "Use this e-mail address" button that posts the form to `POST /confirm-email-change`.

        **Limit:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: The page with the new address and its confirmation button.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: The link is invalid or expired (also when the account's address changed since the request).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: The `page` limit (an HTML page), or the per-address layer (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Confirm the new e-mail address
      description: |-
        The form of the e-mail change page. The address changes and counts as confirmed; the devices stay signed in; the links sent earlier (confirmation, password reset, other changes) stop working; the former address is told, with the new one masked.

        **Limit:** `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: The address is changed.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: The link is invalid, used or expired, or the form is invalid (an HTML page).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Another account took the address in the meantime (an HTML page).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: The `auth` limit (an HTML page), or the per-address layer (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'The database stayed locked (`Retry-After: 1`): nothing changed and the link still works. Also the handler timeout.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: The official server, at the root.
      - url: https://caissa.scacelith.com/api/v1
        description: The official server, under `/api/v1`.
      - url: https://{host}:{port}
        description: Any Scacelith server, at the root.
        variables:
          host:
            default: caissa.scacelith.com
            description: The server's public host name (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: The public API port (`PUBLIC_API_PORT`, else `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Any Scacelith server, under `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: The server's public host name (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: The public API port (`PUBLIC_API_PORT`, else `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Check that the process runs
      description: |-
        Answers 200 `ok` while the process runs. `HEAD` works too. Any other method answers 405 `method_not_allowed` with `Allow: GET, HEAD` (`OPTIONS` included).

        **Limits:** only the per-address layer.
      security: []
      responses:
        '200':
          description: The process runs.
          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`: the per-address layer.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: The official server, at the root.
      - url: https://caissa.scacelith.com/api/v1
        description: The official server, under `/api/v1`.
      - url: https://{host}:{port}
        description: Any Scacelith server, at the root.
        variables:
          host:
            default: caissa.scacelith.com
            description: The server's public host name (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: The public API port (`PUBLIC_API_PORT`, else `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Any Scacelith server, under `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: The server's public host name (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: The public API port (`PUBLIC_API_PORT`, else `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Check that the server accepts players
      description: |-
        Answers 200 `ready` when the server accepts players, and 503 `not_ready` while it starts or stops. `HEAD` works too. Any other method answers 405 `method_not_allowed` with `Allow: GET, HEAD` (`OPTIONS` included).

        **Limits:** only the per-address layer.
      security: []
      responses:
        '200':
          description: The server accepts players.
          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`: the per-address layer.'
        '503':
          description: The server is starting or stopping.
          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: |-
        A session token (`sct_` followed by 43 base64url characters) from a sign-in answer, in the `Authorization` header: `Authorization: Bearer <token>`. The prefix is exactly `Bearer` (case-sensitive) and one space; a value that does not have it, or a token that is not 1 to 512 printable ASCII characters, answers 401 `invalid_token` like any other invalid token. An empty header counts as no header.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: The game id, a positive decimal integer of at most 16 digits without leading zeros, below 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: The session id, as `GET /auth/sessions` gives it, written without leading zeros (`03` is not session 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: The player's user name, without regard to case. The server takes any name that is 2 to 24 characters of `[A-Za-z0-9_.-]` (the widest rule it ever allowed).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: A game id; only older games are listed. Pass the previous page's `next`. An empty value counts as absent.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: The page size, 1 to 50. A larger number of up to 3 digits counts as 50; an empty value counts as absent.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: The token of the e-mail link (43 base64url characters). A missing or wrong one shows the "link invalid or expired" page.
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Every answer of the server forbids caching.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Seconds to wait before trying again; the same value as `retryAfter` in the body.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: The bearer challenge, with `error="invalid_token"` for an invalid token.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: The server closes the connection after this answer.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (with `field` when a field of the body is at fault) or `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: A field breaks the schema
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: The body is not JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: A weak password
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: A PGN that cannot be read
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (no `Authorization` header) or `invalid_token` (the token is malformed, expired, revoked, or belongs to a deleted account).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: No session
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: An invalid token
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: The request is refused.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: A wrong password at re-authentication
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: A banned account
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found`, or a feature that this server turned off.'
      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`: the body did not arrive within 10 seconds. The server closes the connection.'
      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: The request conflicts with the state of the server.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: username_taken
            message: This username is already taken.
    Gone:
      description: '`sso_expired`: the Google sign-in attempt or ticket is unknown, used or expired; start again from the game.'
      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`: the body exceeds `HTTP_BODY_LIMIT` (16,384 bytes by default). The server closes the connection.'
      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`: the request target exceeds 4096 characters.'
      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`: the body is not `application/json`, or its charset is not 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`: the game has more than `GIF_MAX_PLIES` (600) half-moves.'
      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`: solve the `pow` challenge and send the same request again with `pow: { challenge, nonce }`. `reason` says why: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` or `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` (a rate limit, the account budget, or the password hash queue) or `too_many_attempts` (a throttle of the account), with `retryAfter` and a `Retry-After` header.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rateLimited:
              summary: A rate limit
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Too many failures on this account
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: an unexpected failure. The server logs it.'
      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`: a wrong `state` or `iss`, or Google refused the code or sent an ID token that does not verify. It never carries Google''s text.'
      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` or `timeout`: try again after `retryAfter` when it is given.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: The server is busy
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: The database stayed locked (a read)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: The server took too long
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: The animated GIF, as an attachment.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` for a game of this server, `attachment; filename="scacelith-game.gif"` for a PGN sent with `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: The size of the file in bytes.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: An HTML page.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: An HTML page that explains the refusal.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: The form did not arrive within 10 seconds (an HTML page). The server closes the connection.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: An HTML page that explains the conflict.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: The form exceeds `HTTP_BODY_LIMIT` (an HTML page). The server closes the connection.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: The body is neither a form (`application/x-www-form-urlencoded`) nor JSON, or its charset is not UTF-8 (an HTML page).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: A rate limit (an HTML page), with `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: An unexpected failure (an HTML page titled "Server error"). The server logs it.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: The server is busy (the database stayed locked, with `Retry-After`) or took too long (an HTML page titled "Server error").
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
  schemas:
    Error:
      type: object
      description: The one shape of every error answer of the JSON API.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: The `snake_case` error code. A client chooses what to show from it.
          examples:
            - rate_limited
        message:
          type: string
          description: An English sentence, meant for logs and as a fallback.
        retryAfter:
          type: integer
          minimum: 1
          description: Seconds to wait before trying again, present only on refusals that end with time. The answer then also carries a `Retry-After` header with the same value, except the 503 `busy` of the history, game, player and leaderboard reads.
        field:
          type: string
          description: The field or query parameter at fault (`invalid_request`, `invalid_option`, and `invalid_cursor`, `invalid_limit` and `invalid_filter` of `GET /account/games`). A nested field is dotted (`pow.nonce`).
        reason:
          type: string
          description: 'Why: the rule a `weak_password` breaks, or why a proof of work was asked for or refused (`pow_required`).'
          enum:
            - too_short
            - too_long
            - contains_username
            - contains_email
            - too_common
            - required
            - malformed
            - signature
            - endpoint
            - network
            - expired
            - bits
            - work
            - replayed
        pow:
          $ref: '#/components/schemas/PowChallenge'
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: '`banned` only: the end of the ban (epoch ms), `null` for a permanent ban.'
        line:
          type: integer
          minimum: 1
          description: '`invalid_pgn` only: the line of the error, from 1.'
        column:
          type: integer
          minimum: 1
          description: '`invalid_pgn` only: the column of the error, from 1, in characters.'
    PowChallenge:
      type: object
      description: A proof-of-work challenge (the `pow` of a 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: The signed challenge, to send back as it is.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: The number of leading zero bits that `SHA-256(challenge + ":" + nonce)` must have.
        expiresAt:
          type: integer
          format: int64
          description: When the challenge expires (epoch ms), 2 minutes after it was issued.
    PowAnswer:
      type: object
      description: The answer to a proof-of-work challenge.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: The `challenge` of the 428 answer, as it was given.
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: A decimal string of at most 20 digits such that `SHA-256(challenge + ":" + nonce)` starts with `bits` zero bits.
    ServerInfo:
      type: object
      description: What a client needs before it signs in or connects.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: The server's name (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: A UUID that the database gets on its first start. It stays the same across restarts. It is `null` when the database cannot give it.
        motd:
          type: string
          description: The message of the day (`SERVER_MOTD`), possibly empty.
        protocol:
          type: object
          description: The WebSocket protocol (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: The oldest protocol version the server speaks.
            max:
              type: integer
              minimum: 1
              description: The newest protocol version the server speaks.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: The fingerprint of the protocol schema (first 4 bytes of the SHA-256 of its canonical form, as an unsigned integer); informational.
            subprotocol:
              type: string
              description: The WebSocket subprotocol token (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: The WebSocket port that players use (`PUBLIC_WS_PORT`, else `WS_PORT`, else `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: The path of the WebSocket.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Whether new accounts can be created.
        emailVerification:
          type: boolean
          description: Whether new accounts confirm their address (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
          properties:
            google:
              type: boolean
              description: Whether Google sign-in is offered.
        mfa:
          type: boolean
          const: true
          description: Two-step verification is always available.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: The proof-of-work bits that registration needs (0 for none).
        categories:
          type: array
          description: The official (rated) time controls (`RATED_CATEGORIES`), in the server's order. Any other time control is `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: The rules a client can check before it sends a form.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: The shortest user name (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: The longest user name (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: The regular expression a new user name must match.
            passwordMinLength:
              type: integer
              description: The shortest password, in characters (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: The longest password, in bytes of UTF-8.
            customTimeControls:
              type: boolean
              description: Whether challenges and private games may use custom time controls.
            reportsPerDay:
              type: integer
              description: How many reports a player may file per 24 hours (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: The largest WebSocket message a client may send, in bytes.
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 97842216
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: An official time control.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: The category id, minutes and increment seconds (`3+2`).
        baseSec:
          type: number
          minimum: 0
          description: The base time, in seconds.
        incSec:
          type: number
          minimum: 0
          description: The increment per move, in seconds.
    AccountView:
      type: object
      description: The account as its player sees it (`user` of the sign-in answers and of `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: The account id.
        username:
          type: string
          description: The user name.
        email:
          type: string
          format: email
          description: The e-mail address.
        emailVerified:
          type: boolean
          description: Whether the address is confirmed.
        mfaEnabled:
          type: boolean
          description: Whether two-step verification is on.
        googleLinked:
          type: boolean
          description: Whether a Google account is linked.
        hasPassword:
          type: boolean
          description: '`false` for an account created through Google that has not set a password yet.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Whether direct challenges by name are accepted (see `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: When the account was created (epoch ms).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: The last sign-in (epoch ms), or `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: The new address of an e-mail change waiting for its link, or `null`.
    SessionAnswer:
      type: object
      description: A new session.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: The session token, for the `Authorization` header and the WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: The absolute end of the session (epoch ms); the idle limit may end it sooner.
        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: The second step of a sign-in with two-step verification; continue with `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: The token of the step, for `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Seconds left to complete the step.
    LoginAnswer:
      description: A session, or the second step of a sign-in with two-step verification.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: A first Google sign-in; continue with `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: The ticket for `POST /auth/sso/complete`, valid for 10 minutes.
        suggestedUsername:
          type: string
          description: A user name made from the Google name or the address, or `""` when nothing fits.
    SsoNeedsPassword:
      type: object
      description: An account with a password uses the address; continue with `POST /auth/sso/google/link`. Nothing is linked yet.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: The ticket for `POST /auth/sso/google/link`.
        username:
          type: string
          description: The user name of that account (only someone who proved the address to Google sees it).
        expiresIn:
          type: integer
          const: 600
          description: Seconds the ticket stays valid.
    SsoFinishAnswer:
      description: The answer of `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: A Google sign-in attempt.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: The attempt, for `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: The Google URL to open in the system browser, once checked.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: The `state` that Google must send back.
        expiresIn:
          type: integer
          const: 600
          description: Seconds the attempt stays valid.
    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: The user name; then the server's rules (see the description).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: The e-mail address. Trimmed and stored in lower case.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: The password; then the password rules.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: The user name, or the e-mail address (any text with `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Optional. Shown in the list of signed-in devices (such as `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: The `mfaToken` of the sign-in answer (or of a Google sign-in's).
        code:
          type: string
          maxLength: 32
          description: Optional. A 6-digit authenticator code, or a recovery code.
        recoveryCode:
          type: string
          maxLength: 32
          description: Optional. A recovery code. One of `code` and `recoveryCode` is needed.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: The e-mail address.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: The `token` parameter of the reset link.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: The new password; the password rules of registration apply.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: The S256 PKCE challenge of the client's `codeVerifier` (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: The port of the client's listener on `127.0.0.1`.
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: The `attemptId` of `POST /auth/sso/google/start`.
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: The PKCE verifier; its SHA-256 must match the challenge given to `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: The `state` as Google sent it back.
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: The `code` as Google sent it back (printable ASCII without spaces).
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Optional. The `iss` as Google sent it back, when it did.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: The `linkTicket` of `finish`, valid for 10 minutes.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: The account's password.
        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: The `ssoTicket` of `finish`, valid for 10 minutes.
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: The user name; the rules of registration apply.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: The active sessions, the most recently used first.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: An active session (a signed-in device).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: The session id, for `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: The sign-in (epoch ms).
        lastSeenAt:
          type: integer
          format: int64
          description: The last use (epoch ms), updated at most every 5 minutes.
        expiresAt:
          type: integer
          format: int64
          description: The absolute end (epoch ms); the idle limit may end the session sooner.
        clientLabel:
          type:
            - string
            - 'null'
          description: The `clientLabel` of the sign-in, or `null` when it sent none.
        current:
          type: boolean
          description: Whether this is the session making the request.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: One record per category the player has played rated games in.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: The active sanctions.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` while banned, else `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: The rating record of one category.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: The category id.
        rating:
          type: integer
          description: The rating.
        games:
          type: integer
          minimum: 0
          description: The games played in the category (counted or not).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: The highest rating reached.
        provisional:
          type: boolean
          description: '`true` while the rating is unrated or has fewer than `PROVISIONAL_GAMES` counted games (the game shows it as `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: The kind of sanction.
        reason:
          type:
            - string
            - 'null'
          description: The reason given, if any.
        startsAt:
          type: integer
          format: int64
          description: The start (epoch ms).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: The end (epoch ms), `null` when the sanction is permanent.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: The end of the ban (epoch ms), `null` when permanent.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` to accept direct challenges by name, `none` to refuse them.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: The current password.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: The new password; the password rules of registration apply.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: The account's password.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: A code of the pending secret, exactly 6 digits.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: The account's password.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Optional; needed with two-step verification (or `recoveryCode`). An authenticator code, or a recovery code.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Optional. A recovery code (`xxxx-xxxx-xx`; case, spaces and dashes do not matter).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: The account's password.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: An authenticator code (a recovery code is refused).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: The new address; trimmed and lower-cased, then checked like a registration address.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: The account's password.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Optional; needed with two-step verification (or `recoveryCode`). An authenticator code, or a recovery code.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Optional. A recovery code.
    TotpSetup:
      type: object
      description: The pending secret for the authenticator app.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: The secret in base32, to type into the app.
        uri:
          type: string
          format: uri
          description: The `otpauth://totp/...` URI, to show as a QR code.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Seconds per step.
    RecoveryCodeList:
      type: array
      description: The 10 recovery codes, shown this once. Each one works once.
      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: One side of a game.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: The name in the game record (`deleted#<id>` for a deleted account).
        rating:
          type:
            - integer
            - 'null'
          description: The rating at the start, `null` when unknown.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: The rating after the game. A rated game always has it; `null` only for a game that does not count for the ratings (casual, custom, aborted).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: The change of rating, `0` when the rating rules leave the rating where it was (FIDE's zero score, an unrated opponent, the first games of an unrated player); `null` like `ratingAfter`.
    GameSummary:
      type: object
      description: The summary of a stored game.
      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: The game id.
        category:
          type: string
          description: The official category id (`3+2`), or `custom`.
        rated:
          type: boolean
          description: Whether the game counts for the ratings.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: The time control in seconds (`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` (see the game codes).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: The end reason code (see the game codes).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: The result, `*` for an aborted game.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: The number of half-moves.
        startedAt:
          type: integer
          format: int64
          description: The start (epoch ms).
        endedAt:
          type: integer
          format: int64
          description: The end (epoch ms).
    Termination:
      type: string
      description: The name of the end reason (see the game codes); `Unknown` for a code the protocol does not know.
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: A game summary from the side of one player.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: The side of the player.
    HistoryGameSummary:
      description: A game of the signed-in player's history.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: The base time in milliseconds.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: The increment in milliseconds.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: The outcome from the player's side.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: The games of the page, newest first.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: The id to pass as `before` for the next page, or `null` on the last page.
        total:
          type: integer
          minimum: 0
          description: The number of games matching the filter, across all pages.
    MoveRecord:
      type: object
      description: One ply of a game.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: The move in UCI notation, with `n`, `b`, `r` or `q` added for a promotion.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: The time charged for the move (ms), `null` when the record does not have it.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: The mover's clock after the move (ms), `null` when the record does not have it.
    PgnTagsView:
      type: object
      description: The main PGN tags, for display. `Termination` here is the end-reason name; the PGN file uses the PGN standard values.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` or `<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: The UTC start date.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: The rating at the start, or `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: The rating at the start, or `"-"`.
          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: A game record with its moves and clocks. It has the fields of a game summary, without `color` and `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: The base time in milliseconds.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: The increment in milliseconds.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: The name of `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: The id of the game that this one is a rematch of, or `null`.
            moves:
              type: array
              description: One entry per ply.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Only when the token's player played this game; their side.
            reportable:
              type: boolean
              description: Only when the token's player played this game; `true` when `POST /reports` would take a report of the opponent for this game now (the game ended within 7 days, the daily quota is not used up, and the opponent is not already reported for this game).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: The user name, as the player wrote it.
        createdAt:
          type: integer
          format: int64
          description: When the account was created (epoch ms).
        ratings:
          type: array
          description: The official categories only, in the server's order.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Every stored game, casual and aborted ones included.
            rated:
              type: integer
              minimum: 0
              description: Rated games, summed over the rating records.
            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: The user name, as the player wrote it.
        games:
          type: array
          description: The games of the page, newest first.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: The last game's id whenever the page is full (the next page may then be empty), else `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: The category id.
        minGames:
          type: integer
          minimum: 0
          description: The counted games a record needs to be listed (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: When the board was computed (epoch ms).
        players:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LeaderboardEntry'
    LeaderboardEntry:
      type: object
      required:
        - rank
        - username
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
      properties:
        rank:
          type: integer
          minimum: 1
        username:
          type: string
        rating:
          type: integer
        games:
          type: integer
          minimum: 0
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
    GifRequest:
      type: object
      additionalProperties: false
      required:
        - pgn
      properties:
        pgn:
          type: string
          description: The PGN text, at most 65,536 bytes of UTF-8. Only the first game is used.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Optional. The picture size.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Optional. The side at the bottom of the board.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Optional. Milliseconds per move, a JSON number with an integral value.
        coords:
          type: boolean
          default: true
          description: Optional. Whether the file letters and rank numbers are drawn around the board.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: The game, as an integer or a string of 1 to 16 digits.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: The opponent's user name, as in the game record or as it is now, without regard to case. It may not be blank.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: What the report is about.
        comment:
          type:
            - string
            - 'null'
          description: Optional. At most 500 characters once control characters (other than tab and line feed) are removed and the text is trimmed.
    AccountExport:
      type: object
      description: Everything the server keeps about the account (times in epoch ms).
      required:
        - format
        - version
        - exportedAt
        - server
        - notes
        - account
        - ratings
        - ratingRefunds
        - sessions
        - securityEvents
        - sanctions
        - conduct
        - reportsFiled
        - games
      properties:
        format:
          type: string
          const: scacelith-account-export
        version:
          type: integer
          const: 1
        exportedAt:
          type: integer
          format: int64
          description: When the export was made.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: What the file holds and leaves out, in plain English for the player.
          items:
            type: string
        account:
          description: The account view of `GET /account/me`, with `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: The address of the linked Google account, or `null`.
        ratings:
          type: array
          description: The full rating records.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Rating points given back after an opponent was found cheating, added up per UTC day and category, newest first. Neither the games nor the cheaters are named.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Every stored session, newest first, without any token. The retention purge deletes an expired session, and a signed-out one a day after the sign-out.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: The security events, newest first, kept `RETENTION_SECURITY_DAYS`.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Every sanction, lifted ones included. The moderator's name is never included.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: The conduct events recorded for the player's abandoned, aborted and no-show games, kept 30 days.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: The reports the player made.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: The number of games.
            list:
              type: array
              description: Every game, newest first, as summaries of `GET /account/games`. Moves are at `GET /games/{id}` and the PGN at `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: A full rating record.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Whether the player has left the unrated phase.
            countedGames:
              type: integer
              minimum: 0
              description: The games that entered the rating.
            updatedAt:
              type: integer
              format: int64
              description: When the record last changed.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: 00:00 UTC of the day (epoch ms).
        category:
          type: string
        points:
          type: integer
          description: The points given back that day in that category.
    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: When the session was signed out, or `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: The address of the sign-in, erased after `RETENTION_IP_DAYS`.
    SecurityEvent:
      type: object
      description: |-
        A security event. `ip` is given only for what was done while signed in, with the account's password (and second factor) or with a link mailed to its address: `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` and `account_exported`; every other kind has `ip: null`, and `ip` is erased after `RETENTION_IP_DAYS`.

        `detail` keeps only these fields: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` and `method` (`password`, or `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` and `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. A `moderator_action` event keeps only `{ action }`, for the actions `ban`, `unban`, `reset_mfa`, `verify_email` and `revoke_sessions` (the other moderator actions are left out). Every other kind has `detail: null`. The `rating_refund` events are left out: their points are in `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: The kind of event (`login`, `password_changed`...).
        at:
          type: integer
          format: int64
          description: When it happened.
        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: The reported player's current public name.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Whether the report is still open (whether the reported player was sanctioned is not said).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: The token of the e-mail link (the hidden field of the page's form).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: The token of the reset link (the hidden field of the form).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: The new password; the password rules of registration apply.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: The new password again; it must be the same.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: An HTML page in UTF-8 (`text/html; charset=utf-8`), without JavaScript or external resources.
