openapi: 3.1.1
info:
  title: API HTTPS của máy chủ Scacelith
  version: "1.1.0"
  summary: API HTTPS của mọi máy chủ Scacelith, cả máy chủ chính thức lẫn các máy chủ cộng đồng.
  description: |-
    Mọi máy chủ Scacelith, từ máy chủ chính thức `caissa.scacelith.com` đến bất kỳ máy chủ cộng đồng nào, đều phục vụ API HTTPS này. Trò chơi dùng API này cho mọi thứ nằm ngoài bản thân ván cờ: đăng ký và đăng nhập (bao gồm cả xác thực hai yếu tố và Google), trang tài khoản, lịch sử ván đấu, tải xuống PGN và ảnh GIF động của ván cờ, thiết bị đã đăng nhập, tải xuống dữ liệu, xóa tài khoản và báo cáo.

    Việc chơi trực tiếp (ghép đối thủ, thách đấu, nước đi, đồng hồ) đi qua WebSocket của cùng máy chủ đó (`wss://<host>/ws`), được mở bằng một token phiên của API này; phần đó được mô tả trong `PROTOCOL.md`, không phải ở đây.

    Các giá trị mặc định dưới đây là của một máy chủ có cấu hình nguyên gốc; máy chủ cộng đồng có thể thay đổi chúng (`CONFIG.md` liệt kê mọi cài đặt).

    ## URL cơ sở, cổng và giao thức truyền tải

    - Máy chủ chính thức: `https://caissa.scacelith.com/api/v1`, cổng TCP 443.
    - Máy chủ cộng đồng: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` mặc định là 443; khi đứng sau NAT hoặc proxy, cổng mà người chơi dùng là `PUBLIC_API_PORT`.
    - WebSocket của trò chơi mặc định nằm trên cùng cổng (`WS_PORT` chuyển nó sang cổng khác; `GET /info` cho client biết nó ở đâu).
    - HTTP/1.1 qua TLS. Với `TLS_MODE=proxy`, một reverse proxy đặt trước máy chủ đảm nhận việc kết thúc TLS (TLS termination). `TLS_MODE=off` (HTTP thuần) chỉ dành cho phát triển cục bộ.
    - Các trang HTML được mở từ liên kết trong email (`/verify-email`, `/reset-password`, `/confirm-email-change`) nằm ở gốc của máy chủ, bên ngoài `/api/v1`. Các endpoint kiểm tra tình trạng trả lời cả ở gốc lẫn dưới `/api/v1`. Đăng nhập bằng Google không có trang nào trên máy chủ: Google đưa trình duyệt trở lại chính trò chơi, trên `127.0.0.1`, hoặc tới một trang của website đã khởi động việc đăng nhập (`GOOGLE_WEB_REDIRECT_URI`).
    - Dấu gạch chéo ở cuối bị bỏ qua (`/api/v1/info/` tương đương `/api/v1/info`), và tham số đường dẫn được giải mã URL (tham số không phải là mã hóa URL hợp lệ sẽ nhận phản hồi 400 `invalid_request`).

    ## Yêu cầu

    - **Phần thân JSON.** Các yêu cầu `POST`, `PUT` và `DELETE` mang một đối tượng JSON với `Content-Type: application/json`. `charset` khác UTF-8 bị từ chối, và mọi loại nội dung khác cũng vậy (415 `unsupported_media_type`). Phần thân rỗng được coi là `{}`, và đó chính là phần thân mà các endpoint không có tham số nhận (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Kích thước và thời gian.** Phần thân bị giới hạn ở `HTTP_BODY_LIMIT` byte (mặc định 16.384; `POST /gif` có giới hạn riêng là 135.168 byte): 413 `payload_too_large`. Phần thân phải đến trong vòng 10 giây: 408 `request_timeout`. Sau một trong hai lỗi này, máy chủ đóng kết nối.
    - **Schema nghiêm ngặt.** Trường mà endpoint không biết sẽ bị từ chối, trường không được đánh dấu là không bắt buộc thì bắt buộc phải có, và kiểu dữ liệu cũng như độ dài đều được kiểm tra. Mọi lỗi loại này đều nhận phản hồi 400 `invalid_request`, với `field` nêu tên trường (dùng dấu chấm cho trường lồng nhau, ví dụ `pow.nonce`). Chuỗi không được chứa ký tự điều khiển. Độ dài được tính theo đơn vị mã UTF-16, nên một ký tự nằm ngoài Mặt phẳng Đa ngôn ngữ Cơ bản (một emoji) được tính hai lần. Phần thân không phải JSON nhận phản hồi 400 `invalid_json`. `POST /gif` và `POST /reports` tự kiểm tra phần thân của chúng (xem từng endpoint).
    - **Chuỗi truy vấn.** Lần xuất hiện đầu tiên của một tham số được tính, các tham số không xác định bị bỏ qua, và `+` được giải mã thành dấu cách: các endpoint nhận hạng mục thời gian thi đấu chấp nhận cả `3%2B2` lẫn `3+2`.
    - **Phương thức.** `HEAD` là `GET` không có phần thân. `OPTIONS` trên một đường dẫn tồn tại nhận phản hồi 204 với header `Allow` (và các header của yêu cầu preflight CORS, xem bên dưới). Mọi phương thức khác mà đường dẫn không có đều nhận phản hồi 405 `method_not_allowed` kèm `Allow`.
    - **Đích yêu cầu.** Không được vượt quá 4096 ký tự (414 `uri_too_long`). Yêu cầu hoàn toàn không phân tích được, hoặc có phần đầu quá lớn hay đến quá chậm, nhận phản hồi 400, 431 hoặc 408 rỗng và kết nối bị đóng.
    - **CORS: danh sách cho phép, mặc định tắt.** API phục vụ trò chơi, vốn không gửi `Origin`. Khi `CORS_ORIGINS` để trống (mặc định), không header `Access-Control-*` nào được gửi, nên không trang web nào đọc được phản hồi. `CORS_ORIGINS` liệt kê các origin web mà trang của chúng được phép gọi API từ trình duyệt, mỗi origin viết đúng như trình duyệt gửi trong `Origin`: `https://host` hoặc `https://host:port`, chữ thường, không có đường dẫn, không có dấu gạch chéo ở cuối và không có cổng mặc định; `http://` chỉ dành cho `localhost`, `127.0.0.1` và `[::1]` (phát triển cục bộ); không có ký tự đại diện. Máy chủ chính thức đặt `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com` cho website của mình. Khi đó:
      - Mọi phản hồi cho một đường dẫn dưới `/api/v1`, trừ các endpoint kiểm tra tình trạng, dù thành công hay lỗi (kể cả 429 của tầng theo địa chỉ), đều mang `Vary: Origin`. Khi `Origin` của yêu cầu trùng từng byte với một trong các origin được liệt kê, phản hồi còn mang `Access-Control-Allow-Origin: <that origin>` và, trừ phản hồi cho yêu cầu preflight, `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, để trang đọc được mã lỗi, `Retry-After` và tên tệp của các bản tải xuống PGN và GIF.
      - Yêu cầu preflight (`OPTIONS` có `Access-Control-Request-Method`) từ một origin được liệt kê trên một đường dẫn tồn tại nhận phản hồi 204, không có phần thân, với `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (các phương thức của `Allow`), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600` và `Vary: Origin`. Yêu cầu này không cần token và không bị tính vào hạn mức tài khoản hay giới hạn của endpoint nào; giống mọi yêu cầu, nó lấy một token của tầng theo địa chỉ. Trình duyệt dùng lại một preflight trong 10 phút, nhưng chỉ cho đúng URL đó, kể cả chuỗi truy vấn. Yêu cầu không có `Authorization` và không có phần thân JSON, như `GET /info` hay `GET /leaderboard`, không cần preflight.
      - Mọi phản hồi khác cho yêu cầu preflight đều là preflight thất bại, kể cả khi có các header CORS: 404 cho một đường dẫn không tồn tại, hoặc 429 của tầng theo địa chỉ. Khi đó trình duyệt không gửi yêu cầu, và trang thấy một lỗi mạng, không có phản hồi lẫn `Retry-After` của nó.
      - `Access-Control-Allow-Credentials` không bao giờ được gửi, `Access-Control-Allow-Origin: *` cũng vậy: trang gửi token phiên của mình trong `Authorization: Bearer`, giống như trò chơi, và API không đặt cookie nào. Các trang HTML, các endpoint kiểm tra tình trạng và các phản hồi rỗng cho những yêu cầu không đọc được đều không mang header CORS. WebSocket không dùng CORS: `WS_ALLOWED_ORIGINS` liệt kê các trang được phép mở nó.
      - Trang của một origin không được liệt kê không đọc được gì, và các thao tác ghi liên trang của nó không bao giờ được gửi đi: phần thân JSON và `Authorization` cần một yêu cầu preflight, mà phản hồi của nó không có `Access-Control-Allow-Origin` cho origin đó. `Cross-Origin-Resource-Policy: same-origin` luôn có trên mọi phản hồi: nó chỉ áp dụng cho các yêu cầu `no-cors` (một trang khác nhúng một phản hồi), không bao giờ cho các yêu cầu `cors` của một trang được liệt kê.

    ## Phản hồi và lỗi

    - Phản hồi là JSON mã hóa UTF-8, trừ bản tải xuống PGN (`application/x-chess-pgn`), ảnh GIF động (`image/gif`) và các trang HTML. Thời gian được tính bằng mili giây kể từ 1970-01-01 UTC. Id là số nguyên. Id ván cờ có tối đa 16 chữ số nhưng luôn nhỏ hơn 2^53, nên một số JSON (kiểu double) biểu diễn chúng chính xác.
    - Mọi phản hồi của API đều mang `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` và `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (các trang HTML có chính sách riêng). Với `TLS_MODE=native`, phản hồi còn mang `Strict-Transport-Security: max-age=31536000`. Các phản hồi do trình lắng nghe tự viết, trước khi tới API (`GET` và `HEAD` của các endpoint kiểm tra tình trạng, 429 của tầng theo địa chỉ, và 500 `internal_error` khi API hỏng hoàn toàn), mang cùng các header đó trừ `Cross-Origin-Resource-Policy`, và `Content-Type` của chúng là `application/json` không có charset.
    - Phản hồi phải được đọc trong vòng 60 giây kể từ lúc máy chủ chuẩn bị xong, điều này chỉ quan trọng với các phản hồi lớn (một ảnh GIF, bản xuất dữ liệu, một PGN dài). Thời gian máy chủ dùng để chuẩn bị phản hồi không được tính vào khoảng này, cũng không được tính vào 30 giây không có byte nào vào hoặc ra mà sau đó kết nối bị đóng.

    Lỗi có một dạng duy nhất (schema `Error`):

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

    `message` dành cho nhật ký và làm phương án dự phòng; client chọn nội dung hiển thị dựa trên `error`. `retryAfter` (giây) chỉ có mặt ở những lần từ chối có thời hạn, và khi đó phản hồi cũng mang header `Retry-After` với cùng giá trị; ngoại lệ duy nhất là 503 `busy` của các thao tác đọc lịch sử, ván cờ, người chơi và bảng xếp hạng, chỉ có trường này. Một số lỗi có thêm trường: `field` (dữ liệu nhập không hợp lệ), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` và `column` (`invalid_pgn`).

    Các lỗi mà endpoint nào cũng có thể trả về:

    | Mã trạng thái | `error` | Khi nào |
    |---|---|---|
    | 400 | `invalid_request` | Phần thân vi phạm schema của endpoint (`field` cho biết ở đâu), đích yêu cầu hoặc `Content-Length` sai định dạng, một tham số đường dẫn không phải là mã hóa URL hợp lệ, hoặc phần thân bị cắt cụt. |
    | 400 | `invalid_json` | Phần thân không phải JSON. |
    | 401 | `unauthorized` | Không có header `Authorization` trên một endpoint cần phiên. |
    | 401 | `invalid_token` | Token sai định dạng, đã hết hạn, đã bị thu hồi hoặc thuộc về một tài khoản đã bị xóa; cả trên các endpoint mà phiên là không bắt buộc. |
    | 404 | `not_found` | Không có endpoint như vậy. Một số endpoint cũng dùng mã này: không có ván cờ, người chơi hoặc phiên như vậy. |
    | 405 | `method_not_allowed` | Đường dẫn tồn tại cho các phương thức khác (xem `Allow`). |
    | 408 | `request_timeout` | Phần thân không đến trong vòng 10 giây. |
    | 413 | `payload_too_large` | Phần thân vượt quá `HTTP_BODY_LIMIT` (135.168 byte đối với `POST /gif`). |
    | 414 | `uri_too_long` | Đích yêu cầu vượt quá 4096 ký tự. |
    | 415 | `unsupported_media_type` | Phần thân không phải `application/json`, hoặc charset của nó không phải UTF-8. |
    | 429 | `rate_limited` | Một giới hạn tần suất (xem bên dưới): `retryAfter` kèm header `Retry-After`. |
    | 500 | `internal_error` | Một lỗi ngoài dự kiến. Máy chủ ghi lỗi này vào nhật ký. |
    | 503 | `timeout` | Máy chủ không phản hồi trong vòng 30 giây (60 giây đối với việc xuất dữ liệu, 45 giây đối với ảnh GIF, với cài đặt mặc định). |
    | 503 | `server_busy` | Việc tra cứu phiên hoặc thay đổi tài khoản gặp cơ sở dữ liệu bị khóa (`retryAfter` 1), hoặc hàng đợi băm mật khẩu đã đầy (xem bên dưới). Ngoài ra, với `retryAfter` từ 2 đến 5, khi máy chủ bị tồn đọng nặng: với `POST`, `PUT`, `PATCH` hoặc `DELETE`, cơ sở dữ liệu có quá nhiều thao tác ghi đang chờ; với mọi yêu cầu, có quá nhiều yêu cầu trước đó vẫn đang chạy quá thời hạn của chúng, hoặc không thể kiểm tra token phiên trong vòng 5 giây. Yêu cầu chưa được thực thi, và không có gì bị trừ vào các giới hạn tần suất của nó. |

    Các endpoint đọc (lịch sử ván đấu, ván cờ, người chơi, bảng xếp hạng) và endpoint xuất dữ liệu trả về 503 `busy` với `retryAfter: 1` khi cơ sở dữ liệu vẫn bị khóa.

    Các endpoint kiểm tra hoặc băm mật khẩu cũng có thể trả về một trong hai lỗi sau, cả hai đều có `retryAfter` ngẫu nhiên từ 5 đến 15 giây:

    - 503 `server_busy`: hàng đợi băm mật khẩu của máy chủ (`PASSWORD_HASH_QUEUE_MAX`) đã đầy, hoặc thời gian chờ đã hết.
    - 429 `rate_limited`: khi hàng đợi đã đầy một nửa, client này (một địa chỉ IPv4 hoặc một mạng IPv6 /48) đã có `PASSWORD_HASH_WAITERS_PER_SOURCE` lượt băm đang chờ. Lỗi 429 này trả lại các token giới hạn tần suất mà yêu cầu đã lấy.

    Không có gì bị thay đổi và không lần thử thất bại nào bị tính (trừ trên `POST /auth/sso/google/link`, nơi lượt thử của vé và lần thất bại của tài khoản đã bị tính trước khi băm), và liên kết đặt lại mật khẩu vẫn còn hiệu lực.

    Các trang HTML trả lỗi dưới dạng trang HTML với cùng các mã trạng thái.

    ## Xác thực

    Các endpoint cần phiên nhận một bearer token trong header `Authorization` (scheme `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Lấy token.** Token (`sct_` theo sau là 43 ký tự base64url) đến từ `POST /auth/login`, rồi `POST /auth/login/mfa` khi xác thực hai yếu tố được bật, từ `POST /auth/sso/google/finish` và `POST /auth/sso/google/link` (đăng nhập bằng Google), và từ `POST /auth/sso/complete`. Tất cả các endpoint này đều trả về `{ token, expiresAt, user }`. Máy chủ chỉ lưu giá trị SHA-256 của token.
    - **Phiên bắt buộc.** Thiếu header thì nhận phản hồi 401 `unauthorized` với `WWW-Authenticate: Bearer realm="scacelith"`. Token không hợp lệ nhận phản hồi 401 `invalid_token` với `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Client nên quên token đã nhận `invalid_token` và đăng nhập lại.
    - **Phiên không bắt buộc.** Trên `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` và `GET /players/{username}/games`, phiên là không bắt buộc. Không có header, các endpoint này trả về chế độ xem công khai; nếu có gửi header, token trong đó phải hợp lệ.
    - **Kho lưu trữ bận.** Khi cơ sở dữ liệu vẫn bị khóa trong lúc kiểm tra token, endpoint yêu cầu hoặc chấp nhận phiên sẽ trả về 503 `server_busy` với `retryAfter: 1` (và `Retry-After`); khi việc kiểm tra mất hơn 5 giây, endpoint trả về 503 `server_busy` với `retryAfter` từ 2 đến 5. Trong cả hai trường hợp, token chưa được đánh giá: hãy giữ token và gửi lại yêu cầu.
    - **Thời hạn.** Phiên kết thúc ở mốc nào đến trước trong hai mốc: `SESSION_MAX_DAYS` (90) ngày sau khi đăng nhập (đây là `expiresAt`) và `SESSION_IDLE_DAYS` (30) ngày không sử dụng. Mỗi lần sử dụng sẽ lùi hạn không hoạt động lại; máy chủ ghi giá trị mới tối đa 5 phút một lần, ở chế độ nền. Khi cơ sở dữ liệu có nhiều thao tác ghi đang chờ, máy chủ hoãn việc ghi đó sang một yêu cầu sau, trừ trong giờ cuối cùng trước hạn không hoạt động, nên một phiên đang được sử dụng sẽ không hết hạn. Mỗi tài khoản giữ tối đa `MAX_SESSIONS_PER_USER` (10) phiên: một lần đăng nhập mới sẽ thu hồi các phiên cũ nhất vượt quá con số đó.
    - **Thu hồi.** Việc đăng xuất (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) thu hồi phiên; việc đổi mật khẩu (các phiên khác), đặt lại mật khẩu và xóa tài khoản (mọi phiên) cũng thu hồi phiên, quản trị viên cũng vậy. Việc thu hồi qua API có hiệu lực ngay lập tức, và WebSocket được mở bằng phiên bị thu hồi sẽ bị đóng. Việc đổi hoặc đặt lại mật khẩu và việc xóa tài khoản thu hồi phiên trong cùng giao dịch với chính thay đổi đó: khi một trong các thao tác này thất bại, không phiên nào bị thu hồi và cũng không có gì khác bị thay đổi. Máy chủ lưu kết quả tra cứu phiên trong bộ nhớ đệm 30 giây, nên việc thu hồi bằng lệnh quản trị, vốn là một tiến trình riêng, có hiệu lực trong vòng 30 giây.
    - **Phạm vi.** Token thuộc về một máy chủ và cũng mở được WebSocket của máy chủ đó. Không bao giờ gửi token tới máy chủ khác.

    ## Giới hạn tần suất và các cơ chế hạn chế khác

    Các giới hạn áp dụng cho một trong hai phạm vi. **Client:** một địa chỉ IPv4, hoặc một mạng IPv6 /64 (ở chế độ proxy, địa chỉ lấy từ `X-Forwarded-For` do một địa chỉ thuộc `TRUSTED_PROXIES` gửi); một số giới hạn còn tính mỗi mạng IPv6 /48 như một khối, ngoài từng mạng /64 của nó. **Người chơi:** tài khoản đã đăng nhập, bất kể địa chỉ; một giới hạn tính theo người chơi trên endpoint có phiên không bắt buộc sẽ tính theo client đối với yêu cầu không có token.

    Mỗi giới hạn là một thùng token chứa `limit` yêu cầu và được nạp lại liên tục với tốc độ `limit / window`; `retryAfter` là thời gian đến token tiếp theo. Các giới hạn được đánh dấu *dùng chung* còn được tính trên một cửa sổ trượt có cùng độ dài, để không cửa sổ nào chứa nhiều hơn đáng kể so với `limit` yêu cầu. Mọi giới hạn đều được tính cho toàn bộ máy chủ. Khi một trong các giới hạn của endpoint từ chối một yêu cầu, các token mà những giới hạn khác của endpoint đã lấy cho yêu cầu đó sẽ được trả lại. Lần từ chối trả về 429 `rate_limited` với `retryAfter` và `Retry-After`.

    - **Tầng theo địa chỉ.** Mọi yêu cầu (bất kỳ đường dẫn và phương thức nào, kể cả các endpoint kiểm tra tình trạng và các lần nâng cấp WebSocket) trước tiên lấy một token từ hạn mức của client: `HTTP_RATE_PER_IP` (600) mỗi phút với burst bằng nửa phút, và `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) cho một mạng IPv6 /48. Mỗi client cũng chỉ được có tối đa `IP_MAX_INFLIGHT` (32 x `WORKERS`) yêu cầu đang xử lý (vượt quá thì `retryAfter` 1). Client vẫn tiếp tục gửi sau khi bị từ chối sẽ bị chặn: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) lần từ chối trong một phút sẽ chặn client trong 1 phút, rồi 4, 16 và 60 phút ở mỗi lần chặn mới trong vòng 6 giờ. Một lần từ chối của các giới hạn `auth`, `auth_*` và `reauth` được tính là 5; còn lần từ chối của các giới hạn tính theo người chơi thì không bao giờ được tính. Các địa chỉ trong `ABUSE_EXEMPT` bỏ qua tầng này, nhưng không bỏ qua hạn mức tài khoản hay các giới hạn của endpoint.
    - **Hạn mức tài khoản.** Mọi yêu cầu mang token phiên hợp lệ cũng được tính vào tài khoản của token đó: `USER_RATE_PER_MIN` (120) mỗi phút cho tất cả các endpoint cộng lại, bất kể địa chỉ, với burst bằng nửa phút.
    - **Giới hạn của endpoint:**

    | Giới hạn | Mặc định | Tính theo | Endpoint |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 phút, dùng chung | client, và `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) cho mỗi IPv6 /48 | `POST /auth/register`, `/auth/login`, `/auth/login/mfa`, `/auth/verify-email/resend`, `/auth/password/forgot`, `/auth/password/reset`, `/auth/sso/google/link`, `/auth/sso/complete`; `POST /verify-email`, `/reset-password`, `/confirm-email-change` |
    | `auth_register` | `AUTH_REGISTER_PER_HOUR` (10) / giờ, dùng chung | client, gấp 3 lần mức đó cho mỗi IPv6 /48 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / giờ, dùng chung | client, gấp 3 lần mức đó cho mỗi IPv6 /48 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / giờ, dùng chung | client, gấp 3 lần mức đó cho mỗi IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 giờ, dùng chung | client, gấp 3 lần mức đó cho mỗi IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / giờ, dùng chung | client, gấp 3 lần mức đó cho mỗi IPv6 /48 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | các con số của `auth`, một thùng riêng, dùng chung | client và IPv6 /48 | `POST /account/password`, `/account/mfa/totp/setup`, `/account/mfa/totp/enable`, `/account/mfa/totp/disable`, `/account/mfa/recovery-codes`, `/account/email`, `/account/export`, `/account/delete` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 phút, dùng chung | người chơi | cùng các endpoint như `reauth` |
    | `account` | 60 / phút | người chơi | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / phút | người chơi | `GET /account/games` |
    | `account_export` | 5 / giờ, dùng chung | người chơi | `POST /account/export` (kiểm tra trước `reauth`; mọi lần thử đều được tính) |
    | `sessions` | 60 / phút | người chơi | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / phút | người chơi (client nếu không có token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (một thùng chung cho cả bốn) |
    | `gif` | 30 / phút | người chơi | `GET /games/{id}/gif`, `POST /gif` (một thùng chung cho cả hai) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / phút và `GIF_USER_RENDERS_PER_HOUR` (30) / giờ, dùng chung | người chơi | cùng hai endpoint đó, chỉ khi phải tạo GIF (không lấy từ bộ nhớ đệm) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / phút và `GIF_IP_RENDERS_PER_HOUR` (120) / giờ, dùng chung | client (tất cả tài khoản của nó cộng lại), gấp 3 lần mức đó cho mỗi IPv6 /48 | cùng các endpoint đó, như trên |
    | `reports` | 30 / giờ | người chơi | `POST /reports` |
    | `sso_start` | 30 / 10 phút, dùng chung | client, và 90 cho mỗi IPv6 /48 | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (tính chung) |
    | `sso_finish` | 30 / phút | client | `POST /auth/sso/google/finish` |
    | `page` | 60 / phút | client | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` và `GET /leaderboard` không có giới hạn riêng: chỉ có tầng theo địa chỉ. Endpoint có nhiều giới hạn sẽ kiểm tra chúng theo thứ tự nêu trong phần mô tả của endpoint đó.

    Máy chủ xử lý một yêu cầu theo thứ tự sau: tầng theo địa chỉ, rồi các endpoint kiểm tra tình trạng; khớp endpoint; các bước kiểm tra rằng máy chủ không bị tồn đọng nặng (503 `server_busy`): quá nhiều yêu cầu trước đó vẫn đang chạy quá thời hạn của chúng và, với `POST`, `PUT`, `PATCH` hoặc `DELETE`, lượng ghi tồn đọng của cơ sở dữ liệu; xác thực, trong vòng 5 giây (quá thời gian đó thì 503 `server_busy`); hạn mức tài khoản, khi yêu cầu mang phiên; các giới hạn của endpoint; phần thân; chính endpoint đó (các endpoint GIF chỉ lấy giới hạn kết xuất khi phải tạo GIF). Vì vậy, yêu cầu bị từ chối vì token phiên của nó không tiêu tốn token nào của endpoint, còn yêu cầu có phần thân không hợp lệ thì vẫn tiêu tốn.

    Các cơ chế hạn chế khác, do chính các endpoint trả lời:

    - **Đăng nhập thất bại trên cùng một tên đăng nhập** (tên người dùng hoặc email): từ `AUTH_FAILURES_PER_ACCOUNT` (5) lần thất bại trở đi, mỗi lần thử phải chờ gấp đôi lần trước (2 giây, 4 giây, v.v., tối đa 15 phút): 429 `too_many_attempts` với `retryAfter`. Bộ đếm được xóa sau một giờ không có thất bại.
    - **Yếu tố thứ hai sai khi đăng nhập:** cùng quy tắc đó, kể từ mã sai thứ 5 của tài khoản. Một bước đăng nhập chấp nhận tối đa 5 mã sai.
    - **Mã yếu tố thứ hai của một tài khoản:** tối đa `AUTH_MFA_PER_ACCOUNT` (10) mã (mã từ ứng dụng xác thực hoặc mã khôi phục, đúng hay sai) mỗi 15 phút, từ bất kỳ địa chỉ nào, khi đăng nhập và khi xác thực lại; vượt quá thì trả về 429 `too_many_attempts` trước khi mã được kiểm tra, nên mã khôi phục không bị tiêu hao.
    - **Xác thực lại thất bại của một tài khoản** (sai mật khẩu hoặc mã): cùng quy tắc đó (429 `too_many_attempts`), dùng chung cho mọi endpoint có xác thực lại.
    - **Email:** mỗi địa chỉ một thư xác nhận hoặc thư đặt lại mật khẩu mỗi 5 phút (phản hồi vẫn như cũ); mỗi địa chỉ một thông báo “có người đã thử dùng địa chỉ của bạn” mỗi giờ.
    - **Báo cáo:** `REPORTS_PER_DAY` (5) cho mỗi người chơi trong 24 giờ: 429 `report_limit`.

    ## Bằng chứng công việc

    `POST /auth/register` luôn cần bằng chứng công việc khi `POW_REGISTER_BITS` lớn hơn 0 (mặc định là 18; `GET /info` cho biết giá trị này dưới dạng `pow.register`). `POST /auth/login` và `POST /auth/sso/google/link` (cùng một loại thử thách cho cả hai) chỉ cần bằng chứng trong 5 phút sau khi máy chủ thấy một làn sóng đăng nhập thất bại (`POW_LOGIN_TRIGGER_PER_MIN`, rồi `POW_LOGIN_BITS`); client biết được điều này qua phản hồi.

    1. Yêu cầu không có bằng chứng (hoặc có bằng chứng bị từ chối) nhận phản hồi 428 `pow_required` với `reason` và một thử thách `pow` `{ challenge, bits, expiresAt }`.
    2. Tìm một nonce: một chuỗi thập phân tối đa 20 chữ số sao cho `SHA-256(challenge + ":" + nonce)` bắt đầu bằng `bits` bit 0 (tính từ bit có trọng số cao nhất của byte đầu tiên). 18 bit cần trung bình khoảng 260.000 lần băm.
    3. Gửi lại cùng yêu cầu đó với `"pow": { "challenge": "...", "nonce": "123456" }` trong phần thân.

    Một thử thách có hiệu lực trong 2 phút và chỉ dùng được một lần, cho một endpoint và một mạng client (một địa chỉ IPv4 hoặc IPv6 /64). Thử thách được ký, nên máy chủ không lưu gì về nó cho đến khi nó được gửi trở lại. `reason` cho biết lý do bằng chứng bị từ chối: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` hoặc `replayed`.

    ## Xác thực lại

    Các thay đổi tài khoản yêu cầu nhập lại mật khẩu và, khi xác thực hai yếu tố được bật, một yếu tố thứ hai:

    - chỉ mật khẩu: `POST /account/password` và `POST /account/mfa/totp/setup`;
    - mật khẩu và mã từ ứng dụng xác thực, không chấp nhận mã khôi phục: `POST /account/mfa/recovery-codes`;
    - mật khẩu và mã từ ứng dụng xác thực hoặc mã khôi phục: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` và `POST /account/delete`.

    Trong phần thân, `code` chứa mã 6 chữ số từ ứng dụng xác thực và `recoveryCode` chứa mã khôi phục (`xxxx-xxxx-xx`; chữ hoa hay chữ thường, dấu cách và dấu gạch ngang không quan trọng). Mã khôi phục cũng có thể được gửi trong `code` ở những nơi chấp nhận mã khôi phục. Mỗi mã chỉ dùng được một lần: mã từ ứng dụng xác thực đã dùng sẽ bị từ chối cho đến bước 30 giây tiếp theo, và mã khôi phục mất hiệu lực ngay khi được dùng.

    | Mã trạng thái | `error` | Khi nào |
    |---|---|---|
    | 403 | `invalid_password` | Sai mật khẩu, hoặc mật khẩu đã được đặt lại hay thay đổi trong lúc yêu cầu đang chạy (không có gì bị thay đổi). |
    | 403 | `mfa_code_required` | Xác thực hai yếu tố đang bật và không gửi `code` lẫn `recoveryCode`. |
    | 403 | `invalid_code` | Mã sai hoặc đã được dùng. |
    | 400 | `password_not_set` | Tài khoản chỉ dùng Google chưa có mật khẩu (“Quên mật khẩu?” sẽ đặt một mật khẩu). |
    | 429 | `too_many_attempts` | Quá nhiều lần thất bại, hoặc thử quá nhiều mã, trên tài khoản này. |
    | 503 / 429 | `server_busy` / `rate_limited` | Hàng đợi băm mật khẩu đang bận. |

    Mật khẩu và mã sai được tính vào bộ đếm thất bại của tài khoản và được ghi lại thành sự kiện bảo mật.

    ## Cổng metrics

    Ngoài cổng API, máy chủ còn trả lời HTTP thuần trên cổng metrics (`METRICS_PORT`, 9464, gắn với `METRICS_BIND`, mặc định là 127.0.0.1; hãy giữ cổng này không công khai). Cổng này không thuộc API này: `GET /healthz` trả về `ok`; `GET /readyz` trả về `ready` từ khi quá trình khởi động hoàn tất (mọi shard đã phát lại nhật ký của mình và các trình lắng nghe đã được gắn cổng) cho đến khi máy chủ bắt đầu tắt, nếu không thì trả về 503 `not ready`; `GET /metrics` cung cấp các metrics Prometheus và, khi `METRICS_TOKEN` được đặt, cần `Authorization: Bearer <token>` với đúng 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: Mã nguồn của máy chủ Scacelith và tài liệu tham khảo của nó (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Máy chủ chính thức.
  - url: https://{host}:{port}/api/v1
    description: Bất kỳ máy chủ Scacelith nào, chẳng hạn một máy chủ cộng đồng.
    variables:
      host:
        default: caissa.scacelith.com
        description: Tên host công khai của máy chủ (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: Cổng API công khai (`PUBLIC_API_PORT`, nếu không có thì `API_PORT`).
tags:
  - name: server-info
    x-displayName: Thông tin máy chủ
    description: Những gì client cần biết trước khi đăng nhập hoặc kết nối.
  - name: health
    x-displayName: Kiểm tra tình trạng
    description: |-
      Trạng thái còn hoạt động (liveness) và sẵn sàng (readiness) của máy chủ, phục vụ việc giám sát. Các endpoint này trả lời trên cổng API trước bước xác thực và mọi giới hạn của endpoint, nhưng giống mọi yêu cầu, chúng lấy một token của tầng theo địa chỉ; có thể liệt kê máy giám sát trong `ABUSE_EXEMPT`. Mỗi endpoint hoạt động ở cả hai đường dẫn: ở gốc của máy chủ và dưới `/api/v1`.

      Cổng metrics có các endpoint kiểm tra tình trạng riêng, được mô tả trong phần giới thiệu.
  - name: auth
    x-displayName: Đăng ký và đăng nhập
    description: |-
      Tạo tài khoản, đăng nhập bằng mật khẩu (và yếu tố thứ hai), email xác nhận và email đặt lại mật khẩu.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` và `POST /auth/sso/complete` trả về một phiên `{ token, expiresAt, user }` (hoặc, với các endpoint đầu tiên, một bước thứ hai).
  - name: google-sign-in
    x-displayName: Đăng nhập bằng Google
    description: |-
      Có sẵn khi `GET /info` báo `sso.google: true`; nếu không, mọi endpoint bên dưới đều trả về 404 `sso_disabled`. Một website đăng nhập theo cùng cách với client riêng của nó khi `sso.googleWeb` là true (bên dưới, **Trên website**). Trò chơi đăng nhập qua trình duyệt của hệ thống theo luồng dành cho ứng dụng được cài đặt của RFC 8252 (một client “Desktop app”, tức ứng dụng dành cho máy tính): Google đưa trình duyệt trở lại một trình lắng nghe của trò chơi trên `127.0.0.1`, không bao giờ tới máy chủ này, và trò chơi không bao giờ thấy thông tin đăng nhập Google.

      1. Client lắng nghe trên `127.0.0.1:0` (hệ thống chọn cổng) và tạo một cặp PKCE: một `codeVerifier` gồm 43 đến 128 ký tự `[A-Za-z0-9._~-]` và `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, có 43 ký tự và không có phần đệm.
      2. `POST /auth/sso/google/start` với challenge và cổng trả về URL Google và `state` của lượt đăng nhập. Client kiểm tra URL (xem bên dưới) và mở nó trong trình duyệt.
      3. Google đưa trình duyệt tới `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Client chỉ chấp nhận `state` có trong phản hồi của start, và không gửi gì sau một lần chuyển hướng `error=`.
      4. `POST /auth/sso/google/finish` với id của lượt đăng nhập, `codeVerifier`, `state` và `code` (và `iss` khi Google có gửi).
      5. Phản hồi là một phiên, một bước xác thực hai yếu tố (tiếp tục với `POST /auth/login/mfa`), `needsUsername` cho người chơi mới (tiếp tục với `POST /auth/sso/complete`), hoặc `needsPassword` khi một tài khoản có mật khẩu đang dùng địa chỉ đó (tiếp tục với `POST /auth/sso/google/link`).

      **Nhãn origin.** URI chuyển hướng mang một nhãn của máy chủ mà người chơi đã thêm, để trò chơi từ chối một URL mà máy chủ khác lấy được cho người chơi của chính nó. Origin là host viết chữ thường (địa chỉ IPv6 dạng literal đặt trong ngoặc vuông), `:` và cổng API ở dạng thập phân, luôn được viết ra, kể cả 443: máy chủ lấy `SERVER_PUBLIC_HOST` và cổng API công khai của mình, còn trò chơi lấy địa chỉ mà nó đang kết nối tới. Nhãn là 22 ký tự đầu tiên của chuỗi base64url (không đệm) của SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + origin). URI chuyển hướng là `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: luôn là địa chỉ IPv4 dạng literal, và cổng của trình lắng nghe của trò chơi (1024 đến 65535). Máy chủ không bao giờ lấy URI, host hay đường dẫn từ client.

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

      Vì vậy, đăng nhập bằng Google chỉ hoạt động với những người chơi đã thêm máy chủ đúng theo `SERVER_PUBLIC_HOST` và cổng API công khai của nó.

      **Những gì trò chơi kiểm tra trước khi mở trình duyệt.** `authUrl` bắt đầu chính xác bằng `https://accounts.google.com/o/oauth2/v2/auth?` và là ASCII in được dưới 4096 ký tự, và chuỗi truy vấn của nó có đúng một `response_type=code`, đúng một `redirect_uri` bằng URI mà trò chơi tính từ cổng và nhãn origin của mình, đúng một `state` bằng `state` của phản hồi, `code_challenge_method=S256` và một `code_challenge` dài 43 ký tự. Nếu không, trò chơi dừng trình lắng nghe và không mở gì cả. Trò chơi chỉ gửi `finish` và `link` tới máy chủ đã trả lời `start`.

      **Trên website.** Một website (website chính thức: <https://scacelith.com/>) đăng nhập bằng một client “Web application” (ứng dụng web) của cùng dự án Google (`GOOGLE_WEB_CLIENT_ID`). Google đưa trình duyệt trở lại một trang của website do người vận hành cấu hình (`GOOGLE_WEB_REDIRECT_URI`, được liệt kê trong mục “Authorized redirect URIs” của client), và trang đó gửi mã tới `finish`; ở đây máy chủ cũng không lấy URI chuyển hướng nào từ client.

      1. Trang tạo một cặp PKCE như trên, giữ verifier trong thẻ trình duyệt (`sessionStorage`), và gửi `POST /auth/sso/google/web/start` với challenge.
      2. Trang kiểm tra `authUrl` như trò chơi làm, ngoại trừ việc `redirect_uri` của nó phải là một trang thuộc origin của chính trang đó, rồi điều hướng tới URL này. Không tải script Google nào.
      3. Google đưa trình duyệt tới `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. Trang chỉ chấp nhận `state` có trong phản hồi của start và không gửi gì sau một lần chuyển hướng `error=`.
      4. `finish`, `link` và `complete` diễn ra tiếp theo như với trò chơi. Lượt đăng nhập ghi nhớ rằng nó do một website khởi động: mã được trao đổi bằng client và trang của website, và ID token phải ghi tên client đó (mã hoặc ID token của client trò chơi sẽ thất bại với 502 `sso_failed`, và ngược lại).

      Một tài khoản Google dẫn tới cùng một tài khoản Scacelith dù từ trò chơi hay từ website: liên kết lưu subject của Google (`sub`), giá trị định danh tài khoản Google bất kể client nào. Origin của website phải có trong `CORS_ORIGINS` (nếu không, `check-config` sẽ cảnh báo).

      **Đăng nhập bằng Google dẫn tới tài khoản nào:** tài khoản đã được liên kết với tài khoản Google đó; nếu không có, một tài khoản đang hoạt động có địa chỉ mà Google đã xác nhận (nếu tài khoản có mật khẩu, `finish` trả về `needsPassword` và liên kết chỉ được lưu khi mật khẩu, rồi đến yếu tố thứ hai nếu đang bật, được chấp nhận; nếu không có mật khẩu, 409 `sso_account_exists`); nếu vẫn không có, một tài khoản mới (`needsUsername`). Một tài khoản Google không bao giờ được liên kết với tài khoản hiện có chỉ dựa vào địa chỉ. Địa chỉ của tài khoản nhận một thư khi đăng nhập bằng Google tạo tài khoản và khi đăng nhập bằng Google được thêm vào một tài khoản hiện có.
  - name: sessions
    x-displayName: Phiên đăng nhập
    description: Các thiết bị đã đăng nhập vào tài khoản, và việc đăng xuất.
  - name: account
    x-displayName: Tài khoản
    description: Thông tin tài khoản, các tùy chọn và mật khẩu của tài khoản.
  - name: two-step-verification
    x-displayName: Xác thực hai yếu tố
    description: Ứng dụng xác thực (TOTP, RFC 6238), với SHA-1, 6 chữ số, bước 30 giây và dung sai một bước về mỗi phía, cùng các mã khôi phục dùng một lần.
  - name: email-change
    x-displayName: Đổi địa chỉ email
    description: Đổi địa chỉ email của tài khoản, được xác nhận bằng một liên kết gửi tới địa chỉ mới.
  - name: data-export
    x-displayName: Xuất dữ liệu
    description: Mọi thông tin máy chủ lưu giữ về tài khoản, trong một tệp JSON.
  - name: account-deletion
    x-displayName: Xóa tài khoản
    description: Xóa tài khoản vĩnh viễn.
  - name: game-history
    x-displayName: Lịch sử ván đấu
    description: Các ván cờ của chính người chơi đã đăng nhập, có lọc và phân trang.
  - name: games
    x-displayName: Ván cờ và PGN
    description: |-
      Bản ghi ván cờ cùng nước đi và đồng hồ, và các tệp PGN của chúng.

      **Mã ván cờ.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (chỉ những ván đã kết thúc mới được lưu). `result`: `1-0`, `0-1`, `1/2-1/2`, hoặc `*` (bị hủy). `reason`, kèm tên của nó (`termination`) và các từ kết thúc phần nước đi của tệp PGN (bằng tiếng Anh trong tệp, kèm bản dịch trong ngoặc ở bảng dưới); các mã 7, 14 và 21 là hòa (người chơi hết giờ, xin thua hoặc bỏ ván gặp phải đối thủ không thể chiếu hết: FIDE 6.9 và 5.1.2; `DESIGN.md` 6.3 nêu quy tắc), và máy chủ không bao giờ kết thúc ván cờ với mã 4 và 13 (chúng thuộc danh sách lý do dùng chung):

      | Mã | `termination` | Từ trong PGN |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (Chiếu hết) |
      | 2 | `Resignation` | Resignation (Xin thua) |
      | 3 | `Timeout` | Loss on time (Thua do hết giờ) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (Nước đi không hợp lệ thứ hai, xử thua) |
      | 5 | `Stalemate` | Stalemate (Hết nước đi) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (Thế cờ chết, không đủ lực lượng) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (Hết giờ, nhưng đối thủ không thể chiếu hết) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (Lặp lại thế cờ năm lần) |
      | 9 | `SeventyFiveMoves` | 75-move rule (Luật 75 nước) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (Lặp lại thế cờ ba lần, theo yêu cầu) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (Luật 50 nước, theo yêu cầu) |
      | 12 | `Agreement` | Draw by agreement (Hòa theo thỏa thuận) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (Nước đi không hợp lệ thứ hai, nhưng đối thủ không thể chiếu hết) |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate (Xin thua, nhưng đối thủ không thể chiếu hết) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (Rời ván cờ, mất kết nối quá lâu) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (Rời ván cờ, nhưng đối thủ không thể chiếu hết) |
      | 22 | `Aborted` | Game aborted (Ván cờ bị hủy) |
      | 23 | `NoShow` | Aborted: first move not played in time (Bị hủy: không đi nước đầu tiên kịp giờ) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (Xử thua, vi phạm luật chơi công bằng) |
      | 25 | `ServerAborted` | Aborted by the server (Bị máy chủ hủy) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (Bị hủy: cả hai người chơi đều mất kết nối) |

      Mã 14 mới có từ phiên bản phụ 1 của giao thức: client được viết trước phiên bản đó nên hiển thị một mã không xác định như một kết thúc ván cờ thông thường kèm `result` của nó.
  - name: gifs
    x-displayName: Ảnh GIF động
    description: |-
      Một ván cờ dưới dạng ảnh GIF động, để lưu giữ hoặc chia sẻ: bàn cờ nhìn từ trên xuống, mỗi thế cờ một khung hình từ thế cờ ban đầu đến thế cờ cuối cùng, tên và hệ số Elo của người chơi phía trên bàn cờ, nước đi cuối cùng phía dưới bàn cờ, và ở khung hình cuối là kết quả cùng cách ván cờ kết thúc.

      **Chi phí, bộ nhớ đệm và hạn ngạch.** GIF được tạo trên một luồng kết xuất riêng, không bao giờ trên các luồng chạy ván cờ, và với mức ưu tiên CPU thấp nhất: `GIF_THREADS` luồng (mặc định là `WORKERS`), khởi động cùng GIF đầu tiên và dừng sau một phút không có GIF nào. Tối đa `GIF_QUEUE_MAX` (4 x `WORKERS`) GIF chờ một luồng, mỗi GIF chờ tối đa `GIF_QUEUE_TIMEOUT_MS` (10 giây); một lần kết xuất có thể kéo dài `GIF_RENDER_TIMEOUT_MS` (30 giây). Máy chủ giữ các GIF đã tạo trong bộ nhớ đệm `GIF_CACHE_MB` MB (32 x `WORKERS`), GIF lâu không được dùng nhất bị loại trước; GIF lấy từ bộ nhớ đệm, hoặc GIF đang được tạo cho một yêu cầu khác, không tốn lần kết xuất nào. Khóa của bộ nhớ đệm bao gồm mọi thứ làm thay đổi hình ảnh, kể cả tên.

      Mọi yêu cầu đều được tính vào `gif` (30 mỗi phút cho mỗi người chơi, cho cả hai endpoint). GIF phải tạo mới còn được tính vào các giới hạn kết xuất: theo người chơi, `GIF_USER_RENDERS_PER_MIN` (4) mỗi phút và `GIF_USER_RENDERS_PER_HOUR` (30) mỗi giờ; theo client, tất cả tài khoản của nó cộng lại, `GIF_IP_RENDERS_PER_MIN` (12) mỗi phút và `GIF_IP_RENDERS_PER_HOUR` (120) mỗi giờ, và gấp 3 lần mức đó cho mỗi IPv6 /48. Client nên giữ tệp đã tải xuống thay vì yêu cầu lại, và chờ `retryAfter` sau lỗi 429 hoặc 503.

      Kích thước: `small` (ô 32 px: 284 x 350 pixel), `medium` (48 px: 424 x 515), `large` (72 px: 628 x 762); không có tọa độ thì 268 x 342, 400 x 503 và 600 x 748. Thế cờ ban đầu hiển thị ít nhất 1 giây (bằng độ trễ nếu độ trễ dài hơn), thế cờ cuối cùng 3 giây, và GIF lặp lại; sau khung hình đầu tiên, chỉ phần hình ảnh thay đổi mới được lưu. Một ván 40 nước chiếm khoảng 135, 205 và 325 KiB (small, medium, large), một ván 150 nước chiếm 0,5; 0,8 và 1,2 MiB.
  - name: players
    x-displayName: Người chơi
    description: Hồ sơ công khai và các ván cờ gần đây. Chỉ có dữ liệu công khai, không bao giờ có địa chỉ email, phiên, hình phạt hay mức độ liêm chính. Tài khoản đã bị xóa không có hồ sơ.
  - name: leaderboard
    x-displayName: Bảng xếp hạng
    description: Những người chơi hàng đầu của từng hạng mục chính thức.
  - name: reports
    x-displayName: Báo cáo
    description: Báo cáo đối thủ của một ván cờ gần đây tới người kiểm duyệt.
  - name: pages
    x-displayName: Trang HTML
    description: |-
      Các trang dành cho trình duyệt, được mở từ liên kết trong email. Liên kết của chúng trỏ tới `https://<SERVER_PUBLIC_HOST>` (kèm `:<PUBLIC_API_PORT>` khi cổng này không phải 443), bên ngoài `/api/v1`.

      - Các trang không chạy JavaScript và không tải tài nguyên bên ngoài nào. Chúng được phục vụ với `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - `GET` chỉ hiển thị một nút hoặc một biểu mẫu, để trình quét thư mở liên kết không làm liên kết bị dùng mất. Thay đổi diễn ra khi `POST`: một biểu mẫu gửi dưới dạng `application/x-www-form-urlencoded` (phần thân JSON cũng được chấp nhận), với token của liên kết trong một trường ẩn. Trường được gửi hai lần sẽ bị từ chối.
      - Lỗi (giới hạn tần suất, trường không hợp lệ) cũng là trang HTML, có tiêu đề “Request refused” (Yêu cầu bị từ chối) hoặc, từ 500 trở lên, “Server error” (Lỗi máy chủ). Chỉ những lỗi phát hiện trước khi xác định được trang (đích yêu cầu quá dài hoặc sai định dạng, tầng theo địa chỉ) mới được trả về dưới dạng JSON.
x-tagGroups:
  - name: server
    x-displayName: Máy chủ
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Tài khoản
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Ván cờ
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Người chơi và báo cáo
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Trang cho trình duyệt
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Lấy tên, phiên bản, cổng và quy tắc đăng ký của máy chủ
      description: |-
        Những gì client cần trước khi đăng nhập hoặc kết nối: tên và id của máy chủ, các phiên bản giao thức WebSocket và vị trí của WebSocket, quy tắc đăng ký, các mức thời gian thi đấu chính thức và các giới hạn mà client có thể kiểm tra trước khi gửi biểu mẫu.

        **Giới hạn:** chỉ có tầng theo địa chỉ.
      security: []
      responses:
        '200':
          description: Mô tả của máy chủ.
          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`: tầng theo địa chỉ.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Tạo tài khoản
      description: |-
        Tạo tài khoản: sau khi địa chỉ email được xác nhận bằng liên kết gửi tới địa chỉ đó, hoặc ngay lập tức nếu không yêu cầu xác nhận email.

        **Có xác nhận email** (`REQUIRE_EMAIL_VERIFICATION`, mặc định): 202 `verification_sent`. Tài khoản chưa tồn tại: lượt đăng ký chờ trong 24 giờ và giữ chỗ tên người dùng trong thời gian đó. Một liên kết có hiệu lực trong 24 giờ đó được gửi tới địa chỉ, tối đa một liên kết cho mỗi địa chỉ mỗi 5 phút (`POST /auth/verify-email/resend` gửi một liên kết mới); tài khoản được tạo, với địa chỉ đã được xác nhận, khi liên kết được dùng (nút của trang `/verify-email`), và sau đó người chơi có thể đăng nhập. Trước đó, việc đăng nhập bằng tên người dùng đó nhận phản hồi `invalid_credentials`, như với một tài khoản không xác định, và hồ sơ công khai không tồn tại. Một lượt đăng ký mới với cùng địa chỉ sẽ thay thế lượt đang chờ. Phản hồi vẫn như vậy khi một tài khoản khác đã dùng địa chỉ đó: khi đó không có liên kết nào được gửi, thay vào đó chủ sở hữu tài khoản nhận một thông báo (tối đa một lần mỗi giờ), và tên người dùng được giữ chỗ theo cùng cách, để không có gì tiết lộ địa chỉ đó đã có tài khoản hay chưa. Lượt đăng ký có liên kết không được dùng sẽ bị hủy sau 24 giờ, và tên người dùng của nó được giải phóng.

        **Không xác nhận email** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, tài khoản được tạo ngay và có thể đăng nhập.

        **Quy tắc.** `username`: từ `USERNAME_MIN` đến `USERNAME_MAX` ký tự (3 đến 20), gồm chữ cái, chữ số, `_` và `-`, bắt đầu bằng chữ cái hoặc chữ số (`GET /info` cung cấp các giá trị này trong `limits`); các tên dành riêng (`admin`, `moderator`, `deleted`...) và một số tiền tố bị từ chối; duy nhất, không phân biệt chữ hoa chữ thường. `email`: được cắt khoảng trắng hai đầu và lưu bằng chữ thường, chỉ gồm ASCII thuần với tên miền có dấu chấm. `password`: ít nhất `PASSWORD_MIN_LENGTH` (10) ký tự và tối đa 256 byte UTF-8; không được chứa tên người dùng hoặc phần cục bộ của email, và không được là mật khẩu phổ biến.

        **Lỗi**, được kiểm tra theo thứ tự sau: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; các lỗi của hàng đợi băm mật khẩu; 409 `email_taken` (chỉ khi không xác nhận email).

        **Giới hạn:** `auth`, rồi `auth_register` (10 lượt đăng ký mỗi giờ cho mỗi client). **Bằng chứng công việc:** luôn luôn, khi `POW_REGISTER_BITS` lớn hơn 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Lần thử đầu tiên, không có bằng chứng công việc
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Gửi lại kèm bằng chứng công việc
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: Tài khoản đã được tạo và có thể đăng nhập (máy chủ này không yêu cầu xác nhận email).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: Lượt đăng ký đang chờ liên kết xác nhận (hoặc địa chỉ đã có tài khoản; phản hồi không cho biết điều đó).
          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` (kèm `field`), `invalid_json`, `invalid_username`, `invalid_email`, hoặc `weak_password` kèm `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` hoặc `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: máy chủ này đã đóng đăng ký (`GET /info` báo `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: đã có tài khoản mang tên người dùng này, hoặc một lượt đăng ký đang chờ của địa chỉ khác đang giữ chỗ tên này. `email_taken`: một tài khoản khác đang dùng địa chỉ này (chỉ khi không xác nhận email).'
        '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`: giới hạn `auth` hoặc `auth_register`, hoặc hàng đợi băm mật khẩu.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hàng đợi băm mật khẩu, hoặc cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Đăng nhập bằng mật khẩu
      description: |-
        Đăng nhập bằng tên người dùng hoặc địa chỉ email và mật khẩu. Có hai phản hồi khả dĩ, cả hai đều có mã trạng thái 200: một phiên, hoặc, khi xác thực hai yếu tố được bật, một bước thứ hai cần hoàn tất trong vòng 5 phút bằng `POST /auth/login/mfa`.

        Tài khoản không xác định, sai mật khẩu và tài khoản không có mật khẩu đều nhận cùng một phản hồi, sau cùng một khoảng thời gian: 401 `invalid_credentials`. Các bước kiểm tra tài khoản (`banned`, `email_unverified`) chỉ diễn ra sau khi mật khẩu đúng. Từ `AUTH_FAILURES_PER_ACCOUNT` (5) lần thất bại trên cùng một tên đăng nhập, mỗi lần thử phải chờ (429 `too_many_attempts`).

        **Giới hạn:** `auth`. **Bằng chứng công việc:** chỉ trong một làn sóng đăng nhập thất bại (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: Một phiên, hoặc bước thứ hai của lần đăng nhập có xác thực hai yếu tố.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Đã đăng nhập
                  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: Xác thực hai yếu tố đang bật
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: tài khoản không xác định, sai mật khẩu, hoặc tài khoản không có mật khẩu.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Chỉ sau khi mật khẩu đúng: `banned`, kèm `until` (mili giây Unix, `null` nếu đình chỉ vĩnh viễn), hoặc `email_unverified` (một tài khoản cũ hơn có địa chỉ chưa được xác nhận).'
        '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` (độ trễ sau thất bại của tên đăng nhập này), hoặc `rate_limited` (giới hạn `auth`, hàng đợi băm mật khẩu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hàng đợi băm mật khẩu, hoặc cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Hoàn tất đăng nhập bằng yếu tố thứ hai
      description: |-
        Bước thứ hai của lần đăng nhập có xác thực hai yếu tố, sau khi `POST /auth/login` hoặc một lần đăng nhập bằng Google (`POST /auth/sso/google/finish` hoặc `POST /auth/sso/google/link`) trả về `mfaRequired`. Gửi `code` (mã 6 chữ số từ ứng dụng xác thực, hoặc mã khôi phục) hoặc `recoveryCode`. Mã khôi phục dùng ở đây sẽ mất hiệu lực.

        Một bước chấp nhận tối đa 5 mã sai; bước này cũng kết thúc khi mật khẩu được đặt lại hoặc thay đổi. Sau bước mật khẩu của việc liên kết Google, liên kết chỉ được lưu khi mã được chấp nhận ở đây.

        **Giới hạn:** `auth`, và tối đa `AUTH_MFA_PER_ACCOUNT` (10) mã mỗi 15 phút cho tài khoản, từ bất kỳ địa chỉ nào; vượt quá thì mã không được kiểm tra, nên mã khôi phục không bị tiêu hao.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Mã từ ứng dụng xác thực
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Mã khôi phục
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Đã đăng nhập.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: không gửi `code` lẫn `recoveryCode`, hoặc phần thân vi phạm schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: bước này đã hết hạn, đã được dùng, hoặc đã kết thúc sau 5 mã sai, hoặc mật khẩu đã được đặt lại hay thay đổi kể từ bước đầu tiên (hãy đăng nhập lại). `invalid_code`: mã sai hoặc đã được dùng.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (kèm `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: chỉ sau bước mật khẩu của việc liên kết Google, tài khoản Google đã được liên kết với một tài khoản khác trong lúc đó.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: chỉ sau bước mật khẩu của việc liên kết Google, tài khoản đã thay đổi trong lúc đó; hãy bắt đầu đăng nhập lại.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: độ trễ sau thất bại của tài khoản, hoặc tài khoản đã dùng hết `AUTH_MFA_PER_ACCOUNT` mã trong 15 phút gần nhất. `rate_limited`: giới hạn `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` với `retryAfter: 1`: cơ sở dữ liệu vẫn bị khóa (sau một bước liên kết Google, chưa có gì được liên kết: hãy bắt đầu đăng nhập lại). `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Đăng xuất phiên này
      description: |-
        Thu hồi phiên của token được dùng. WebSocket được mở bằng phiên này sẽ bị đóng. Không có phần thân (hoặc `{}`).

        **Giới hạn:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Đã đăng xuất.
          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`: phần thân khác `{}`; `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`: giới hạn `sessions` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Đăng xuất mọi phiên
      description: |-
        Thu hồi mọi phiên của tài khoản, kể cả phiên này. Các WebSocket được mở bằng những phiên đó sẽ bị đóng. Không có phần thân (hoặc `{}`).

        **Giới hạn:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Mọi phiên đã được đăng xuất.
          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`: phần thân khác `{}`; `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`: giới hạn `sessions` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Gửi lại liên kết xác nhận
      description: |-
        Gửi lại liên kết xác nhận email. Phản hồi luôn là 202 `accepted` bất kể địa chỉ nào, để không bao giờ tiết lộ có tài khoản hay lượt đăng ký nào đang dùng địa chỉ đó hay không.

        Yêu cầu chỉ có tác dụng tối đa một lần mỗi 5 phút cho mỗi địa chỉ. Lượt đăng ký đang chờ với địa chỉ đó được tính lại đủ 24 giờ, dù có tài khoản khác dùng địa chỉ đó hay không, để tên người dùng của nó được giữ chỗ lâu như nhau trong cả hai trường hợp. Liên kết chỉ được gửi cho lượt đăng ký đó khi địa chỉ chưa có tài khoản (một liên kết mới, có hiệu lực 24 giờ, thay thế liên kết trước), hoặc cho một tài khoản đang hoạt động, chưa xác nhận, có địa chỉ đó.

        **Giới hạn:** `auth`, rồi `auth_mail` (10 mỗi giờ cho mỗi client).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Đã chấp nhận (bất kể địa chỉ nào).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedStatus'
              example:
                status: accepted
        '400':
          $ref: '#/components/responses/BadRequest'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `auth` hoặc `auth_mail` (bất kể địa chỉ nào).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` với `retryAfter: 1`: cơ sở dữ liệu vẫn bị khóa trong lúc tra cứu tài khoản hoặc lưu liên kết của một tài khoản chưa xác nhận (việc gia hạn lượt đăng ký đang chờ chỉ được thực hiện ở mức cố gắng tối đa và không bao giờ làm yêu cầu thất bại); khoảng 5 phút của địa chỉ vẫn bắt đầu tính, nên một yêu cầu mới trong khoảng đó không có tác dụng gì. `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Gửi liên kết đặt lại mật khẩu
      description: |-
        Gửi liên kết đặt lại mật khẩu, có hiệu lực trong một giờ, mở trang `/reset-password`. Phản hồi luôn là 202 `accepted` bất kể địa chỉ nào. Liên kết chỉ được gửi tới một tài khoản đang hoạt động, tối đa một lần mỗi 5 phút cho mỗi địa chỉ. Tài khoản chỉ dùng Google đặt mật khẩu đầu tiên theo cách này.

        Việc khôi phục mật khẩu có các giới hạn nghiêm ngặt nhất của API, tất cả đều được tính cho toàn bộ máy chủ: 3 yêu cầu mỗi giờ (`AUTH_FORGOT_PER_HOUR`) và 10 yêu cầu mỗi 24 giờ (`AUTH_FORGOT_PER_DAY`) cho mỗi client (một địa chỉ IPv4 hoặc một mạng IPv6 /64), gấp 3 lần các con số đó cho mỗi IPv6 /48, cộng thêm giới hạn `auth` (20 mỗi 10 phút) và giới hạn một thư cho mỗi địa chỉ mỗi 5 phút. Cả 202 lẫn 429 đều không cho biết có tài khoản nào đang dùng địa chỉ đó hay không. Việc đặt mật khẩu mới có giới hạn riêng (`auth_reset`).

        **Giới hạn:** `auth`, `auth_forgot`, rồi `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Đã chấp nhận (bất kể địa chỉ nào).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedStatus'
              example:
                status: accepted
        '400':
          $ref: '#/components/responses/BadRequest'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `auth`, `auth_forgot` hoặc `auth_forgot_day` (bất kể địa chỉ nào).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` với `retryAfter: 1`: cơ sở dữ liệu vẫn bị khóa trong lúc tra cứu tài khoản hoặc lưu liên kết; khoảng 5 phút của địa chỉ vẫn bắt đầu tính, nên một yêu cầu mới trong khoảng đó không gửi liên kết nào. `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Đặt mật khẩu mới bằng token của liên kết đặt lại mật khẩu
      description: |-
        Đặt mật khẩu mới bằng token của một liên kết đặt lại mật khẩu (trang `/reset-password` cũng làm điều tương tự). Mật khẩu mới tuân theo các quy tắc khi đăng ký.

        Việc đặt lại có các tác động sau, trong một giao dịch duy nhất (tất cả hoặc không gì cả): thu hồi mọi phiên và hủy việc đổi email đang chờ; các liên kết đặt lại mật khẩu khác của tài khoản ngừng hoạt động; địa chỉ được coi là đã xác nhận (liên kết đã chứng minh điều đó); chủ sở hữu nhận một thư. Xác thực hai yếu tố không bị ảnh hưởng. Sau lỗi của hàng đợi băm mật khẩu, lỗi 503 hoặc 500, không có gì thay đổi: mật khẩu cũ và các phiên vẫn giữ nguyên, và liên kết vẫn còn hiệu lực.

        **Giới hạn:** `auth`, rồi `auth_reset` (10 mỗi giờ cho mỗi client, 30 cho mỗi IPv6 /48, dùng chung với trang: mỗi lần thử đều băm một mật khẩu).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Mật khẩu đã được thay đổi và mọi phiên đã được đăng xuất.
          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`: liên kết không hợp lệ, đã được dùng hoặc đã hết hạn, hoặc đã được gửi tới một địa chỉ mà tài khoản không còn dùng. `weak_password` (kèm `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`: giới hạn `auth` hoặc `auth_reset`, hoặc hàng đợi băm mật khẩu.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: hàng đợi băm mật khẩu, hoặc cơ sở dữ liệu vẫn bị khóa (`retryAfter: 1`, không có gì thay đổi). `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Bắt đầu đăng nhập bằng Google
      description: |-
        Bắt đầu một lượt đăng nhập bằng Google cho challenge PKCE và cổng của trình lắng nghe của trò chơi trên `127.0.0.1`. Phản hồi cung cấp URL Google cần mở trong trình duyệt của hệ thống và `state` của lượt đăng nhập; lượt đăng nhập có hiệu lực trong 10 phút.

        `authUrl` chứa `client_id`, `redirect_uri` (tạo từ `redirectPort` và nhãn origin của máy chủ), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 của verifier riêng của máy chủ dành cho Google) với `code_challenge_method=S256`, và `prompt=select_account`. Trò chơi kiểm tra URL này trước khi mở (xem phần mô tả của mục).

        **Giới hạn:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: Lượt đăng nhập.
          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`: máy chủ này không cung cấp đăng nhập bằng 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`: giới hạn `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Bắt đầu đăng nhập bằng Google trên website
      description: |-
        Bắt đầu một lượt đăng nhập bằng Google của website cho challenge PKCE của trang. Phản hồi giống như với trò chơi: URL Google cần điều hướng tới, và `state` của lượt đăng nhập; lượt đăng nhập có hiệu lực trong 10 phút. Có sẵn khi `GET /info` báo `sso.googleWeb: true`.

        `authUrl` chứa `client_id` của website và `redirect_uri=GOOGLE_WEB_REDIRECT_URI`, cùng các tham số khác giống như với trò chơi. Trang kiểm tra URL này trước khi điều hướng tới (xem **Trên website** trong phần mô tả của mục).

        **Giới hạn:** `sso_start`, dùng chung với start của trò chơi.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      responses:
        '200':
          description: Lượt đăng nhập.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoStartAnswer'
              example:
                attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
                authUrl: https://accounts.google.com/o/oauth2/v2/auth?client_id=1234567890-web.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fscacelith.com%2Faccount%2Fgoogle%2F&response_type=code&scope=openid%20email%20profile&state=yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ&nonce=gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU&code_challenge=ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc&code_challenge_method=S256&prompt=select_account
                state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
                expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: `codeChallenge` bị thiếu hoặc sai định dạng, hoặc có bất kỳ trường nào khác (kể cả `redirectPort`).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: máy chủ này không cung cấp đăng nhập bằng Google, hoặc không cung cấp cho website.'
        '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`: giới hạn `sso_start` (dùng chung với start của trò chơi).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Chuyển phản hồi của Google cho máy chủ
      description: |-
        Chuyển cho máy chủ `code` và `state` mà Google đã gửi tới trình lắng nghe của trò chơi hoặc tới trang của website, kèm id của lượt đăng nhập và verifier PKCE. Id của lượt đăng nhập vô dụng nếu không có verifier, và mã vô dụng nếu không có verifier PKCE riêng của máy chủ cùng client secret.

        Máy chủ kiểm tra lượt đăng nhập (không xác định, đã dùng hoặc đã hết hạn: 410), rồi kiểm tra verifier (verifier sai vẫn để lượt đăng nhập dùng được), rồi sử dụng lượt đăng nhập một lần, kiểm tra `state` và `iss`, trao đổi mã với Google và xác minh ID token. Phản hồi (200) là một trong các dạng sau:

        - `{ token, expiresAt, user }`: đã đăng nhập vào tài khoản được liên kết;
        - `{ mfaRequired, mfaToken, expiresIn }`: tiếp tục với `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: một tài khoản mới; tiếp tục với `POST /auth/sso/complete` trong vòng 10 phút. `suggestedUsername` lấy từ tên Google hoặc địa chỉ email, và là `""` khi không có tên nào phù hợp;
        - `{ needsPassword, linkTicket, username, expiresIn }`: tài khoản có địa chỉ đó có mật khẩu; tiếp tục với `POST /auth/sso/google/link`. Chưa có gì được liên kết.

        **Giới hạn:** `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: Một phiên, một bước thứ hai, hoặc bước tiếp theo của lần đăng nhập bằng Google đầu tiên.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Đã đăng nhập vào tài khoản được liên kết
                  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: Xác thực hai yếu tố đang bật
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Người chơi mới
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Một tài khoản có mật khẩu đang dùng địa chỉ này
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: verifier không khớp với challenge của lượt đăng nhập (lượt đăng nhập vẫn dùng được). `sso_email_unverified`: Google chưa xác nhận địa chỉ email. `registration_closed`. `account_disabled`. Chỉ với tài khoản đã liên kết: `banned` (kèm `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: máy chủ này không cung cấp đăng nhập bằng Google.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: một tài khoản đang hoạt động không có mật khẩu đang dùng địa chỉ này (tài khoản đó không được nêu tên).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: lượt đăng nhập không xác định, đã được dùng hoặc đã hết hạn.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Liên kết Google với tài khoản hiện có bằng mật khẩu của tài khoản đó
      description: |-
        Sau khi `finish` trả về `needsPassword`: liên kết Google với tài khoản hiện có bằng mật khẩu của tài khoản đó, được nhập trong trò chơi. Phản hồi (200) là một phiên (liên kết được lưu và người chơi đã đăng nhập), hoặc, khi xác thực hai yếu tố được bật, `{ mfaRequired, mfaToken, expiresIn }`: tiếp tục với `POST /auth/login/mfa`, và liên kết chỉ được lưu khi một mã được chấp nhận ở đó.

        Mật khẩu sai vẫn để vé dùng được, tổng cộng 5 lượt thử. Một lượt thử bị tính ngay trước khi kiểm tra mật khẩu: 429 `too_many_attempts` hoặc 428 `pow_required` không tính lượt nào, còn lần từ chối của hàng đợi băm mật khẩu (503 `server_busy`, 429 `rate_limited`) đã tính một lượt và được coi là một lần thất bại của tài khoản. Độ trễ sau thất bại dùng cùng bộ đếm với `POST /auth/login`.

        **Giới hạn:** `auth` (kèm cách tính theo IPv6 /48 của nó). **Bằng chứng công việc:** như với `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: Một phiên, hoặc bước thứ hai của quá trình đăng nhập.
          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`: sai mật khẩu; vé vẫn còn, tổng cộng được thử 5 lần.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (kèm `until`), chỉ sau khi nhập đúng mật khẩu.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: máy chủ này không cung cấp đăng nhập bằng Google.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: trong lúc đó, tài khoản Google đã được liên kết với một tài khoản khác.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: vé không xác định, đã được dùng hoặc đã hết hạn, lần sai mật khẩu thứ 5, tài khoản có trạng thái hoặc địa chỉ đã thay đổi kể từ `finish`, hoặc tài khoản có trạng thái, địa chỉ, mật khẩu hoặc xác thực hai yếu tố thay đổi trong lúc liên kết đang được lưu. Hãy bắt đầu đăng nhập lại.'
        '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` (độ trễ sau thất bại của tài khoản), hoặc `rate_limited` (giới hạn `auth`, hàng đợi băm mật khẩu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: hàng đợi băm mật khẩu, hoặc cơ sở dữ liệu vẫn bị khóa (`retryAfter: 1`, chưa liên kết gì). `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Tạo tài khoản cho lần đăng nhập bằng Google đầu tiên
      description: |-
        Sau khi `finish` trả về `needsUsername`: tạo tài khoản với tên người dùng đã chọn (áp dụng các quy tắc đăng ký) và đăng nhập vào tài khoản đó. Tài khoản không có mật khẩu: `hasPassword` là `false`, và “Quên mật khẩu?” (`POST /auth/password/forgot`) sẽ đặt mật khẩu cho tài khoản.

        **Giới hạn:** `auth` (kèm phần đếm theo IPv6 /48 của giới hạn này).
      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: Tài khoản đã được tạo và đăng nhập.
          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`: máy chủ này không cung cấp đăng nhập bằng Google.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (một tài khoản đã có tên người dùng này, hoặc một lượt đăng ký đang chờ của địa chỉ khác đang giữ tên này), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: vé không xác định, đã được dùng hoặc đã hết hạn.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Liệt kê các thiết bị đã đăng nhập
      description: |-
        Các phiên đang hoạt động của tài khoản (thiết bị đã đăng nhập), phiên được dùng gần đây nhất đứng trước. `current` đánh dấu phiên đang gửi yêu cầu. `lastSeenAt` được cập nhật nhiều nhất 5 phút một lần (chậm hơn khi máy chủ bị tồn đọng nặng); `expiresAt` là thời điểm kết thúc tuyệt đối, và hạn không hoạt động có thể kết thúc phiên sớm hơn.

        **Giới hạn:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Các phiên đang hoạt động.
          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`: giới hạn `sessions` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Đăng xuất một thiết bị
      description: |-
        Đăng xuất một phiên của tài khoản; cũng có thể đăng xuất chính phiên hiện tại. WebSocket đã mở bằng phiên đó bị đóng. Không có phần thân (hoặc `{}`).

        **Giới hạn:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Phiên đã được đăng xuất.
          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`: phần thân khác `{}`, hoặc `id` không phải mã hóa URL hợp lệ; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: tài khoản này không có phiên đang hoạt động nào với id này.'
        '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`: giới hạn `sessions` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Lấy tài khoản, hệ số Elo và các hình phạt đang có hiệu lực
      description: |-
        Tài khoản như chính người chơi sở hữu nó nhìn thấy: dạng xem tài khoản (cũng là `user` của mọi phản hồi đăng nhập), một bản ghi hệ số cho mỗi hạng mục mà người chơi đã chơi ván tính điểm, các hình phạt đang có hiệu lực và lệnh đình chỉ. Mức độ liêm chính của hệ thống chống gian lận không bao giờ được hiển thị.

        **Giới hạn:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Tài khoản.
          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`, hoặc `invalid_token` (kể cả khi tài khoản đã bị xóa).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `account` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Nhận hoặc từ chối lời thách đấu trực tiếp
      description: |-
        Đặt việc người chơi khác có được thách đấu người chơi này qua tên hay không. Với `none`, lời thách đấu trực tiếp bị từ chối: người thách đấu được báo rằng người chơi không sẵn sàng. Không cần xác thực lại.

        **Giới hạn:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Các tùy chọn hiện đang có hiệu lực.
          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`: giới hạn `account` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Đổi mật khẩu
      description: |-
        Đổi mật khẩu. Cần mật khẩu hiện tại, nhưng không cần yếu tố thứ hai, kể cả khi xác thực hai yếu tố đang bật. Mật khẩu mới tuân theo các quy tắc đăng ký (được kiểm tra sau mật khẩu hiện tại).

        Việc đổi mật khẩu có các tác động sau, trong một giao dịch (tất cả hoặc không gì cả): thu hồi mọi phiên khác (phiên này vẫn đăng nhập), hủy yêu cầu đổi email đang chờ, làm các liên kết đặt lại mật khẩu của tài khoản hết hiệu lực, và gửi thư cho chủ tài khoản.

        **Xác thực lại:** chỉ mật khẩu. **Giới hạn:** `reauth`, sau đó `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: Mật khẩu đã được đổi.
          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` (tài khoản chỉ dùng Google), `weak_password` (kèm `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: sai mật khẩu hiện tại, hoặc mật khẩu đã được đặt lại hoặc đổi trong lúc yêu cầu đang được kiểm tra.'
        '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` (các lần xác thực lại thất bại của tài khoản), hoặc `rate_limited` (giới hạn `reauth` hoặc `reauth_user`, hạn mức tài khoản, hàng đợi băm mật khẩu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: hàng đợi băm mật khẩu, hoặc cơ sở dữ liệu vẫn bị khóa (`retryAfter: 1`, không có gì thay đổi). `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Bắt đầu bật xác thực hai yếu tố
      description: |-
        Lưu một khóa bí mật mới đang chờ cho ứng dụng xác thực, thay thế mọi khóa bí mật đang chờ trước đó, và trả về khóa này cho ứng dụng xác thực (dạng văn bản và dạng URI `otpauth://` để hiển thị thành mã QR). Xác thực hai yếu tố chưa được bật: `POST /account/mfa/totp/enable` bật nó bằng một mã của khóa bí mật này.

        **Xác thực lại:** chỉ mật khẩu. **Giới hạn:** `reauth`, sau đó `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Khóa bí mật đang chờ.
          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` (tài khoản chỉ dùng 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` (được kiểm tra trước mật khẩu).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (các lần xác thực lại thất bại của tài khoản), hoặc `rate_limited` (giới hạn `reauth` hoặc `reauth_user`, hạn mức tài khoản, hàng đợi băm mật khẩu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hàng đợi băm mật khẩu, hoặc cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Hoàn tất bật xác thực hai yếu tố và nhận mã khôi phục
      description: |-
        Bật xác thực hai yếu tố bằng một mã của khóa bí mật đang chờ (đúng 6 chữ số). Ở đây không hỏi mật khẩu; mật khẩu đã được nhập ở bước thiết lập. Phản hồi chứa 10 mã khôi phục, chỉ hiển thị lần này.

        **Giới hạn:** `reauth`, sau đó `reauth_user`; một mã sai được tính vào số lần xác thực lại thất bại của tài khoản.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Xác thực hai yếu tố đã được bật.
          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` không phải đúng 6 chữ số, hoặc phần thân vi phạm schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: mã sai (hãy kiểm tra giờ của thiết bị).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: không có khóa bí mật đang chờ (hãy gọi `POST /account/mfa/totp/setup` trước).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (các lần xác thực lại thất bại của tài khoản), hoặc `rate_limited` (giới hạn `reauth` hoặc `reauth_user`, hạn mức tài khoản).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Tắt xác thực hai yếu tố
      description: |-
        Tắt xác thực hai yếu tố. Khóa bí mật và các mã khôi phục bị xóa, và chủ tài khoản nhận được một thư.

        **Xác thực lại:** mật khẩu và một mã từ ứng dụng xác thực hoặc một mã khôi phục (bắt buộc có một trong hai: `code` hoặc `recoveryCode`). **Giới hạn:** `reauth`, sau đó `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: Xác thực hai yếu tố đã được tắt.
          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` (tài khoản chỉ dùng Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (không có cả `code` lẫn `recoveryCode`, được kiểm tra trước mật khẩu), `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` (các lần xác thực lại thất bại, hoặc đã thử quá nhiều mã trên tài khoản này), hoặc `rate_limited` (giới hạn `reauth` hoặc `reauth_user`, hạn mức tài khoản, hàng đợi băm mật khẩu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hàng đợi băm mật khẩu, hoặc cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Thay các mã khôi phục
      description: |-
        Thay các mã khôi phục bằng 10 mã mới; các mã cũ hết hiệu lực.

        **Xác thực lại:** mật khẩu và một mã từ ứng dụng xác thực trong `code` (mã khôi phục bị từ chối với 403 `invalid_code`). **Giới hạn:** `reauth`, sau đó `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: Các mã khôi phục mới, chỉ hiển thị lần này.
          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` (tài khoản chỉ dùng Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (kể cả với mã khôi phục).'
        '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` (các lần xác thực lại thất bại, hoặc đã thử quá nhiều mã trên tài khoản này), hoặc `rate_limited` (giới hạn `reauth` hoặc `reauth_user`, hạn mức tài khoản, hàng đợi băm mật khẩu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hàng đợi băm mật khẩu, hoặc cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Đổi địa chỉ email
      description: |-
        Đổi địa chỉ email của tài khoản. `newEmail` được cắt bỏ khoảng trắng ở hai đầu và chuyển thành chữ thường, rồi được kiểm tra như một địa chỉ đăng ký.

        **Có xác nhận email** (mặc định): 202 `verification_sent`. Một liên kết có hiệu lực trong 24 giờ được gửi tới địa chỉ mới; liên kết này mở `/confirm-email-change`, và địa chỉ chỉ thay đổi khi người chơi nhấn nút trên trang đó. Trước đó, `GET /account/me` hiển thị địa chỉ mới dưới dạng `pendingEmail`. Một yêu cầu mới thay thế yêu cầu đang chờ; việc đổi hoặc đặt lại mật khẩu sẽ hủy yêu cầu đó. Mỗi địa chỉ mới nhận nhiều nhất một liên kết trong mỗi 5 phút, bất kể ai yêu cầu (yêu cầu cho chính thay đổi đang chờ giữ nguyên liên kết đã gửi trước đó, liên kết này vẫn còn hiệu lực); phản hồi vẫn như nhau. Địa chỉ hiện tại nhận được thông báo rằng đã có yêu cầu đổi sang một địa chỉ đã che bớt (`a***@example.org`). Phản hồi và `pendingEmail` vẫn như nhau khi một tài khoản khác đã dùng địa chỉ mới: khi đó không có liên kết nào được gửi, nên thay đổi đó không bao giờ hoàn tất, và thay vào đó chủ của địa chỉ kia nhận được một thông báo (nhiều nhất một lần mỗi giờ).

        Khi liên kết được xác nhận, địa chỉ thay đổi và được coi là đã xác nhận, các thiết bị vẫn đăng nhập, các liên kết đã gửi trước đó (xác nhận, đặt lại mật khẩu, các yêu cầu đổi khác) hết hiệu lực, và địa chỉ cũ được thông báo, với địa chỉ mới đã che bớt.

        **Không có xác nhận email** (`REQUIRE_EMAIL_VERIFICATION=false`): địa chỉ thay đổi ngay (200 `email_changed`), và địa chỉ cũ được thông báo. Nếu một tài khoản khác dùng địa chỉ đó, phản hồi là 409 `email_taken`, và chủ của địa chỉ đó nhận được thông báo.

        **Xác thực lại:** mật khẩu và, khi bật xác thực hai yếu tố, một mã từ ứng dụng xác thực hoặc một mã khôi phục. `invalid_email` và `same_email` được kiểm tra trước mật khẩu, nên không tính là thất bại. **Giới hạn:** `reauth`, sau đó `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: Địa chỉ đã thay đổi ngay (máy chủ này không yêu cầu xác nhận email).
          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: Địa chỉ mới, như đã được lưu.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Một liên kết xác nhận đã được gửi tới địa chỉ mới (hoặc địa chỉ đó thuộc về một tài khoản khác; phản hồi không cho biết).
          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` (địa chỉ hiện tại của tài khoản), `password_not_set` (tài khoản chỉ dùng Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (kể cả khi một lần đổi hoặc đặt lại mật khẩu đã vượt trước yêu cầu: không có liên kết nào được gửi), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: chỉ khi không có xác nhận email.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (các lần xác thực lại thất bại, hoặc đã thử quá nhiều mã trên tài khoản này), hoặc `rate_limited` (giới hạn `reauth` hoặc `reauth_user`, hạn mức tài khoản, hàng đợi băm mật khẩu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: hàng đợi băm mật khẩu, hoặc cơ sở dữ liệu vẫn bị khóa (`retryAfter: 1`: không có gì thay đổi, và có thể gửi lại đúng yêu cầu đó). `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Tải xuống dữ liệu của tài khoản
      description: |-
        Mọi thông tin máy chủ lưu giữ về tài khoản, dưới dạng một tệp JSON để lưu lại (`format` `scacelith-account-export`, `version` 1). Mỗi lần xuất dữ liệu ghi lại một sự kiện bảo mật (`account_exported`). Mảng `notes` của tài liệu cho người chơi biết, bằng tiếng Anh đơn giản, những gì không được đưa vào.

        **Không bao giờ có trong bản xuất dữ liệu:** mã băm mật khẩu, khóa bí mật của xác thực hai yếu tố và các mã khôi phục; mọi token phiên hoặc token của liên kết email, hay mã băm của chúng; dữ liệu của hệ thống chống gian lận (mức độ liêm chính và điểm số, các bất thường, kết quả phân tích các ván cờ, trọng số của một báo cáo); các báo cáo mà người chơi khác đã gửi về người chơi; danh tính của người kiểm duyệt; dữ liệu riêng tư của người chơi khác (đối thủ xuất hiện bằng tên công khai và hệ số Elo, không có gì cho biết một người chơi khác có bị áp dụng hình phạt hay không, và không có địa chỉ IP nào có thể thuộc về người khác).

        **Xác thực lại:** mật khẩu và, khi bật xác thực hai yếu tố, một mã từ ứng dụng xác thực hoặc một mã khôi phục. **Giới hạn:** `account_export` (5 lần mỗi giờ cho mỗi người chơi, tính trên toàn máy chủ; mọi lần thử đều được tính, kể cả lần thất bại; được kiểm tra đầu tiên), sau đó `reauth` và `reauth_user`. **Giới hạn thời gian:** 60 giây.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Tài liệu xuất dữ liệu, dưới dạng tệp đính kèm.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Các ký tự trong tên người dùng không phải chữ cái, chữ số, `_`, `.` và `-` được thay bằng `_`.'
              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` (tài khoản chỉ dùng 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` (các lần xác thực lại thất bại, hoặc đã thử quá nhiều mã trên tài khoản này), hoặc `rate_limited` (giới hạn `account_export`, `reauth` hoặc `reauth_user`, hạn mức tài khoản, hàng đợi băm mật khẩu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (cơ sở dữ liệu vẫn bị khóa, kèm header `Retry-After: 1`), `server_busy` (hàng đợi băm mật khẩu), `timeout` (60 giây). Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Xóa tài khoản
      description: |-
        Xóa tài khoản; không thể hoàn tác thao tác này.

        - Mọi phiên bị thu hồi ngay lập tức: từ đó trở đi, token nhận 401 `invalid_token`.
        - Tên người dùng trở thành `deleted#<id>`, trong tài khoản và trong mọi bản ghi ván cờ.
        - Những dữ liệu sau bị xóa: địa chỉ email, mã băm mật khẩu, khóa bí mật hai yếu tố và các mã khôi phục, các phiên và token của liên kết email, liên kết với tài khoản Google, bản ghi liêm chính của hệ thống chống gian lận, và các địa chỉ IP được lưu cùng các sự kiện bảo mật.
        - Hệ số Elo và các ván cờ được giữ lại. Các ván cờ vẫn đọc được dưới tên ẩn danh, và `GET /players/{username}` trả về 404 cho tên cũ.

        **Xác thực lại:** mật khẩu và, khi bật xác thực hai yếu tố, một mã từ ứng dụng xác thực hoặc một mã khôi phục. **Giới hạn:** `reauth`, sau đó `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: Tài khoản đã bị xóa.
          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` (tài khoản chỉ dùng 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` (các lần xác thực lại thất bại, hoặc đã thử quá nhiều mã trên tài khoản này), hoặc `rate_limited` (giới hạn `reauth` hoặc `reauth_user`, hạn mức tài khoản, hàng đợi băm mật khẩu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hàng đợi băm mật khẩu, hoặc cơ sở dữ liệu vẫn bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Liệt kê các ván cờ của người chơi, có lọc và phân trang
      description: |-
        Các ván cờ của người chơi đã đăng nhập, mới nhất trước, có lọc và phân trang, kèm số ván khớp với bộ lọc. Mọi tham số truy vấn đều không bắt buộc, và giá trị rỗng được coi như không có.

        Phân trang: truyền `next` của trang trước làm `before`; `next` là `null` ở trang cuối. `total` đếm số ván khớp với bộ lọc, trên tất cả các trang.

        **Giới hạn:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: Một id hạng mục chính thức (`3+2`, hoặc `3%2B2`), hoặc `custom` cho mọi ván có thời gian thi đấu khác.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Chỉ các ván tính điểm (`true`) hoặc giao hữu (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Chỉ các ván thắng, thua hoặc hòa, xét từ phía người chơi. Ván bị hủy chỉ xuất hiện khi không dùng bộ lọc này.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Một trang của lịch sử ván đấu.
          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` không phải id ván cờ), `invalid_limit`, hoặc `invalid_filter` (`category`, `rated` hoặc `result`), mỗi lỗi kèm `field` nêu tên tham số.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `account_games` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (cơ sở dữ liệu vẫn bị khóa; `retryAfter: 1` trong phần thân, không có header `Retry-After`), `server_busy` (việc tra cứu phiên gặp cơ sở dữ liệu bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Lấy bản ghi ván cờ kèm các nước đi và đồng hồ
      description: |-
        Một bản ghi ván cờ, kèm các nước đi và đồng hồ. Khi không có token, hoặc với token của một người chơi không tham gia ván đó, phản hồi là bản công khai. Khi người chơi của token đã chơi ván đó, phản hồi có thêm `you` và `reportable`.

        **Giới hạn:** `public_read` (theo người chơi khi có token, theo client khi không có).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Bản ghi ván cờ.
          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`: không phải số nguyên dương có tối đa 16 chữ số (nhỏ hơn 2^53); `invalid_request`: không phải mã hóa URL hợp lệ.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: đã gửi một token và token đó không hợp lệ.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: không có ván cờ này.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `public_read` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (cơ sở dữ liệu vẫn bị khóa; `retryAfter: 1` trong phần thân, không có header `Retry-After`), `server_busy` (việc tra cứu phiên gặp cơ sở dữ liệu bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Tải xuống một ván cờ dưới dạng tệp PGN
      description: |-
        Cùng ván cờ đó dưới dạng tệp PGN: một ván với ký tự xuống dòng `\n`, và phần nước đi được chia thành các dòng dưới 80 cột. Phản hồi như nhau dù có hay không có token.

        Các thẻ PGN, theo thứ tự này: `Event` (`<SERVER_NAME> rated <category>` hoặc `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (ngày bắt đầu theo UTC), `Round`, `White`, `Black`, `Result` (`*` cho ván bị hủy), `UTCDate` và `UTCTime` (thời điểm bắt đầu), `WhiteElo` và `BlackElo` (hệ số Elo lúc bắt đầu, hoặc `-`), `WhiteRatingDiff` và `BlackRatingDiff` (mức thay đổi, chẳng hạn `+10` và `-10`, trong mọi ván tính điểm, `+0` khi các quy tắc tính hệ số giữ nguyên hệ số; ván giao hữu, ván có thời gian thi đấu tùy chỉnh hoặc ván bị hủy không có hai thẻ này), `TimeControl` (tính bằng giây), `Termination` (một giá trị chuẩn của PGN: `normal`; `time forfeit`, hết giờ, kể cả khi ván kết thúc hòa; `abandoned`; `rules infraction`, nước đi không hợp lệ thứ hai hoặc bị xử thua vì vi phạm chơi công bằng; `unterminated`, ván bị hủy), `PlyCount`, `ScacelithGameId` (id dạng thập phân).

        Mỗi nước đi mang `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: đồng hồ của người vừa đi sau nước đi đó và thời gian bị tính cho nước đi, theo phần mười giây (cắt bỏ phần lẻ). Một giá trị được lược bỏ khi bản ghi không có giá trị đó. Sau nước đi cuối cùng là lý do kết thúc viết bằng chữ, rồi đến kết quả.

        **Giới hạn:** `public_read` (theo người chơi khi có token, theo client khi không có).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Tệp 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: 'Văn bản PGN, mã hóa 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`: không phải số nguyên dương có tối đa 16 chữ số (nhỏ hơn 2^53); `invalid_request`: không phải mã hóa URL hợp lệ.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: đã gửi một token và token đó không hợp lệ.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: không có ván cờ này.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `public_read` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: chẳng hạn khi các nước đi đã lưu không thể phát lại đến đúng kết thúc đã lưu (máy chủ ghi lỗi này vào nhật ký).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (cơ sở dữ liệu vẫn bị khóa; `retryAfter: 1` trong phần thân, không có header `Retry-After`), `server_busy` (việc tra cứu phiên gặp cơ sở dữ liệu bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Tải xuống một ván cờ của máy chủ này dưới dạng ảnh GIF động
      description: |-
        Ván cờ dưới dạng ảnh GIF động. Tên và hệ số Elo lấy từ bản ghi ván cờ (hệ số lúc bắt đầu, tài khoản đã xóa hiện là `deleted#<id>`), kết quả và cách kết thúc cũng vậy (`Resignation`, `Loss on time`…).

        Các bước kiểm tra theo thứ tự: `gif_disabled`, các tùy chọn hình ảnh (`size`, `orientation`, `delay`, `coords`), id ván cờ, ván cờ, độ dài của ván.

        **Yêu cầu phiên:** hạn ngạch được tính theo tài khoản. **Giới hạn:** `gif` cho mọi yêu cầu; `gif_user_min`, `gif_user_hour`, `gif_ip_min` và `gif_ip_hour` chỉ khi phải tạo ảnh GIF (xem phần mô tả của mục). **Giới hạn thời gian:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 giây (mặc định là 45 giây).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: Kích thước hình ảnh, `small` (ô 32 px), `medium` (48 px) hoặc `large` (72 px).
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Bên ở phía dưới bàn cờ.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Số mili giây cho mỗi nước đi (từ 1 đến 6 chữ số thập phân).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Có vẽ chữ cái của cột và số của hàng quanh bàn cờ (`1`) hay không (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` kèm `field` (`size`, `orientation`, `delay` hoặc `coords`): một giá trị nằm ngoài các giá trị cho phép. `invalid_game_id`. `invalid_request`: không phải mã hóa URL hợp lệ.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: không có ván cờ này. `gif_disabled`: máy chủ đã tắt ảnh GIF (`GIF_ENABLED=false`; token `gif` được hoàn lại).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `gif`, một giới hạn kết xuất, hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: không thể tạo ảnh GIF (máy chủ ghi lý do vào nhật ký). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` kèm `retryAfter` (từ 3 đến 10 giây) và `Retry-After`: hàng đợi kết xuất đã đầy, hoặc ảnh GIF đã chờ một luồng rảnh trong `GIF_QUEUE_TIMEOUT_MS` (10 giây); mọi token giới hạn mà yêu cầu đã lấy đều được hoàn lại. `busy`: cơ sở dữ liệu vẫn bị khóa (`retryAfter: 1` chỉ có trong phần thân). `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Tạo ảnh GIF động của bất kỳ ván cờ nào được gửi dưới dạng PGN
      description: |-
        Cùng hình ảnh như `GET /games/{id}/gif` cho bất kỳ ván cờ nào được gửi dưới dạng văn bản PGN: một ván do trò chơi lưu, một tệp xuất từ trang web khác, một ván gõ bằng tay. Chỉ ván đầu tiên trong văn bản được dùng.

        - Trình đọc PGN chấp nhận những gì trình đọc của chính trò chơi chấp nhận: mọi PGN mà máy chủ này ghi ra và các tệp xuất thông thường của các trang web khác (chú thích, biến thể, NAG và chú thích đồng hồ được bỏ qua; số thứ tự nước đi và SAN được đọc một cách linh hoạt; thẻ `FEN` cho thế cờ xuất phát, trừ khi `SetUp` là `"0"`).
        - Tên và hệ số Elo lấy từ các thẻ `White`, `Black`, `WhiteElo` và `BlackElo`. Các chữ cái có dấu bị bỏ dấu, các ký tự khác nằm ngoài ASCII in được trở thành `?`, và tên dài bị cắt bớt (48 ký tự). Kết quả lấy từ thẻ `Result`, nếu không có thì từ cuối phần nước đi. Thẻ `Termination` được hiển thị trừ khi nó là `normal`: khi đó thế cờ cuối cùng tự nói lên diễn biến (chiếu hết, hết nước đi).
        - Endpoint này tự kiểm tra phần thân của nó: một trường không xác định hoặc `pgn` bị thiếu hay không phải chuỗi trả về 400 `invalid_request` kèm `field`; một tùy chọn sai trả về 400 `invalid_option` (`delayMs` phải là số JSON, `coords` phải là boolean; `null` bị từ chối).

        **Yêu cầu phiên:** hạn ngạch được tính theo tài khoản. **Giới hạn:** như với `GET /games/{id}/gif`. **Giới hạn phần thân:** 135.168 byte, bất kể `HTTP_BODY_LIMIT` (PGN dưới dạng chuỗi JSON, tính cả các ký tự thoát). **Giới hạn thời gian:** mặc định là 45 giây.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Một ván ngắn được gõ vào, không có tọa độ
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Một tệp PGN với mọi tùy chọn
                value:
                  pgn: |
                    [White "alice"]
                    [Black "bob"]
                    [WhiteElo "1500"]
                    [BlackElo "1520"]
                    [Result "1-0"]

                    1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0
                  size: small
                  orientation: black
                  delayMs: 800
                  coords: true
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request` kèm `field` (một trường không xác định, hoặc `pgn` bị thiếu hay không phải chuỗi; không có `field` khi phần thân không phải object); `invalid_json`; `invalid_option` kèm `field` (`size`, `orientation`, `delayMs` hoặc `coords`); `invalid_pgn` kèm `line` và `column` (đếm từ 1, cột tính theo ký tự) và `message` của trình đọc: một nước đi không hợp lệ hoặc mơ hồ, một thẻ bị hỏng, một biến thể cờ không xác định, hơn 65.536 byte.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: máy chủ đã tắt ảnh GIF (`GIF_ENABLED=false`; token `gif` được hoàn lại).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: phần thân lớn hơn 135.168 byte.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `gif`, một giới hạn kết xuất, hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: không thể tạo ảnh GIF (máy chủ ghi lý do vào nhật ký). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` kèm `retryAfter` (từ 3 đến 10 giây) và `Retry-After`: hàng đợi kết xuất đã đầy, hoặc ảnh GIF đã chờ một luồng rảnh quá lâu; mọi token giới hạn mà yêu cầu đã lấy đều được hoàn lại. `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Lấy hồ sơ công khai của một người chơi
      description: |-
        Hồ sơ công khai của một người chơi: hệ số Elo trong các hạng mục chính thức (theo thứ tự của máy chủ) và số ván. `games.total` đếm mọi ván đã lưu, kể cả ván giao hữu và ván bị hủy; `games.rated`, `wins`, `draws` và `losses` được cộng dồn trên các bản ghi hệ số, nên chỉ gồm các ván tính điểm. Phản hồi như nhau dù có hay không có token.

        **Giới hạn:** `public_read` (theo người chơi khi có token, theo client khi không có).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Hồ sơ.
          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`: không phải từ 2 đến 24 ký tự thuộc `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: đã gửi một token và token đó không hợp lệ.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: không có người chơi này, hoặc tài khoản đã bị xóa.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `public_read` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (cơ sở dữ liệu vẫn bị khóa; `retryAfter: 1` trong phần thân, không có header `Retry-After`), `server_busy` (việc tra cứu phiên gặp cơ sở dữ liệu bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Liệt kê các ván gần đây của một người chơi
      description: |-
        Các ván gần đây của người chơi, mới nhất trước, được phân trang như lịch sử ván đấu nhưng không có bộ lọc hay tổng số. `color` là bên của người chơi này. `next` là id của ván cuối cùng mỗi khi trang đầy, nên trang tiếp theo có thể trống. Người chơi được tra cứu trước tiên: người chơi không xác định trả về 404 bất kể chuỗi truy vấn ra sao.

        **Giới hạn:** `public_read` (theo người chơi khi có token, theo client khi không có).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Một trang các ván cờ của người chơi.
          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` không phải id ván cờ), `invalid_limit` (hai lỗi sau cùng này không kèm `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: đã gửi một token và token đó không hợp lệ.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: không có người chơi này, hoặc tài khoản đã bị xóa.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: giới hạn `public_read` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (cơ sở dữ liệu vẫn bị khóa; `retryAfter: 1` trong phần thân, không có header `Retry-After`), `server_busy` (việc tra cứu phiên gặp cơ sở dữ liệu bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Lấy những người chơi hàng đầu của một hạng mục
      description: |-
        100 bản ghi hệ số đứng đầu của một hạng mục chính thức, trong số các bản ghi có ít nhất `minGames` (`PROVISIONAL_GAMES`) ván được tính, không gồm các tài khoản đã xóa và những người đã được xác nhận gian lận. Máy chủ tính lại mỗi bảng nhiều nhất 10 giây một lần; `updatedAt` cho biết thời điểm tính. Mỗi hạng mục chỉ có một lượt tính chạy tại một thời điểm: trong lúc đó, các yêu cầu khác nhận danh sách trước đó, hoặc chờ lượt tính khi chưa có danh sách nào.

        **Giới hạn:** chỉ có tầng theo địa chỉ.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Một id hạng mục chính thức (`3+2`, hoặc `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Số người chơi, từ 1 đến 100. Một số lớn hơn có tối đa 3 chữ số được tính là 100; giá trị rỗng được coi như không có.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: Bảng xếp hạng.
          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` (bị thiếu, hoặc không phải hạng mục chính thức), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: tầng theo địa chỉ.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (cơ sở dữ liệu vẫn bị khóa; `retryAfter: 1` trong phần thân, không có header `Retry-After`), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Báo cáo đối thủ của một ván gần đây
      description: |-
        Báo cáo đối thủ trong một ván cờ của chính người chơi đã kết thúc trong vòng 7 ngày qua. Bản thân một báo cáo không bao giờ thay đổi hệ số Elo, hình phạt hay mức độ liêm chính. Báo cáo nâng mức ưu tiên xem xét mà người kiểm duyệt nhìn thấy, và, trừ trường hợp `abuse`, yêu cầu phân tích ván cờ bằng chương trình cờ.

        Một báo cáo trùng đối thủ cho cùng ván cờ nhận cùng phản hồi và không thay đổi gì; phản hồi không bao giờ cho biết điều gì về tài khoản bị báo cáo. `GET /games/{id}` cho những người chơi của ván biết trước liệu một báo cáo có được tiếp nhận hay không (`reportable`).

        Endpoint này tự kiểm tra phần thân của nó, theo thứ tự `gameId`, `reported`, `category`, `comment`: một lỗi trả về 400 `invalid_request` không kèm `field`. Các trường mà nó không biết sẽ bị bỏ qua.

        **Giới hạn:** `reports` (theo người chơi), sau đó `REPORTS_PER_DAY` (5) báo cáo cho mỗi người chơi trong 24 giờ (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: Báo cáo đã được tiếp nhận (hoặc đã được gửi từ trước).
          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` (không kèm `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: không phải đối thủ của người báo cáo trong một ván cờ đã kết thúc trong vòng 7 ngày qua.'
        '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`, kèm header `Retry-After`): đã gửi `REPORTS_PER_DAY` báo cáo trong 24 giờ qua. `rate_limited`: giới hạn `reports` hoặc hạn mức tài khoản.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (việc tra cứu phiên gặp cơ sở dữ liệu bị khóa), `timeout`. Ngoài ra, `server_busy` với `retryAfter` từ 2 đến 5 khi máy chủ bị tồn đọng nặng (xem phần giới thiệu): yêu cầu chưa được thực thi.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Máy chủ chính thức (các trang nằm ở gốc, ngoài `/api/v1`).
      - url: https://{host}:{port}
        description: Bất kỳ máy chủ Scacelith nào (các trang nằm ở gốc, ngoài `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Tên host công khai của máy chủ (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Cổng API công khai (`PUBLIC_API_PORT`, nếu không có thì `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Hiển thị trang xác nhận email
      description: |-
        Trang của một liên kết xác nhận email (khi đăng ký, hoặc từ một email xác nhận được gửi lại). Trang chỉ hiển thị nút “Confirm my e-mail address” (Xác nhận địa chỉ email của tôi), để trình quét thư mở liên kết không làm liên kết bị dùng mất; nút này gửi biểu mẫu tới `POST /verify-email`.

        **Giới hạn:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Trang kèm nút xác nhận.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Liên kết không hợp lệ hoặc đã hết hạn (một trang HTML).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Giới hạn `page` (một trang HTML), hoặc tầng theo địa chỉ (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Xác nhận địa chỉ email
      description: |-
        Biểu mẫu của trang xác nhận. Biểu mẫu xác nhận địa chỉ; với một lượt đăng ký mới, đến lúc này tài khoản mới được tạo, và người chơi có thể đăng nhập.

        **Giới hạn:** `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: Địa chỉ đã được xác nhận (và tài khoản của lượt đăng ký mới đã được tạo).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Liên kết không hợp lệ, đã được dùng hoặc đã hết hạn, hoặc biểu mẫu không hợp lệ (một trang HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Liên kết của một lượt đăng ký mới có tên người dùng hoặc địa chỉ đã bị một tài khoản khác chiếm trong lúc chờ; không có tài khoản nào được tạo (một trang HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Giới hạn `auth` (một trang HTML), hoặc tầng theo địa chỉ (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Cơ sở dữ liệu vẫn bị khóa (`Retry-After: 1`), hoặc máy chủ bị tồn đọng nặng (`Retry-After` từ 2 đến 5, xem phần giới thiệu): không có gì thay đổi và liên kết vẫn dùng được. Cũng có thể do handler hết thời gian chờ.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Máy chủ chính thức (các trang nằm ở gốc, ngoài `/api/v1`).
      - url: https://{host}:{port}
        description: Bất kỳ máy chủ Scacelith nào (các trang nằm ở gốc, ngoài `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Tên host công khai của máy chủ (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Cổng API công khai (`PUBLIC_API_PORT`, nếu không có thì `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Hiển thị biểu mẫu đặt lại mật khẩu
      description: |-
        Trang của một liên kết đặt lại mật khẩu: biểu mẫu nhập mật khẩu mới (nhập mật khẩu hai lần). Biểu mẫu được gửi tới `POST /reset-password`.

        **Giới hạn:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Biểu mẫu nhập mật khẩu mới.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Liên kết không hợp lệ (kể cả khi liên kết đã được gửi tới một địa chỉ mà tài khoản không còn dùng nữa).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Giới hạn `page` (một trang HTML), hoặc tầng theo địa chỉ (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Đặt mật khẩu mới từ biểu mẫu đặt lại mật khẩu
      description: |-
        Biểu mẫu của trang đặt lại mật khẩu; biểu mẫu làm đúng những gì `POST /auth/password/reset` làm, trong một giao dịch: mọi thiết bị bị đăng xuất, yêu cầu đổi email đang chờ bị hủy, các liên kết đặt lại mật khẩu khác hết hiệu lực, địa chỉ được coi là đã xác nhận và chủ tài khoản nhận được một thư.

        Liên kết được kiểm tra trước tiên, sau đó đến việc hai mật khẩu có giống nhau không, rồi đến các quy tắc mật khẩu. Khi máy chủ bận, biểu mẫu được trả lại kèm `Retry-After` và liên kết vẫn còn hiệu lực.

        **Giới hạn:** `auth`, sau đó `auth_reset` (dùng chung với `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: Mật khẩu đã được đổi, và mọi thiết bị đã bị đăng xuất.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Biểu mẫu được hiển thị lại kèm lỗi (hai mật khẩu khác nhau, mật khẩu yếu), liên kết không hợp lệ, hoặc biểu mẫu không hợp lệ (một trang 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: Giới hạn `auth` hoặc `auth_reset` (một trang HTML), biểu mẫu được hiển thị lại kèm `Retry-After` khi client này có quá nhiều lượt băm mật khẩu đang chờ (liên kết vẫn còn hiệu lực), hoặc tầng theo địa chỉ (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Biểu mẫu được hiển thị lại kèm `Retry-After` khi máy chủ bận (hàng đợi băm mật khẩu, hoặc cơ sở dữ liệu vẫn bị khóa); liên kết vẫn còn hiệu lực. Khi máy chủ bị tồn đọng nặng (xem phần giới thiệu), thay vào đó là một trang “Server error” (Lỗi máy chủ) kèm `Retry-After` (từ 2 đến 5): chưa có gì được thực thi và liên kết vẫn còn hiệu lực. Cũng có thể do handler hết thời gian chờ.'
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Máy chủ chính thức (các trang nằm ở gốc, ngoài `/api/v1`).
      - url: https://{host}:{port}
        description: Bất kỳ máy chủ Scacelith nào (các trang nằm ở gốc, ngoài `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Tên host công khai của máy chủ (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Cổng API công khai (`PUBLIC_API_PORT`, nếu không có thì `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Hiển thị trang xác nhận đổi email
      description: |-
        Trang của một liên kết đổi email: trang hiển thị địa chỉ mới và tên của tài khoản, kèm nút “Use this e-mail address” (Dùng địa chỉ email này) gửi biểu mẫu tới `POST /confirm-email-change`.

        **Giới hạn:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Trang kèm địa chỉ mới và nút xác nhận.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Liên kết không hợp lệ hoặc đã hết hạn (kể cả khi địa chỉ của tài khoản đã thay đổi kể từ lúc yêu cầu).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Giới hạn `page` (một trang HTML), hoặc tầng theo địa chỉ (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Xác nhận địa chỉ email mới
      description: |-
        Biểu mẫu của trang đổi email. Địa chỉ thay đổi và được coi là đã xác nhận; các thiết bị vẫn đăng nhập; các liên kết đã gửi trước đó (xác nhận, đặt lại mật khẩu, các yêu cầu đổi khác) hết hiệu lực; địa chỉ cũ được thông báo, với địa chỉ mới đã che bớt.

        **Giới hạn:** `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: Địa chỉ đã được đổi.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Liên kết không hợp lệ, đã được dùng hoặc đã hết hạn, hoặc biểu mẫu không hợp lệ (một trang HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Một tài khoản khác đã chiếm địa chỉ đó trong lúc chờ (một trang HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Giới hạn `auth` (một trang HTML), hoặc tầng theo địa chỉ (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Cơ sở dữ liệu vẫn bị khóa (`Retry-After: 1`), hoặc máy chủ bị tồn đọng nặng (`Retry-After` từ 2 đến 5, xem phần giới thiệu): không có gì thay đổi và liên kết vẫn dùng được. Cũng có thể do handler hết thời gian chờ.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Máy chủ chính thức, ở gốc.
      - url: https://caissa.scacelith.com/api/v1
        description: Máy chủ chính thức, dưới `/api/v1`.
      - url: https://{host}:{port}
        description: Bất kỳ máy chủ Scacelith nào, ở gốc.
        variables:
          host:
            default: caissa.scacelith.com
            description: Tên host công khai của máy chủ (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Cổng API công khai (`PUBLIC_API_PORT`, nếu không có thì `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Bất kỳ máy chủ Scacelith nào, dưới `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Tên host công khai của máy chủ (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Cổng API công khai (`PUBLIC_API_PORT`, nếu không có thì `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Kiểm tra tiến trình có đang chạy
      description: |-
        Trả về 200 `ok` khi tiến trình đang chạy. `HEAD` cũng dùng được. Mọi phương thức khác trả về 405 `method_not_allowed` kèm `Allow: GET, HEAD` (kể cả `OPTIONS`).

        **Giới hạn:** chỉ có tầng theo địa chỉ.
      security: []
      responses:
        '200':
          description: Tiến trình đang chạy.
          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`: tầng theo địa chỉ.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Máy chủ chính thức, ở gốc.
      - url: https://caissa.scacelith.com/api/v1
        description: Máy chủ chính thức, dưới `/api/v1`.
      - url: https://{host}:{port}
        description: Bất kỳ máy chủ Scacelith nào, ở gốc.
        variables:
          host:
            default: caissa.scacelith.com
            description: Tên host công khai của máy chủ (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Cổng API công khai (`PUBLIC_API_PORT`, nếu không có thì `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Bất kỳ máy chủ Scacelith nào, dưới `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Tên host công khai của máy chủ (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Cổng API công khai (`PUBLIC_API_PORT`, nếu không có thì `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Kiểm tra máy chủ có đang nhận người chơi
      description: |-
        Trả về 200 `ready` khi máy chủ nhận người chơi, và 503 `not_ready` khi máy chủ đang khởi động hoặc đang dừng. `HEAD` cũng dùng được. Mọi phương thức khác trả về 405 `method_not_allowed` kèm `Allow: GET, HEAD` (kể cả `OPTIONS`).

        **Giới hạn:** chỉ có tầng theo địa chỉ.
      security: []
      responses:
        '200':
          description: Máy chủ đang nhận người chơi.
          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`: tầng theo địa chỉ.'
        '503':
          description: Máy chủ đang khởi động hoặc đang dừng.
          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: |-
        Một token phiên (`sct_` theo sau là 43 ký tự base64url) lấy từ phản hồi đăng nhập, đặt trong header `Authorization`: `Authorization: Bearer <token>`. Tiền tố phải đúng là `Bearer` (phân biệt chữ hoa chữ thường) và một dấu cách; một giá trị không có tiền tố này, hoặc một token không gồm từ 1 đến 512 ký tự ASCII in được, trả về 401 `invalid_token` như mọi token không hợp lệ khác. Header rỗng được coi như không có header.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: Id ván cờ, một số nguyên thập phân dương có tối đa 16 chữ số, không có số 0 ở đầu, nhỏ hơn 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: Id phiên, như `GET /auth/sessions` cung cấp, viết không có số 0 ở đầu (`03` không phải phiên 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Tên người dùng của người chơi, không phân biệt chữ hoa chữ thường. Máy chủ chấp nhận mọi tên gồm từ 2 đến 24 ký tự thuộc `[A-Za-z0-9_.-]` (quy tắc rộng nhất mà máy chủ từng cho phép).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Một id ván cờ; chỉ các ván cũ hơn được liệt kê. Truyền `next` của trang trước. Giá trị rỗng được coi như không có.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Kích thước trang, từ 1 đến 50. Một số lớn hơn có tối đa 3 chữ số được tính là 50; giá trị rỗng được coi như không có.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Token của liên kết email (43 ký tự base64url). Token bị thiếu hoặc sai sẽ hiển thị trang “link invalid or expired” (Liên kết không hợp lệ hoặc đã hết hạn).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Mọi phản hồi của máy chủ đều cấm lưu vào bộ nhớ đệm.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Số giây cần chờ trước khi thử lại; cùng giá trị với `retryAfter` trong phần thân.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Challenge xác thực kiểu Bearer, kèm `error="invalid_token"` khi token không hợp lệ.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: Máy chủ đóng kết nối sau phản hồi này.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (kèm `field` khi một trường của phần thân có lỗi) hoặc `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Một trường vi phạm schema
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Phần thân không phải JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Mật khẩu yếu
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: Một PGN không đọc được
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (không có header `Authorization`) hoặc `invalid_token` (token sai định dạng, đã hết hạn, đã bị thu hồi, hoặc thuộc về một tài khoản đã bị xóa).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Không có phiên
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Token không hợp lệ
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: Yêu cầu bị từ chối.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Sai mật khẩu khi xác thực lại
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Tài khoản bị đình chỉ
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found`, hoặc một tính năng mà máy chủ này đã tắt.'
      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`: phần thân không đến trong vòng 10 giây. Máy chủ đóng kết nối.'
      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: Yêu cầu xung đột với trạng thái của máy chủ.
      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`: lượt đăng nhập bằng Google hoặc vé không xác định, đã được dùng hoặc đã hết hạn; hãy bắt đầu đăng nhập lại.'
      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`: phần thân vượt quá `HTTP_BODY_LIMIT` (mặc định 16.384 byte). Máy chủ đóng kết nối.'
      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`: đích yêu cầu vượt quá 4096 ký tự.'
      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`: phần thân không phải `application/json`, hoặc charset của nó không phải 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`: ván cờ có hơn `GIF_MAX_PLIES` (600) nửa nước.'
      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`: hãy giải thử thách `pow` và gửi lại đúng yêu cầu đó kèm `pow: { challenge, nonce }`. `reason` cho biết lý do: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` hoặc `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` (một giới hạn tần suất, hạn mức tài khoản, hoặc hàng đợi băm mật khẩu) hoặc `too_many_attempts` (một cơ chế hạn chế của tài khoản), kèm `retryAfter` và header `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: Một giới hạn tần suất
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Quá nhiều lần thất bại trên tài khoản này
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: một lỗi ngoài dự kiến. Máy chủ ghi lỗi này vào nhật ký.'
      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` hoặc `iss` sai, hoặc Google đã từ chối mã hay gửi một ID token không xác minh được (kể cả mã hoặc ID token của client kia, của trò chơi hoặc của trang web). Lỗi này không bao giờ mang nội dung văn bản của 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` hoặc `timeout`: hãy thử lại sau `retryAfter` khi có giá trị này.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Máy chủ đang bận
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: Cơ sở dữ liệu vẫn bị khóa (một thao tác đọc)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Máy chủ mất quá nhiều thời gian
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: Ảnh GIF động, dưới dạng tệp đính kèm.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` cho một ván cờ của máy chủ này, `attachment; filename="scacelith-game.gif"` cho một PGN được gửi bằng `POST /gif`.'
          schema:
            type: string
            pattern: '^attachment; filename="scacelith-([1-9][0-9]{0,15}|game)\.gif"$'
          example: attachment; filename="scacelith-4100000000001.gif"
        Content-Length:
          description: Kích thước của tệp, tính bằng byte.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: Một trang HTML.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: Một trang HTML giải thích lý do từ chối.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Biểu mẫu không đến trong vòng 10 giây (một trang HTML). Máy chủ đóng kết nối.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: Một trang HTML giải thích xung đột.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Biểu mẫu vượt quá `HTTP_BODY_LIMIT` (một trang HTML). Máy chủ đóng kết nối.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Phần thân không phải biểu mẫu (`application/x-www-form-urlencoded`) cũng không phải JSON, hoặc charset của nó không phải UTF-8 (một trang HTML).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Một giới hạn tần suất (một trang HTML), kèm `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: Một lỗi ngoài dự kiến (trang HTML có tiêu đề “Server error” (Lỗi máy chủ)). Máy chủ ghi lỗi này vào nhật ký.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Máy chủ đang bận (cơ sở dữ liệu vẫn bị khóa, hoặc máy chủ bị tồn đọng nặng; kèm `Retry-After`) hoặc mất quá nhiều thời gian (trang HTML có tiêu đề “Server error” (Lỗi máy chủ)).
      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: Dạng duy nhất của mọi phản hồi lỗi trong API JSON.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: Mã lỗi dạng `snake_case`. Client dựa vào mã này để chọn nội dung hiển thị.
          examples:
            - rate_limited
        message:
          type: string
          description: Một câu tiếng Anh, dùng cho nhật ký và làm phương án dự phòng.
        retryAfter:
          type: integer
          minimum: 1
          description: Số giây cần chờ trước khi thử lại, chỉ có ở những lần từ chối sẽ hết hiệu lực theo thời gian. Khi đó phản hồi cũng mang header `Retry-After` với cùng giá trị, trừ 503 `busy` của các thao tác đọc lịch sử, ván cờ, người chơi và bảng xếp hạng.
        field:
          type: string
          description: Trường hoặc tham số truy vấn có lỗi (`invalid_request`, `invalid_option`, và `invalid_cursor`, `invalid_limit` và `invalid_filter` của `GET /account/games`). Trường lồng nhau được viết bằng dấu chấm (`pow.nonce`).
        reason:
          type: string
          description: 'Lý do: quy tắc mà một `weak_password` vi phạm, hoặc lý do bằng chứng công việc được yêu cầu hay bị từ chối (`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: 'Chỉ với `banned`: thời điểm kết thúc đình chỉ (mili giây Unix), `null` nếu đình chỉ vĩnh viễn.'
        line:
          type: integer
          minimum: 1
          description: 'Chỉ với `invalid_pgn`: dòng có lỗi, đếm từ 1.'
        column:
          type: integer
          minimum: 1
          description: 'Chỉ với `invalid_pgn`: cột có lỗi, đếm từ 1, tính theo ký tự.'
    PowChallenge:
      type: object
      description: Một thử thách bằng chứng công việc (`pow` của 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: Thử thách đã ký, cần gửi lại nguyên vẹn.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Số bit 0 ở đầu mà `SHA-256(challenge + ":" + nonce)` phải có.
        expiresAt:
          type: integer
          format: int64
          description: Thời điểm thử thách hết hạn (mili giây Unix), 2 phút sau khi được cấp.
    PowAnswer:
      type: object
      description: Lời giải cho một thử thách bằng chứng công việc.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: '`challenge` của phản hồi 428, giữ nguyên như đã nhận.'
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Một chuỗi số thập phân có tối đa 20 chữ số sao cho `SHA-256(challenge + ":" + nonce)` bắt đầu bằng `bits` bit 0.
    ServerInfo:
      type: object
      description: Những gì client cần trước khi đăng nhập hoặc kết nối.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Tên của máy chủ (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: Một UUID mà cơ sở dữ liệu nhận được ở lần khởi động đầu tiên. Giá trị này không đổi qua các lần khởi động lại. Giá trị là `null` khi cơ sở dữ liệu không thể cung cấp nó.
        motd:
          type: string
          description: Thông điệp trong ngày (`SERVER_MOTD`), có thể rỗng.
        protocol:
          type: object
          description: Giao thức WebSocket (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: Phiên bản giao thức cũ nhất mà máy chủ hỗ trợ.
            max:
              type: integer
              minimum: 1
              description: Phiên bản giao thức mới nhất mà máy chủ hỗ trợ.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 'Dấu vân tay của schema giao thức (4 byte đầu của SHA-256 dạng chuẩn tắc của nó, dưới dạng số nguyên không dấu: `0xc649140f`, 3326678031, với phiên bản phụ 2 của giao thức); chỉ mang tính thông tin, không bao giờ được so sánh.'
            subprotocol:
              type: string
              description: Token giao thức con của WebSocket (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: Cổng WebSocket mà người chơi sử dụng (`PUBLIC_WS_PORT`, nếu không có thì `WS_PORT`, nếu cũng không có thì `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Đường dẫn của WebSocket.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Có thể tạo tài khoản mới hay không.
        emailVerification:
          type: boolean
          description: Tài khoản mới có phải xác nhận địa chỉ hay không (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Đăng nhập bằng Google có được cung cấp cho trò chơi hay không.
            googleWeb:
              type: boolean
              description: Đăng nhập bằng Google có được cung cấp cho cả trang web hay không (`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: Xác thực hai yếu tố luôn khả dụng.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Số bit bằng chứng công việc mà việc đăng ký cần (0 nếu không cần).
        categories:
          type: array
          description: Các mức thời gian thi đấu chính thức, tức được tính điểm (`RATED_CATEGORIES`), theo thứ tự của máy chủ. Mọi thời gian thi đấu khác là `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Các quy tắc mà client có thể kiểm tra trước khi gửi biểu mẫu.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: Độ dài tối thiểu của tên người dùng (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: Độ dài tối đa của tên người dùng (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: Biểu thức chính quy mà tên người dùng mới phải khớp.
            passwordMinLength:
              type: integer
              description: Độ dài tối thiểu của mật khẩu, tính bằng ký tự (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: Độ dài tối đa của mật khẩu, tính bằng byte UTF-8.
            customTimeControls:
              type: boolean
              description: Lời thách đấu và ván riêng tư có được dùng thời gian thi đấu tùy chỉnh hay không.
            reportsPerDay:
              type: integer
              description: Số báo cáo mà một người chơi có thể gửi trong mỗi 24 giờ (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: Kích thước tối đa của một thông điệp WebSocket mà client có thể gửi, tính bằng byte.
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 3326678031
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
          googleWeb: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: Một mức thời gian thi đấu chính thức.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Id hạng mục, gồm số phút và số giây cộng thêm (`3+2`).
        baseSec:
          type: number
          minimum: 0
          description: Thời gian cơ bản, tính bằng giây.
        incSec:
          type: number
          minimum: 0
          description: Thời gian cộng thêm cho mỗi nước đi, tính bằng giây.
    AccountView:
      type: object
      description: Tài khoản như chính người chơi sở hữu nó nhìn thấy (`user` của các phản hồi đăng nhập và của `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: Id tài khoản.
        username:
          type: string
          description: Tên người dùng.
        email:
          type: string
          format: email
          description: Địa chỉ email.
        emailVerified:
          type: boolean
          description: Địa chỉ đã được xác nhận hay chưa.
        mfaEnabled:
          type: boolean
          description: Xác thực hai yếu tố có đang bật hay không.
        googleLinked:
          type: boolean
          description: Có tài khoản Google nào được liên kết hay không.
        hasPassword:
          type: boolean
          description: '`false` với tài khoản được tạo qua Google và chưa đặt mật khẩu.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Có nhận lời thách đấu trực tiếp qua tên hay không (xem `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Thời điểm tài khoản được tạo (mili giây Unix).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Lần đăng nhập gần nhất (mili giây Unix), hoặc `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Địa chỉ mới của một yêu cầu đổi email đang chờ xác nhận qua liên kết, hoặc `null`.
    SessionAnswer:
      type: object
      description: Một phiên mới.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Token phiên, dùng cho header `Authorization` và WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: Thời điểm kết thúc tuyệt đối của phiên (mili giây Unix); hạn không hoạt động có thể kết thúc phiên sớm hơn.
        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: Bước thứ hai của một lần đăng nhập có xác thực hai yếu tố; tiếp tục với `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: Token của bước này, dùng cho `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Số giây còn lại để hoàn tất bước này.
    LoginAnswer:
      description: Một phiên, hoặc bước thứ hai của một lần đăng nhập có xác thực hai yếu tố.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Lần đăng nhập bằng Google đầu tiên; tiếp tục với `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: Vé dùng cho `POST /auth/sso/complete`, có hiệu lực trong 10 phút.
        suggestedUsername:
          type: string
          description: Một tên người dùng tạo từ tên Google hoặc địa chỉ email, hoặc `""` khi không có tên nào phù hợp.
    SsoNeedsPassword:
      type: object
      description: Một tài khoản có mật khẩu đang dùng địa chỉ này; tiếp tục với `POST /auth/sso/google/link`. Chưa có gì được liên kết.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Vé dùng cho `POST /auth/sso/google/link`.
        username:
          type: string
          description: Tên người dùng của tài khoản đó (chỉ người đã chứng minh với Google quyền sở hữu địa chỉ mới thấy được).
        expiresIn:
          type: integer
          const: 600
          description: Số giây vé còn hiệu lực.
    SsoFinishAnswer:
      description: Phản hồi của `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: Một lượt đăng nhập bằng Google.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Lượt đăng nhập, dùng cho `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: URL của Google để mở trong trình duyệt của hệ thống, sau khi đã kiểm tra.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state` mà Google phải gửi lại.'
        expiresIn:
          type: integer
          const: 600
          description: Số giây lượt đăng nhập còn hiệu lực.
    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: Tên người dùng; sau đó áp dụng các quy tắc của máy chủ (xem phần mô tả).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Địa chỉ email. Được cắt bỏ khoảng trắng ở hai đầu và lưu bằng chữ thường.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Mật khẩu; sau đó áp dụng các quy tắc mật khẩu.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Tên người dùng, hoặc địa chỉ email (bất kỳ văn bản nào có `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Không bắt buộc. Hiển thị trong danh sách thiết bị đã đăng nhập (chẳng hạn `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: '`mfaToken` của phản hồi đăng nhập (hoặc của phản hồi đăng nhập bằng Google).'
        code:
          type: string
          maxLength: 32
          description: Không bắt buộc. Một mã 6 chữ số từ ứng dụng xác thực, hoặc một mã khôi phục.
        recoveryCode:
          type: string
          maxLength: 32
          description: 'Không bắt buộc. Một mã khôi phục. Cần có một trong hai: `code` hoặc `recoveryCode`.'
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Địa chỉ email.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Tham số `token` của liên kết đặt lại mật khẩu.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Mật khẩu mới; áp dụng các quy tắc mật khẩu khi đăng ký.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Challenge PKCE S256 của `codeVerifier` của client (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: Cổng của trình lắng nghe của client trên `127.0.0.1`.
    SsoWebStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Challenge PKCE S256 của `codeVerifier` của trang (`BASE64URL(SHA-256(codeVerifier))`).
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: '`attemptId` của `POST /auth/sso/google/start`.'
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: Verifier PKCE; SHA-256 của nó phải khớp với challenge đã gửi cho `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state` đúng như Google đã gửi lại.'
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: '`code` đúng như Google đã gửi lại (ASCII in được, không có dấu cách).'
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Không bắt buộc. `iss` đúng như Google đã gửi lại, nếu Google có gửi.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`linkTicket` của `finish`, có hiệu lực trong 10 phút.'
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Mật khẩu của tài khoản.
        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: '`ssoTicket` của `finish`, có hiệu lực trong 10 phút.'
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Tên người dùng; áp dụng các quy tắc đăng ký.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Các phiên đang hoạt động, phiên được dùng gần đây nhất đứng trước.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Một phiên đang hoạt động (một thiết bị đã đăng nhập).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: Id phiên, dùng cho `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: Thời điểm đăng nhập (mili giây Unix).
        lastSeenAt:
          type: integer
          format: int64
          description: Lần sử dụng gần nhất (mili giây Unix), được cập nhật nhiều nhất 5 phút một lần (chậm hơn khi máy chủ bị tồn đọng nặng).
        expiresAt:
          type: integer
          format: int64
          description: Thời điểm kết thúc tuyệt đối (mili giây Unix); hạn không hoạt động có thể kết thúc phiên sớm hơn.
        clientLabel:
          type:
            - string
            - 'null'
          description: '`clientLabel` của lần đăng nhập, hoặc `null` khi lần đăng nhập không gửi nhãn nào.'
        current:
          type: boolean
          description: Đây có phải là phiên đang gửi yêu cầu hay không.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Một bản ghi cho mỗi hạng mục mà người chơi đã chơi ván tính điểm.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Các hình phạt đang có hiệu lực.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` khi đang bị đình chỉ, nếu không thì `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Bản ghi hệ số của một hạng mục.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: Id hạng mục.
        rating:
          type: integer
          description: Hệ số Elo.
        games:
          type: integer
          minimum: 0
          description: Số ván đã chơi trong hạng mục (dù có được tính hay không).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Hệ số cao nhất từng đạt được.
        provisional:
          type: boolean
          description: '`true` khi bản ghi còn ở giai đoạn chưa có hệ số hoặc có ít hơn `PROVISIONAL_GAMES` ván được tính (trò chơi hiển thị hệ số này dạng `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Loại hình phạt.
        reason:
          type:
            - string
            - 'null'
          description: Lý do được đưa ra, nếu có.
        startsAt:
          type: integer
          format: int64
          description: Thời điểm bắt đầu (mili giây Unix).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Thời điểm kết thúc (mili giây Unix), `null` khi hình phạt là vĩnh viễn.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Thời điểm kết thúc đình chỉ (mili giây Unix), `null` nếu đình chỉ vĩnh viễn.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` để nhận lời thách đấu trực tiếp qua tên, `none` để từ chối.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Mật khẩu hiện tại.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Mật khẩu mới; áp dụng các quy tắc mật khẩu khi đăng ký.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Mật khẩu của tài khoản.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Một mã của khóa bí mật đang chờ, đúng 6 chữ số.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Mật khẩu của tài khoản.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Không bắt buộc; cần khi bật xác thực hai yếu tố (hoặc `recoveryCode`). Một mã từ ứng dụng xác thực, hoặc một mã khôi phục.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Không bắt buộc. Một mã khôi phục (`xxxx-xxxx-xx`; chữ hoa chữ thường, dấu cách và dấu gạch ngang không quan trọng).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Mật khẩu của tài khoản.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Một mã từ ứng dụng xác thực (mã khôi phục bị từ chối).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Địa chỉ mới; được cắt bỏ khoảng trắng ở hai đầu và chuyển thành chữ thường, rồi được kiểm tra như một địa chỉ đăng ký.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Mật khẩu của tài khoản.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Không bắt buộc; cần khi bật xác thực hai yếu tố (hoặc `recoveryCode`). Một mã từ ứng dụng xác thực, hoặc một mã khôi phục.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Không bắt buộc. Một mã khôi phục.
    TotpSetup:
      type: object
      description: Khóa bí mật đang chờ dành cho ứng dụng xác thực.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Khóa bí mật dạng base32, để nhập vào ứng dụng.
        uri:
          type: string
          format: uri
          description: URI `otpauth://totp/...`, để hiển thị thành mã QR.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Số giây của mỗi bước.
    RecoveryCodeList:
      type: array
      description: 10 mã khôi phục, chỉ hiển thị lần này. Mỗi mã chỉ dùng được một lần.
      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: Một bên của ván cờ.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Tên trong bản ghi ván cờ (`deleted#<id>` với tài khoản đã bị xóa).
        rating:
          type:
            - integer
            - 'null'
          description: Hệ số lúc bắt đầu, `null` khi không rõ.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Hệ số sau ván cờ. Ván tính điểm luôn có giá trị này; chỉ là `null` với ván không được tính hệ số (giao hữu, thời gian thi đấu tùy chỉnh, bị hủy).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: Mức thay đổi hệ số, `0` khi các quy tắc tính hệ số giữ nguyên hệ số (trường hợp điểm số bằng 0 theo quy định của FIDE, đối thủ chưa có hệ số, các ván đầu tiên của người chơi chưa có hệ số); `null` như với `ratingAfter`.
    GameSummary:
      type: object
      description: Tóm tắt của một ván cờ đã lưu.
      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 ván cờ.
        category:
          type: string
          description: Id hạng mục chính thức (`3+2`), hoặc `custom`.
        rated:
          type: boolean
          description: Ván cờ có được tính hệ số hay không.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Thời gian thi đấu tính bằng giây (`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` (xem phần mã ván cờ).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Mã lý do kết thúc (xem phần mã ván cờ).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Kết quả, `*` với ván bị hủy.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Số nửa nước.
        startedAt:
          type: integer
          format: int64
          description: Thời điểm bắt đầu (mili giây Unix).
        endedAt:
          type: integer
          format: int64
          description: Thời điểm kết thúc (mili giây Unix).
    Termination:
      type: string
      description: Tên của lý do kết thúc (xem phần mã ván cờ); `Unknown` với mã mà giao thức không biết.
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - ResignationVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: Tóm tắt ván cờ xét từ phía một người chơi.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Bên của người chơi.
    HistoryGameSummary:
      description: Một ván trong lịch sử ván đấu của người chơi đã đăng nhập.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Thời gian cơ bản tính bằng mili giây.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Thời gian cộng thêm tính bằng mili giây.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: Kết cục xét từ phía người chơi.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Các ván của trang, mới nhất trước.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Id cần truyền làm `before` cho trang tiếp theo, hoặc `null` ở trang cuối.
        total:
          type: integer
          minimum: 0
          description: Số ván khớp với bộ lọc, trên tất cả các trang.
    MoveRecord:
      type: object
      description: Một nửa nước của ván cờ.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: Nước đi theo ký hiệu UCI, thêm `n`, `b`, `r` hoặc `q` khi phong cấp.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Thời gian bị tính cho nước đi (ms), `null` khi bản ghi không có giá trị này.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Đồng hồ của người vừa đi sau nước đi đó (ms), `null` khi bản ghi không có giá trị này.
    PgnTagsView:
      type: object
      description: Các thẻ PGN chính, để hiển thị. Ở đây `Termination` là tên của lý do kết thúc; tệp PGN dùng các giá trị chuẩn của PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` hoặc `<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: Ngày bắt đầu theo 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: Hệ số lúc bắt đầu, hoặc `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Hệ số lúc bắt đầu, hoặc `"-"`.
          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: Một bản ghi ván cờ kèm các nước đi và đồng hồ. Bản ghi có các trường của một bản tóm tắt ván cờ, không có `color` và `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Thời gian cơ bản tính bằng mili giây.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Thời gian cộng thêm tính bằng mili giây.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: Tên của `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: Id của ván cờ được tái đấu bằng ván này, hoặc `null`.
            moves:
              type: array
              description: Một mục cho mỗi nửa nước.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Chỉ khi người chơi của token đã chơi ván này; bên của người chơi đó.
            reportable:
              type: boolean
              description: Chỉ khi người chơi của token đã chơi ván này; `true` khi `POST /reports` sẽ tiếp nhận ngay lúc này một báo cáo về đối thủ cho ván này (ván đã kết thúc trong vòng 7 ngày, hạn ngạch trong ngày chưa dùng hết, và đối thủ chưa bị báo cáo cho ván này).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Tên người dùng, như người chơi đã viết.
        createdAt:
          type: integer
          format: int64
          description: Thời điểm tài khoản được tạo (mili giây Unix).
        ratings:
          type: array
          description: Chỉ các hạng mục chính thức, theo thứ tự của máy chủ.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Mọi ván đã lưu, kể cả ván giao hữu và ván bị hủy.
            rated:
              type: integer
              minimum: 0
              description: Các ván tính điểm, cộng dồn trên các bản ghi hệ số.
            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: Tên người dùng, như người chơi đã viết.
        games:
          type: array
          description: Các ván của trang, mới nhất trước.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Id của ván cuối cùng mỗi khi trang đầy (khi đó trang tiếp theo có thể trống), nếu không thì `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: Id hạng mục.
        minGames:
          type: integer
          minimum: 0
          description: Số ván được tính mà một bản ghi cần có để được liệt kê (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Thời điểm bảng xếp hạng được tính (mili giây 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: Văn bản PGN, tối đa 65.536 byte UTF-8. Chỉ ván đầu tiên được dùng.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Không bắt buộc. Kích thước hình ảnh.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Không bắt buộc. Bên ở phía dưới bàn cờ.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Không bắt buộc. Số mili giây cho mỗi nước đi, một số JSON có giá trị nguyên.
        coords:
          type: boolean
          default: true
          description: Không bắt buộc. Có vẽ chữ cái của cột và số của hàng quanh bàn cờ hay không.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: Ván cờ, dưới dạng số nguyên hoặc chuỗi gồm từ 1 đến 16 chữ số.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: Tên người dùng của đối thủ, như trong bản ghi ván cờ hoặc như hiện tại, không phân biệt chữ hoa chữ thường. Không được để trống.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: Báo cáo về vấn đề gì.
        comment:
          type:
            - string
            - 'null'
          description: Không bắt buộc. Tối đa 500 ký tự sau khi đã loại bỏ các ký tự điều khiển (trừ tab và xuống dòng) và cắt bỏ khoảng trắng ở hai đầu văn bản.
    AccountExport:
      type: object
      description: Mọi thông tin máy chủ lưu giữ về tài khoản (thời gian tính bằng mili giây 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: Thời điểm tạo bản xuất dữ liệu.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Những gì tệp chứa và không chứa, viết bằng tiếng Anh đơn giản cho người chơi.
          items:
            type: string
        account:
          description: Dạng xem tài khoản của `GET /account/me`, kèm `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: Địa chỉ của tài khoản Google đã liên kết, hoặc `null`.
        ratings:
          type: array
          description: Các bản ghi hệ số đầy đủ.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Số điểm hệ số được hoàn lại sau khi một đối thủ bị phát hiện gian lận, cộng dồn theo ngày UTC và theo hạng mục, mới nhất trước. Không nêu tên các ván cờ cũng như những người gian lận.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Mọi phiên đã lưu, mới nhất trước, không kèm token nào. Đợt dọn dữ liệu theo thời hạn lưu giữ xóa phiên đã hết hạn, và xóa phiên đã đăng xuất một ngày sau khi đăng xuất.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Các sự kiện bảo mật, mới nhất trước, được lưu giữ trong `RETENTION_SECURITY_DAYS` ngày.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Mọi hình phạt, kể cả những hình phạt đã gỡ bỏ. Tên của người kiểm duyệt không bao giờ được đưa vào.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Các sự kiện về hành vi được ghi lại cho các ván cờ bị bỏ dở, bị hủy và các ván không đi nước đầu tiên kịp giờ của người chơi, được lưu giữ 30 ngày.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Các báo cáo mà người chơi đã gửi.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Số ván.
            list:
              type: array
              description: Mọi ván cờ, mới nhất trước, dưới dạng các bản tóm tắt của `GET /account/games`. Các nước đi có ở `GET /games/{id}` và PGN ở `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Một bản ghi hệ số đầy đủ.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Người chơi đã ra khỏi giai đoạn chưa có hệ số hay chưa.
            countedGames:
              type: integer
              minimum: 0
              description: Số ván đã được tính vào hệ số.
            updatedAt:
              type: integer
              format: int64
              description: Thời điểm bản ghi thay đổi lần gần nhất.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: 00:00 UTC của ngày đó (mili giây Unix).
        category:
          type: string
        points:
          type: integer
          description: Số điểm được hoàn lại trong ngày đó ở hạng mục đó.
    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: Thời điểm phiên được đăng xuất, hoặc `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: Địa chỉ IP của lần đăng nhập, bị xóa sau `RETENTION_IP_DAYS` ngày.
    SecurityEvent:
      type: object
      description: |-
        Một sự kiện bảo mật. `ip` chỉ được cung cấp cho những việc đã làm khi đang đăng nhập, bằng mật khẩu của tài khoản (và yếu tố thứ hai) hoặc bằng một liên kết được gửi tới địa chỉ email của tài khoản: `register`, `email_verified`, `login`, `sso_login`, `sso_linked`, `sso_account_created`, `recovery_code_used`, `password_reset`, `password_changed`, `reauth_failed`, `mfa_setup_started`, `mfa_enabled`, `mfa_disabled`, `recovery_codes_regenerated`, `session_revoked`, `sessions_revoked_all`, `email_change_requested`, `email_changed`, `email_change_refused` và `account_exported`; mọi loại khác có `ip: null`, và `ip` bị xóa sau `RETENTION_IP_DAYS` ngày.

        `detail` chỉ giữ các trường sau: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` và `method` (`password`, hoặc `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` và `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. Một sự kiện `moderator_action` chỉ giữ `{ action }`, với các thao tác `ban`, `unban`, `reset_mfa`, `verify_email` và `revoke_sessions` (các thao tác khác của người kiểm duyệt không được đưa vào). Mọi loại khác có `detail: null`. Các sự kiện `rating_refund` không được đưa vào: số điểm của chúng nằm trong `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Loại sự kiện (`login`, `password_changed`…).
        at:
          type: integer
          format: int64
          description: Thời điểm xảy ra.
        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: Tên công khai hiện tại của người chơi bị báo cáo.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Báo cáo còn đang mở hay không (không cho biết người chơi bị báo cáo có bị áp dụng hình phạt hay không).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Token của liên kết email (trường ẩn trong biểu mẫu của trang).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Token của liên kết đặt lại mật khẩu (trường ẩn của biểu mẫu).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Mật khẩu mới; áp dụng các quy tắc mật khẩu khi đăng ký.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Nhập lại mật khẩu mới; hai mật khẩu phải giống nhau.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: Một trang HTML mã hóa UTF-8 (`text/html; charset=utf-8`), không có JavaScript hay tài nguyên bên ngoài.
