openapi: 3.1.1
info:
  title: Scacelith 服务器 HTTPS API
  version: "0.9.0"
  summary: 所有 Scacelith 服务器（官方服务器和社区服务器）共用的 HTTPS API。
  description: |-
    每台 Scacelith 服务器，无论是官方的 `caissa.scacelith.com` 还是任何社区服务器，都提供此 HTTPS API。游戏用它处理对局本身以外的一切：注册和登录（包括双重验证和 Google 登录）、账号页面、对局记录、PGN 下载和对局的 GIF 动图、已登录的设备、数据下载、删除账号以及举报。

    实时对弈（匹配、挑战、着法、棋钟）通过同一服务器的 WebSocket（`wss://<host>/ws`）进行，该连接使用本 API 的会话令牌打开；相关说明见 `PROTOCOL.md`，不在本文档中。

    下文给出的默认值是未改动配置的服务器所用的值；社区服务器可能会更改它们（`CONFIG.md` 列出了所有配置项）。

    ## 基础 URL、端口和传输

    - 官方服务器：`https://caissa.scacelith.com/api/v1`，TCP 端口 443。
    - 社区服务器：`https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`。`API_PORT` 默认为 443；位于 NAT 或代理之后时，棋手使用的端口是 `PUBLIC_API_PORT`。
    - 游戏的 WebSocket 默认使用同一端口（可用 `WS_PORT` 更改；`GET /info` 会告诉客户端它在哪个端口）。
    - 基于 TLS 的 HTTP/1.1。使用 `TLS_MODE=proxy` 时，由服务器前面的反向代理终止 TLS。`TLS_MODE=off`（明文 HTTP）仅用于本地开发。
    - 从邮件链接打开的 HTML 页面（`/verify-email`、`/reset-password`、`/confirm-email-change`）位于服务器根路径下，不在 `/api/v1` 中。健康检查端点在根路径和 `/api/v1` 下都会响应。Google 登录在服务器上没有页面：Google 会把浏览器送回游戏本身，地址为 `127.0.0.1`。
    - 末尾的斜杠会被忽略（`/api/v1/info/` 等同于 `/api/v1/info`），路径参数会经过 URL 解码（不是有效 URL 编码的参数返回 400 `invalid_request`）。

    ## 请求

    - **JSON 请求体。** `POST`、`PUT` 和 `DELETE` 请求携带一个 JSON 对象，并带有 `Content-Type: application/json`。UTF-8 以外的 `charset` 会被拒绝，其他任何内容类型也会被拒绝（415 `unsupported_media_type`）。空请求体视为 `{}`，没有参数的端点接受的正是它（`POST /auth/logout`、`POST /auth/logout-all`、`DELETE /auth/sessions/{id}`）。
    - **大小和时间。** 请求体最多 `HTTP_BODY_LIMIT` 字节（默认 16,384；`POST /gif` 有自己的上限，为 135,168 字节），超出时返回 413 `payload_too_large`。请求体必须在 10 秒内到达，否则返回 408 `request_timeout`。出现这两种错误中的任何一种后，服务器都会关闭连接。
    - **严格的结构定义。** 端点不认识的字段会被拒绝，未标记为可选的字段均为必填，类型和长度都会经过检查。任一检查失败都返回 400 `invalid_request`，并由 `field` 指出字段名（嵌套字段用点号连接，例如 `pow.nonce`）。字符串不得包含控制字符。长度按 UTF-16 码元计算，因此基本多文种平面之外的字符（如表情符号）计为两个。不是 JSON 的请求体返回 400 `invalid_json`。`POST /gif` 和 `POST /reports` 自行检查其请求体（见各端点）。
    - **查询字符串。** 同一参数以第一次出现的为准，未知参数会被忽略，`+` 解码为空格：接受用时类别的端点同时接受 `3%2B2` 和 `3+2`。
    - **方法。** `HEAD` 就是不带响应体的 `GET`。对存在的路径使用 `OPTIONS` 返回 204，并带有 `Allow` 标头。该路径不支持的其他任何方法返回 405 `method_not_allowed`，并带有 `Allow`。
    - **请求目标。** 不得超过 4096 个字符（414 `uri_too_long`）。完全无法解析的请求，或请求头过大、到达过慢的请求，会得到一个空的 400、431 或 408 响应，并且连接会被关闭。
    - **不支持 CORS。** 本 API 为游戏服务，而不是为网页服务。服务器从不发送任何 `Access-Control-*` 标头，因此网页无法读取响应；由于只接受 JSON 请求体，跨站写入需要预检请求，而预检会失败。

    ## 响应和错误

    - 响应为 UTF-8 编码的 JSON，但 PGN 下载（`application/x-chess-pgn`）、GIF 动图（`image/gif`）和 HTML 页面除外。时间为自 1970-01-01 UTC 起的毫秒数。ID 均为整数。对局 ID 最多 16 位数字，但始终小于 2^53，因此 JSON 数字（双精度浮点数）可以精确表示。
    - API 的每个响应都带有 `Cache-Control: no-store`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: no-referrer`、`X-Frame-Options: DENY`、`Cross-Origin-Resource-Policy: same-origin` 和 `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`（HTML 页面有自己的策略）。使用 `TLS_MODE=native` 时，还带有 `Strict-Transport-Security: max-age=31536000`。
    - 从服务器准备好响应的那一刻起，响应必须在 60 秒内被读完，这只对大型响应（GIF、数据导出、很长的 PGN）有影响。服务器准备响应所花的时间既不计入这 60 秒，也不计入“30 秒内没有任何字节收发即关闭连接”的那 30 秒。

    错误只有一种格式（`Error` 结构）：

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

    `message` 用于日志记录和兜底显示；客户端应根据 `error` 决定显示什么。`retryAfter`（秒）仅出现在会随时间解除的拒绝中，此时响应还会带有值相同的 `Retry-After` 标头；唯一的例外是读取对局记录、对局、棋手和排行榜时的 503 `busy`，它只有该字段。部分错误会附加字段：`field`（无效输入）、`reason`（`weak_password`、`pow_required`）、`pow`（`pow_required`）、`until`（`banned`）、`line` 和 `column`（`invalid_pgn`）。

    任何端点都可能返回的错误：

    | 状态码 | `error` | 情形 |
    |---|---|---|
    | 400 | `invalid_request` | 请求体不符合端点的结构定义（由 `field` 指出位置），请求目标或 `Content-Length` 格式错误，路径参数不是有效的 URL 编码，或请求体被截断。 |
    | 400 | `invalid_json` | 请求体不是 JSON。 |
    | 401 | `unauthorized` | 在需要会话的端点上没有 `Authorization` 标头。 |
    | 401 | `invalid_token` | 令牌格式错误、已过期、已被吊销或属于已删除的账号；在会话可选的端点上也会如此。 |
    | 404 | `not_found` | 没有该端点。部分端点也使用它：没有该对局、棋手或会话。 |
    | 405 | `method_not_allowed` | 该路径存在，但只支持其他方法（见 `Allow`）。 |
    | 408 | `request_timeout` | 请求体未在 10 秒内到达。 |
    | 413 | `payload_too_large` | 请求体超过 `HTTP_BODY_LIMIT`（`POST /gif` 为 135,168 字节）。 |
    | 414 | `uri_too_long` | 请求目标超过 4096 个字符。 |
    | 415 | `unsupported_media_type` | 请求体不是 `application/json`，或其字符集不是 UTF-8。 |
    | 429 | `rate_limited` | 触发了速率限制（见下文）：带有 `retryAfter` 和 `Retry-After` 标头。 |
    | 500 | `internal_error` | 意外故障。服务器会将其记入日志。 |
    | 503 | `timeout` | 服务器未在 30 秒内响应（默认设置下，导出为 60 秒，GIF 为 45 秒）。 |
    | 503 | `server_busy` | 查询会话或更改账号时发现数据库被锁定（`retryAfter` 为 1），或密码哈希队列已满（见下文）。 |

    读取类端点（对局记录、对局、棋手、排行榜）和导出在数据库持续被锁定时返回 503 `busy`，并带有 `retryAfter: 1`。

    校验或哈希密码的端点还可能返回以下错误之一，两者都带有 5 到 15 秒之间的随机 `retryAfter`：

    - 503 `server_busy`：服务器的密码哈希队列（`PASSWORD_HASH_QUEUE_MAX`）已满，或等待超时。
    - 429 `rate_limited`：队列已半满，而该客户端（一个 IPv4 地址或一个 IPv6 /48）已有 `PASSWORD_HASH_WAITERS_PER_SOURCE` 个哈希在等待。此 429 会退还该请求所占用的速率限制令牌。

    此时没有任何更改，也不计入失败尝试（`POST /auth/sso/google/link` 除外，它的票据尝试次数和账号失败计数在哈希之前就已扣除），重置链接也仍然有效。

    HTML 页面以 HTML 页面的形式返回错误，状态码相同。

    ## 身份验证

    需要会话的端点在 `Authorization` 标头中接受 Bearer 令牌（`bearerAuth` 方案）：

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **获取令牌。** 令牌（`sct_` 后跟 43 个 base64url 字符）来自 `POST /auth/login`（开启双重验证时，随后还需调用 `POST /auth/login/mfa`）、`POST /auth/sso/google/finish` 和 `POST /auth/sso/google/link`（Google 登录），以及 `POST /auth/sso/complete`。它们都返回 `{ token, expiresAt, user }`。服务器只存储令牌的 SHA-256 哈希值。
    - **必需会话。** 缺少该标头时返回 401 `unauthorized`，并带有 `WWW-Authenticate: Bearer realm="scacelith"`。令牌无效时返回 401 `invalid_token`，并带有 `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`。客户端收到 `invalid_token` 时，应丢弃该令牌并重新登录。
    - **可选会话。** 在 `GET /games/{id}`、`GET /games/{id}/pgn`、`GET /players/{username}` 和 `GET /players/{username}/games` 上，会话是可选的。不带该标头时，它们返回公开视图；如果发送了该标头，其中的令牌必须有效。
    - **有效期。** 会话在以下两者中先到者结束：登录后满 `SESSION_MAX_DAYS`（90）天（即 `expiresAt`），或连续 `SESSION_IDLE_DAYS`（30）天未使用。每次使用都会推迟空闲期限；服务器最多每 5 分钟写入一次新值。一个账号最多保留 `MAX_SESSIONS_PER_USER`（10）个会话：超出此数量时，新的登录会吊销最旧的会话。
    - **吊销。** 退出登录（`POST /auth/logout`、`POST /auth/logout-all`、`DELETE /auth/sessions/{id}`）会吊销会话；修改密码（吊销其他会话）、重置密码和删除账号（吊销所有会话）以及管理员也会吊销会话。通过 API 进行的吊销立即生效，用已吊销会话打开的 WebSocket 会被关闭。服务器会将会话查询结果缓存 30 秒，因此通过管理命令（一个独立的进程）进行的吊销会在 30 秒内生效。
    - **作用范围。** 令牌只属于一台服务器，也用于打开该服务器的 WebSocket。切勿将其发送到其他服务器。

    ## 速率限制和其他限流

    限制按以下两种范围之一计算。**客户端：** 一个 IPv4 地址或一个 IPv6 /64（在代理模式下，地址取自 `TRUSTED_PROXIES` 中的地址所发送的 `X-Forwarded-For`）；部分限制除了分别计算每个 /64 网络外，还会把每个 IPv6 /48 作为一个整体计算。**棋手：** 已登录的账号，无论其地址为何；在会话可选的端点上按棋手计算的限制，对不带令牌的请求改为按客户端计算。

    每个限制都是一个令牌桶，可容纳 `limit` 个请求，并以 `limit / window` 的速率持续补充；`retryAfter` 是距下一个令牌的时间。标为*共享*的限制还会在同样长度的滑动窗口内计数，使任何窗口内的请求数都不会明显超过 `limit`。所有限制都在整台服务器范围内计数。当端点的某个限制拒绝请求时，其他限制为该请求占用的令牌会被退还。拒绝时返回 429 `rate_limited`，并带有 `retryAfter` 和 `Retry-After`。

    - **按地址限制层。** 每个请求（任何路径和方法，包括健康检查端点和 WebSocket 升级）首先从其客户端的额度中占用一个令牌：每分钟 `HTTP_RATE_PER_IP`（600）个，允许半分钟的突发量；每个 IPv6 /48 为 `HTTP_RATE_PER_PREFIX`（4 x `HTTP_RATE_PER_IP`）。一个客户端同时进行中的请求最多为 `IP_MAX_INFLIGHT`（32 x `WORKERS`）个（超出时 `retryAfter` 为 1）。被拒绝后仍继续请求的客户端会被封锁：一分钟内被拒绝 `ABUSE_BLOCK_REFUSALS_PER_MIN`（600）次将被封锁 1 分钟，此后 6 小时内每次再被封锁，时长依次为 4、16 和 60 分钟。`auth`、`auth_*` 和 `reauth` 限制的一次拒绝计为 5 次；按棋手计算的限制从不计入。`ABUSE_EXEMPT` 中的地址跳过这一层，但不跳过账号额度和端点限制。
    - **账号额度。** 每个携带有效会话令牌的请求还会计入其账号：所有端点合计每分钟 `USER_RATE_PER_MIN`（120）个，无论来自哪个地址，允许半分钟的突发量。
    - **端点限制：**

    | 限制 | 默认值 | 计数单位 | 端点 |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP`（20）/ 10 分钟，共享 | 客户端；每个 IPv6 /48 为 `AUTH_RATE_PER_PREFIX`（5 x `AUTH_RATE_PER_IP`） | `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）/ 小时，共享 | 客户端；每个 IPv6 /48 为其 3 倍 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR`（10）/ 小时，共享 | 客户端；每个 IPv6 /48 为其 3 倍 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR`（3）/ 小时，共享 | 客户端；每个 IPv6 /48 为其 3 倍 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY`（10）/ 24 小时，共享 | 客户端；每个 IPv6 /48 为其 3 倍 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR`（10）/ 小时，共享 | 客户端；每个 IPv6 /48 为其 3 倍 | `POST /auth/password/reset`、`POST /reset-password` |
    | `reauth` | 数值同 `auth`，但使用独立的令牌桶，共享 | 客户端和 IPv6 /48 | 需要输入密码的账号更改（见“重新验证身份”），以及 `POST /account/export` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER`（10）/ 10 分钟，共享 | 棋手 | 与 `reauth` 相同的端点 |
    | `account` | 60 / 分钟 | 棋手 | `GET /account/me`、`PUT /account/preferences` |
    | `account_games` | 60 / 分钟 | 棋手 | `GET /account/games` |
    | `account_export` | 5 / 小时，共享 | 棋手 | `POST /account/export`（在 `reauth` 之前检查；每次尝试都计数） |
    | `sessions` | 60 / 分钟 | 棋手 | `POST /auth/logout`、`/auth/logout-all`、`GET /auth/sessions`、`DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / 分钟 | 棋手（不带令牌时按客户端） | `GET /players/{username}`、`/players/{username}/games`、`/games/{id}`、`/games/{id}/pgn`（四者共用一个令牌桶） |
    | `gif` | 30 / 分钟 | 棋手 | `GET /games/{id}/gif`、`POST /gif`（两者共用一个令牌桶） |
    | `gif_user_min`、`gif_user_hour` | `GIF_USER_RENDERS_PER_MIN`（4）/ 分钟和 `GIF_USER_RENDERS_PER_HOUR`（30）/ 小时，共享 | 棋手 | 同样这两个端点，仅在需要生成 GIF 时（而非取自缓存） |
    | `gif_ip_min`、`gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN`（12）/ 分钟和 `GIF_IP_RENDERS_PER_HOUR`（120）/ 小时，共享 | 客户端（其所有账号合计）；每个 IPv6 /48 为其 3 倍 | 同上，条件相同 |
    | `reports` | 30 / 小时 | 棋手 | `POST /reports` |
    | `sso_start` | 30 / 10 分钟，共享 | 客户端；每个 IPv6 /48 为 90 | `POST /auth/sso/google/start` |
    | `sso_finish` | 30 / 分钟 | 客户端 | `POST /auth/sso/google/finish` |
    | `page` | 60 / 分钟 | 客户端 | `GET /verify-email`、`/reset-password`、`/confirm-email-change` |

    `GET /info` 和 `GET /leaderboard` 没有自己的限制，只受按地址限制层约束。有多个限制的端点按其说明中给出的顺序检查这些限制。

    服务器按以下顺序处理请求：按地址限制层，然后是健康检查端点；端点匹配；身份验证；账号额度（请求携带会话时）；端点的限制；请求体；端点本身（GIF 端点仅在确实需要生成 GIF 时才占用其渲染限制）。因此，因令牌问题被拒绝的请求不会消耗端点的任何令牌，而请求体无效的请求则会消耗。

    其他限流，由端点自身返回：

    - **同一登录名的登录失败**（用户名或邮箱）：从第 `AUTH_FAILURES_PER_ACCOUNT`（5）次失败起，每次尝试都必须等待前一次的两倍时长（2 秒、4 秒，依此类推，最长 15 分钟）：返回 429 `too_many_attempts`，并带有 `retryAfter`。连续一小时没有失败后，计数器清零。
    - **登录时的第二重验证失败：** 从该账号第 5 次输错验证码起，适用同样的规则。一个登录步骤最多接受 5 次错误的验证码。
    - **同一账号的第二重验证码：** 每 15 分钟最多 `AUTH_MFA_PER_ACCOUNT`（10）个验证码（身份验证器验证码或恢复码，无论对错），无论来自哪个地址，登录和重新验证身份时都计算在内；超出后，在检查验证码之前就返回 429 `too_many_attempts`，因此恢复码不会被消耗。
    - **同一账号的重新验证身份失败**（密码或验证码错误）：同样的规则（429 `too_many_attempts`），由所有需要重新验证身份的端点共享。
    - **邮件：** 每个地址每 5 分钟最多一封确认或重置邮件（响应保持不变）；每个地址每小时最多一封“有人试图使用你的地址”通知。
    - **举报：** 每位棋手 24 小时内最多 `REPORTS_PER_DAY`（5）次，超出时返回 429 `report_limit`。

    ## 工作量证明

    当 `POW_REGISTER_BITS` 大于 0 时（默认 18；`GET /info` 以 `pow.register` 给出），`POST /auth/register` 始终需要工作量证明。`POST /auth/login` 和 `POST /auth/sso/google/link`（两者使用同一种质询）仅在服务器发现一波登录失败后的 5 分钟内才需要（由 `POW_LOGIN_TRIGGER_PER_MIN` 触发，难度为 `POW_LOGIN_BITS`）；客户端可从响应中得知。

    1. 不带证明（或证明被拒绝）的请求返回 428 `pow_required`，并带有 `reason` 和一个 `pow` 质询 `{ challenge, bits, expiresAt }`。
    2. 找出一个随机数（nonce）：一个最多 20 位数字的十进制字符串，使 `SHA-256(challenge + ":" + nonce)` 以 `bits` 个零位开头（从第一个字节的最高有效位算起）。18 位平均约需 260,000 次哈希。
    3. 再次发送同一请求，并在请求体中加入 `"pow": { "challenge": "...", "nonce": "123456" }`。

    质询的有效期为 2 分钟，只能使用一次，并且只适用于一个端点和一个客户端网络（一个 IPv4 地址或 IPv6 /64）。质询带有签名，因此在它被送回之前，服务器不保存任何与之相关的信息。`reason` 说明证明被拒绝的原因：`required`、`malformed`、`signature`、`endpoint`、`network`、`expired`、`bits`、`work` 或 `replayed`。

    ## 重新验证身份

    更改账号时需要再次输入密码；开启双重验证时，还需要第二重验证：

    - 仅需密码：`POST /account/password` 和 `POST /account/mfa/totp/setup`；
    - 密码和身份验证器验证码（不接受恢复码）：`POST /account/mfa/recovery-codes`；
    - 密码和身份验证器验证码或恢复码：`POST /account/mfa/totp/disable`、`POST /account/email`、`POST /account/export` 和 `POST /account/delete`。

    在请求体中，`code` 填写 6 位身份验证器验证码，`recoveryCode` 填写恢复码（`xxxx-xxxx-xx`；大小写、空格和连字符均不影响）。在接受恢复码的地方，也可以把恢复码放在 `code` 中发送。每个验证码只能使用一次：已用过的身份验证器验证码在下一个 30 秒时间步到来之前都会被拒绝，恢复码一经使用即失效。

    | 状态码 | `error` | 情形 |
    |---|---|---|
    | 403 | `invalid_password` | 密码错误。 |
    | 403 | `mfa_code_required` | 已开启双重验证，但既未发送 `code` 也未发送 `recoveryCode`。 |
    | 403 | `invalid_code` | 验证码错误或已被使用。 |
    | 400 | `password_not_set` | 仅使用 Google 登录的账号尚未设置密码（可通过“忘记密码”设置）。 |
    | 429 | `too_many_attempts` | 此账号失败次数过多，或尝试的验证码过多。 |
    | 503 / 429 | `server_busy` / `rate_limited` | 密码哈希队列繁忙。 |

    密码和验证码的失败会计入该账号的失败计数器，并记录为安全事件。

    ## 指标端口

    除 API 端口外，服务器还在指标端口上以明文 HTTP 响应（`METRICS_PORT`，默认 9464，绑定到 `METRICS_BIND`，默认 127.0.0.1；请勿对外公开）。它不属于本 API：`GET /healthz` 返回 `ok`；`GET /readyz` 在启动完成后（每个分片都已重放其日志，所有监听器均已绑定）直到开始关闭之前返回 `ready`，否则返回 503 `not ready`；`GET /metrics` 提供 Prometheus 指标，设置了 `METRICS_TOKEN` 时，需要带有与之完全一致的令牌的 `Authorization: Bearer <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: Scacelith 服务器的源代码及其参考文档（API.md、PROTOCOL.md、CONFIG.md）。
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: 官方服务器。
  - url: https://{host}:{port}/api/v1
    description: 任意 Scacelith 服务器，例如社区服务器。
    variables:
      host:
        default: caissa.scacelith.com
        description: 服务器的公开主机名（`SERVER_PUBLIC_HOST`）。
      port:
        default: "443"
        description: 公开的 API 端口（`PUBLIC_API_PORT`，未设置时为 `API_PORT`）。
tags:
  - name: server-info
    x-displayName: 服务器信息
    description: 客户端在登录或连接之前需要了解的信息。
  - name: health
    x-displayName: 健康检查
    description: |-
      服务器的存活状态和就绪状态，供监控使用。它们在 API 端口上响应，先于身份验证和所有端点限制，但与任何请求一样，会占用按地址限制层的一个令牌；可以把监控主机列入 `ABUSE_EXEMPT`。每个端点都有两个可用路径：服务器根路径下和 `/api/v1` 下。

      指标端口有自己的健康检查端点，见简介部分。
  - name: auth
    x-displayName: 注册和登录
    description: |-
      创建账号、使用密码（及第二重验证）登录、确认邮件和密码重置邮件。

      `POST /auth/login`、`POST /auth/login/mfa`、`POST /auth/sso/google/finish`、`POST /auth/sso/google/link` 和 `POST /auth/sso/complete` 返回一个会话 `{ token, expiresAt, user }`（其中前几个也可能返回第二步）。
  - name: google-sign-in
    x-displayName: Google 登录
    description: |-
      仅当 `GET /info` 给出 `sso.google: true` 时提供；否则下列所有端点都返回 404 `sso_disabled`。游戏通过系统浏览器，按 RFC 8252 的已安装应用流程（“桌面应用”类型的客户端）登录：Google 把浏览器送回游戏在 `127.0.0.1` 上的监听器，而从不送到本服务器，游戏也从不接触任何 Google 凭据。

      1. 客户端在 `127.0.0.1:0` 上监听（由系统选择端口），并生成一对 PKCE 值：由 43 到 128 个 `[A-Za-z0-9._~-]` 字符组成的 `codeVerifier`，以及 `codeChallenge = BASE64URL(SHA-256(codeVerifier))`，后者为 43 个字符，不带填充。
      2. 带上质询值和端口调用 `POST /auth/sso/google/start`，返回 Google URL 和本次尝试的 `state`。客户端检查该 URL（见下文），然后在浏览器中打开它。
      3. Google 把浏览器重定向到 `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`。客户端只接受第 2 步响应中的 `state`，遇到 `error=` 重定向时不发送任何内容。
      4. 带上尝试 ID、`codeVerifier`、`state` 和 `code`（如果 Google 发送了 `iss`，也一并带上）调用 `POST /auth/sso/google/finish`。
      5. 响应为以下之一：一个会话；一个双重验证步骤（继续调用 `POST /auth/login/mfa`）；新棋手的 `needsUsername`（继续调用 `POST /auth/sso/complete`）；或者当某个设有密码的账号使用该邮箱地址时，返回 `needsPassword`（继续调用 `POST /auth/sso/google/link`）。

      **来源标记。** 重定向 URI 带有棋手所添加服务器的标记，这样，其他服务器为自己的棋手获取的 URL 会被游戏拒绝。来源由小写主机名（IPv6 字面量需加方括号）、`:` 和十进制 API 端口组成，端口始终写出，443 也不例外：服务器使用 `SERVER_PUBLIC_HOST` 及其公开 API 端口，游戏则使用它所连接的地址。标记是 SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + 来源) 经 base64url 编码（不带填充）后的前 22 个字符。重定向 URI 为 `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`：始终使用 IPv4 字面量，端口为游戏监听器的端口（1024 到 65535）。服务器从不接收客户端提供的 URI、主机名或路径。

      | 来源 | 标记 |
      |---|---|
      | `play.scacelith.example:443` | `IhcScoV7eDOzTEcSnqPUPt` |
      | `localhost:8443` | `TFGx7zQ_8QlGZW5zpqznCr` |
      | `[::1]:8443` | `XToJm0DG5PjciEVmZa9Cho` |
      | `127.0.0.1:50443` | `3r653wM5ZjYsHcAJljmCwY` |

      因此，只有以与 `SERVER_PUBLIC_HOST` 及其公开 API 端口完全一致的地址添加服务器的棋手，才能使用 Google 登录。

      **游戏在打开浏览器之前检查的内容。** `authUrl` 必须恰好以 `https://accounts.google.com/o/oauth2/v2/auth?` 开头，由可打印 ASCII 字符组成且少于 4096 个字符；其查询部分必须恰好有一个 `response_type=code`，恰好有一个 `redirect_uri` 且等于游戏根据其端口和来源标记算出的 URI，恰好有一个 `state` 且等于响应中的 `state`，还必须有 `code_challenge_method=S256` 和一个 43 个字符的 `code_challenge`。否则，游戏会停止监听器，不打开任何页面。游戏只向响应了 `start` 的那台服务器发送 `finish` 和 `link` 请求。

      **Google 登录会进入哪个账号：** 已关联该 Google 账号的账号；否则为使用 Google 已确认邮箱地址的有效账号（若该账号设有密码，`finish` 返回 `needsPassword`，只有在其密码以及（若已开启）第二重验证都通过后才会保存关联；若没有密码，则返回 409 `sso_account_exists`）；否则为新账号（`needsUsername`）。Google 账号绝不会仅凭邮箱地址就关联到现有账号。当 Google 登录创建了账号，或被添加到现有账号时，该账号的邮箱地址都会收到一封邮件。
  - name: sessions
    x-displayName: 会话
    description: 账号的已登录设备，以及退出登录。
  - name: account
    x-displayName: 账号
    description: 账号信息、偏好设置和密码。
  - name: two-step-verification
    x-displayName: 双重验证
    description: 身份验证器应用（TOTP，RFC 6238），使用 SHA-1、6 位数字、30 秒时间步，前后各容许一个时间步的偏差；另有一次性恢复码。
  - name: email-change
    x-displayName: 修改邮箱地址
    description: 修改账号的邮箱地址，需通过发送到新地址的链接确认。
  - name: data-export
    x-displayName: 数据导出
    description: 服务器保存的有关该账号的全部信息，以一个 JSON 文件提供。
  - name: account-deletion
    x-displayName: 删除账号
    description: 永久删除账号。
  - name: game-history
    x-displayName: 对局记录
    description: 已登录棋手自己的对局，支持筛选和分页。
  - name: games
    x-displayName: 对局和 PGN
    description: |-
      包含着法和棋钟时间的棋谱，以及对应的 PGN 文件。

      **对局代码。** `status`：1 `WhiteWins`、2 `BlackWins`、3 `Draw`、4 `Aborted`（只保存已结束的对局）。`result`：`1-0`、`0-1`、`1/2-1/2` 或 `*`（已中止）。`reason` 及其名称（`termination`）和 PGN 文件着法部分末尾所写的（英文）文字见下表；代码 7 和 21 为和棋（超时或弃局的一方所面对的对手无法将死），服务器从不以代码 4 和 13 结束对局（它们属于共用的结束原因列表）：

      | 代码 | `termination` | 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: GIF 动图
    description: |-
      以 GIF 动图呈现的对局，可保存或分享：俯视的棋盘，从初始局面到最终局面每个局面一帧，棋盘上方是双方棋手的名字和等级分，下方是最后一步着法，最后一帧显示结果和对局的结束方式。

      **开销、缓存和配额。** GIF 在专用的渲染线程上以最低的 CPU 优先级生成，从不占用运行对局的线程：共 `GIF_THREADS` 个线程（默认等于 `WORKERS`），在生成第一个 GIF 时启动，一分钟内没有新的 GIF 时停止。最多 `GIF_QUEUE_MAX`（4 x `WORKERS`）个 GIF 排队等待线程，每个最多等待 `GIF_QUEUE_TIMEOUT_MS`（10 秒）；一次渲染最长可持续 `GIF_RENDER_TIMEOUT_MS`（30 秒）。服务器把已生成的 GIF 保存在大小为 `GIF_CACHE_MB` MB（32 x `WORKERS`）的缓存中，最久未使用的最先淘汰；取自缓存的 GIF，或正在为另一个请求生成的 GIF，不消耗渲染次数。缓存键涵盖所有会改变画面的因素，包括名字。

      每个请求都计入 `gif`（两个端点合计，每位棋手每分钟 30 次）。需要生成的 GIF 还计入渲染限制：每位棋手每分钟 `GIF_USER_RENDERS_PER_MIN`（4）次、每小时 `GIF_USER_RENDERS_PER_HOUR`（30）次；每个客户端（其所有账号合计）每分钟 `GIF_IP_RENDERS_PER_MIN`（12）次、每小时 `GIF_IP_RENDERS_PER_HOUR`（120）次，每个 IPv6 /48 为其 3 倍。客户端应保留已下载的文件，而不是再次请求；收到 429 或 503 后，应等待 `retryAfter`。

      尺寸：`small`（每格 32 像素：284 x 350 像素）、`medium`（48 像素：424 x 515）、`large`（72 像素：628 x 762）；不带坐标时分别为 268 x 342、400 x 503 和 600 x 748。初始局面至少停留 1 秒（若设定的帧间隔更长，则按帧间隔），最终局面停留 3 秒，GIF 循环播放；第一帧之后，只存储画面中发生变化的部分。一盘 40 回合的对局约为 135、205 和 325 KiB（小、中、大），150 回合的对局约为 0.5、0.8 和 1.2 MiB。
  - name: players
    x-displayName: 棋手
    description: 公开资料和最近的对局。只包含公开数据，绝不包含邮箱地址、会话、处罚或诚信等级。已删除的账号没有资料页。
  - name: leaderboard
    x-displayName: 排行榜
    description: 每个官方用时类别中排名靠前的棋手。
  - name: reports
    x-displayName: 举报
    description: 向管理员举报最近一局对局的对手。
  - name: pages
    x-displayName: HTML 页面
    description: |-
      供浏览器使用的页面，从邮件中的链接打开。这些链接指向 `https://<SERVER_PUBLIC_HOST>`（端口不是 443 时附加 `:<PUBLIC_API_PORT>`），不在 `/api/v1` 下。

      - 这些页面不运行 JavaScript，也不加载任何外部资源。提供页面时带有 `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`。
      - `GET` 只显示一个按钮或表单，这样邮件扫描程序打开链接时不会把它用掉。更改在 `POST` 时才发生：以 `application/x-www-form-urlencoded` 发送的表单（也接受 JSON 请求体），链接的令牌放在一个隐藏字段中。重复给出的字段会被拒绝。
      - 错误（速率限制、无效字段）也以 HTML 页面返回，标题为“Request refused”（请求被拒绝），状态码为 500 及以上时为“Server error”（服务器错误）。只有在匹配到页面之前就发现的失败（请求目标过长或格式错误、按地址限制层）才以 JSON 返回。
x-tagGroups:
  - name: server
    x-displayName: 服务器
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: 账号
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: 对局
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: 棋手和举报
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: 浏览器页面
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: 获取服务器的名称、版本、端口和注册规则
      description: |-
        客户端在登录或连接之前需要的信息：服务器的名称和 ID、WebSocket 协议版本及 WebSocket 的位置、注册规则、官方用时，以及客户端在提交表单之前可以自行检查的各项限制。

        **限制：** 仅按地址限制层。
      security: []
      responses:
        '200':
          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`：按地址限制层。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`。'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: 创建账号
      description: |-
        创建账号：在通过发送到邮箱的链接确认邮箱地址后创建；若不需要邮件确认，则立即创建。

        **需要邮件确认时**（`REQUIRE_EMAIL_VERIFICATION`，默认）：返回 202 `verification_sent`。此时账号尚不存在：注册申请等待 24 小时，并在此期间保留其用户名。一个在这 24 小时内有效的链接会发送到该地址，每个地址每 5 分钟最多一封（`POST /auth/verify-email/resend` 可发送新链接）；使用该链接时（`/verify-email` 页面上的按钮），账号才会被创建，其邮箱地址也随之确认，棋手随后即可登录。在此之前，用该用户名登录会返回 `invalid_credentials`，与未知账号相同，公开资料也不存在。使用同一地址的新注册申请会取代正在等待的那个。如果该地址已被另一个账号使用，响应也完全相同：此时不发送链接，改为向该账号的所有者发送一条通知（每小时最多一条），用户名也同样被保留，因此无从判断该地址是否已有账号。链接未被使用的注册申请在 24 小时后被丢弃，其用户名重新可用。

        **不需要邮件确认时**（`REQUIRE_EMAIL_VERIFICATION=false`）：返回 201 `ready`，账号立即创建，可以登录。

        **规则。** `username`：`USERNAME_MIN` 到 `USERNAME_MAX` 个字符（3 到 20），由字母、数字、`_` 和 `-` 组成，以字母或数字开头（`GET /info` 在 `limits` 中给出这些值）；保留名称（`admin`、`moderator`、`deleted`……）和某些前缀会被拒绝；不区分大小写地保持唯一。`email`：去除首尾空白并以小写存储，须为纯 ASCII，且域名中含有点号。`password`：至少 `PASSWORD_MIN_LENGTH`（10）个字符，最多 256 字节 UTF-8；不得包含用户名或邮箱地址的本地部分，也不得是常见密码。

        **错误**，按以下顺序检查：403 `registration_closed`；400 `invalid_username`；400 `invalid_email`；400 `weak_password`；409 `username_taken`；428 `pow_required`；密码哈希队列错误；409 `email_taken`（仅在不需要邮件确认时）。

        **限制：** `auth`，然后是 `auth_register`（每个客户端每小时 10 次注册）。**工作量证明：** 当 `POW_REGISTER_BITS` 大于 0 时始终需要。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: 第一次尝试，不带工作量证明
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: 带上工作量证明再次发送
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: 账号已创建，可以登录（此服务器不需要邮件确认）。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: 注册申请正在等待其确认链接被使用（也可能该地址已有账号；响应不会透露这一点）。
          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`（带有 `field`）、`invalid_json`、`invalid_username`、`invalid_email`，或带有 `reason` 的 `weak_password`：`too_short`、`too_long`、`contains_username`、`contains_email` 或 `too_common`。'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`：此服务器已关闭注册（`GET /info` 给出 `registration: closed`）。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`：已有账号使用该用户名，或另一个地址的待确认注册申请保留了它。`email_taken`：另一个账号正在使用该地址（仅在不需要邮件确认时）。'
        '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`：`auth` 或 `auth_register` 限制，或密码哈希队列。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（密码哈希队列，或数据库持续被锁定）、`timeout`。'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: 使用密码登录
      description: |-
        使用用户名或邮箱地址加密码登录。可能有两种响应，状态码均为 200：一个会话；或者在开启双重验证时，返回一个需在 5 分钟内通过 `POST /auth/login/mfa` 完成的第二步。

        未知账号、密码错误和没有密码的账号都会在相同的时间后得到相同的响应：401 `invalid_credentials`。账号状态检查（`banned`、`email_unverified`）只在密码正确之后进行。同一登录名失败达到 `AUTH_FAILURES_PER_ACCOUNT`（5）次后，每次尝试都必须等待（429 `too_many_attempts`）。

        **限制：** `auth`。**工作量证明：** 仅在出现一波登录失败期间需要（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: 一个会话，或开启双重验证时登录的第二步。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: 已登录
                  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: 已开启双重验证
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`：未知账号、密码错误，或账号没有密码。'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '仅在密码正确之后：`banned`，带有 `until`（Unix 毫秒时间戳，永久封禁时为 `null`）；或 `email_unverified`（邮箱地址尚未确认的较早账号）。'
        '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`（该登录名的失败延迟），或 `rate_limited`（`auth` 限制、密码哈希队列）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（密码哈希队列，或数据库持续被锁定）、`timeout`。'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: 使用第二重验证完成登录
      description: |-
        开启双重验证时登录的第二步，在 `POST /auth/login` 或 Google 登录（`POST /auth/sso/google/finish` 或 `POST /auth/sso/google/link`）返回 `mfaRequired` 之后调用。发送 `code`（6 位身份验证器验证码或恢复码）或 `recoveryCode`。在此使用的恢复码即告失效。

        一个步骤最多接受 5 次错误的验证码；密码被重置或修改时，该步骤也会结束。在 Google 关联的密码步骤之后，只有在此处验证码通过，关联才会被保存。

        **限制：** `auth`，以及每个账号每 15 分钟最多 `AUTH_MFA_PER_ACCOUNT`（10）个验证码，无论来自哪个地址；超出后不再检查验证码，因此恢复码不会被消耗。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: 身份验证器验证码
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: 恢复码
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: 已登录。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`：既未发送 `code` 也未发送 `recoveryCode`，或请求体不符合结构定义；`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`：该步骤已过期、已被使用、或因 5 次错误验证码而结束，或者自第一步以来密码已被重置或修改（请重新登录）。`invalid_code`：验证码错误或已被使用。'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned`（带有 `until`）、`email_unverified`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`：仅在 Google 关联的密码步骤之后出现，表示该 Google 账号在此期间已被关联到另一个账号。'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`：仅在 Google 关联的密码步骤之后出现，表示账号在此期间发生了变化；请从游戏中重新开始。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`：账号的失败延迟，或最近 15 分钟内的 `AUTH_MFA_PER_ACCOUNT` 个验证码额度已用完。`rate_limited`：`auth` 限制。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '带有 `retryAfter: 1` 的 `server_busy`：数据库持续被锁定（若发生在 Google 关联步骤之后，则未进行任何关联：请从游戏中重新开始）。`timeout`。'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: 退出当前会话
      description: |-
        吊销所用令牌对应的会话。用它打开的 WebSocket 会被关闭。无请求体（或 `{}`）。

        **限制：** `sessions`。
      security:
        - bearerAuth: []
      responses:
        '200':
          description: 已退出登录。
          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`：请求体不是 `{}`；`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`：`sessions` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（数据库持续被锁定）、`timeout`。'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: 退出所有会话
      description: |-
        吊销该账号的所有会话，包括当前会话。用这些会话打开的 WebSocket 都会被关闭。无请求体（或 `{}`）。

        **限制：** `sessions`。
      security:
        - bearerAuth: []
      responses:
        '200':
          description: 所有会话均已退出登录。
          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`：请求体不是 `{}`；`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`：`sessions` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（数据库持续被锁定）、`timeout`。'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: 重新发送确认链接
      description: |-
        重新发送邮箱确认链接。无论地址为何，响应都是 202 `accepted`，因此绝不会透露是否有账号或注册申请在使用该地址。

        每个地址每 5 分钟最多处理一次请求。使用该地址等待确认的注册申请会重新获得 24 小时，无论另一个账号是否在使用该地址，这样两种情况下其用户名被保留的时间都一样长。只有在以下情况下才会发送链接：该地址没有账号时，为该注册申请发送（新链接有效期 24 小时，取代之前的链接）；或者为使用该地址、状态正常但尚未确认的账号发送。

        **限制：** `auth`，然后是 `auth_mail`（每个客户端每小时 10 次）。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: 已接受（无论地址为何）。
          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`：`auth` 或 `auth_mail` 限制（无论地址为何）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '带有 `retryAfter: 1` 的 `server_busy`：查询账号时数据库持续被锁定（为等待中的注册申请续期只是尽力而为，从不导致请求失败）。`timeout`。'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: 发送密码重置链接
      description: |-
        发送一个有效期为一小时的密码重置链接，该链接会打开 `/reset-password` 页面。无论地址为何，响应都是 202 `accepted`。链接只发送给状态正常的账号，每个地址每 5 分钟最多一次。仅使用 Google 登录的账号通过这种方式设置第一个密码。

        找回密码的限制是本 API 中最严格的，全部在整台服务器范围内计数：每个客户端（一个 IPv4 地址或一个 IPv6 /64）每小时 3 次（`AUTH_FORGOT_PER_HOUR`）、每 24 小时 10 次（`AUTH_FORGOT_PER_DAY`），每个 IPv6 /48 为这些数值的 3 倍；此外还有 `auth` 限制（每 10 分钟 20 次）以及每个地址每 5 分钟一封邮件的限制。202 和 429 都不会透露是否有账号使用该地址。设置新密码有自己的限制（`auth_reset`）。

        **限制：** `auth`、`auth_forgot`，然后是 `auth_forgot_day`。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: 已接受（无论地址为何）。
          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`：`auth`、`auth_forgot` 或 `auth_forgot_day` 限制（无论地址为何）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '带有 `retryAfter: 1` 的 `server_busy`：数据库持续被锁定。`timeout`。'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: 使用重置链接的令牌设置新密码
      description: |-
        使用重置链接中的令牌设置新密码（`/reset-password` 页面的作用相同）。新密码须遵守注册时的规则。

        重置会吊销所有会话，并取消待处理的邮箱更改；该账号的其他重置链接随即失效；邮箱地址视为已确认（链接已证明这一点）；账号所有者会收到一封邮件。双重验证不受影响。发生密码哈希队列错误或 503 后，链接仍然有效。

        **限制：** `auth`，然后是 `auth_reset`（每个客户端每小时 10 次，每个 IPv6 /48 为 30 次，与该页面共享：每次尝试都要对密码进行哈希）。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: 密码已修改，所有会话均已退出登录。
          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`：链接无效、已被使用或已过期，或者它被发送到了该账号已不再使用的地址。`weak_password`（带有 `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`：`auth` 或 `auth_reset` 限制，或密码哈希队列。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`：密码哈希队列，或数据库持续被锁定（`retryAfter: 1`，未做任何更改）。`timeout`。'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: 开始 Google 登录
      description: |-
        针对 PKCE 质询值和游戏在 `127.0.0.1` 上的监听端口，开始一次 Google 登录尝试。响应给出要在系统浏览器中打开的 Google URL 以及本次尝试的 `state`；尝试的有效期为 10 分钟。

        `authUrl` 包含 `client_id`、`redirect_uri`（由 `redirectPort` 和服务器的来源标记构成）、`response_type=code`、`scope=openid email profile`、`state`、`nonce`、`code_challenge`（服务器自己面向 Google 的 PKCE 验证值的 S256 结果）及 `code_challenge_method=S256`，以及 `prompt=select_account`。游戏在打开它之前会先进行检查（见本分组的说明）。

        **限制：** `sso_start`。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: 本次登录尝试。
          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 登录。'
        '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`：`sso_start` 限制。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（数据库持续被锁定）、`timeout`。'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: 将 Google 的响应交给服务器
      description: |-
        把 Google 发送到游戏监听器的 `code` 和 `state`，连同尝试 ID 和 PKCE 验证值一起交给服务器。没有验证值，尝试 ID 毫无用处；没有服务器自己的 PKCE 验证值和客户端密钥，授权码也毫无用处。

        服务器先检查尝试（未知、已使用或已过期：410），然后检查验证值（错误的验证值不会使尝试失效），随后使用该尝试（仅一次），检查 `state` 和 `iss`，向 Google 兑换授权码并验证 ID 令牌。响应（200）为以下之一：

        - `{ token, expiresAt, user }`：已登录到关联的账号；
        - `{ mfaRequired, mfaToken, expiresIn }`：继续调用 `POST /auth/login/mfa`；
        - `{ needsUsername, ssoTicket, suggestedUsername }`：新账号；请在 10 分钟内继续调用 `POST /auth/sso/complete`。`suggestedUsername` 取自 Google 名称或邮箱地址，没有合适的名称时为 `""`；
        - `{ needsPassword, linkTicket, username, expiresIn }`：使用该地址的账号设有密码；继续调用 `POST /auth/sso/google/link`。此时尚未进行任何关联。

        **限制：** `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: 一个会话、第二步，或首次 Google 登录的下一步。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: 已登录到关联的账号
                  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: 已开启双重验证
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: 新棋手
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: 设有密码的账号正在使用该地址
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`：验证值与该尝试的质询值不匹配（尝试仍可使用）。`sso_email_unverified`：Google 尚未确认该地址。`registration_closed`。`account_disabled`。仅针对已关联的账号：`banned`（带有 `until`）、`email_unverified`。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`：此服务器不提供 Google 登录。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`：某个没有密码的有效账号正在使用该地址（不会透露是哪个账号）。'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`：尝试未知、已使用或已过期。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`sso_finish` 限制。'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（数据库持续被锁定）、`timeout`。'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: 凭密码将 Google 关联到现有账号
      description: |-
        在 `finish` 返回 `needsPassword` 之后调用：使用棋手在游戏中输入的该账号密码，将 Google 关联到现有账号。响应（200）为一个会话（关联已保存，棋手已登录）；或者在开启双重验证时，为 `{ mfaRequired, mfaToken, expiresIn }`：继续调用 `POST /auth/login/mfa`，只有在那里验证码通过后，关联才会被保存。

        密码错误时票据仍可使用，总共可尝试 5 次。尝试次数在检查密码之前扣除：429 `too_many_attempts` 或 428 `pow_required` 不扣除次数，而被密码哈希队列拒绝（503 `server_busy`、429 `rate_limited`）时已扣除一次，并计为该账号的一次失败。失败延迟与 `POST /auth/login` 使用同一个计数器。

        **限制：** `auth`（包括其 IPv6 /48 计数）。**工作量证明：** 与 `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: 一个会话，或登录的第二步。
          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`：密码错误；票据仍然保留，总共可尝试 5 次。'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned`（带有 `until`），仅在密码正确之后。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`：此服务器不提供 Google 登录。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`：该 Google 账号在此期间已被关联到另一个账号。'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`：票据未知、已使用或已过期；第 5 次密码错误；账号的状态或地址自 `finish` 以来已发生变化；或者在保存关联期间，账号的状态、地址、密码或双重验证发生了变化。请从游戏中重新开始。'
        '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`（账号的失败延迟），或 `rate_limited`（`auth` 限制、密码哈希队列）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`：密码哈希队列，或数据库持续被锁定（`retryAfter: 1`，未进行任何关联）。`timeout`。'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: 为首次 Google 登录创建账号
      description: |-
        在 `finish` 返回 `needsUsername` 之后调用：使用所选用户名创建账号（适用注册规则）并登录。该账号没有密码：`hasPassword` 为 `false`，可通过“忘记密码”（`POST /auth/password/forgot`）为它设置密码。

        **限制：** `auth`（包括其 IPv6 /48 计数）。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoCompleteRequest'
            example:
              ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
              username: alice
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: 账号已创建并已登录。
          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 登录。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`（已有账号使用该用户名，或另一个地址的待确认注册申请保留了它）、`sso_already_linked`、`email_taken`。'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`：票据未知、已使用或已过期。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`auth` 限制。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（数据库持续被锁定）、`timeout`。'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: 列出已登录的设备
      description: |-
        账号的有效会话（已登录的设备），最近使用的排在最前。`current` 标记发出此请求的会话。`lastSeenAt` 最多每 5 分钟更新一次；`expiresAt` 是绝对截止时间，空闲期限可能使会话更早结束。

        **限制：** `sessions`。
      security:
        - bearerAuth: []
      responses:
        '200':
          description: 有效会话。
          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`：`sessions` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（数据库持续被锁定）、`timeout`。'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: 让一台设备退出登录
      description: |-
        让账号的某个会话退出登录；也可以是当前会话。用它打开的 WebSocket 会被关闭。无请求体（或 `{}`）。

        **限制：** `sessions`。
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: 该会话已退出登录。
          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`：请求体不是 `{}`，或 `id` 不是有效的 URL 编码；`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：此账号没有该 ID 的有效会话。'
        '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`：`sessions` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（数据库持续被锁定）、`timeout`。'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: 获取账号、等级分和生效中的处罚
      description: |-
        棋手自己所见的账号信息：账号视图（也是每个登录响应中的 `user`）、棋手下过计分对局的每个用时类别各一条等级分记录、生效中的处罚以及封禁。反作弊系统的诚信等级从不显示。

        **限制：** `account`。
      security:
        - bearerAuth: []
      responses:
        '200':
          description: 账号信息。
          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`，或 `invalid_token`（账号已被删除时也是如此）。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`account` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（数据库持续被锁定）、`timeout`。'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: 接受或拒绝直接挑战
      description: |-
        设置其他棋手是否可以通过用户名挑战此棋手。设为 `none` 时，直接挑战会被拒绝：挑战者会被告知该棋手无法对局。无需重新验证身份。

        **限制：** `account`。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: 当前生效的偏好设置。
          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`：`account` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（数据库持续被锁定）、`timeout`。'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: 修改密码
      description: |-
        修改密码。需要当前密码，但即使开启了双重验证，也不需要第二重验证。新密码须遵守注册规则（在检查当前密码之后检查）。

        修改密码会吊销其他所有会话（当前会话保持登录），取消待处理的邮箱更改，使该账号的密码重置链接失效，并向账号所有者发送一封邮件。

        **重新验证身份：** 仅需密码。**限制：** `reauth`，然后是 `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: 密码已修改。
          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`（仅使用 Google 登录的账号）、`weak_password`（带有 `reason`）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`：当前密码错误，或者在检查请求期间密码恰好被重置或修改。'
        '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`（该账号重新验证身份失败），或 `rate_limited`（`reauth` 或 `reauth_user` 限制、账号额度、密码哈希队列）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（密码哈希队列，或数据库持续被锁定）、`timeout`。'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: 开始开启双重验证
      description: |-
        保存一个新的待启用身份验证器密钥（取代之前任何待启用的密钥），并返回它供身份验证器应用使用（以文本形式，以及可显示为二维码的 `otpauth://` URI）。此时双重验证尚未开启：`POST /account/mfa/totp/enable` 使用由该密钥生成的验证码将其开启。

        **重新验证身份：** 仅需密码。**限制：** `reauth`，然后是 `reauth_user`。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: 待启用的密钥。
          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`（仅使用 Google 登录的账号）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`（在检查密码之前检查）。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（该账号重新验证身份失败），或 `rate_limited`（`reauth` 或 `reauth_user` 限制、账号额度、密码哈希队列）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（密码哈希队列，或数据库持续被锁定）、`timeout`。'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: 完成开启双重验证并获取恢复码
      description: |-
        使用由待启用密钥生成的验证码（恰好 6 位数字）开启双重验证。此处不要求密码；密码已在设置步骤中提供。响应包含 10 个恢复码，仅显示这一次。

        **限制：** `reauth`，然后是 `reauth_user`；错误的验证码计入该账号的重新验证身份失败次数。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: 双重验证已开启。
          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` 不是恰好 6 位数字，或请求体不符合结构定义；`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`：验证码错误（请检查设备的时间）。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`；`mfa_setup_required`：没有待启用的密钥（请先调用 `POST /account/mfa/totp/setup`）。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（该账号重新验证身份失败），或 `rate_limited`（`reauth` 或 `reauth_user` 限制、账号额度）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（数据库持续被锁定）、`timeout`。'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: 关闭双重验证
      description: |-
        关闭双重验证。密钥和恢复码会被删除，账号所有者会收到一封邮件。

        **重新验证身份：** 密码，以及身份验证器验证码或恢复码（`code` 和 `recoveryCode` 必须提供其一）。**限制：** `reauth`，然后是 `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: 双重验证已关闭。
          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`（仅使用 Google 登录的账号）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required`（既没有 `code` 也没有 `recoveryCode`，在检查密码之前检查）、`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`（重新验证身份失败，或此账号尝试的验证码过多），或 `rate_limited`（`reauth` 或 `reauth_user` 限制、账号额度、密码哈希队列）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（密码哈希队列，或数据库持续被锁定）、`timeout`。'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: 更换恢复码
      description: |-
        用 10 个新恢复码替换现有恢复码；旧恢复码随即失效。

        **重新验证身份：** 密码，以及放在 `code` 中的身份验证器验证码（恢复码会被拒绝，返回 403 `invalid_code`）。**限制：** `reauth`，然后是 `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: 新的恢复码，仅显示这一次。
          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`（仅使用 Google 登录的账号）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`、`mfa_code_required`、`invalid_code`（提供恢复码时也是如此）。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '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`（重新验证身份失败，或此账号尝试的验证码过多），或 `rate_limited`（`reauth` 或 `reauth_user` 限制、账号额度、密码哈希队列）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（密码哈希队列，或数据库持续被锁定）、`timeout`。'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: 修改邮箱地址
      description: |-
        修改账号的邮箱地址。`newEmail` 会去除首尾空白并转为小写，然后按注册时的地址规则检查。

        **需要邮件确认时**（默认）：返回 202 `verification_sent`。一个有效期为 24 小时的链接会发送到新地址；它会打开 `/confirm-email-change`，只有当棋手按下该页面上的按钮时，地址才会更改。在此之前，`GET /account/me` 将新地址显示为 `pendingEmail`。新的请求会取代待处理的请求；修改或重置密码会取消它。无论由谁发起，每个新地址每 5 分钟最多收到一个链接（针对已在待处理中的同一更改再次请求时，保留先前发送的链接，该链接仍然有效）；响应保持不变。当前地址会收到一条通知，告知有人请求将地址改为某个经过打码的地址（`a***@example.org`）。如果新地址已被另一个账号使用，响应和 `pendingEmail` 也完全相同：此时不发送链接，因此这次更改永远不会完成，改为向该地址的所有者发送一条通知（每小时最多一条）。

        链接确认后，地址随即更改并视为已确认，各设备保持登录状态，先前发送的链接（确认、密码重置、其他更改）失效，原地址会收到通知，其中的新地址经过打码处理。

        **不需要邮件确认时**（`REQUIRE_EMAIL_VERIFICATION=false`）：地址立即更改（200 `email_changed`），原地址会收到通知。如果另一个账号正在使用该地址，响应为 409 `email_taken`，该地址的所有者会收到通知。

        **重新验证身份：** 密码；开启双重验证时，还需身份验证器验证码或恢复码。`invalid_email` 和 `same_email` 在检查密码之前检查，因此不计为失败。**限制：** `reauth`，然后是 `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: 地址已立即更改（此服务器不需要邮件确认）。
          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: 新地址，与存储的形式一致。
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: 确认链接已发送到新地址（也可能该地址属于另一个账号；响应不会透露这一点）。
          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`（即账号当前的地址）、`password_not_set`（仅使用 Google 登录的账号）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`（若在处理请求期间密码先被修改或重置，也会返回此错误：不发送链接）、`mfa_code_required`、`invalid_code`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`：仅在不需要邮件确认时。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（重新验证身份失败，或此账号尝试的验证码过多），或 `rate_limited`（`reauth` 或 `reauth_user` 限制、账号额度、密码哈希队列）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`：密码哈希队列，或数据库持续被锁定（`retryAfter: 1`：未做任何更改，可以再次发送相同的请求）。`timeout`。'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: 下载账号数据
      description: |-
        服务器保存的有关该账号的全部信息，以一个可保存的 JSON 文件提供（`format` 为 `scacelith-account-export`，`version` 为 1）。导出会记录一个安全事件（`account_exported`）。文档中的 `notes` 数组用简明的英文告诉棋手哪些内容未包含在内。

        **导出中绝不包含：** 密码哈希、双重验证密钥和恢复码；任何会话令牌或链接令牌，或其哈希值；反作弊系统的数据（诚信等级和评分、异常情况、对局分析、举报的权重）；其他棋手对该棋手的举报；管理员的身份；其他棋手的私人数据（对手仅以其公开名称和等级分出现，不会透露其他棋手是否受到处罚，也不包含任何可能属于他人的 IP 地址）。

        **重新验证身份：** 密码；开启双重验证时，还需身份验证器验证码或恢复码。**限制：** `account_export`（每位棋手每小时 5 次，在整台服务器范围内计数；每次尝试都计数，包括失败的尝试；最先检查），然后是 `reauth` 和 `reauth_user`。**时间限制：** 60 秒。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: 导出文档，以附件形式提供。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`。用户名中除字母、数字、`_`、`.` 和 `-` 以外的字符都会变为 `_`。'
              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`（仅使用 Google 登录的账号）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`、`mfa_code_required`、`invalid_code`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（重新验证身份失败，或此账号尝试的验证码过多），或 `rate_limited`（`account_export`、`reauth` 或 `reauth_user` 限制、账号额度、密码哈希队列）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（数据库持续被锁定，带有 `Retry-After: 1` 标头）、`server_busy`（密码哈希队列）、`timeout`（60 秒）。'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: 删除账号
      description: |-
        删除账号；此操作无法撤销。

        - 所有会话立即被吊销：此后使用该令牌会得到 401 `invalid_token`。
        - 用户名在账号和所有棋谱中都变为 `deleted#<id>`。
        - 以下内容会被清除：邮箱地址、密码哈希、双重验证密钥和恢复码、会话和链接令牌、Google 关联、反作弊系统的诚信记录，以及随安全事件存储的 IP 地址。
        - 等级分和对局会保留。对局仍可在匿名名称下查看，`GET /players/{username}` 对原用户名返回 404。

        **重新验证身份：** 密码；开启双重验证时，还需身份验证器验证码或恢复码。**限制：** `reauth`，然后是 `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: 账号已删除。
          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`（仅使用 Google 登录的账号）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`、`mfa_code_required`、`invalid_code`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（重新验证身份失败，或此账号尝试的验证码过多），或 `rate_limited`（`reauth` 或 `reauth_user` 限制、账号额度、密码哈希队列）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（密码哈希队列，或数据库持续被锁定）、`timeout`。'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: 列出棋手的对局（支持筛选和分页）
      description: |-
        已登录棋手的对局，最新的排在最前，支持筛选和分页，并给出符合筛选条件的对局数。所有查询参数都是可选的，空值视为未提供。

        分页：将上一页的 `next` 作为 `before` 传入；最后一页的 `next` 为 `null`。`total` 统计所有页中符合筛选条件的对局数。

        **限制：** `account_games`。
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: 官方用时类别 ID（`3+2` 或 `3%2B2`），或 `custom`，表示所有使用其他用时的对局。
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: 仅计分对局（`true`）或仅不计分对局（`false`）。
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: 从棋手一方来看，仅显示胜局、负局或和局。已中止的对局只在不使用此筛选条件时出现。
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: 对局记录的一页。
          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` 不是对局 ID）、`invalid_limit` 或 `invalid_filter`（`category`、`rated` 或 `result`），均带有指出参数名的 `field`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`account_games` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（数据库持续被锁定；响应体中带有 `retryAfter: 1`，但没有 `Retry-After` 标头）、`server_busy`（查询会话时发现数据库被锁定）、`timeout`。'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: 获取包含着法和棋钟时间的棋谱
      description: |-
        一份棋谱，包含着法和棋钟时间。不带令牌，或者令牌所属棋手没有参加该对局时，返回公开版本。当令牌所属棋手参加了该对局时，响应还会附加 `you` 和 `reportable`。

        **限制：** `public_read`（带令牌时按棋手计算，不带令牌时按客户端计算）。
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: 棋谱。
          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`：不是最多 16 位数字（小于 2^53）的正整数；`invalid_request`：不是有效的 URL 编码。'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`：发送了令牌，但令牌无效。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：没有该对局。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`public_read` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（数据库持续被锁定；响应体中带有 `retryAfter: 1`，但没有 `Retry-After` 标头）、`server_busy`（查询会话时发现数据库被锁定）、`timeout`。'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: 以 PGN 文件下载对局
      description: |-
        以 PGN 文件提供的同一对局：文件包含一局对局，使用 `\n` 换行，着法部分每行少于 80 列。无论是否带有令牌，响应都相同。

        标签按以下顺序排列：`Event`（`<SERVER_NAME> rated <category>` 或 `<SERVER_NAME> casual <category>`）、`Site`（`SERVER_PUBLIC_HOST`）、`Date`（UTC 开始日期）、`Round`、`White`、`Black`、`Result`（已中止的对局为 `*`）、`UTCDate` 和 `UTCTime`（开始时间）、`WhiteElo` 和 `BlackElo`（开始时的等级分，或 `-`）、`WhiteRatingDiff` 和 `BlackRatingDiff`（等级分变化，例如 `+10` 和 `-10`，每局计分对局都有；若等级分规则使等级分保持不变，则为 `+0`；不计分对局、自定义用时对局和已中止的对局都没有这两个标签）、`TimeControl`（以秒为单位）、`Termination`（PGN 标准值：`normal`；`time forfeit`，即超时，包括以和棋告终的情况；`abandoned`；`rules infraction`，即第二次违例着法或因违反公平对弈而判负；`unterminated`，即已中止的对局）、`PlyCount`、`ScacelithGameId`（十进制 ID）。

        每步着法都带有 `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`：走棋方在该步之后的棋钟剩余时间，以及该步所计的用时，精确到十分之一秒（截断）。若记录中缺少某个值，则省略该值。最后一步之后，先以英文文字写出结束原因，然后是结果。

        **限制：** `public_read`（带令牌时按棋手计算，不带令牌时按客户端计算）。
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: PGN 文件。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-<id>.pgn"`。'
              schema:
                type: string
                pattern: '^attachment; filename="scacelith-[1-9][0-9]{0,15}\.pgn"$'
              example: attachment; filename="scacelith-4100000000001.pgn"
          content:
            application/x-chess-pgn:
              schema:
                type: string
                description: 'PGN 文本，采用 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`：不是最多 16 位数字（小于 2^53）的正整数；`invalid_request`：不是有效的 URL 编码。'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`：发送了令牌，但令牌无效。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：没有该对局。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`public_read` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`：原因之一是已存储的着法无法重放到已存储的结局（服务器会记入日志）。'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（数据库持续被锁定；响应体中带有 `retryAfter: 1`，但没有 `Retry-After` 标头）、`server_busy`（查询会话时发现数据库被锁定）、`timeout`。'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: 以 GIF 动图下载本服务器上的对局
      description: |-
        以 GIF 动图呈现的对局。名字和等级分取自棋谱（开始时的等级分，已删除的账号显示为 `deleted#<id>`），结果和结束方式（`Resignation`、`Loss on time`……）也同样取自棋谱。

        检查按以下顺序进行：`gif_disabled`、图像选项（`size`、`orientation`、`delay`、`coords`）、对局 ID、对局、对局长度。

        **需要会话：** 配额按账号计算。**限制：** 每个请求都计入 `gif`；仅在需要生成 GIF 时才计入 `gif_user_min`、`gif_user_hour`、`gif_ip_min` 和 `gif_ip_hour`（见本分组的说明）。**时间限制：** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 秒（默认 45 秒）。
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: 图像尺寸：`small`（每格 32 像素）、`medium`（48 像素）或 `large`（72 像素）。
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: 位于棋盘下方的一方。
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: 每步着法的毫秒数（1 到 6 位十进制数字）。
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: 是否在棋盘四周绘制坐标（直线字母和横线数字）：绘制为 `1`，不绘制为 `0`。
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '带有 `field`（`size`、`orientation`、`delay` 或 `coords`）的 `invalid_option`：值不在允许范围内。`invalid_game_id`。`invalid_request`：不是有效的 URL 编码。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：没有该对局。`gif_disabled`：服务器已关闭 GIF 功能（`GIF_ENABLED=false`；`gif` 令牌会被退还）。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`gif` 限制、某项渲染限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`：无法生成 GIF（服务器会在日志中记录原因）。`internal_error`。'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '带有 `retryAfter`（3 到 10 秒）和 `Retry-After` 的 `server_busy`：渲染队列已满，或 GIF 等待空闲线程已达 `GIF_QUEUE_TIMEOUT_MS`（10 秒）；该请求占用的所有限制令牌都会被退还。`busy`：数据库持续被锁定（仅在响应体中带有 `retryAfter: 1`）。`timeout`。'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: 将以 PGN 发送的任意对局制作成 GIF 动图
      description: |-
        为以 PGN 文本发送的任意对局生成与 `GET /games/{id}/gif` 相同的图像：游戏保存的对局、从其他网站导出的对局、手工输入的对局均可。只使用文本中的第一局对局。

        - PGN 解析器接受游戏自带解析器所接受的内容：本服务器写出的所有 PGN，以及其他网站的常见导出格式（注释、变着、数字注释符号（NAG）和棋钟标注会被跳过；回合编号和 SAN 着法以宽松方式读取；除非 `SetUp` 为 `"0"`，否则由 `FEN` 标签给出起始局面）。
        - 名字和等级分取自 `White`、`Black`、`WhiteElo` 和 `BlackElo` 标签。带重音符号的字母会去掉重音符号，可打印 ASCII 以外的其他字符变为 `?`，过长的名字会被截断（48 个字符）。结果取自 `Result` 标签，若没有则取自着法部分的末尾。除非 `Termination` 标签为 `normal`，否则会显示它；为该值时，最终局面本身就说明了一切（将死、逼和）。
        - 此端点自行检查请求体：未知字段，或 `pgn` 缺失或不是字符串时，返回带有 `field` 的 400 `invalid_request`；选项错误时返回 400 `invalid_option`（`delayMs` 必须是 JSON 数字，`coords` 必须是布尔值；不接受 `null`）。

        **需要会话：** 配额按账号计算。**限制：** 与 `GET /games/{id}/gif` 相同。**请求体上限：** 135,168 字节，与 `HTTP_BODY_LIMIT` 无关（按 JSON 字符串形式的 PGN 计算，包括其转义字符）。**时间限制：** 默认 45 秒。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: 手工输入的短对局，不带坐标
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: 使用所有选项的 PGN 文件
                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: '带有 `field` 的 `invalid_request`（未知字段，或 `pgn` 缺失或不是字符串；请求体不是对象时不带 `field`）；`invalid_json`；带有 `field`（`size`、`orientation`、`delayMs` 或 `coords`）的 `invalid_option`；带有 `line` 和 `column`（从 1 开始，列按字符计算）以及解析器 `message` 的 `invalid_pgn`：不合规则或有歧义的着法、损坏的标签、未知的变体、超过 65,536 字节。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`：服务器已关闭 GIF 功能（`GIF_ENABLED=false`；`gif` 令牌会被退还）。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`：请求体超过 135,168 字节。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`gif` 限制、某项渲染限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`：无法生成 GIF（服务器会在日志中记录原因）。`internal_error`。'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '带有 `retryAfter`（3 到 10 秒）和 `Retry-After` 的 `server_busy`：渲染队列已满，或 GIF 等待空闲线程的时间过长；该请求占用的所有限制令牌都会被退还。`timeout`。'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: 获取棋手的公开资料
      description: |-
        棋手的公开资料：各官方用时类别的等级分（按服务器的顺序）和对局数。`games.total` 统计所有已存储的对局，包括不计分对局和已中止的对局；`games.rated`、`wins`、`draws` 和 `losses` 是对各条等级分记录求和得出的，因此只包含计分对局。无论是否带有令牌，响应都相同。

        **限制：** `public_read`（带令牌时按棋手计算，不带令牌时按客户端计算）。
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: 公开资料。
          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`：不是由 2 到 24 个 `[A-Za-z0-9_.-]` 字符组成。'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`：发送了令牌，但令牌无效。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：没有该棋手，或该账号已删除。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`public_read` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（数据库持续被锁定；响应体中带有 `retryAfter: 1`，但没有 `Retry-After` 标头）、`server_busy`（查询会话时发现数据库被锁定）、`timeout`。'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: 列出棋手最近的对局
      description: |-
        该棋手最近的对局，最新的排在最前，分页方式与对局记录相同，但没有筛选条件和总数。`color` 是该棋手所执的一方。只要本页已满，`next` 就是最后一局的 ID，因此下一页可能为空。服务器会先查找棋手：无论查询参数如何，未知棋手都返回 404。

        **限制：** `public_read`（带令牌时按棋手计算，不带令牌时按客户端计算）。
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: 该棋手对局的一页。
          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` 不是对局 ID）、`invalid_limit`（后两者不带 `field`）。'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`：发送了令牌，但令牌无效。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：没有该棋手，或该账号已删除。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`public_read` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（数据库持续被锁定；响应体中带有 `retryAfter: 1`，但没有 `Retry-After` 标头）、`server_busy`（查询会话时发现数据库被锁定）、`timeout`。'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: 获取某个用时类别的顶尖棋手
      description: |-
        某个官方用时类别中排名前 100 的等级分记录，要求已计入的对局至少为 `minGames`（`PROVISIONAL_GAMES`）局，已删除的账号和已确认的作弊者不在其中。服务器最多每 10 秒重新计算一次各个排行榜；`updatedAt` 给出计算时间。

        **限制：** 仅按地址限制层。
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: 官方用时类别 ID（`3+2` 或 `3%2B2`）。
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: 棋手人数，1 到 100。不超过 3 位数字的更大数值按 100 计；空值视为未提供。
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: 排行榜。
          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`（缺失，或不是官方用时类别）、`invalid_limit`。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：按地址限制层。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（数据库持续被锁定；响应体中带有 `retryAfter: 1`，但没有 `Retry-After` 标头）、`timeout`。'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: 举报最近一局对局的对手
      description: |-
        举报棋手自己在最近 7 天内结束的某局对局中的对手。举报本身绝不会改变等级分、处罚或诚信等级。它会提高管理员所看到的审核优先级；除 `abuse` 外，还会请求对该对局进行引擎分析。

        针对同一对局再次举报同一对手会得到相同的响应，且不会有任何改变；响应绝不会透露有关被举报账号的任何信息。`GET /games/{id}` 会事先告知对局双方举报是否会被受理（`reportable`）。

        此端点自行检查请求体，顺序为 `gameId`、`reported`、`category`、`comment`：检查失败时返回不带 `field` 的 400 `invalid_request`。未知字段会被忽略。

        **限制：** `reports`（按棋手计算），然后是每位棋手 24 小时内 `REPORTS_PER_DAY`（5）次举报（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: 举报已收到（或此前已提交过）。
          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`（不带 `field`）、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`：被举报者不是举报者在最近 7 天内结束的对局中的对手。'
        '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`，带有 `Retry-After` 标头）：最近 24 小时内已提交 `REPORTS_PER_DAY` 次举报。`rate_limited`：`reports` 限制或账号额度。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（查询会话时发现数据库被锁定）、`timeout`。'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: 官方服务器（页面位于根路径下，不在 `/api/v1` 中）。
      - url: https://{host}:{port}
        description: 任意 Scacelith 服务器（页面位于根路径下，不在 `/api/v1` 中）。
        variables:
          host:
            default: caissa.scacelith.com
            description: 服务器的公开主机名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公开的 API 端口（`PUBLIC_API_PORT`，未设置时为 `API_PORT`）。
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: 显示邮箱确认页面
      description: |-
        邮箱确认链接所打开的页面（注册时发送的确认邮件，或重新发送的确认邮件）。页面只显示一个“Confirm my e-mail address”（确认我的邮箱地址）按钮，这样邮件扫描程序打开链接时不会把它用掉；该按钮将表单提交到 `POST /verify-email`。

        **限制：** `page`。
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: 带有确认按钮的页面。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: 链接无效或已过期（HTML 页面）。
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`page` 限制（HTML 页面），或按地址限制层（JSON `rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: 确认邮箱地址
      description: |-
        确认页面的表单。它会确认邮箱地址；对于新的注册申请，此时才会创建账号，棋手随后即可登录。

        **限制：** `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: 邮箱地址已确认（对于新的注册申请，账号也已创建）。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: 链接无效、已被使用或已过期，或表单无效（HTML 页面）。
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: 新注册申请的链接，但其用户名或地址在此期间已被另一个账号占用；不会创建账号（HTML 页面）。
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`auth` 限制（HTML 页面），或按地址限制层（JSON `rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: '数据库持续被锁定（`Retry-After: 1`）：未做任何更改，链接仍然有效。处理程序超时时也返回此状态码。'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: 官方服务器（页面位于根路径下，不在 `/api/v1` 中）。
      - url: https://{host}:{port}
        description: 任意 Scacelith 服务器（页面位于根路径下，不在 `/api/v1` 中）。
        variables:
          host:
            default: caissa.scacelith.com
            description: 服务器的公开主机名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公开的 API 端口（`PUBLIC_API_PORT`，未设置时为 `API_PORT`）。
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: 显示密码重置表单
      description: |-
        密码重置链接所打开的页面：新密码表单（密码需输入两次）。表单提交到 `POST /reset-password`。

        **限制：** `page`。
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: 新密码表单。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: 链接无效（链接被发送到了该账号已不再使用的地址时也是如此）。
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`page` 限制（HTML 页面），或按地址限制层（JSON `rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: 通过重置表单设置新密码
      description: |-
        重置页面的表单；其作用与 `POST /auth/password/reset` 相同：所有设备都会退出登录，待处理的邮箱更改会被取消，其他重置链接失效，邮箱地址视为已确认，账号所有者会收到一封邮件。

        先检查链接，然后检查两次输入的密码是否一致，最后检查密码规则。服务器繁忙时，会重新返回表单并带有 `Retry-After`，链接仍然有效。

        **限制：** `auth`，然后是 `auth_reset`（与 `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: 密码已修改，所有设备均已退出登录。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: 重新返回带有错误信息的表单（两次密码不一致、密码太弱），或链接无效，或表单无效（HTML 页面）。
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`auth` 或 `auth_reset` 限制（HTML 页面）；该客户端等待中的密码哈希过多时，重新返回带有 `Retry-After` 的表单（链接仍然有效）；或按地址限制层（JSON `rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 服务器繁忙时（密码哈希队列，或数据库持续被锁定），重新返回带有 `Retry-After` 的表单；链接仍然有效。处理程序超时时也返回此状态码。
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: 官方服务器（页面位于根路径下，不在 `/api/v1` 中）。
      - url: https://{host}:{port}
        description: 任意 Scacelith 服务器（页面位于根路径下，不在 `/api/v1` 中）。
        variables:
          host:
            default: caissa.scacelith.com
            description: 服务器的公开主机名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公开的 API 端口（`PUBLIC_API_PORT`，未设置时为 `API_PORT`）。
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: 显示邮箱更改确认页面
      description: |-
        邮箱更改链接所打开的页面：显示新地址和账号名称，并带有一个“Use this e-mail address”（使用此邮箱地址）按钮，该按钮将表单提交到 `POST /confirm-email-change`。

        **限制：** `page`。
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: 显示新地址及其确认按钮的页面。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: 链接无效或已过期（自发出请求以来账号的地址已发生变化时也是如此）。
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`page` 限制（HTML 页面），或按地址限制层（JSON `rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: 确认新邮箱地址
      description: |-
        邮箱更改页面的表单。地址随即更改并视为已确认；各设备保持登录状态；先前发送的链接（确认、密码重置、其他更改）失效；原地址会收到通知，其中的新地址经过打码处理。

        **限制：** `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: 地址已更改。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: 链接无效、已被使用或已过期，或表单无效（HTML 页面）。
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: 该地址在此期间已被另一个账号占用（HTML 页面）。
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`auth` 限制（HTML 页面），或按地址限制层（JSON `rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: '数据库持续被锁定（`Retry-After: 1`）：未做任何更改，链接仍然有效。处理程序超时时也返回此状态码。'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: 官方服务器，根路径下。
      - url: https://caissa.scacelith.com/api/v1
        description: 官方服务器，`/api/v1` 下。
      - url: https://{host}:{port}
        description: 任意 Scacelith 服务器，根路径下。
        variables:
          host:
            default: caissa.scacelith.com
            description: 服务器的公开主机名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公开的 API 端口（`PUBLIC_API_PORT`，未设置时为 `API_PORT`）。
      - url: https://{host}:{port}/api/v1
        description: 任意 Scacelith 服务器，`/api/v1` 下。
        variables:
          host:
            default: caissa.scacelith.com
            description: 服务器的公开主机名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公开的 API 端口（`PUBLIC_API_PORT`，未设置时为 `API_PORT`）。
    get:
      operationId: getLiveness
      tags:
        - health
      summary: 检查进程是否在运行
      description: |-
        进程运行期间返回 200 `ok`。也可以使用 `HEAD`。其他任何方法（包括 `OPTIONS`）都返回 405 `method_not_allowed`，并带有 `Allow: GET, HEAD`。

        **限制：** 仅按地址限制层。
      security: []
      responses:
        '200':
          description: 进程正在运行。
          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`：按地址限制层。'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: 官方服务器，根路径下。
      - url: https://caissa.scacelith.com/api/v1
        description: 官方服务器，`/api/v1` 下。
      - url: https://{host}:{port}
        description: 任意 Scacelith 服务器，根路径下。
        variables:
          host:
            default: caissa.scacelith.com
            description: 服务器的公开主机名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公开的 API 端口（`PUBLIC_API_PORT`，未设置时为 `API_PORT`）。
      - url: https://{host}:{port}/api/v1
        description: 任意 Scacelith 服务器，`/api/v1` 下。
        variables:
          host:
            default: caissa.scacelith.com
            description: 服务器的公开主机名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公开的 API 端口（`PUBLIC_API_PORT`，未设置时为 `API_PORT`）。
    get:
      operationId: getReadiness
      tags:
        - health
      summary: 检查服务器是否接受棋手
      description: |-
        服务器接受棋手时返回 200 `ready`，启动或停止期间返回 503 `not_ready`。也可以使用 `HEAD`。其他任何方法（包括 `OPTIONS`）都返回 405 `method_not_allowed`，并带有 `Allow: GET, HEAD`。

        **限制：** 仅按地址限制层。
      security: []
      responses:
        '200':
          description: 服务器正在接受棋手。
          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`：按地址限制层。'
        '503':
          description: 服务器正在启动或停止。
          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: |-
        从登录响应中得到的会话令牌（`sct_` 后跟 43 个 base64url 字符），放在 `Authorization` 标头中：`Authorization: Bearer <token>`。前缀必须恰好是 `Bearer`（区分大小写）加一个空格；没有该前缀的值，或者不是由 1 到 512 个可打印 ASCII 字符组成的令牌，都会像其他无效令牌一样返回 401 `invalid_token`。空标头视为没有标头。
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: 对局 ID，最多 16 位、无前导零的十进制正整数，小于 2^53。
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: 会话 ID，与 `GET /auth/sessions` 给出的一致，不带前导零（`03` 不是会话 3）。
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: 棋手的用户名，不区分大小写。服务器接受任何由 2 到 24 个 `[A-Za-z0-9_.-]` 字符组成的名称（这是它曾经允许过的最宽松的规则）。
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: 一个对局 ID；只列出比它更早的对局。传入上一页的 `next`。空值视为未提供。
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: 每页数量，1 到 50。不超过 3 位数字的更大数值按 50 计；空值视为未提供。
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: 邮件链接中的令牌（43 个 base64url 字符）。缺失或错误时，显示“链接无效或已过期”页面。
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: 服务器的每个响应都禁止缓存。
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: 重试前需等待的秒数；与响应体中的 `retryAfter` 值相同。
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Bearer 质询，令牌无效时带有 `error="invalid_token"`。
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: 服务器在发送此响应后关闭连接。
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request`（请求体中某个字段有问题时带有 `field`）或 `invalid_json`。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: 某个字段不符合结构定义
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: 请求体不是 JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: 密码太弱
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: 无法读取的 PGN
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized`（没有 `Authorization` 标头）或 `invalid_token`（令牌格式错误、已过期、已被吊销或属于已删除的账号）。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: 没有会话
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: 无效的令牌
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: 请求被拒绝。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: 重新验证身份时密码错误
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: 已被封禁的账号
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found`，或此服务器已关闭的功能。'
      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`：请求体未在 10 秒内到达。服务器会关闭连接。'
      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: 请求与服务器的当前状态冲突。
      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`：Google 登录尝试或票据未知、已使用或已过期；请从游戏中重新开始。'
      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`：请求体超过 `HTTP_BODY_LIMIT`（默认 16,384 字节）。服务器会关闭连接。'
      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`：请求目标超过 4096 个字符。'
      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`：请求体不是 `application/json`，或其字符集不是 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`：对局超过 `GIF_MAX_PLIES`（600）个半回合。'
      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`：解出 `pow` 质询，然后带上 `pow: { challenge, nonce }` 再次发送同一请求。`reason` 说明原因：`required`、`malformed`、`signature`、`endpoint`、`network`、`expired`、`bits`、`work` 或 `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`（速率限制、账号额度或密码哈希队列）或 `too_many_attempts`（账号限流），带有 `retryAfter` 和 `Retry-After` 标头。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rateLimited:
              summary: 速率限制
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: 此账号失败次数过多
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`：意外故障。服务器会将其记入日志。'
      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`：`state` 或 `iss` 错误，或者 Google 拒绝了授权码，或发送了无法通过验证的 ID 令牌。此错误绝不包含 Google 返回的文字。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: sso_failed
            message: Google sign-in could not be completed.
    ServiceUnavailable:
      description: '`server_busy`、`busy` 或 `timeout`：如果给出了 `retryAfter`，请在其后重试。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: 服务器繁忙
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: 数据库持续被锁定（读取操作）
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: 服务器响应超时
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: GIF 动图，以附件形式提供。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '本服务器上的对局为 `attachment; filename="scacelith-<id>.gif"`，通过 `POST /gif` 发送的 PGN 为 `attachment; filename="scacelith-game.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: 文件大小，以字节为单位。
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: 一个 HTML 页面。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: 说明拒绝原因的 HTML 页面。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: 表单未在 10 秒内到达（HTML 页面）。服务器会关闭连接。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: 说明冲突的 HTML 页面。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: 表单超过 `HTTP_BODY_LIMIT`（HTML 页面）。服务器会关闭连接。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: 请求体既不是表单（`application/x-www-form-urlencoded`）也不是 JSON，或其字符集不是 UTF-8（HTML 页面）。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: 速率限制（HTML 页面），带有 `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: 意外故障（HTML 页面，标题为“Server error”，即“服务器错误”）。服务器会将其记入日志。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: 服务器繁忙（数据库持续被锁定，带有 `Retry-After`）或处理超时（HTML 页面，标题为“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: JSON API 所有错误响应的统一格式。
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: '`snake_case` 形式的错误代码。客户端据此决定显示什么。'
          examples:
            - rate_limited
        message:
          type: string
          description: 一句英文，用于日志记录和兜底显示。
        retryAfter:
          type: integer
          minimum: 1
          description: 重试前需等待的秒数，仅出现在会随时间解除的拒绝中。此时响应还会带有值相同的 `Retry-After` 标头，但读取对局记录、对局、棋手和排行榜时的 503 `busy` 除外。
        field:
          type: string
          description: 出错的字段或查询参数（用于 `invalid_request`、`invalid_option`，以及 `GET /account/games` 的 `invalid_cursor`、`invalid_limit` 和 `invalid_filter`）。嵌套字段用点号连接（`pow.nonce`）。
        reason:
          type: string
          description: '原因：`weak_password` 违反的规则，或要求或拒绝工作量证明的原因（`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`：封禁的结束时间（Unix 毫秒时间戳），永久封禁时为 `null`。'
        line:
          type: integer
          minimum: 1
          description: '仅用于 `invalid_pgn`：错误所在的行，从 1 开始。'
        column:
          type: integer
          minimum: 1
          description: '仅用于 `invalid_pgn`：错误所在的列，从 1 开始，按字符计算。'
    PowChallenge:
      type: object
      description: 工作量证明质询（428 `pow_required` 中的 `pow`）。
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: 已签名的质询，须原样发回。
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: '`SHA-256(challenge + ":" + nonce)` 必须具有的前导零位数。'
        expiresAt:
          type: integer
          format: int64
          description: 质询的过期时间（Unix 毫秒时间戳），为签发后 2 分钟。
    PowAnswer:
      type: object
      description: 对工作量证明质询的应答。
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: 428 响应中的 `challenge`，原样提供。
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: 最多 20 位数字的十进制字符串，使 `SHA-256(challenge + ":" + nonce)` 以 `bits` 个零位开头。
    ServerInfo:
      type: object
      description: 客户端在登录或连接之前需要的信息。
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: 服务器的名称（`SERVER_NAME`）。
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: 数据库首次启动时获得的 UUID。重启后保持不变。数据库无法提供时为 `null`。
        motd:
          type: string
          description: 每日消息（`SERVER_MOTD`），可能为空。
        protocol:
          type: object
          description: WebSocket 协议（`PROTOCOL.md`）。
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: 服务器支持的最旧协议版本。
            max:
              type: integer
              minimum: 1
              description: 服务器支持的最新协议版本。
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 协议结构定义的指纹（其规范形式的 SHA-256 的前 4 个字节，表示为无符号整数）；仅供参考。
            subprotocol:
              type: string
              description: WebSocket 子协议标识（`Sec-WebSocket-Protocol`）。
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: 棋手使用的 WebSocket 端口（`PUBLIC_WS_PORT`；未设置时为 `WS_PORT`；仍未设置时为 `API_PORT`）。
        wsPath:
          type: string
          const: /ws
          description: WebSocket 的路径。
        registration:
          type: string
          enum:
            - open
            - closed
          description: 是否可以创建新账号。
        emailVerification:
          type: boolean
          description: 新账号是否需要确认其邮箱地址（`REQUIRE_EMAIL_VERIFICATION`）。
        sso:
          type: object
          required:
            - google
          properties:
            google:
              type: boolean
              description: 是否提供 Google 登录。
        mfa:
          type: boolean
          const: true
          description: 双重验证始终可用。
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: 注册所需的工作量证明位数（0 表示不需要）。
        categories:
          type: array
          description: 官方（计分）用时（`RATED_CATEGORIES`），按服务器的顺序排列。其他任何用时都属于 `custom`。
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: 客户端在提交表单之前可以自行检查的规则。
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: 用户名的最短长度（`USERNAME_MIN`）。
            usernameMax:
              type: integer
              description: 用户名的最大长度（`USERNAME_MAX`）。
            usernamePattern:
              type: string
              description: 新用户名必须匹配的正则表达式。
            passwordMinLength:
              type: integer
              description: 密码的最短长度，以字符计（`PASSWORD_MIN_LENGTH`）。
            passwordMaxBytes:
              type: integer
              description: 密码的最大长度，以 UTF-8 字节计。
            customTimeControls:
              type: boolean
              description: 挑战和私人对局是否可以使用自定义用时。
            reportsPerDay:
              type: integer
              description: 每位棋手每 24 小时可提交的举报次数（`REPORTS_PER_DAY`）。
            wsMaxMessageBytes:
              type: integer
              description: 客户端可发送的最大 WebSocket 消息，以字节计。
      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: 一种官方用时。
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 用时类别 ID，由分钟数和加秒秒数组成（`3+2`）。
        baseSec:
          type: number
          minimum: 0
          description: 基本用时，以秒计。
        incSec:
          type: number
          minimum: 0
          description: 每步的加秒，以秒计。
    AccountView:
      type: object
      description: 棋手自己所见的账号信息（登录响应和 `GET /account/me` 中的 `user`）。
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: 账号 ID。
        username:
          type: string
          description: 用户名。
        email:
          type: string
          format: email
          description: 邮箱地址。
        emailVerified:
          type: boolean
          description: 邮箱地址是否已确认。
        mfaEnabled:
          type: boolean
          description: 是否已开启双重验证。
        googleLinked:
          type: boolean
          description: 是否已关联 Google 账号。
        hasPassword:
          type: boolean
          description: '对于通过 Google 创建、尚未设置密码的账号，为 `false`。'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: 是否接受通过用户名发起的直接挑战（见 `PUT /account/preferences`）。
        createdAt:
          type: integer
          format: int64
          description: 账号的创建时间（Unix 毫秒时间戳）。
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: 上次登录时间（Unix 毫秒时间戳），或 `null`。
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: 等待链接确认的邮箱更改中的新地址，或 `null`。
    SessionAnswer:
      type: object
      description: 一个新会话。
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: 会话令牌，用于 `Authorization` 标头和 WebSocket。
        expiresAt:
          type: integer
          format: int64
          description: 会话的绝对截止时间（Unix 毫秒时间戳）；空闲期限可能使其更早结束。
        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: 开启双重验证时登录的第二步；继续调用 `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: 该步骤的令牌，用于 `POST /auth/login/mfa`。
        expiresIn:
          type: integer
          const: 300
          description: 完成该步骤剩余的秒数。
    LoginAnswer:
      description: 一个会话，或开启双重验证时登录的第二步。
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: 首次 Google 登录；继续调用 `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: 用于 `POST /auth/sso/complete` 的票据，有效期 10 分钟。
        suggestedUsername:
          type: string
          description: 根据 Google 名称或邮箱地址生成的用户名；没有合适的名称时为 `""`。
    SsoNeedsPassword:
      type: object
      description: 设有密码的账号正在使用该地址；继续调用 `POST /auth/sso/google/link`。此时尚未进行任何关联。
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: 用于 `POST /auth/sso/google/link` 的票据。
        username:
          type: string
          description: 该账号的用户名（只有已向 Google 证明拥有该地址的人才能看到）。
        expiresIn:
          type: integer
          const: 600
          description: 票据的剩余有效秒数。
    SsoFinishAnswer:
      description: '`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: 一次 Google 登录尝试。
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: 尝试的标识，用于 `POST /auth/sso/google/finish`。
        authUrl:
          type: string
          format: uri
          description: 经检查后在系统浏览器中打开的 Google URL。
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Google 必须发回的 `state`。
        expiresIn:
          type: integer
          const: 600
          description: 尝试的剩余有效秒数。
    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: 用户名；须符合服务器的规则（见端点说明）。
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: 邮箱地址。去除首尾空白并以小写存储。
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: 密码；须符合密码规则。
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: 用户名或邮箱地址（任何包含 `@` 的文本都视为邮箱地址）。
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: 可选。显示在已登录设备列表中（例如 `Scacelith 1.4 (Windows)`）。
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: 登录响应（或 Google 登录响应）中的 `mfaToken`。
        code:
          type: string
          maxLength: 32
          description: 可选。6 位身份验证器验证码或恢复码。
        recoveryCode:
          type: string
          maxLength: 32
          description: 可选。恢复码。`code` 和 `recoveryCode` 必须提供其一。
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: 邮箱地址。
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: 重置链接中的 `token` 参数。
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: 新密码；适用注册时的密码规则。
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: 客户端 `codeVerifier` 的 S256 PKCE 质询值（`BASE64URL(SHA-256(codeVerifier))`）。
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: 客户端在 `127.0.0.1` 上的监听端口。
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: '`POST /auth/sso/google/start` 返回的 `attemptId`。'
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: PKCE 验证值；其 SHA-256 必须与提供给 `start` 的质询值相符。
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Google 发回的 `state`，原样提供。
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: Google 发回的 `code`，原样提供（不含空格的可打印 ASCII 字符）。
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: 可选。Google 发回了 `iss` 时，原样提供。
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`finish` 返回的 `linkTicket`，有效期 10 分钟。'
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: 该账号的密码。
        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: '`finish` 返回的 `ssoTicket`，有效期 10 分钟。'
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: 用户名；适用注册规则。
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: 有效会话，最近使用的排在最前。
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: 一个有效会话（一台已登录的设备）。
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: 会话 ID，用于 `DELETE /auth/sessions/{id}`。
        createdAt:
          type: integer
          format: int64
          description: 登录时间（Unix 毫秒时间戳）。
        lastSeenAt:
          type: integer
          format: int64
          description: 最后使用时间（Unix 毫秒时间戳），最多每 5 分钟更新一次。
        expiresAt:
          type: integer
          format: int64
          description: 绝对截止时间（Unix 毫秒时间戳）；空闲期限可能使会话更早结束。
        clientLabel:
          type:
            - string
            - 'null'
          description: 登录时提供的 `clientLabel`；未提供时为 `null`。
        current:
          type: boolean
          description: 是否为发出此请求的会话。
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: 棋手下过计分对局的每个用时类别各一条记录。
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: 生效中的处罚。
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '封禁期间为 `{ until }`，否则为 `null`。'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: 某个用时类别的等级分记录。
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: 用时类别 ID。
        rating:
          type: integer
          description: 等级分。
        games:
          type: integer
          minimum: 0
          description: 在该用时类别中下过的对局数（无论是否计入等级分）。
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: 曾达到的最高等级分。
        provisional:
          type: boolean
          description: '等级分尚未定级，或计入的对局少于 `PROVISIONAL_GAMES` 局时为 `true`（游戏中显示为 `1510?`）。'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: 处罚类型。
        reason:
          type:
            - string
            - 'null'
          description: 给出的理由（如有）。
        startsAt:
          type: integer
          format: int64
          description: 开始时间（Unix 毫秒时间戳）。
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: 结束时间（Unix 毫秒时间戳），永久处罚时为 `null`。
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: 封禁的结束时间（Unix 毫秒时间戳），永久封禁时为 `null`。
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` 表示接受通过用户名发起的直接挑战，`none` 表示拒绝。'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: 当前密码。
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: 新密码；适用注册时的密码规则。
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: 该账号的密码。
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: 由待启用密钥生成的验证码，恰好 6 位数字。
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: 该账号的密码。
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: 可选；开启双重验证时必须提供（或改为提供 `recoveryCode`）。身份验证器验证码或恢复码。
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: 可选。恢复码（`xxxx-xxxx-xx`；大小写、空格和连字符均不影响）。
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: 该账号的密码。
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: 身份验证器验证码（不接受恢复码）。
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: 新地址；去除首尾空白并转为小写，然后按注册时的地址规则检查。
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: 该账号的密码。
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: 可选；开启双重验证时必须提供（或改为提供 `recoveryCode`）。身份验证器验证码或恢复码。
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: 可选。恢复码。
    TotpSetup:
      type: object
      description: 供身份验证器应用使用的待启用密钥。
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: base32 编码的密钥，用于手动输入到应用中。
        uri:
          type: string
          format: uri
          description: '`otpauth://totp/...` URI，用于显示为二维码。'
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: 每个时间步的秒数。
    RecoveryCodeList:
      type: array
      description: 10 个恢复码，仅显示这一次。每个恢复码只能使用一次。
      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: 对局中的一方。
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: 棋谱中的名字（已删除的账号为 `deleted#<id>`）。
        rating:
          type:
            - integer
            - 'null'
          description: 开始时的等级分，未知时为 `null`。
        ratingAfter:
          type:
            - integer
            - 'null'
          description: 对局后的等级分。计分对局总会有此值；仅当对局不计入等级分时（不计分、自定义用时、已中止）为 `null`。
        ratingDiff:
          type:
            - integer
            - 'null'
          description: 等级分变化；若等级分规则使等级分保持不变（FIDE 的零分规则、对手尚未定级、尚未定级棋手的最初几局），则为 `0`；为 `null` 的情况与 `ratingAfter` 相同。
    GameSummary:
      type: object
      description: 已存储对局的摘要。
      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: 对局 ID。
        category:
          type: string
          description: 官方用时类别 ID（`3+2`），或 `custom`。
        rated:
          type: boolean
          description: 该对局是否计入等级分。
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 以秒表示的用时（`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`（见对局代码）。'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: 结束原因代码（见对局代码）。
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: 结果，已中止的对局为 `*`。
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: 半回合数。
        startedAt:
          type: integer
          format: int64
          description: 开始时间（Unix 毫秒时间戳）。
        endedAt:
          type: integer
          format: int64
          description: 结束时间（Unix 毫秒时间戳）。
    Termination:
      type: string
      description: 结束原因的名称（见对局代码）；协议不认识的代码为 `Unknown`。
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: 从某一方棋手角度看的对局摘要。
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: 该棋手所执的一方。
    HistoryGameSummary:
      description: 已登录棋手对局记录中的一局。
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: 基本用时，以毫秒计。
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: 加秒，以毫秒计。
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: 从该棋手一方来看的胜负结果。
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: 本页的对局，最新的排在最前。
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: 获取下一页时作为 `before` 传入的 ID；最后一页为 `null`。
        total:
          type: integer
          minimum: 0
          description: 所有页中符合筛选条件的对局数。
    MoveRecord:
      type: object
      description: 对局中的一个半回合。
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: 以 UCI 记法表示的着法；升变时附加 `n`、`b`、`r` 或 `q`。
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: 该步所计的用时（毫秒），记录中没有时为 `null`。
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: 走棋方在该步之后的棋钟剩余时间（毫秒），记录中没有时为 `null`。
    PgnTagsView:
      type: object
      description: 主要的 PGN 标签，用于显示。此处的 `Termination` 是结束原因的名称；PGN 文件中使用的是 PGN 标准值。
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` 或 `<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: UTC 开始日期。
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: 开始时的等级分，或 `"-"`。
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: 开始时的等级分，或 `"-"`。
          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: 包含着法和棋钟时间的棋谱。它具有对局摘要的各个字段，但没有 `color` 和 `outcome`。
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: 基本用时，以毫秒计。
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: 加秒，以毫秒计。
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: '`status` 的名称。'
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: 本局作为再战所对应的原对局 ID，或 `null`。
            moves:
              type: array
              description: 每个半回合一条记录。
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: 仅当令牌所属棋手参加了该对局时出现；表示其所执的一方。
            reportable:
              type: boolean
              description: 仅当令牌所属棋手参加了该对局时出现；如果 `POST /reports` 此刻会受理针对该对局中对手的举报（对局在 7 天内结束、每日配额尚未用完、且尚未因该对局举报过该对手），则为 `true`。
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: 用户名，保留棋手书写时的大小写形式。
        createdAt:
          type: integer
          format: int64
          description: 账号的创建时间（Unix 毫秒时间戳）。
        ratings:
          type: array
          description: 仅包含官方用时类别，按服务器的顺序排列。
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: 所有已存储的对局，包括不计分对局和已中止的对局。
            rated:
              type: integer
              minimum: 0
              description: 计分对局数，对各条等级分记录求和得出。
            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: 用户名，保留棋手书写时的大小写形式。
        games:
          type: array
          description: 本页的对局，最新的排在最前。
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: 只要本页已满，即为最后一局的 ID（此时下一页可能为空），否则为 `null`。
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: 用时类别 ID。
        minGames:
          type: integer
          minimum: 0
          description: 记录进入排行榜所需的已计入对局数（`PROVISIONAL_GAMES`）。
        updatedAt:
          type: integer
          format: int64
          description: 排行榜的计算时间（Unix 毫秒时间戳）。
        players:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LeaderboardEntry'
    LeaderboardEntry:
      type: object
      required:
        - rank
        - username
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
      properties:
        rank:
          type: integer
          minimum: 1
        username:
          type: string
        rating:
          type: integer
        games:
          type: integer
          minimum: 0
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
    GifRequest:
      type: object
      additionalProperties: false
      required:
        - pgn
      properties:
        pgn:
          type: string
          description: PGN 文本，最多 65,536 字节 UTF-8。只使用第一局对局。
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: 可选。图像尺寸。
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: 可选。位于棋盘下方的一方。
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: 可选。每步着法的毫秒数，值为整数的 JSON 数字。
        coords:
          type: boolean
          default: true
          description: 可选。是否在棋盘四周绘制坐标（直线字母和横线数字）。
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: 对局，以整数或由 1 到 16 位数字组成的字符串表示。
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: 对手的用户名，可以是棋谱中的名字，也可以是其当前的名字，不区分大小写。不得为空白。
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: 举报的内容类别。
        comment:
          type:
            - string
            - 'null'
          description: 可选。去除控制字符（制表符和换行符除外）并去除首尾空白后，最多 500 个字符。
    AccountExport:
      type: object
      description: 服务器保存的有关该账号的全部信息（时间均为 Unix 毫秒时间戳）。
      required:
        - format
        - version
        - exportedAt
        - server
        - notes
        - account
        - ratings
        - ratingRefunds
        - sessions
        - securityEvents
        - sanctions
        - conduct
        - reportsFiled
        - games
      properties:
        format:
          type: string
          const: scacelith-account-export
        version:
          type: integer
          const: 1
        exportedAt:
          type: integer
          format: int64
          description: 导出的时间。
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`。'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`。'
        notes:
          type: array
          description: 以简明的英文向棋手说明文件包含和不包含哪些内容。
          items:
            type: string
        account:
          description: '`GET /account/me` 的账号视图，外加 `googleEmail`。'
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: 已关联 Google 账号的邮箱地址，或 `null`。
        ratings:
          type: array
          description: 完整的等级分记录。
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: 因对手被查出作弊而退还的等级分，按 UTC 日期和用时类别汇总，最新的排在最前。既不列出相关对局，也不点名作弊者。
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: 所有已存储的会话，最新的排在最前，不含任何令牌。保留期清理会删除已过期的会话；已退出登录的会话则在退出登录一天后删除。
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: 安全事件，最新的排在最前，保留 `RETENTION_SECURITY_DAYS` 天。
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: 所有处罚，包括已解除的处罚。绝不包含管理员的名字。
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: 针对棋手弃局、中止和未按时走出第一步的对局所记录的行为事件，保留 30 天。
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: 该棋手提交的举报。
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: 对局数。
            list:
              type: array
              description: 所有对局，最新的排在最前，格式与 `GET /account/games` 的摘要相同。着法可通过 `GET /games/{id}` 获取，PGN 可通过 `GET /games/{id}/pgn` 获取。
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: 一条完整的等级分记录。
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: 棋手是否已结束未定级阶段。
            countedGames:
              type: integer
              minimum: 0
              description: 计入等级分的对局数。
            updatedAt:
              type: integer
              format: int64
              description: 该记录最后一次变更的时间。
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: 当天的 UTC 00:00（Unix 毫秒时间戳）。
        category:
          type: string
        points:
          type: integer
          description: 当天在该用时类别中退还的分数。
    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: 会话退出登录的时间，或 `null`。
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: 登录时的 IP 地址，在 `RETENTION_IP_DAYS` 天后清除。
    SecurityEvent:
      type: object
      description: |-
        一个安全事件。只有通过已登录的会话、凭账号密码（及第二重验证）或凭发送到其邮箱地址的链接所进行的操作，才会给出 `ip`：`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` 和 `account_exported`；其他所有类型均为 `ip: null`，且 `ip` 会在 `RETENTION_IP_DAYS` 天后清除。

        `detail` 只保留以下字段：`login`：`method`；`sso_login`、`sso_account_created`：`provider`；`sso_linked`：`provider` 和 `method`（`password` 或 `password+totp`）；`login_failed`：`failures`；`login_lockout`：`retryAfterMs`；`mfa_failed`：`attempts`；`recovery_code_used`：`remaining`；`reauth_failed`：`factor`；`session_revoked` 和 `sessions_revoked_all`：`reason`；`email_change_refused`：`reason`；`sanction_auto`：`kind`、`gameId`、`until`。`moderator_action` 事件只保留 `{ action }`，且仅限 `ban`、`unban`、`reset_mfa`、`verify_email` 和 `revoke_sessions` 这几种操作（其他管理员操作不包含在内）。其他所有类型均为 `detail: null`。`rating_refund` 事件不包含在内：其分数见 `ratingRefunds`。
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: 事件类型（`login`、`password_changed`……）。
        at:
          type: integer
          format: int64
          description: 发生时间。
        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: 被举报棋手当前的公开名称。
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: 举报是否仍在处理中（不会透露被举报棋手是否受到处罚）。
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: 邮件链接中的令牌（页面表单中的隐藏字段）。
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: 重置链接中的令牌（表单中的隐藏字段）。
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: 新密码；适用注册时的密码规则。
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: 再次输入新密码；必须与新密码一致。
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: UTF-8 编码的 HTML 页面（`text/html; charset=utf-8`），不含 JavaScript 或外部资源。
