openapi: 3.1.1
info:
  title: API HTTPS server Scacelith
  version: "1.1.0"
  summary: API HTTPS setiap server Scacelith, baik server resmi maupun server komunitas.
  description: |-
    Setiap server Scacelith, baik server resmi `caissa.scacelith.com` maupun server komunitas mana pun, melayani API HTTPS ini. Game menggunakannya untuk semua hal di luar permainan itu sendiri: pendaftaran dan proses masuk (termasuk autentikasi dua faktor dan Google), halaman akun, riwayat permainan, unduhan PGN dan GIF animasi permainan, perangkat yang masuk, unduhan data, penghapusan akun, dan laporan.

    Permainan real-time (pencarian lawan, tantangan, langkah, jam) berjalan melalui WebSocket server yang sama (`wss://<host>/ws`), yang dibuka dengan token sesi dari API ini; WebSocket tersebut dijelaskan di `PROTOCOL.md`, bukan di sini.

    Nilai default di bawah ini adalah nilai server dengan konfigurasi yang belum diubah; server komunitas dapat mengubahnya (`CONFIG.md` mencantumkan setiap pengaturan).

    ## URL dasar, port, dan transport

    - Server resmi: `https://caissa.scacelith.com/api/v1`, port TCP 443.
    - Server komunitas: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` bernilai 443 secara default; di balik NAT atau proxy, port yang digunakan pemain adalah `PUBLIC_API_PORT`.
    - WebSocket game berada di port yang sama secara default (`WS_PORT` memindahkannya; `GET /info` memberi tahu klien letaknya).
    - HTTP/1.1 melalui TLS. Dengan `TLS_MODE=proxy`, reverse proxy di depan server yang mengakhiri TLS. `TLS_MODE=off` (HTTP biasa) hanya untuk pengembangan lokal.
    - Halaman HTML yang dibuka dari tautan di email (`/verify-email`, `/reset-password`, `/confirm-email-change`) berada di root server, di luar `/api/v1`. Endpoint pemeriksaan kesehatan merespons baik di root maupun di bawah `/api/v1`. Masuk dengan Google tidak memiliki halaman di server: Google mengarahkan browser kembali ke game itu sendiri, di `127.0.0.1`, atau ke halaman situs web yang memulainya (`GOOGLE_WEB_REDIRECT_URI`).
    - Garis miring di akhir diabaikan (`/api/v1/info/` sama dengan `/api/v1/info`), dan parameter path didekode dari encoding URL (parameter yang bukan encoding URL yang valid menghasilkan 400 `invalid_request`).

    ## Permintaan

    - **Body JSON.** Permintaan `POST`, `PUT`, dan `DELETE` membawa objek JSON dengan `Content-Type: application/json`. `charset` selain UTF-8 ditolak, begitu pula tipe konten lainnya (415 `unsupported_media_type`). Body kosong dianggap sebagai `{}`, yang memang diterima oleh endpoint tanpa parameter (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Ukuran dan waktu.** Body dibatasi hingga `HTTP_BODY_LIMIT` byte (16.384 secara default; `POST /gif` memiliki batasnya sendiri, yaitu 135.168 byte): 413 `payload_too_large`. Body harus tiba dalam 10 detik: 408 `request_timeout`. Setelah salah satu kesalahan ini, server menutup koneksi.
    - **Skema ketat.** Field yang tidak dikenal oleh endpoint ditolak, field yang tidak ditandai opsional wajib ada, dan tipe serta panjangnya diperiksa. Setiap kegagalan tersebut menghasilkan 400 `invalid_request`, dengan `field` yang menyebutkan field yang bermasalah (dengan notasi titik untuk field bertingkat, seperti `pow.nonce`). String tidak boleh berisi karakter kontrol. Panjang dihitung dalam unit kode UTF-16, sehingga karakter di luar Basic Multilingual Plane (emoji) dihitung dua kali. Body yang bukan JSON menghasilkan 400 `invalid_json`. `POST /gif` dan `POST /reports` memeriksa body-nya sendiri (lihat masing-masing endpoint).
    - **String kueri.** Kemunculan pertama suatu parameter yang berlaku, parameter yang tidak dikenal diabaikan, dan `+` didekode menjadi spasi: endpoint yang menerima kategori kontrol waktu menerima `3%2B2` maupun `3+2`.
    - **Metode.** `HEAD` adalah `GET` tanpa body. `OPTIONS` pada path yang ada merespons 204 dengan header `Allow` (beserta header preflight CORS, lihat di bawah). Metode lain yang tidak dimiliki path tersebut menghasilkan 405 `method_not_allowed` dengan `Allow`.
    - **Target permintaan.** Panjangnya tidak boleh melebihi 4096 karakter (414 `uri_too_long`). Permintaan yang sama sekali tidak dapat diurai, atau yang bagian kepalanya terlalu besar atau tiba terlalu lambat, mendapat respons 400, 431, atau 408 yang kosong, dan koneksinya ditutup.
    - **CORS: daftar izin, nonaktif secara default.** API ini melayani game, yang tidak mengirim `Origin`. Jika `CORS_ORIGINS` kosong (default), tidak pernah ada header `Access-Control-*` yang dikirim, sehingga tidak ada halaman web yang dapat membaca respons. `CORS_ORIGINS` mencantumkan origin web yang halamannya boleh memanggil API dari browser, masing-masing persis seperti yang dikirim browser dalam `Origin`: `https://host` atau `https://host:port`, dalam huruf kecil, tanpa path, garis miring di akhir, atau port default; `http://` hanya untuk `localhost`, `127.0.0.1`, dan `[::1]` (pengembangan lokal); tanpa wildcard. Server resmi menetapkan `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com` untuk situs webnya. Dengan begitu:
      - Setiap respons untuk path di bawah `/api/v1` kecuali endpoint pemeriksaan kesehatan, baik yang berhasil maupun yang berupa kesalahan (termasuk 429 dari lapisan per alamat), membawa `Vary: Origin`. Jika `Origin` permintaan sama persis, byte demi byte, dengan salah satu origin yang terdaftar, respons juga membawa `Access-Control-Allow-Origin: <that origin>` dan, kecuali pada respons terhadap preflight, `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, sehingga halaman dapat membaca kode kesalahan, `Retry-After`, dan nama file unduhan PGN dan GIF.
      - Preflight (`OPTIONS` dengan `Access-Control-Request-Method`) dari origin yang terdaftar pada path yang ada merespons 204, tanpa body, dengan `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (metode dari `Allow`), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600`, dan `Vary: Origin`. Preflight tidak memerlukan token sesi dan tidak dihitung terhadap jatah akun mana pun maupun batas endpoint mana pun; seperti permintaan lainnya, preflight mengambil satu token dari lapisan per alamat. Browser menggunakan ulang preflight selama 10 menit, tetapi hanya untuk URL yang persis sama, termasuk string kuerinya. Permintaan tanpa `Authorization` dan tanpa body JSON, seperti `GET /info` atau `GET /leaderboard`, tidak memerlukan preflight.
      - Respons lain apa pun terhadap preflight berarti preflight gagal, meskipun membawa header CORS: 404 untuk path yang tidak ada, atau 429 dari lapisan per alamat. Browser kemudian tidak mengirim permintaan tersebut, dan halaman melihat kesalahan jaringan, tanpa respons maupun header `Retry-After` miliknya.
      - `Access-Control-Allow-Credentials` tidak pernah dikirim, begitu pula `Access-Control-Allow-Origin: *`: halaman mengirim token sesinya dalam `Authorization: Bearer`, seperti yang dilakukan game, dan API tidak menetapkan cookie. Halaman HTML, endpoint pemeriksaan kesehatan, dan respons kosong untuk permintaan yang tidak terbaca tidak membawa header CORS. WebSocket tidak menggunakan CORS: `WS_ALLOWED_ORIGINS` mencantumkan halaman yang boleh membukanya.
      - Halaman dari origin yang tidak terdaftar tidak dapat membaca apa pun, dan penulisan lintas situsnya tidak pernah terkirim: body JSON dan `Authorization` memerlukan preflight, yang responsnya tidak memiliki `Access-Control-Allow-Origin` untuk origin tersebut. `Cross-Origin-Resource-Policy: same-origin` tetap ada pada setiap respons: header ini hanya berlaku untuk permintaan `no-cors` (situs lain yang menyematkan respons), tidak pernah untuk permintaan `cors` dari halaman yang terdaftar.

    ## Respons dan kesalahan

    - Respons berupa JSON dalam UTF-8, kecuali unduhan PGN (`application/x-chess-pgn`), GIF animasi (`image/gif`), dan halaman HTML. Waktu dinyatakan dalam milidetik sejak 1970-01-01 UTC. ID berupa bilangan bulat. ID permainan memiliki hingga 16 digit tetapi tetap di bawah 2^53, sehingga angka JSON (double) dapat menampungnya dengan tepat.
    - Setiap respons API membawa `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin`, dan `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (halaman HTML memiliki kebijakannya sendiri). Dengan `TLS_MODE=native`, respons juga membawa `Strict-Transport-Security: max-age=31536000`. Respons yang ditulis sendiri oleh listener, sebelum API (`GET` dan `HEAD` endpoint pemeriksaan kesehatan, 429 dari lapisan per alamat, dan 500 `internal_error` ketika API gagal sepenuhnya), membawa header yang sama kecuali `Cross-Origin-Resource-Policy`, dan `Content-Type` respons tersebut adalah `application/json` tanpa charset.
    - Respons harus dibaca dalam 60 detik sejak server selesai menyiapkannya, yang hanya berpengaruh untuk respons berukuran besar (GIF, ekspor data, PGN yang panjang). Waktu yang dibutuhkan server untuk menyiapkan respons tidak dihitung dalam batas ini, maupun dalam 30 detik tanpa satu byte pun masuk atau keluar yang membuat koneksi ditutup.

    Kesalahan memiliki satu bentuk (skema `Error`):

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

    `message` ditujukan untuk log dan sebagai cadangan; klien memilih apa yang ditampilkan berdasarkan `error`. `retryAfter` (detik) hanya ada pada penolakan yang berakhir seiring berjalannya waktu, dan respons tersebut juga membawa header `Retry-After` dengan nilai yang sama; satu-satunya pengecualian adalah 503 `busy` pada pembacaan riwayat, permainan, pemain, dan papan peringkat, yang hanya memiliki field tersebut. Beberapa kesalahan menambahkan field: `field` (input tidak valid), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` dan `column` (`invalid_pgn`).

    Kesalahan yang dapat diberikan oleh endpoint mana pun:

    | Status | `error` | Kapan |
    |---|---|---|
    | 400 | `invalid_request` | Body melanggar skema endpoint (`field` menunjukkan letaknya), target permintaan atau `Content-Length` tidak berformat benar, parameter path bukan encoding URL yang valid, atau body terpotong. |
    | 400 | `invalid_json` | Body bukan JSON. |
    | 401 | `unauthorized` | Tidak ada header `Authorization` pada endpoint yang memerlukan sesi. |
    | 401 | `invalid_token` | Token tidak berformat benar, kedaluwarsa, dicabut, atau milik akun yang telah dihapus; juga pada endpoint yang sesinya opsional. |
    | 404 | `not_found` | Endpoint tidak ada. Beberapa endpoint juga menggunakannya: permainan, pemain, atau sesi tidak ada. |
    | 405 | `method_not_allowed` | Path tersebut ada untuk metode lain (lihat `Allow`). |
    | 408 | `request_timeout` | Body tidak tiba dalam 10 detik. |
    | 413 | `payload_too_large` | Body melebihi `HTTP_BODY_LIMIT` (135.168 byte untuk `POST /gif`). |
    | 414 | `uri_too_long` | Target permintaan melebihi 4096 karakter. |
    | 415 | `unsupported_media_type` | Body bukan `application/json`, atau charset-nya bukan UTF-8. |
    | 429 | `rate_limited` | Batas laju (lihat di bawah): `retryAfter` ditambah header `Retry-After`. |
    | 500 | `internal_error` | Kegagalan tak terduga. Server mencatatnya di log. |
    | 503 | `timeout` | Server tidak merespons dalam 30 detik (60 detik untuk ekspor, 45 detik untuk GIF dengan pengaturan default). |
    | 503 | `server_busy` | Pencarian sesi atau perubahan akun mendapati database terkunci (`retryAfter` 1), atau antrean hash kata sandi penuh (lihat di bawah). Juga, dengan `retryAfter` 2 hingga 5, saat server sangat tertinggal: untuk `POST`, `PUT`, `PATCH`, atau `DELETE`, database memiliki terlalu banyak penulisan yang menunggu; untuk permintaan apa pun, terlalu banyak permintaan sebelumnya yang masih berjalan melewati batas waktunya, atau token sesi tidak dapat diperiksa dalam 5 detik. Permintaan tidak dijalankan, dan batas lajunya tidak mengambil apa pun. |

    Endpoint baca (riwayat permainan, permainan, pemain, papan peringkat) dan ekspor merespons 503 `busy` dengan `retryAfter: 1` ketika database tetap terkunci.

    Endpoint yang memeriksa atau meng-hash kata sandi juga dapat merespons dengan salah satu kesalahan berikut, keduanya dengan `retryAfter` acak 5 hingga 15 detik:

    - 503 `server_busy`: antrean hash kata sandi server (`PASSWORD_HASH_QUEUE_MAX`) penuh, atau waktu tunggunya habis.
    - 429 `rate_limited`: begitu antrean terisi setengah, klien ini (alamat IPv4 atau /48 IPv6) sudah memiliki `PASSWORD_HASH_WAITERS_PER_SOURCE` hash yang menunggu. 429 ini mengembalikan token batas laju yang telah diambil permintaan tersebut.

    Tidak ada yang diubah dan tidak ada percobaan gagal yang dihitung (kecuali pada `POST /auth/sso/google/link`, yang percobaan tiket dan kegagalan akunnya sudah diambil sebelum hash), dan tautan pengaturan ulang tetap valid.

    Halaman HTML menyampaikan kesalahannya sebagai halaman HTML dengan kode status yang sama.

    ## Autentikasi

    Endpoint yang memerlukan sesi menerima token bearer dalam header `Authorization` (skema `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Mendapatkan token.** Token (`sct_` diikuti 43 karakter base64url) diperoleh dari `POST /auth/login`, lalu `POST /auth/login/mfa` jika autentikasi dua faktor aktif, dari `POST /auth/sso/google/finish` dan `POST /auth/sso/google/link` (masuk dengan Google), serta dari `POST /auth/sso/complete`. Semuanya merespons `{ token, expiresAt, user }`. Server hanya menyimpan SHA-256 dari token tersebut.
    - **Sesi wajib.** Header yang tidak ada menghasilkan 401 `unauthorized` dengan `WWW-Authenticate: Bearer realm="scacelith"`. Token yang tidak valid menghasilkan 401 `invalid_token` dengan `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Klien sebaiknya melupakan token yang mendapat `invalid_token` dan masuk lagi.
    - **Sesi opsional.** Pada `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}`, dan `GET /players/{username}/games`, sesi bersifat opsional. Tanpa header, endpoint tersebut merespons dengan tampilan publik; jika header dikirim, tokennya harus valid.
    - **Penyimpanan sibuk.** Jika database tetap terkunci saat token diperiksa, endpoint yang mewajibkan atau menerima sesi merespons 503 `server_busy` dengan `retryAfter: 1` (dan `Retry-After`); jika pemeriksaan memakan waktu lebih dari 5 detik, endpoint merespons 503 `server_busy` dengan `retryAfter` 2 hingga 5. Dalam kedua kasus, token tersebut belum dinilai: simpan token itu dan kirim ulang permintaan.
    - **Masa berlaku.** Sesi berakhir pada mana yang lebih dulu tercapai: `SESSION_MAX_DAYS` (90) hari setelah masuk (inilah `expiresAt`) atau `SESSION_IDLE_DAYS` (30) hari tanpa penggunaan. Setiap penggunaan memperpanjang batas tidak aktif; server menulis nilai barunya paling sering setiap 5 menit, di latar belakang. Selama database-nya memiliki banyak penulisan yang menunggu, server menunda penulisan itu ke permintaan berikutnya, kecuali dalam satu jam terakhir sebelum batas tidak aktif, sehingga sesi yang sedang digunakan tidak kedaluwarsa. Satu akun menyimpan paling banyak `MAX_SESSIONS_PER_USER` (10) sesi: proses masuk yang baru mencabut sesi tertua yang melebihi jumlah tersebut.
    - **Pencabutan.** Keluar (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) mencabut sesi; demikian pula perubahan kata sandi (sesi-sesi lainnya), pengaturan ulang kata sandi dan penghapusan akun (semua sesi), serta administrator. Pencabutan melalui API langsung berlaku, dan WebSocket yang dibuka dengan sesi yang dicabut akan ditutup. Perubahan atau pengaturan ulang kata sandi dan penghapusan akun mencabut sesi dalam transaksi yang sama dengan perubahan itu sendiri: jika salah satunya gagal, tidak ada sesi yang dicabut dan tidak ada hal lain yang berubah. Server menyimpan hasil pencarian sesi dalam cache selama 30 detik, sehingga pencabutan oleh perintah admin, yang merupakan proses terpisah, berlaku dalam 30 detik.
    - **Cakupan.** Token terikat pada satu server dan juga membuka WebSocket server tersebut. Jangan pernah mengirimkannya ke server lain.

    ## Batas laju dan pembatasan lainnya

    Batas berlaku pada salah satu dari dua cakupan. **Klien:** alamat IPv4, atau /64 IPv6 (dalam mode proxy, alamatnya berasal dari `X-Forwarded-For` yang dikirim oleh alamat `TRUSTED_PROXIES`); beberapa batas juga menghitung setiap /48 IPv6 secara keseluruhan, selain setiap jaringan /64-nya. **Pemain:** akun yang sedang masuk, apa pun alamatnya; batas yang dihitung per pemain pada endpoint dengan sesi opsional dihitung per klien untuk permintaan tanpa token.

    Setiap batas adalah token bucket yang menampung `limit` permintaan dan terisi ulang secara terus-menerus dengan laju `limit / window`; `retryAfter` adalah waktu hingga token berikutnya. Batas yang ditandai *bersama* juga dihitung dalam jendela geser (sliding window) dengan panjang yang sama, sehingga tidak ada jendela yang menampung jauh lebih banyak dari `limit` permintaan. Setiap batas dihitung untuk seluruh server. Jika salah satu batas suatu endpoint menolak permintaan, token yang telah diambil batas-batas lainnya untuk permintaan itu dikembalikan. Penolakan menghasilkan 429 `rate_limited` dengan `retryAfter` dan `Retry-After`.

    - **Lapisan per alamat.** Setiap permintaan (path dan metode apa pun, termasuk endpoint pemeriksaan kesehatan dan upgrade WebSocket) pertama-tama mengambil satu token dari jatah kliennya: `HTTP_RATE_PER_IP` (600) per menit dengan burst setengah menit, dan `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) untuk /48 IPv6. Klien juga boleh memiliki paling banyak `IP_MAX_INFLIGHT` (32 x `WORKERS`) permintaan yang sedang diproses (`retryAfter` 1 jika melebihinya). Klien yang terus mengirim setelah ditolak akan diblokir: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) penolakan dalam satu menit memblokirnya selama 1 menit, lalu 4, 16, dan 60 menit pada setiap pemblokiran baru dalam 6 jam. Penolakan oleh batas `auth`, `auth_*`, dan `reauth` dihitung 5; penolakan oleh batas yang dihitung per pemain tidak pernah dihitung. Alamat dalam `ABUSE_EXEMPT` melewati lapisan ini, tetapi tidak melewati jatah akun atau batas endpoint.
    - **Jatah akun.** Setiap permintaan yang membawa token sesi yang valid juga dihitung terhadap akunnya: `USER_RATE_PER_MIN` (120) per menit untuk semua endpoint sekaligus, apa pun alamatnya, dengan burst setengah menit.
    - **Batas endpoint:**

    | Batas | Default | Dihitung per | Endpoint |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 menit, bersama | klien, dan `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) per /48 IPv6 | `POST /auth/register`, `/auth/login`, `/auth/login/mfa`, `/auth/verify-email/resend`, `/auth/password/forgot`, `/auth/password/reset`, `/auth/sso/google/link`, `/auth/sso/complete`; `POST /verify-email`, `/reset-password`, `/confirm-email-change` |
    | `auth_register` | `AUTH_REGISTER_PER_HOUR` (10) / jam, bersama | klien, 3 kali lipatnya per /48 IPv6 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / jam, bersama | klien, 3 kali lipatnya per /48 IPv6 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / jam, bersama | klien, 3 kali lipatnya per /48 IPv6 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 jam, bersama | klien, 3 kali lipatnya per /48 IPv6 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / jam, bersama | klien, 3 kali lipatnya per /48 IPv6 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | angka yang sama dengan `auth`, bucket tersendiri, bersama | klien dan /48 IPv6 | `POST /account/password`, `/account/mfa/totp/setup`, `/account/mfa/totp/enable`, `/account/mfa/totp/disable`, `/account/mfa/recovery-codes`, `/account/email`, `/account/export`, `/account/delete` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 menit, bersama | pemain | endpoint yang sama dengan `reauth` |
    | `account` | 60 / menit | pemain | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / menit | pemain | `GET /account/games` |
    | `account_export` | 5 / jam, bersama | pemain | `POST /account/export` (diperiksa sebelum `reauth`; setiap percobaan dihitung) |
    | `sessions` | 60 / menit | pemain | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / menit | pemain (klien jika tanpa token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (satu bucket untuk keempatnya) |
    | `gif` | 30 / menit | pemain | `GET /games/{id}/gif`, `POST /gif` (satu bucket untuk keduanya) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / menit dan `GIF_USER_RENDERS_PER_HOUR` (30) / jam, bersama | pemain | dua endpoint yang sama, hanya jika GIF harus dibuat (bukan dari cache) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / menit dan `GIF_IP_RENDERS_PER_HOUR` (120) / jam, bersama | klien (semua akunnya sekaligus), 3 kali lipatnya per /48 IPv6 | sama, seperti di atas |
    | `reports` | 30 / jam | pemain | `POST /reports` |
    | `sso_start` | 30 / 10 menit, bersama | klien, dan 90 per /48 IPv6 | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (digabung) |
    | `sso_finish` | 30 / menit | klien | `POST /auth/sso/google/finish` |
    | `page` | 60 / menit | klien | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` dan `GET /leaderboard` tidak memiliki batas sendiri: hanya lapisan per alamat. Endpoint dengan beberapa batas memeriksanya sesuai urutan yang diberikan dalam deskripsinya.

    Server menangani permintaan dalam urutan berikut: lapisan per alamat, lalu endpoint pemeriksaan kesehatan; pencocokan endpoint; pemeriksaan bahwa server tidak sangat tertinggal (503 `server_busy`): terlalu banyak permintaan sebelumnya yang masih berjalan melewati batas waktunya dan, untuk `POST`, `PUT`, `PATCH`, atau `DELETE`, tumpukan penulisan database; autentikasi, dalam 5 detik (503 `server_busy` jika lebih); jatah akun, jika permintaan membawa sesi; batas endpoint; body; endpoint itu sendiri (endpoint GIF mengambil token batas render-nya hanya jika ada GIF yang harus dibuat). Jadi, permintaan yang ditolak karena token sesinya tidak menghabiskan satu pun token endpoint, sedangkan permintaan dengan body yang tidak valid tetap menghabiskannya.

    Pembatasan lainnya, yang dijawab oleh endpoint itu sendiri:

    - **Percobaan masuk yang gagal pada satu nama login** (nama pengguna atau email): mulai dari `AUTH_FAILURES_PER_ACCOUNT` (5) kegagalan, setiap percobaan harus menunggu dua kali lebih lama dari sebelumnya (2 detik, 4 detik, dan seterusnya, hingga 15 menit): 429 `too_many_attempts` dengan `retryAfter`. Penghitung kembali ke nol setelah satu jam tanpa kegagalan.
    - **Faktor kedua yang gagal saat masuk:** aturan yang sama mulai dari kode salah ke-5 pada akun tersebut. Satu tahap masuk menerima paling banyak 5 kode yang salah.
    - **Kode faktor kedua dari satu akun:** paling banyak `AUTH_MFA_PER_ACCOUNT` (10) kode (kode autentikator atau kode pemulihan, benar atau salah) per 15 menit, dari alamat mana pun, saat masuk dan saat autentikasi ulang; jika melebihinya, 429 `too_many_attempts` sebelum kode diperiksa, sehingga kode pemulihan tidak terpakai.
    - **Autentikasi ulang yang gagal pada suatu akun** (kata sandi atau kode salah): aturan yang sama (429 `too_many_attempts`), yang berlaku bersama untuk semua endpoint yang melakukan autentikasi ulang.
    - **Email:** satu email konfirmasi atau pengaturan ulang per alamat setiap 5 menit (responsnya tetap sama); satu pemberitahuan "seseorang mencoba menggunakan alamat Anda" per alamat per jam.
    - **Laporan:** `REPORTS_PER_DAY` (5) per pemain dalam 24 jam: 429 `report_limit`.

    ## Proof of work

    `POST /auth/register` selalu memerlukan proof of work jika `POW_REGISTER_BITS` lebih dari 0 (18 secara default; `GET /info` memberikannya sebagai `pow.register`). `POST /auth/login` dan `POST /auth/sso/google/link` (satu jenis challenge untuk keduanya) memerlukannya hanya selama 5 menit setelah server melihat gelombang percobaan masuk yang gagal (`POW_LOGIN_TRIGGER_PER_MIN`, lalu `POW_LOGIN_BITS`); klien mengetahuinya dari respons.

    1. Permintaan tanpa bukti (atau dengan bukti yang ditolak) menghasilkan 428 `pow_required` dengan `reason` dan challenge `pow` `{ challenge, bits, expiresAt }`.
    2. Temukan nonce: string desimal paling banyak 20 digit sehingga `SHA-256(challenge + ":" + nonce)` diawali dengan `bits` bit nol (bit paling signifikan dari byte pertama lebih dulu). 18 bit membutuhkan rata-rata sekitar 260.000 hash.
    3. Kirim ulang permintaan yang sama dengan `"pow": { "challenge": "...", "nonce": "123456" }` di dalam body.

    Challenge berlaku selama 2 menit dan hanya sekali, untuk satu endpoint dan satu jaringan klien (alamat IPv4 atau /64 IPv6). Challenge ditandatangani, sehingga server tidak menyimpan apa pun tentangnya sampai challenge itu kembali. `reason` menyebutkan alasan bukti ditolak: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work`, atau `replayed`.

    ## Autentikasi ulang

    Perubahan akun meminta kata sandi lagi dan, jika autentikasi dua faktor aktif, faktor kedua:

    - hanya kata sandi: `POST /account/password` dan `POST /account/mfa/totp/setup`;
    - kata sandi dan kode autentikator, sedangkan kode pemulihan ditolak: `POST /account/mfa/recovery-codes`;
    - kata sandi dan kode autentikator atau kode pemulihan: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export`, dan `POST /account/delete`.

    Di dalam body, `code` berisi kode autentikator 6 digit dan `recoveryCode` berisi kode pemulihan (`xxxx-xxxx-xx`; huruf besar/kecil, spasi, dan tanda hubung tidak berpengaruh). Kode pemulihan juga dapat dikirim dalam `code` di tempat yang menerima kode pemulihan. Setiap kode hanya berlaku sekali: kode autentikator yang sudah dipakai ditolak hingga interval 30 detik berikutnya, dan kode pemulihan hangus begitu dipakai.

    | Status | `error` | Kapan |
    |---|---|---|
    | 403 | `invalid_password` | Kata sandi salah, atau kata sandi diatur ulang atau diubah saat permintaan berjalan (tidak ada yang diubah). |
    | 403 | `mfa_code_required` | Autentikasi dua faktor aktif dan baik `code` maupun `recoveryCode` tidak dikirim. |
    | 403 | `invalid_code` | Kode salah atau sudah pernah dipakai. |
    | 400 | `password_not_set` | Akun yang hanya menggunakan Google belum memiliki kata sandi ("Lupa kata sandi?" menetapkannya). |
    | 429 | `too_many_attempts` | Terlalu banyak kegagalan, atau terlalu banyak kode yang dicoba, pada akun ini. |
    | 503 / 429 | `server_busy` / `rate_limited` | Antrean hash kata sandi sedang sibuk. |

    Kata sandi dan kode yang gagal dihitung dalam penghitung kegagalan akun dan dicatat sebagai peristiwa keamanan.

    ## Port metrik

    Selain port API, server merespons HTTP biasa pada port metrik (`METRICS_PORT`, 9464, terikat ke `METRICS_BIND`, 127.0.0.1 secara default; jaga agar tetap privat). Port ini bukan bagian dari API ini: `GET /healthz` merespons `ok`; `GET /readyz` merespons `ready` setelah proses start-up selesai (setiap shard telah memutar ulang jurnalnya dan listener telah terikat) dan hingga proses shutdown dimulai, jika tidak 503 `not ready`; `GET /metrics` menyajikan metrik Prometheus dan, jika `METRICS_TOKEN` ditetapkan, memerlukan `Authorization: Bearer <token>` dengan token yang persis sama.
  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: Kode sumber server Scacelith dan dokumentasi referensinya (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Server resmi.
  - url: https://{host}:{port}/api/v1
    description: Server Scacelith mana pun, seperti server komunitas.
    variables:
      host:
        default: caissa.scacelith.com
        description: Nama host publik server (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: Port API publik (`PUBLIC_API_PORT`, atau `API_PORT` jika tidak ditetapkan).
tags:
  - name: server-info
    x-displayName: Info server
    description: Hal yang perlu diketahui klien sebelum masuk atau terhubung.
  - name: health
    x-displayName: Pemeriksaan kesehatan
    description: |-
      Status hidup (liveness) dan kesiapan (readiness) server, untuk pemantauan. Endpoint ini merespons di port API sebelum autentikasi dan sebelum semua batas endpoint, tetapi seperti permintaan lainnya, endpoint ini mengambil satu token dari lapisan per alamat; host pemantauan dapat dicantumkan dalam `ABUSE_EXEMPT`. Kedua path berfungsi untuk setiap endpoint: di root server dan di bawah `/api/v1`.

      Port metrik memiliki endpoint pemeriksaan kesehatan sendiri, yang dijelaskan di pendahuluan.
  - name: auth
    x-displayName: Pendaftaran dan masuk
    description: |-
      Membuat akun, masuk dengan kata sandi (dan faktor kedua), email konfirmasi dan email pengaturan ulang kata sandi.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link`, dan `POST /auth/sso/complete` merespons dengan sesi `{ token, expiresAt, user }` (atau, untuk endpoint-endpoint pertama, tahap kedua).
  - name: google-sign-in
    x-displayName: Masuk dengan Google
    description: |-
      Tersedia jika `GET /info` menyatakan `sso.google: true`; jika tidak, setiap endpoint di bawah merespons 404 `sso_disabled`. Situs web masuk dengan cara yang sama menggunakan kliennya sendiri jika `sso.googleWeb` bernilai true (di bawah, **Di situs web**). Game masuk melalui browser sistem dengan alur aplikasi terinstal dari RFC 8252 (klien "Aplikasi desktop"): Google mengarahkan browser kembali ke listener game di `127.0.0.1`, tidak pernah ke server ini, dan game tidak pernah melihat kredensial Google.

      1. Klien mendengarkan di `127.0.0.1:0` (sistem yang memilih port) dan membuat pasangan PKCE: `codeVerifier` sepanjang 43 hingga 128 karakter `[A-Za-z0-9._~-]` dan `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, yang terdiri atas 43 karakter tanpa padding.
      2. `POST /auth/sso/google/start` dengan challenge dan port tersebut mengembalikan URL Google dan `state` percobaan itu. Klien memeriksa URL tersebut (lihat di bawah) dan membukanya di browser.
      3. Google mengarahkan browser ke `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Klien hanya menerima `state` dari respons start, dan tidak mengirim apa pun setelah pengalihan `error=`.
      4. `POST /auth/sso/google/finish` dengan ID percobaan, `codeVerifier`, `state`, dan `code` (serta `iss` jika Google mengirimkannya).
      5. Responsnya berupa sesi, tahap autentikasi dua faktor (lanjutkan dengan `POST /auth/login/mfa`), `needsUsername` untuk pemain baru (lanjutkan dengan `POST /auth/sso/complete`), atau `needsPassword` jika alamat tersebut digunakan oleh akun yang memiliki kata sandi (lanjutkan dengan `POST /auth/sso/google/link`).

      **Tag origin.** URI pengalihan membawa tag dari server yang ditambahkan pemain, sehingga URL yang diperoleh server lain untuk pemainnya sendiri ditolak oleh game. Origin adalah host dalam huruf kecil (literal IPv6 dalam kurung siku), `:`, dan port API dalam desimal, yang selalu ditulis, termasuk 443: server menggunakan `SERVER_PUBLIC_HOST` dan port API publiknya, sedangkan game menggunakan alamat yang dihubunginya. Tag adalah 22 karakter pertama dari base64url (tanpa padding) dari SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + origin). URI pengalihan adalah `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: selalu literal IPv4, dan port listener game (1024 hingga 65535). Server tidak pernah mengambil URI, host, atau path dari klien.

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

      Jadi, masuk dengan Google hanya berfungsi bagi pemain yang menambahkan server persis dengan `SERVER_PUBLIC_HOST` dan port API publiknya.

      **Yang diperiksa game sebelum membuka browser.** `authUrl` diawali persis dengan `https://accounts.google.com/o/oauth2/v2/auth?` dan berupa ASCII yang dapat dicetak dengan panjang kurang dari 4096 karakter, dan kuerinya memiliki tepat satu `response_type=code`, tepat satu `redirect_uri` yang sama dengan URI yang dihitung game dari port dan tag origin-nya, tepat satu `state` yang sama dengan `state` dalam respons, `code_challenge_method=S256`, dan `code_challenge` sepanjang 43 karakter. Jika tidak, game menghentikan listener-nya dan tidak membuka apa pun. Game hanya mengirim `finish` dan `link` ke server yang merespons `start`.

      **Di situs web.** Situs web (situs resmi: <https://scacelith.com/>) masuk dengan klien "Aplikasi web" dari project Google yang sama (`GOOGLE_WEB_CLIENT_ID`). Google mengarahkan browser kembali ke satu halaman situs yang dikonfigurasi operator (`GOOGLE_WEB_REDIRECT_URI`, tercantum dalam "URI pengalihan yang diotorisasi" milik klien tersebut), dan halaman itu mengirim kode ke `finish`; di sini pun server tidak mengambil URI pengalihan dari klien.

      1. Halaman membuat pasangan PKCE seperti di atas, menyimpan verifier di tab (`sessionStorage`), dan mengirim `POST /auth/sso/google/web/start` dengan challenge tersebut.
      2. Halaman memeriksa `authUrl` seperti yang dilakukan game, kecuali bahwa `redirect_uri`-nya harus berupa halaman dari origin-nya sendiri, lalu membuka URL tersebut. Tidak ada skrip Google yang dimuat.
      3. Google mengarahkan browser ke `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. Halaman hanya menerima `state` dari respons start dan tidak mengirim apa pun setelah pengalihan `error=`.
      4. `finish`, `link`, dan `complete` mengikuti seperti untuk game. Percobaan tersebut mengingat bahwa situs web yang memulainya: kode ditukar dengan klien dan halaman situs web, dan token ID harus menyebutkan klien tersebut (kode atau token ID dari klien game gagal dengan 502 `sso_failed`, begitu pula sebaliknya).

      Akun Google menjangkau akun Scacelith yang sama dari game maupun dari situs web: tautan tersebut menyimpan subject Google (`sub`), yang mengidentifikasi akun Google apa pun kliennya. Origin situs web harus ada dalam `CORS_ORIGINS` (`check-config` memberi peringatan jika tidak).

      **Akun mana yang dijangkau proses masuk dengan Google:** akun yang sudah tertaut ke akun Google tersebut; jika tidak ada, akun aktif dengan alamat yang telah dikonfirmasi Google (jika akun itu memiliki kata sandi, `finish` merespons `needsPassword` dan tautan baru disimpan setelah kata sandinya lolos, lalu faktor keduanya jika aktif; jika tanpa kata sandi, 409 `sso_account_exists`); jika tidak ada juga, akun baru (`needsUsername`). Akun Google tidak pernah ditautkan ke akun yang sudah ada hanya berdasarkan alamatnya. Alamat akun menerima email ketika proses masuk dengan Google membuat akun dan ketika opsi masuk dengan Google ditambahkan ke akun yang sudah ada.
  - name: sessions
    x-displayName: Sesi
    description: Perangkat yang masuk ke akun, dan proses keluar.
  - name: account
    x-displayName: Akun
    description: Tampilan akun, preferensinya, dan kata sandinya.
  - name: two-step-verification
    x-displayName: Autentikasi dua faktor
    description: Aplikasi autentikator (TOTP, RFC 6238), dengan SHA-1, 6 digit, interval 30 detik, dan toleransi satu interval ke kedua arah, ditambah kode pemulihan sekali pakai.
  - name: email-change
    x-displayName: Perubahan alamat email
    description: Mengubah alamat email akun, yang dikonfirmasi dengan tautan yang dikirim ke alamat baru.
  - name: data-export
    x-displayName: Ekspor data
    description: Semua yang disimpan server tentang akun, dalam satu file JSON.
  - name: account-deletion
    x-displayName: Penghapusan akun
    description: Menghapus akun secara permanen.
  - name: game-history
    x-displayName: Riwayat permainan
    description: Permainan milik pemain yang sedang masuk, dengan filter dan pembagian halaman.
  - name: games
    x-displayName: Permainan dan PGN
    description: |-
      Catatan permainan beserta langkah dan jamnya, serta file PGN-nya.

      **Kode permainan.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (hanya permainan yang telah selesai yang disimpan). `result`: `1-0`, `0-1`, `1/2-1/2`, atau `*` (dibatalkan). `reason`, beserta namanya (`termination`) dan kata-kata yang mengakhiri teks langkah dalam file PGN (dalam bahasa Inggris di dalam file, diikuti terjemahannya di bawah); kode 7, 14, dan 21 adalah remis (pemain yang kehabisan waktu, menyerah, atau meninggalkan permainan menghadapi lawan yang tidak dapat memberi skakmat: FIDE 6.9 dan 5.1.2; `DESIGN.md` 6.3 menjelaskan aturannya), dan server tidak pernah mengakhiri permainan dengan kode 4 dan 13 (keduanya termasuk dalam daftar alasan bersama):

      | Kode | `termination` | Kata-kata dalam PGN |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (skakmat) |
      | 2 | `Resignation` | Resignation (menyerah) |
      | 3 | `Timeout` | Loss on time (kalah waktu) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (langkah ilegal kedua, dinyatakan kalah) |
      | 5 | `Stalemate` | Stalemate (pat) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (posisi mati, materi tidak cukup) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (waktu habis, tetapi lawan tidak dapat memberi skakmat) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (pengulangan posisi lima kali) |
      | 9 | `SeventyFiveMoves` | 75-move rule (aturan 75 langkah) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (pengulangan posisi tiga kali, diklaim) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (aturan 50 langkah, diklaim) |
      | 12 | `Agreement` | Draw by agreement (remis atas kesepakatan) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (langkah ilegal kedua, tetapi lawan tidak dapat memberi skakmat) |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate (menyerah, tetapi lawan tidak dapat memberi skakmat) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (meninggalkan permainan, terputus terlalu lama) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (meninggalkan permainan, tetapi lawan tidak dapat memberi skakmat) |
      | 22 | `Aborted` | Game aborted (permainan dibatalkan) |
      | 23 | `NoShow` | Aborted: first move not played in time (dibatalkan: langkah pertama tidak dijalankan tepat waktu) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (dinyatakan kalah karena pelanggaran fair play) |
      | 25 | `ServerAborted` | Aborted by the server (dibatalkan oleh server) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (dibatalkan: kedua pemain terputus) |

      Kode 14 baru ada sejak versi minor 1 protokol: klien yang ditulis sebelumnya sebaiknya menampilkan kode yang tidak dikenal sebagai akhir permainan biasa dengan `result`-nya.
  - name: gifs
    x-displayName: GIF animasi
    description: |-
      Permainan sebagai GIF animasi, untuk disimpan atau dibagikan: papan dilihat dari atas, satu frame per posisi dari awal hingga posisi akhir, nama dan rating pemain di atas papan, langkah terakhir di bawahnya, dan pada frame terakhir, hasil serta cara permainan berakhir.

      **Biaya, cache, dan kuota.** GIF dibuat di thread rendering tersendiri, tidak pernah di thread yang menjalankan permainan, dan dengan prioritas CPU terendah: `GIF_THREADS` thread (`WORKERS` secara default), yang dimulai bersama GIF pertama dan dihentikan setelah satu menit tanpa GIF. Hingga `GIF_QUEUE_MAX` (4 x `WORKERS`) GIF dapat menunggu thread, masing-masing paling lama `GIF_QUEUE_TIMEOUT_MS` (10 detik); satu render boleh berlangsung hingga `GIF_RENDER_TIMEOUT_MS` (30 detik). Server menyimpan GIF yang telah dibuatnya dalam cache sebesar `GIF_CACHE_MB` MB (32 x `WORKERS`), dengan yang paling lama tidak digunakan dibuang lebih dulu; GIF dari cache, atau GIF yang sedang dibuat untuk permintaan lain, tidak memakan render. Kunci cache mencakup semua hal yang mengubah gambar, termasuk nama.

      Setiap permintaan dihitung dalam `gif` (30 per menit per pemain untuk kedua endpoint). GIF yang harus dibuat juga dihitung dalam batas render: per pemain `GIF_USER_RENDERS_PER_MIN` (4) per menit dan `GIF_USER_RENDERS_PER_HOUR` (30) per jam; per klien, semua akunnya sekaligus, `GIF_IP_RENDERS_PER_MIN` (12) per menit dan `GIF_IP_RENDERS_PER_HOUR` (120) per jam, serta 3 kali lipatnya per /48 IPv6. Klien sebaiknya menyimpan file yang telah diunduhnya alih-alih memintanya lagi, dan menunggu `retryAfter` setelah 429 atau 503.

      Ukuran: `small` (petak 32 px: 284 x 350 piksel), `medium` (48 px: 424 x 515), `large` (72 px: 628 x 762); tanpa koordinat 268 x 342, 400 x 503, dan 600 x 748. Posisi awal ditampilkan paling sedikit 1 detik (atau selama jeda jika jeda lebih lama), posisi akhir 3 detik, dan GIF berulang; setelah frame pertama, hanya bagian gambar yang berubah yang disimpan. Permainan 40 langkah memakan sekitar 135, 205, dan 325 KiB (small, medium, large), permainan 150 langkah 0,5, 0,8, dan 1,2 MiB.
  - name: players
    x-displayName: Pemain
    description: Profil publik dan permainan terbaru. Hanya data publik, tidak pernah alamat email, sesi, sanksi, atau tingkat integritas. Akun yang telah dihapus tidak memiliki profil.
  - name: leaderboard
    x-displayName: Papan peringkat
    description: Pemain teratas dari setiap kategori resmi.
  - name: reports
    x-displayName: Laporan
    description: Melaporkan lawan dari permainan terbaru kepada moderator.
  - name: pages
    x-displayName: Halaman HTML
    description: |-
      Halaman untuk browser, yang dibuka dari tautan di email. Tautannya mengarah ke `https://<SERVER_PUBLIC_HOST>` (dengan `:<PUBLIC_API_PORT>` jika bukan 443), di luar `/api/v1`.

      - Halaman-halaman ini tidak menjalankan JavaScript dan tidak memuat sumber daya eksternal. Halaman disajikan dengan `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - `GET` hanya menampilkan tombol atau formulir, sehingga pemindai email yang membuka tautan tidak membuat tautan itu terpakai. Perubahan terjadi pada `POST`: formulir yang dikirim sebagai `application/x-www-form-urlencoded` (body JSON juga diterima), dengan token tautan dalam field tersembunyi. Field yang diberikan dua kali ditolak.
      - Kesalahan (batas laju, field tidak valid) juga berupa halaman HTML, berjudul "Request refused" (permintaan ditolak) atau, mulai dari 500, "Server error" (kesalahan server). Hanya kegagalan yang ditemukan sebelum halaman dicocokkan (target permintaan yang terlalu panjang atau tidak berformat benar, lapisan per alamat) yang direspons dalam JSON.
x-tagGroups:
  - name: server
    x-displayName: Server
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Akun
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Permainan
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Pemain dan laporan
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Halaman browser
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Ambil nama, versi, port, dan aturan pendaftaran server
      description: |-
        Hal yang dibutuhkan klien sebelum masuk atau terhubung: nama dan ID server, versi protokol WebSocket dan letak WebSocket, aturan pendaftaran, kontrol waktu resmi, dan batas yang dapat diperiksa klien sebelum mengirim formulir.

        **Batas:** hanya lapisan per alamat.
      security: []
      responses:
        '200':
          description: Deskripsi server.
          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`: lapisan per alamat.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Buat akun
      description: |-
        Membuat akun: setelah alamat emailnya dikonfirmasi dengan tautan yang dikirim ke alamat tersebut, atau langsung jika tanpa konfirmasi email.

        **Dengan konfirmasi email** (`REQUIRE_EMAIL_VERIFICATION`, default): 202 `verification_sent`. Belum ada akun yang dibuat: pendaftaran menunggu selama 24 jam dan menahan nama penggunanya selama itu. Tautan yang berlaku selama 24 jam tersebut dikirim ke alamat itu, paling banyak satu per alamat setiap 5 menit (`POST /auth/verify-email/resend` mengirim tautan baru); akun dibuat, dengan alamat yang sudah terkonfirmasi, saat tautan digunakan (tombol pada halaman `/verify-email`), dan pemain kemudian dapat masuk. Sampai saat itu, proses masuk dengan nama pengguna tersebut menghasilkan `invalid_credentials`, seperti untuk akun yang tidak dikenal, dan profil publiknya tidak ada. Pendaftaran baru dengan alamat yang sama menggantikan pendaftaran yang menunggu. Responsnya sama jika akun lain sudah menggunakan alamat tersebut: dalam hal ini tidak ada tautan yang dikirim, pemiliknya menerima pemberitahuan sebagai gantinya (paling banyak satu per jam), dan nama pengguna ditahan dengan cara yang sama, sehingga tidak ada yang mengungkapkan apakah alamat tersebut memiliki akun. Pendaftaran yang tautannya tidak digunakan dihapus setelah 24 jam, dan nama penggunanya bebas lagi.

        **Tanpa konfirmasi email** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, akun langsung dibuat dan dapat masuk.

        **Aturan.** `username`: `USERNAME_MIN` hingga `USERNAME_MAX` karakter (3 hingga 20), berupa huruf, angka, `_`, dan `-`, diawali huruf atau angka (`GET /info` memberikannya sebagai `limits`); nama yang dicadangkan (`admin`, `moderator`, `deleted`...) dan beberapa awalan ditolak; unik tanpa membedakan huruf besar/kecil. `email`: dipangkas spasi di awal dan akhirnya lalu disimpan dalam huruf kecil, berupa ASCII biasa dengan domain bertitik. `password`: paling sedikit `PASSWORD_MIN_LENGTH` (10) karakter dan paling banyak 256 byte UTF-8; tidak boleh mengandung nama pengguna atau bagian lokal alamat email, dan tidak boleh berupa kata sandi yang umum.

        **Kesalahan**, diperiksa dalam urutan berikut: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; kesalahan antrean hash kata sandi; 409 `email_taken` (hanya tanpa konfirmasi email).

        **Batas:** `auth`, lalu `auth_register` (10 pendaftaran per jam per klien). **Proof of work:** selalu, jika `POW_REGISTER_BITS` lebih dari 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Percobaan pertama, tanpa proof of work
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Dikirim ulang dengan proof of work
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: Akun telah dibuat dan dapat masuk (tanpa konfirmasi email di server ini).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: Pendaftaran menunggu tautan konfirmasinya (atau alamat tersebut sudah memiliki akun; respons tidak mengungkapkannya).
          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` (dengan `field`), `invalid_json`, `invalid_username`, `invalid_email`, atau `weak_password` dengan `reason`: `too_short`, `too_long`, `contains_username`, `contains_email`, atau `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: pendaftaran ditutup di server ini (`GET /info` menyatakan `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: sudah ada akun dengan nama pengguna tersebut, atau pendaftaran yang menunggu dari alamat lain menahannya. `email_taken`: akun lain menggunakan alamat tersebut (hanya tanpa konfirmasi 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`: batas `auth` atau `auth_register`, atau antrean hash kata sandi.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (antrean hash kata sandi, atau database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Masuk dengan kata sandi
      description: |-
        Masuk dengan nama pengguna atau alamat email dan kata sandi. Ada dua kemungkinan respons, keduanya dengan status 200: sesi, atau, jika autentikasi dua faktor aktif, tahap kedua yang harus diselesaikan dalam 5 menit dengan `POST /auth/login/mfa`.

        Akun yang tidak dikenal, kata sandi yang salah, dan akun tanpa kata sandi mendapat respons yang sama, setelah waktu yang sama: 401 `invalid_credentials`. Pemeriksaan akun (`banned`, `email_unverified`) baru dilakukan setelah kata sandi benar. Mulai dari `AUTH_FAILURES_PER_ACCOUNT` (5) kegagalan pada satu nama login, setiap percobaan harus menunggu (429 `too_many_attempts`).

        **Batas:** `auth`. **Proof of work:** hanya selama gelombang percobaan masuk yang gagal (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: Sesi, atau tahap kedua dari proses masuk dengan autentikasi dua faktor.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Berhasil masuk
                  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: Autentikasi dua faktor aktif
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: akun tidak dikenal, kata sandi salah, atau akun tanpa kata sandi.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Hanya setelah kata sandi benar: `banned`, dengan `until` (epoch ms, `null` untuk penangguhan permanen), atau `email_unverified` (akun lama yang alamatnya belum dikonfirmasi).'
        '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` (jeda setelah kegagalan untuk nama login ini), atau `rate_limited` (batas `auth`, antrean hash kata sandi).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (antrean hash kata sandi, atau database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Selesaikan proses masuk dengan faktor kedua
      description: |-
        Tahap kedua dari proses masuk dengan autentikasi dua faktor, setelah `POST /auth/login` atau masuk dengan Google (`POST /auth/sso/google/finish` atau `POST /auth/sso/google/link`) merespons `mfaRequired`. Kirim `code` (kode autentikator 6 digit, atau kode pemulihan) atau `recoveryCode`. Kode pemulihan yang digunakan di sini akan hangus.

        Satu tahap menerima paling banyak 5 kode yang salah; tahap ini juga berakhir jika kata sandi diatur ulang atau diubah. Setelah tahap kata sandi dari penautan Google, tautan baru disimpan jika kode lolos di sini.

        **Batas:** `auth`, dan paling banyak `AUTH_MFA_PER_ACCOUNT` (10) kode per 15 menit untuk akun tersebut, dari alamat mana pun; jika melebihinya, kode tidak diperiksa, sehingga kode pemulihan tidak terpakai.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Kode autentikator
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Kode pemulihan
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Berhasil masuk.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: baik `code` maupun `recoveryCode` tidak dikirim, atau body melanggar skema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: tahap tersebut kedaluwarsa, sudah digunakan, atau berakhir setelah 5 kode salah, atau kata sandi diatur ulang atau diubah sejak tahap pertama (masuk lagi). `invalid_code`: kode salah atau sudah pernah dipakai.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (dengan `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: hanya setelah tahap kata sandi dari penautan Google, akun Google tersebut telah ditautkan ke akun lain sementara itu.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: hanya setelah tahap kata sandi dari penautan Google, akun tersebut berubah sementara itu; mulai proses masuk lagi.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: jeda setelah kegagalan untuk akun tersebut, atau jatah `AUTH_MFA_PER_ACCOUNT` kode untuk akun itu dalam 15 menit terakhir sudah habis. `rate_limited`: batas `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` dengan `retryAfter: 1`: database tetap terkunci (setelah tahap penautan Google, tidak ada yang ditautkan: mulai proses masuk lagi). `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Keluar dari sesi ini
      description: |-
        Mencabut sesi dari token yang digunakan. WebSocket yang dibuka dengan sesi tersebut ditutup. Tanpa body (atau `{}`).

        **Batas:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Berhasil keluar.
          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`: body selain `{}`; `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`: batas `sessions` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Keluar dari semua sesi
      description: |-
        Mencabut semua sesi akun, termasuk sesi ini. WebSocket yang dibuka dengan sesi-sesi tersebut ditutup. Tanpa body (atau `{}`).

        **Batas:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Semua sesi telah dikeluarkan.
          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`: body selain `{}`; `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`: batas `sessions` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Kirim ulang tautan konfirmasi
      description: |-
        Mengirim ulang tautan konfirmasi email. Responsnya 202 `accepted` apa pun alamatnya, sehingga tidak pernah mengungkapkan apakah alamat tersebut digunakan oleh akun atau pendaftaran.

        Permintaan hanya berdampak paling banyak sekali setiap 5 menit per alamat. Pendaftaran yang menunggu dengan alamat tersebut mendapatkan kembali 24 jamnya, baik ada maupun tidak ada akun lain yang menggunakan alamat itu, sehingga nama penggunanya tetap ditahan selama waktu yang sama dalam kedua kasus. Tautan hanya dikirim untuk pendaftaran tersebut jika alamat itu tidak memiliki akun (tautan baru, berlaku 24 jam, menggantikan tautan sebelumnya), atau untuk akun aktif yang belum dikonfirmasi dengan alamat tersebut.

        **Batas:** `auth`, lalu `auth_mail` (10 per jam per klien).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Diterima (apa pun alamatnya).
          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`: batas `auth` atau `auth_mail` (apa pun alamatnya).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` dengan `retryAfter: 1`: database tetap terkunci saat akun dicari atau saat tautan untuk akun yang belum dikonfirmasi disimpan (pembaruan pendaftaran yang menunggu bersifat best effort dan tidak pernah membuat permintaan gagal); 5 menit untuk alamat tersebut tetap dimulai, sehingga permintaan baru dalam rentang itu tidak berdampak apa pun. `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Kirim tautan pengaturan ulang kata sandi
      description: |-
        Mengirim tautan pengaturan ulang kata sandi yang berlaku selama satu jam dan membuka halaman `/reset-password`. Responsnya 202 `accepted` apa pun alamatnya. Tautan hanya dikirim ke akun aktif, paling banyak sekali setiap 5 menit per alamat. Akun yang hanya menggunakan Google menetapkan kata sandi pertamanya dengan cara ini.

        Pemulihan kata sandi memiliki batas paling ketat di API ini, semuanya dihitung untuk seluruh server: 3 permintaan per jam (`AUTH_FORGOT_PER_HOUR`) dan 10 per 24 jam (`AUTH_FORGOT_PER_DAY`) per klien (alamat IPv4 atau /64 IPv6), 3 kali lipat angka tersebut per /48 IPv6, selain batas `auth` (20 per 10 menit) dan batas satu email per alamat setiap 5 menit. Baik 202 maupun 429 tidak mengungkapkan apakah suatu akun menggunakan alamat tersebut. Penetapan kata sandi baru memiliki batasnya sendiri (`auth_reset`).

        **Batas:** `auth`, `auth_forgot`, lalu `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Diterima (apa pun alamatnya).
          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`: batas `auth`, `auth_forgot`, atau `auth_forgot_day` (apa pun alamatnya).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` dengan `retryAfter: 1`: database tetap terkunci saat akun dicari atau saat tautan disimpan; 5 menit untuk alamat tersebut tetap dimulai, sehingga permintaan baru dalam rentang itu tidak mengirim tautan. `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Tetapkan kata sandi baru dengan token tautan pengaturan ulang
      description: |-
        Menetapkan kata sandi baru dengan token dari tautan pengaturan ulang (halaman `/reset-password` melakukan hal yang sama). Kata sandi baru mengikuti aturan pendaftaran.

        Pengaturan ulang memiliki efek berikut, dalam satu transaksi (semuanya, atau tidak sama sekali): semua sesi dicabut dan perubahan email yang tertunda dibatalkan; tautan pengaturan ulang lain untuk akun tersebut tidak berfungsi lagi; alamat dianggap terkonfirmasi (tautan telah membuktikannya); pemilik akun menerima email. Autentikasi dua faktor tidak diubah. Setelah kesalahan antrean hash kata sandi, 503, atau 500, tidak ada yang berubah: kata sandi lama dan sesi-sesi tetap ada, dan tautan tetap valid.

        **Batas:** `auth`, lalu `auth_reset` (10 per jam per klien, 30 per /48 IPv6, digunakan bersama dengan halaman tersebut: setiap percobaan meng-hash kata sandi).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Kata sandi telah diubah dan semua sesi telah dikeluarkan.
          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`: tautan tidak valid, sudah digunakan, atau kedaluwarsa, atau dikirim ke alamat yang tidak lagi dimiliki akun tersebut. `weak_password` (dengan `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`: batas `auth` atau `auth_reset`, atau antrean hash kata sandi.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: antrean hash kata sandi, atau database tetap terkunci (`retryAfter: 1`, tidak ada yang berubah). `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Mulai masuk dengan Google
      description: |-
        Memulai percobaan masuk dengan Google untuk challenge PKCE dan port listener game di `127.0.0.1`. Respons memberikan URL Google yang harus dibuka di browser sistem dan `state` percobaan tersebut; percobaan berlaku selama 10 menit.

        `authUrl` berisi `client_id`, `redirect_uri` (dibentuk dari `redirectPort` dan tag origin server), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 dari verifier milik server sendiri untuk Google) dengan `code_challenge_method=S256`, dan `prompt=select_account`. Game memeriksanya sebelum membukanya (lihat deskripsi bagian).

        **Batas:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: Percobaan masuk.
          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`: opsi masuk dengan Google tidak tersedia di server ini.'
        '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`: batas `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Mulai masuk dengan Google di situs web
      description: |-
        Memulai percobaan masuk dengan Google dari situs web untuk challenge PKCE halamannya. Responsnya sama seperti untuk game: URL Google yang harus dibuka, dan `state` percobaan tersebut; percobaan berlaku selama 10 menit. Tersedia jika `GET /info` menyatakan `sso.googleWeb: true`.

        `authUrl` berisi `client_id` situs web dan `redirect_uri=GOOGLE_WEB_REDIRECT_URI`, dengan parameter lain yang sama seperti untuk game. Halaman memeriksanya sebelum membukanya (lihat **Di situs web** dalam deskripsi bagian).

        **Batas:** `sso_start`, digunakan bersama dengan start dari game.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      responses:
        '200':
          description: Percobaan masuk.
          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` tidak ada atau tidak berformat benar, atau ada field lain (termasuk `redirectPort`).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: opsi masuk dengan Google tidak tersedia di server ini, atau tidak tersedia untuk situs web.'
        '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`: batas `sso_start` (digunakan bersama dengan start dari game).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Serahkan respons Google ke server
      description: |-
        Menyerahkan kepada server `code` dan `state` yang dikirim Google ke listener game atau ke halaman situs web, beserta ID percobaan dan verifier PKCE. ID percobaan tidak berguna tanpa verifier, dan kode tidak berguna tanpa verifier PKCE milik server sendiri dan rahasia kliennya.

        Server memeriksa percobaan (tidak dikenal, sudah digunakan, atau kedaluwarsa: 410), lalu verifier (verifier yang salah membuat percobaan tetap dapat digunakan), lalu memakai percobaan tersebut sekali, memeriksa `state` dan `iss`, menukar kode dengan Google, dan memverifikasi token ID. Responsnya (200) adalah salah satu dari:

        - `{ token, expiresAt, user }`: berhasil masuk ke akun yang tertaut;
        - `{ mfaRequired, mfaToken, expiresIn }`: lanjutkan dengan `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: akun baru; lanjutkan dengan `POST /auth/sso/complete` dalam 10 menit. `suggestedUsername` berasal dari nama Google atau alamat email, dan bernilai `""` jika tidak ada yang cocok;
        - `{ needsPassword, linkTicket, username, expiresIn }`: akun dengan alamat tersebut memiliki kata sandi; lanjutkan dengan `POST /auth/sso/google/link`. Belum ada yang ditautkan.

        **Batas:** `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: Sesi, tahap kedua, atau tahap berikutnya dari proses masuk pertama dengan Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Berhasil masuk ke akun yang tertaut
                  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: Autentikasi dua faktor aktif
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Pemain baru
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Akun dengan kata sandi menggunakan alamat tersebut
                  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 tidak cocok dengan challenge percobaan tersebut (percobaan tetap dapat digunakan). `sso_email_unverified`: Google belum mengonfirmasi alamat tersebut. `registration_closed`. `account_disabled`. Hanya untuk akun yang tertaut: `banned` (dengan `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: opsi masuk dengan Google tidak tersedia di server ini.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: akun aktif tanpa kata sandi menggunakan alamat tersebut (akun itu tidak disebutkan).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: percobaan tidak dikenal, sudah digunakan, atau kedaluwarsa.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: batas `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Tautkan Google ke akun yang sudah ada dengan kata sandinya
      description: |-
        Setelah `finish` merespons `needsPassword`: menautkan Google ke akun yang sudah ada dengan kata sandi akun tersebut, yang diketik di game. Responsnya (200) berupa sesi (tautan disimpan dan pemain berhasil masuk), atau, jika autentikasi dua faktor aktif, `{ mfaRequired, mfaToken, expiresIn }`: lanjutkan dengan `POST /auth/login/mfa`, dan tautan baru disimpan jika kode lolos di sana.

        Kata sandi yang salah membuat tiket tetap dapat digunakan, hingga total 5 percobaan. Satu percobaan diambil tepat sebelum kata sandi diperiksa: 429 `too_many_attempts` atau 428 `pow_required` tidak mengambil percobaan, sedangkan penolakan oleh antrean hash kata sandi (503 `server_busy`, 429 `rate_limited`) sudah mengambil satu dan dihitung sebagai kegagalan akun. Jeda setelah kegagalan menggunakan penghitung yang sama dengan `POST /auth/login`.

        **Batas:** `auth` (beserta hitungan per /48 IPv6-nya). **Proof of work:** seperti untuk `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: Sesi, atau tahap kedua proses masuk.
          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`: kata sandi salah; tiket tetap berlaku, untuk total 5 percobaan.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (dengan `until`), hanya setelah kata sandi yang benar.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: opsi masuk dengan Google tidak tersedia di server ini.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: sementara itu akun Google tersebut telah ditautkan ke akun lain.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: tiket yang tidak dikenal, sudah dipakai, atau kedaluwarsa; kata sandi salah yang ke-5; akun yang status atau alamatnya berubah sejak `finish`; atau akun yang status, alamat, kata sandi, atau autentikasi dua faktornya berubah saat penautan sedang disimpan. Mulai lagi proses masuk.'
        '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` (jeda akun setelah kegagalan), atau `rate_limited` (batas `auth`, antrean hash kata sandi).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: antrean hash kata sandi, atau database tetap terkunci (`retryAfter: 1`, tidak ada yang ditautkan). `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Buat akun untuk proses masuk pertama dengan Google
      description: |-
        Setelah `finish` merespons `needsUsername`: membuat akun dengan nama pengguna yang dipilih (aturan pendaftaran berlaku), lalu masuk dengan akun tersebut. Akun ini tidak memiliki kata sandi: `hasPassword` bernilai `false`, dan "Lupa kata sandi?" (`POST /auth/password/forgot`) memberinya kata sandi.

        **Batas:** `auth` (beserta hitungan per IPv6 /48-nya).
      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: Akun telah dibuat dan berhasil masuk.
          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`: opsi masuk dengan Google tidak tersedia di server ini.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (sebuah akun memakai nama pengguna itu, atau pendaftaran yang menunggu dari alamat lain menahannya), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: tiket yang tidak dikenal, sudah dipakai, atau kedaluwarsa.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: batas `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Tampilkan daftar perangkat yang masuk
      description: |-
        Sesi aktif akun (perangkat yang masuk), diurutkan dari yang paling baru dipakai. `current` menandai sesi yang membuat permintaan. `lastSeenAt` diperbarui paling sering sekali setiap 5 menit (lebih lambat saat server sangat tertinggal); `expiresAt` adalah akhir mutlaknya, dan batas waktu tidak aktif dapat mengakhiri sesi lebih awal.

        **Batas:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Sesi-sesi yang aktif.
          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`: batas `sessions` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Keluarkan satu perangkat
      description: |-
        Mengeluarkan satu sesi akun; sesi saat ini juga boleh dikeluarkan. WebSocket yang dibuka dengan sesi itu ditutup. Tanpa body (atau `{}`).

        **Batas:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Sesi telah dikeluarkan.
          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`: body selain `{}`, atau `id` yang bukan encoding URL yang valid; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: tidak ada sesi aktif dengan id ini di akun ini.'
        '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`: batas `sessions` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Ambil akun, rating, dan sanksi aktifnya
      description: |-
        Akun sebagaimana dilihat pemainnya: tampilan akun (juga `user` di setiap respons masuk), satu catatan rating per kategori tempat pemain pernah memainkan permainan dinilai, sanksi yang aktif, dan penangguhan. Tingkat integritas dari sistem antikecurangan tidak pernah ditampilkan.

        **Batas:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Akun.
          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`, atau `invalid_token` (juga jika akun telah dihapus).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: batas `account` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Terima atau tolak tantangan langsung
      description: |-
        Menetapkan apakah pemain lain boleh menantang pemain ini melalui namanya. Dengan `none`, tantangan langsung ditolak: penantang diberi tahu bahwa pemain itu tidak tersedia. Tanpa autentikasi ulang.

        **Batas:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Preferensi yang kini berlaku.
          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`: batas `account` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Ubah kata sandi
      description: |-
        Mengubah kata sandi. Kata sandi saat ini diperlukan, tetapi faktor kedua tidak, bahkan saat autentikasi dua faktor aktif. Kata sandi baru mengikuti aturan pendaftaran (diperiksa setelah kata sandi saat ini).

        Perubahan ini memiliki efek berikut, dalam satu transaksi (semuanya, atau tidak sama sekali): mencabut semua sesi lain (sesi ini tetap masuk), membatalkan perubahan email yang tertunda, membuat tautan pengaturan ulang kata sandi akun tidak berlaku lagi, dan mengirim email kepada pemiliknya.

        **Autentikasi ulang:** hanya kata sandi. **Batas:** `reauth`, lalu `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: Kata sandi telah diubah.
          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` (akun yang hanya memakai Google), `weak_password` (dengan `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: kata sandi saat ini salah, atau pengaturan ulang atau perubahan kata sandi terjadi saat permintaan sedang diperiksa.'
        '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` (autentikasi ulang akun yang gagal), atau `rate_limited` (batas `reauth` atau `reauth_user`, jatah akun, antrean hash kata sandi).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: antrean hash kata sandi, atau database tetap terkunci (`retryAfter: 1`, tidak ada yang berubah). `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Mulai mengaktifkan autentikasi dua faktor
      description: |-
        Menyimpan kunci rahasia autentikator baru yang tertunda, yang menggantikan kunci rahasia tertunda sebelumnya, lalu mengembalikannya untuk aplikasi autentikator (sebagai teks dan sebagai URI `otpauth://` untuk ditampilkan sebagai kode QR). Autentikasi dua faktor belum aktif: `POST /account/mfa/totp/enable` mengaktifkannya dengan kode dari kunci rahasia ini.

        **Autentikasi ulang:** hanya kata sandi. **Batas:** `reauth`, lalu `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Kunci rahasia yang tertunda.
          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` (akun yang hanya memakai 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` (diperiksa sebelum kata sandi).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (autentikasi ulang akun yang gagal), atau `rate_limited` (batas `reauth` atau `reauth_user`, jatah akun, antrean hash kata sandi).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (antrean hash kata sandi, atau database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Selesaikan pengaktifan autentikasi dua faktor dan dapatkan kode pemulihan
      description: |-
        Mengaktifkan autentikasi dua faktor dengan kode dari kunci rahasia yang tertunda (tepat 6 digit). Kata sandi tidak diminta di sini; kata sandi sudah diberikan saat penyiapan. Respons berisi 10 kode pemulihan, yang ditampilkan hanya kali ini.

        **Batas:** `reauth`, lalu `reauth_user`; kode yang salah dihitung sebagai kegagalan autentikasi ulang akun.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Autentikasi dua faktor aktif.
          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` tidak tepat 6 digit, atau body melanggar skema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: kode salah (periksa waktu pada perangkat).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: tidak ada kunci rahasia yang tertunda (panggil `POST /account/mfa/totp/setup` terlebih dahulu).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (autentikasi ulang akun yang gagal), atau `rate_limited` (batas `reauth` atau `reauth_user`, jatah akun).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Nonaktifkan autentikasi dua faktor
      description: |-
        Menonaktifkan autentikasi dua faktor. Kunci rahasia dan kode pemulihan dihapus, dan pemiliknya menerima email.

        **Autentikasi ulang:** kata sandi dan kode autentikator atau kode pemulihan (salah satu dari `code` atau `recoveryCode` wajib ada). **Batas:** `reauth`, lalu `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: Autentikasi dua faktor nonaktif.
          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` (akun yang hanya memakai Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (tidak ada `code` maupun `recoveryCode`, diperiksa sebelum kata sandi), `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` (autentikasi ulang yang gagal, atau terlalu banyak kode dicoba pada akun ini), atau `rate_limited` (batas `reauth` atau `reauth_user`, jatah akun, antrean hash kata sandi).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (antrean hash kata sandi, atau database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Ganti kode pemulihan
      description: |-
        Mengganti kode pemulihan dengan 10 kode baru; kode lama tidak berlaku lagi.

        **Autentikasi ulang:** kata sandi dan kode autentikator di `code` (kode pemulihan ditolak dengan 403 `invalid_code`). **Batas:** `reauth`, lalu `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: Kode pemulihan baru, yang ditampilkan hanya kali ini.
          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` (akun yang hanya memakai Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (juga untuk kode pemulihan).'
        '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` (autentikasi ulang yang gagal, atau terlalu banyak kode dicoba pada akun ini), atau `rate_limited` (batas `reauth` atau `reauth_user`, jatah akun, antrean hash kata sandi).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (antrean hash kata sandi, atau database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Ubah alamat email
      description: |-
        Mengubah alamat email akun. Spasi di awal dan akhir `newEmail` dihapus dan hurufnya diubah menjadi huruf kecil, lalu alamat itu diperiksa seperti alamat pendaftaran.

        **Dengan konfirmasi email** (default): 202 `verification_sent`. Tautan yang berlaku selama 24 jam dikirim ke alamat baru; tautan itu membuka `/confirm-email-change`, dan alamat akun hanya berubah saat pemain menekan tombol di halaman itu. Sampai saat itu, `GET /account/me` menampilkan alamat baru sebagai `pendingEmail`. Permintaan baru menggantikan permintaan yang tertunda; perubahan atau pengaturan ulang kata sandi membatalkannya. Paling banyak satu tautan dikirim ke alamat baru yang sama setiap 5 menit, siapa pun yang meminta (permintaan untuk perubahan yang sudah tertunda mempertahankan tautan yang dikirim sebelumnya, yang tetap berlaku); responsnya sama. Alamat saat ini menerima pemberitahuan bahwa perubahan ke alamat yang disamarkan (`a***@example.org`) telah diminta. Respons dan `pendingEmail` tetap sama jika akun lain sudah memakai alamat baru tersebut: dalam hal itu tidak ada tautan yang dikirim, sehingga perubahan itu tidak pernah selesai, dan sebagai gantinya pemilik alamat itu menerima pemberitahuan (paling banyak satu per jam).

        Saat tautan dikonfirmasi, alamat berubah dan dianggap terkonfirmasi, perangkat tetap masuk, tautan yang dikirim sebelumnya (konfirmasi, pengaturan ulang kata sandi, perubahan lain) tidak berlaku lagi, dan alamat lama diberi tahu, dengan alamat baru disamarkan.

        **Tanpa konfirmasi email** (`REQUIRE_EMAIL_VERIFICATION=false`): alamat langsung berubah (200 `email_changed`), dan alamat lama diberi tahu. Jika akun lain memakai alamat itu, responsnya 409 `email_taken`, dan pemilik alamat itu menerima pemberitahuan tersebut.

        **Autentikasi ulang:** kata sandi dan, dengan autentikasi dua faktor, kode autentikator atau kode pemulihan. `invalid_email` dan `same_email` diperiksa sebelum kata sandi, sehingga keduanya tidak dihitung sebagai kegagalan. **Batas:** `reauth`, lalu `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: Alamat langsung berubah (server ini tidak memakai konfirmasi 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: Alamat baru, sebagaimana disimpan.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Tautan konfirmasi telah dikirim ke alamat baru (atau alamat itu milik akun lain; respons tidak memberitahukannya).
          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` (alamat akun saat ini), `password_not_set` (akun yang hanya memakai Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (juga jika perubahan atau pengaturan ulang kata sandi mendahului permintaan ini: tidak ada tautan yang dikirim), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: hanya tanpa konfirmasi 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` (autentikasi ulang yang gagal, atau terlalu banyak kode dicoba pada akun ini), atau `rate_limited` (batas `reauth` atau `reauth_user`, jatah akun, antrean hash kata sandi).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: antrean hash kata sandi, atau database tetap terkunci (`retryAfter: 1`: tidak ada yang berubah, dan permintaan yang sama dapat dikirim lagi). `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Unduh data akun
      description: |-
        Semua yang disimpan server tentang akun, sebagai satu file JSON untuk disimpan (`format` `scacelith-account-export`, `version` 1). Ekspor ini mencatat sebuah peristiwa keamanan (`account_exported`). Array `notes` dalam dokumen memberi tahu pemain, dalam bahasa Inggris yang sederhana, apa saja yang tidak disertakan.

        **Tidak pernah ada dalam ekspor:** hash kata sandi, kunci rahasia autentikasi dua faktor, dan kode pemulihan; token sesi atau token tautan apa pun, maupun hash-nya; data sistem antikecurangan (tingkat dan skor integritas, anomali, analisis permainan, bobot sebuah laporan); laporan yang dibuat pemain lain tentang pemain ini; identitas moderator; data pribadi pemain lain (lawan muncul dengan nama publik dan ratingnya, tidak ada yang menunjukkan apakah pemain lain pernah dikenai sanksi, dan tidak ada alamat IP yang mungkin milik orang lain yang disertakan).

        **Autentikasi ulang:** kata sandi dan, dengan autentikasi dua faktor, kode autentikator atau kode pemulihan. **Batas:** `account_export` (5 per jam per pemain, untuk seluruh server; setiap percobaan dihitung, termasuk yang gagal; diperiksa pertama kali), lalu `reauth` dan `reauth_user`. **Batas waktu:** 60 detik.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Dokumen ekspor, sebagai lampiran.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Karakter selain huruf, angka, `_`, `.`, dan `-` dalam nama pengguna menjadi `_`.'
              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` (akun yang hanya memakai 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` (autentikasi ulang yang gagal, atau terlalu banyak kode dicoba pada akun ini), atau `rate_limited` (batas `account_export`, `reauth`, atau `reauth_user`, jatah akun, antrean hash kata sandi).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (database tetap terkunci, dengan header `Retry-After: 1`), `server_busy` (antrean hash kata sandi), `timeout` (60 detik). Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Hapus akun
      description: |-
        Menghapus akun; tindakan ini tidak dapat dibatalkan.

        - Semua sesi langsung dicabut: sejak saat itu token sesi mendapat 401 `invalid_token`.
        - Nama pengguna menjadi `deleted#<id>`, di akun dan di setiap catatan permainan.
        - Yang dihapus: alamat email, hash kata sandi, kunci rahasia autentikasi dua faktor dan kode pemulihan, sesi dan token tautan, penautan Google, catatan integritas dari sistem antikecurangan, serta alamat IP yang disimpan bersama peristiwa keamanan.
        - Rating dan permainan tetap disimpan. Permainan tetap dapat dibaca dengan nama anonim itu, dan `GET /players/{username}` merespons 404 untuk nama lama.

        **Autentikasi ulang:** kata sandi dan, dengan autentikasi dua faktor, kode autentikator atau kode pemulihan. **Batas:** `reauth`, lalu `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: Akun telah dihapus.
          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` (akun yang hanya memakai 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` (autentikasi ulang yang gagal, atau terlalu banyak kode dicoba pada akun ini), atau `rate_limited` (batas `reauth` atau `reauth_user`, jatah akun, antrean hash kata sandi).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (antrean hash kata sandi, atau database tetap terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Tampilkan daftar permainan pemain, dengan filter dan per halaman
      description: |-
        Permainan pemain yang sedang masuk, dari yang terbaru, dengan filter dan per halaman, beserta jumlah permainan yang cocok dengan filter. Setiap parameter kueri bersifat opsional, dan nilai kosong dianggap tidak ada.

        Penomoran halaman: kirim `next` dari halaman sebelumnya sebagai `before`; `next` bernilai `null` di halaman terakhir. `total` menghitung permainan yang cocok dengan filter, di semua halaman.

        **Batas:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: Id kategori resmi (`3+2`, atau `3%2B2`), atau `custom` untuk setiap permainan dengan kontrol waktu lain.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Hanya permainan dinilai (`true`) atau santai (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Hanya permainan yang berakhir menang, kalah, atau remis, dari sisi pemain. Permainan yang dibatalkan hanya muncul tanpa filter ini.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Satu halaman riwayat.
          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` bukan id permainan), `invalid_limit`, atau `invalid_filter` (`category`, `rated`, atau `result`), masing-masing dengan `field` yang menyebutkan parameternya.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: batas `account_games` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (database tetap terkunci; `retryAfter: 1` di body, tanpa header `Retry-After`), `server_busy` (pencarian sesi mendapati database terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Ambil catatan permainan beserta langkah dan jamnya
      description: |-
        Satu catatan permainan, beserta langkah dan jamnya. Tanpa token, atau dengan token pemain yang tidak memainkan permainan itu, responsnya adalah respons publik. Jika pemain pemilik token memainkan permainan itu, respons menambahkan `you` dan `reportable`.

        **Batas:** `public_read` (per pemain jika ada token, per klien jika tidak).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Catatan permainan.
          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`: bukan bilangan bulat positif dengan paling banyak 16 digit (di bawah 2^53); `invalid_request`: bukan encoding URL yang valid.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: token dikirim tetapi tidak valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: permainan tersebut tidak ada.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: batas `public_read` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (database tetap terkunci; `retryAfter: 1` di body, tanpa header `Retry-After`), `server_busy` (pencarian sesi mendapati database terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Unduh permainan sebagai file PGN
      description: |-
        Permainan yang sama sebagai file PGN: satu permainan dengan akhir baris `\n`, dan teks langkah dalam baris di bawah 80 kolom. Responsnya sama dengan atau tanpa token.

        Tag, dalam urutan ini: `Event` (`<SERVER_NAME> rated <category>` atau `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (tanggal mulai dalam UTC), `Round`, `White`, `Black`, `Result` (`*` untuk permainan yang dibatalkan), `UTCDate` dan `UTCTime` (waktu mulai), `WhiteElo` dan `BlackElo` (rating saat mulai, atau `-`), `WhiteRatingDiff` dan `BlackRatingDiff` (perubahannya, seperti `+10` dan `-10`, di setiap permainan dinilai, `+0` jika aturan rating membiarkan rating tetap seperti semula; permainan santai, kustom, atau yang dibatalkan tidak memiliki keduanya), `TimeControl` (dalam detik), `Termination` (nilai standar PGN: `normal`; `time forfeit`, jatuhnya bendera, juga jika berakhir remis; `abandoned`; `rules infraction`, langkah ilegal kedua atau kekalahan karena pelanggaran fair play; `unterminated`, permainan yang dibatalkan), `PlyCount`, `ScacelithGameId` (id desimal).

        Setiap langkah membawa `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: jam pemain yang melangkah setelah langkah itu dan waktu yang dibebankan untuknya, dalam persepuluhan detik (dipotong). Suatu nilai dihilangkan jika catatan tidak memilikinya. Setelah langkah terakhir, alasan berakhirnya permainan ditulis dengan kata-kata, lalu hasilnya.

        **Batas:** `public_read` (per pemain jika ada token, per klien jika tidak).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: File 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: 'Teks PGN, dalam 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`: bukan bilangan bulat positif dengan paling banyak 16 digit (di bawah 2^53); `invalid_request`: bukan encoding URL yang valid.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: token dikirim tetapi tidak valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: permainan tersebut tidak ada.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: batas `public_read` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: antara lain, langkah-langkah yang tersimpan tidak dapat diputar ulang hingga akhir permainan yang tersimpan (server mencatatnya di log).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (database tetap terkunci; `retryAfter: 1` di body, tanpa header `Retry-After`), `server_busy` (pencarian sesi mendapati database terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Unduh permainan dari server ini sebagai GIF animasi
      description: |-
        Permainan sebagai GIF animasi. Nama dan rating diambil dari catatan permainan (rating saat mulai, akun yang dihapus sebagai `deleted#<id>`), demikian pula hasil dan akhir permainannya (`Resignation`, `Loss on time`...).

        Pemeriksaan dilakukan dalam urutan ini: `gif_disabled`, opsi gambar (`size`, `orientation`, `delay`, `coords`), id permainan, permainannya, panjangnya.

        **Sesi wajib:** kuota dihitung per akun. **Batas:** `gif` pada setiap permintaan; `gif_user_min`, `gif_user_hour`, `gif_ip_min`, dan `gif_ip_hour` hanya jika GIF harus dibuat (lihat deskripsi bagian). **Batas waktu:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 detik (45 detik secara default).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: Ukuran gambar, `small` (petak 32 px), `medium` (48 px), atau `large` (72 px).
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Pihak yang berada di bagian bawah papan.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Milidetik per langkah (1 hingga 6 digit desimal).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Apakah huruf lajur dan angka baris digambar di sekeliling papan (`1`) atau tidak (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` dengan `field` (`size`, `orientation`, `delay`, atau `coords`): nilai di luar nilai yang diizinkan. `invalid_game_id`. `invalid_request`: bukan encoding URL yang valid.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: permainan tersebut tidak ada. `gif_disabled`: server menonaktifkan GIF (`GIF_ENABLED=false`; token `gif` dikembalikan).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: batas `gif`, batas render, atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: GIF tidak dapat dibuat (server mencatat penyebabnya di log). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` dengan `retryAfter` (3 hingga 10 detik) dan `Retry-After`: antrean render penuh, atau GIF menunggu `GIF_QUEUE_TIMEOUT_MS` (10 detik) untuk thread yang kosong; setiap token batas yang diambil permintaan itu dikembalikan. `busy`: database tetap terkunci (`retryAfter: 1` hanya di body). `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Buat GIF animasi dari permainan apa pun yang dikirim sebagai PGN
      description: |-
        Gambar yang sama seperti `GET /games/{id}/gif` untuk permainan apa pun yang dikirim sebagai teks PGN: permainan yang disimpan oleh game, ekspor dari situs lain, permainan yang diketik manual. Hanya permainan pertama dalam teks yang digunakan.

        - Pembaca PGN menerima apa yang diterima pembaca milik game itu sendiri: setiap PGN yang ditulis server ini dan ekspor umum dari situs lain (komentar, variasi, NAG, dan anotasi jam dilewati; nomor langkah dan SAN dibaca secara longgar; tag `FEN` memberikan posisi awal kecuali `SetUp` bernilai `"0"`).
        - Nama dan rating diambil dari tag `White`, `Black`, `WhiteElo`, dan `BlackElo`. Huruf beraksen kehilangan aksennya, karakter lain di luar ASCII yang dapat dicetak menjadi `?`, dan nama yang panjang dipotong (48 karakter). Hasil diambil dari tag `Result`, atau jika tidak ada, dari akhir teks langkah. Tag `Termination` ditampilkan kecuali nilainya `normal`: dalam hal itu posisi akhir sudah menunjukkan cara permainan berakhir (skakmat, pat).
        - Endpoint ini memeriksa body-nya sendiri: field yang tidak dikenal, atau `pgn` yang tidak ada atau bukan string, menghasilkan respons 400 `invalid_request` dengan `field`; opsi yang salah menghasilkan respons 400 `invalid_option` (`delayMs` harus berupa angka JSON, `coords` berupa boolean; `null` ditolak).

        **Sesi wajib:** kuota dihitung per akun. **Batas:** sama seperti untuk `GET /games/{id}/gif`. **Batas ukuran body:** 135.168 byte, berapa pun `HTTP_BODY_LIMIT` (PGN sebagai string JSON, termasuk escape-nya). **Batas waktu:** 45 detik secara default.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Permainan pendek yang diketik, tanpa koordinat
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: File PGN dengan semua opsi
                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` dengan `field` (field yang tidak dikenal, atau `pgn` tidak ada atau bukan string; tanpa `field` jika body bukan objek); `invalid_json`; `invalid_option` dengan `field` (`size`, `orientation`, `delayMs`, atau `coords`); `invalid_pgn` dengan `line` dan `column` (mulai dari 1, kolom dalam karakter) serta `message` dari pembaca PGN: langkah ilegal atau ambigu, tag yang rusak, varian catur yang tidak dikenal, lebih dari 65.536 byte.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: server menonaktifkan GIF (`GIF_ENABLED=false`; token `gif` dikembalikan).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: body lebih dari 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`: batas `gif`, batas render, atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: GIF tidak dapat dibuat (server mencatat penyebabnya di log). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` dengan `retryAfter` (3 hingga 10 detik) dan `Retry-After`: antrean render penuh, atau GIF terlalu lama menunggu thread yang kosong; setiap token batas yang diambil permintaan itu dikembalikan. `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Ambil profil publik pemain
      description: |-
        Profil publik pemain: rating dalam kategori resmi (dalam urutan server) dan jumlah permainan. `games.total` menghitung setiap permainan yang tersimpan, termasuk permainan santai dan yang dibatalkan; `games.rated`, `wins`, `draws`, dan `losses` dijumlahkan dari catatan rating, jadi hanya permainan dinilai. Responsnya sama dengan atau tanpa token.

        **Batas:** `public_read` (per pemain jika ada token, per klien jika tidak).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Profil.
          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`: bukan 2 hingga 24 karakter dari `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: token dikirim tetapi tidak valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: pemain tersebut tidak ada, atau akunnya telah dihapus.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: batas `public_read` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (database tetap terkunci; `retryAfter: 1` di body, tanpa header `Retry-After`), `server_busy` (pencarian sesi mendapati database terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Tampilkan daftar permainan terbaru pemain
      description: |-
        Permainan terbaru pemain, dari yang terbaru, per halaman seperti riwayat tetapi tanpa filter atau total. `color` adalah pihak pemain ini. `next` adalah id permainan terakhir setiap kali halaman penuh, sehingga halaman berikutnya mungkin kosong. Pemain dicari terlebih dahulu: pemain yang tidak dikenal menghasilkan 404, apa pun isi kuerinya.

        **Batas:** `public_read` (per pemain jika ada token, per klien jika tidak).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Satu halaman permainan pemain.
          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` bukan id permainan), `invalid_limit` (keduanya tanpa `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: token dikirim tetapi tidak valid.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: pemain tersebut tidak ada, atau akunnya telah dihapus.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: batas `public_read` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (database tetap terkunci; `retryAfter: 1` di body, tanpa header `Retry-After`), `server_busy` (pencarian sesi mendapati database terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Ambil pemain teratas suatu kategori
      description: |-
        100 catatan rating teratas dari suatu kategori resmi yang memiliki setidaknya `minGames` (`PROVISIONAL_GAMES`) permainan terhitung; akun yang dihapus dan pemain yang terbukti curang tidak disertakan. Server menghitung ulang setiap papan peringkat paling sering sekali setiap 10 detik; `updatedAt` menunjukkan kapan. Hanya satu perhitungan per kategori yang berjalan pada satu waktu: sementara itu, permintaan lain mendapat daftar sebelumnya, atau menunggu perhitungan itu jika belum ada daftar.

        **Batas:** hanya lapisan per alamat.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Id kategori resmi (`3+2`, atau `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Jumlah pemain, 1 hingga 100. Angka yang lebih besar hingga 3 digit dianggap 100; nilai kosong dianggap tidak ada.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: Papan peringkat.
          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` (tidak ada, atau bukan kategori resmi), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: lapisan per alamat.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (database tetap terkunci; `retryAfter: 1` di body, tanpa header `Retry-After`), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Laporkan lawan dalam permainan baru-baru ini
      description: |-
        Melaporkan lawan dalam salah satu permainan pemain sendiri yang berakhir dalam 7 hari terakhir. Laporan tidak pernah dengan sendirinya mengubah rating, sanksi, atau tingkat integritas. Laporan menaikkan prioritas peninjauan yang dilihat moderator, dan, kecuali untuk `abuse`, meminta analisis mesin atas permainan itu.

        Laporan atas lawan yang sama untuk permainan yang sama mendapat respons yang sama dan tidak mengubah apa pun; respons tidak pernah memberi tahu apa pun tentang akun yang dilaporkan. `GET /games/{id}` memberi tahu para pemain permainan itu sebelumnya apakah laporan akan diterima (`reportable`).

        Endpoint ini memeriksa body-nya sendiri, dalam urutan `gameId`, `reported`, `category`, `comment`: kegagalan menghasilkan respons 400 `invalid_request` tanpa `field`. Field yang tidak dikenalnya diabaikan.

        **Batas:** `reports` (per pemain), lalu `REPORTS_PER_DAY` (5) laporan per pemain dalam 24 jam (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: Laporan diterima (atau sudah pernah diajukan).
          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` (tanpa `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: bukan lawan pelapor dalam permainan yang berakhir dalam 7 hari terakhir.'
        '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`, dengan header `Retry-After`): `REPORTS_PER_DAY` laporan dalam 24 jam terakhir. `rate_limited`: batas `reports` atau jatah akun.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (pencarian sesi mendapati database terkunci), `timeout`. Juga `server_busy` dengan `retryAfter` 2 hingga 5 saat server sangat tertinggal (lihat pendahuluan): permintaan tidak dijalankan.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Server resmi (halaman berada di root, di luar `/api/v1`).
      - url: https://{host}:{port}
        description: Server Scacelith mana pun (halaman berada di root, di luar `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Nama host publik server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Port API publik (`PUBLIC_API_PORT`, atau jika tidak ada, `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Tampilkan halaman konfirmasi email
      description: |-
        Halaman dari tautan konfirmasi email (pendaftaran, atau email konfirmasi yang dikirim ulang). Halaman ini hanya menampilkan tombol "Confirm my e-mail address" (konfirmasi alamat email saya), agar tautan tidak terpakai oleh pemindai email yang membukanya; tombol itu mengirim formulir ke `POST /verify-email`.

        **Batas:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Halaman beserta tombol konfirmasinya.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Tautan tidak valid atau kedaluwarsa (halaman HTML).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Batas `page` (halaman HTML), atau lapisan per alamat (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Konfirmasi alamat email
      description: |-
        Formulir halaman konfirmasi. Formulir ini mengonfirmasi alamat; untuk pendaftaran baru, akun dibuat saat itu juga, dan pemain dapat masuk.

        **Batas:** `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: Alamat telah dikonfirmasi (dan akun dari pendaftaran baru telah dibuat).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Tautan tidak valid, sudah dipakai, atau kedaluwarsa, atau formulir tidak valid (halaman HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Tautan dari pendaftaran baru yang nama pengguna atau alamatnya sementara itu telah diambil akun lain; tidak ada akun yang dibuat (halaman HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Batas `auth` (halaman HTML), atau lapisan per alamat (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Database tetap terkunci (`Retry-After: 1`), atau server sangat tertinggal (`Retry-After` 2 hingga 5, lihat pendahuluan): tidak ada yang berubah dan tautan masih berlaku. Juga saat batas waktu handler habis.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Server resmi (halaman berada di root, di luar `/api/v1`).
      - url: https://{host}:{port}
        description: Server Scacelith mana pun (halaman berada di root, di luar `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Nama host publik server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Port API publik (`PUBLIC_API_PORT`, atau jika tidak ada, `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Tampilkan formulir pengaturan ulang kata sandi
      description: |-
        Halaman dari tautan pengaturan ulang kata sandi: formulir kata sandi baru (kata sandi dua kali). Formulir dikirim ke `POST /reset-password`.

        **Batas:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Formulir kata sandi baru.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Tautan tidak valid (juga jika tautan itu dikirim ke alamat yang tidak lagi dimiliki akun).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Batas `page` (halaman HTML), atau lapisan per alamat (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Tetapkan kata sandi baru dari formulir pengaturan ulang
      description: |-
        Formulir halaman pengaturan ulang; formulir ini melakukan hal yang sama dengan `POST /auth/password/reset`, dalam satu transaksi: semua perangkat dikeluarkan, perubahan email yang tertunda dibatalkan, tautan pengaturan ulang lainnya tidak berlaku lagi, alamat dianggap terkonfirmasi, dan pemiliknya menerima email.

        Tautan diperiksa lebih dulu, lalu apakah kedua kata sandi sama, lalu aturan kata sandi. Saat server sibuk, formulir dikembalikan dengan `Retry-After` dan tautan tetap berlaku.

        **Batas:** `auth`, lalu `auth_reset` (digunakan bersama dengan `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: Kata sandi telah diubah, dan semua perangkat telah dikeluarkan.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Formulir lagi beserta kesalahannya (kata sandi berbeda, kata sandi lemah), tautan tidak valid, atau formulir tidak valid (halaman 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: Batas `auth` atau `auth_reset` (halaman HTML), formulir lagi dengan `Retry-After` jika klien ini memiliki terlalu banyak hash kata sandi yang menunggu (tautan tetap berlaku), atau lapisan per alamat (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Formulir lagi dengan `Retry-After` saat server sibuk (antrean hash kata sandi, atau database tetap terkunci); tautan tetap berlaku. Saat server sangat tertinggal (lihat pendahuluan), sebagai gantinya halaman "Server error" (kesalahan server) dengan `Retry-After` (2 hingga 5): tidak ada yang dijalankan dan tautan tetap berlaku. Juga saat batas waktu handler habis.'
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Server resmi (halaman berada di root, di luar `/api/v1`).
      - url: https://{host}:{port}
        description: Server Scacelith mana pun (halaman berada di root, di luar `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Nama host publik server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Port API publik (`PUBLIC_API_PORT`, atau jika tidak ada, `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Tampilkan halaman konfirmasi perubahan email
      description: |-
        Halaman dari tautan perubahan email: menampilkan alamat baru dan nama akun, dengan tombol "Use this e-mail address" (gunakan alamat email ini) yang mengirim formulir ke `POST /confirm-email-change`.

        **Batas:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Halaman dengan alamat baru dan tombol konfirmasinya.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Tautan tidak valid atau kedaluwarsa (juga jika alamat akun telah berubah sejak permintaan dibuat).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Batas `page` (halaman HTML), atau lapisan per alamat (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Konfirmasi alamat email baru
      description: |-
        Formulir halaman perubahan email. Alamat berubah dan dianggap terkonfirmasi; perangkat tetap masuk; tautan yang dikirim sebelumnya (konfirmasi, pengaturan ulang kata sandi, perubahan lain) tidak berlaku lagi; alamat lama diberi tahu, dengan alamat baru disamarkan.

        **Batas:** `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: Alamat telah diubah.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Tautan tidak valid, sudah dipakai, atau kedaluwarsa, atau formulir tidak valid (halaman HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Sementara itu akun lain telah mengambil alamat tersebut (halaman HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Batas `auth` (halaman HTML), atau lapisan per alamat (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Database tetap terkunci (`Retry-After: 1`), atau server sangat tertinggal (`Retry-After` 2 hingga 5, lihat pendahuluan): tidak ada yang berubah dan tautan masih berlaku. Juga saat batas waktu handler habis.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Server resmi, di root.
      - url: https://caissa.scacelith.com/api/v1
        description: Server resmi, di bawah `/api/v1`.
      - url: https://{host}:{port}
        description: Server Scacelith mana pun, di root.
        variables:
          host:
            default: caissa.scacelith.com
            description: Nama host publik server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Port API publik (`PUBLIC_API_PORT`, atau jika tidak ada, `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Server Scacelith mana pun, di bawah `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Nama host publik server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Port API publik (`PUBLIC_API_PORT`, atau jika tidak ada, `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Periksa bahwa proses berjalan
      description: |-
        Merespons 200 `ok` selama proses berjalan. `HEAD` juga berfungsi. Metode lain apa pun merespons 405 `method_not_allowed` dengan `Allow: GET, HEAD` (termasuk `OPTIONS`).

        **Batas:** hanya lapisan per alamat.
      security: []
      responses:
        '200':
          description: Proses berjalan.
          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`: lapisan per alamat.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Server resmi, di root.
      - url: https://caissa.scacelith.com/api/v1
        description: Server resmi, di bawah `/api/v1`.
      - url: https://{host}:{port}
        description: Server Scacelith mana pun, di root.
        variables:
          host:
            default: caissa.scacelith.com
            description: Nama host publik server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Port API publik (`PUBLIC_API_PORT`, atau jika tidak ada, `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Server Scacelith mana pun, di bawah `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Nama host publik server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Port API publik (`PUBLIC_API_PORT`, atau jika tidak ada, `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Periksa bahwa server menerima pemain
      description: |-
        Merespons 200 `ready` saat server menerima pemain, dan 503 `not_ready` saat server sedang dimulai atau dihentikan. `HEAD` juga berfungsi. Metode lain apa pun merespons 405 `method_not_allowed` dengan `Allow: GET, HEAD` (termasuk `OPTIONS`).

        **Batas:** hanya lapisan per alamat.
      security: []
      responses:
        '200':
          description: Server menerima pemain.
          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`: lapisan per alamat.'
        '503':
          description: Server sedang dimulai atau dihentikan.
          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: |-
        Token sesi (`sct_` diikuti 43 karakter base64url) dari respons masuk, di header `Authorization`: `Authorization: Bearer <token>`. Prefiksnya tepat `Bearer` (peka huruf besar/kecil) dan satu spasi; nilai yang tidak memilikinya, atau token yang bukan 1 hingga 512 karakter ASCII yang dapat dicetak, menghasilkan respons 401 `invalid_token` seperti token tidak valid lainnya. Header kosong dianggap sama dengan tidak ada header.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: Id permainan, bilangan bulat desimal positif dengan paling banyak 16 digit tanpa nol di depan, di bawah 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: Id sesi, sebagaimana diberikan oleh `GET /auth/sessions`, ditulis tanpa nol di depan (`03` bukan sesi 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Nama pengguna pemain, tanpa memperhatikan huruf besar/kecil. Server menerima nama apa pun yang terdiri dari 2 hingga 24 karakter `[A-Za-z0-9_.-]` (aturan terlonggar yang pernah diizinkannya).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Id permainan; hanya permainan yang lebih lama yang ditampilkan. Kirim `next` dari halaman sebelumnya. Nilai kosong dianggap tidak ada.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Ukuran halaman, 1 hingga 50. Angka yang lebih besar hingga 3 digit dianggap 50; nilai kosong dianggap tidak ada.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Token tautan email (43 karakter base64url). Token yang tidak ada atau salah menampilkan halaman "link invalid or expired" (tautan tidak valid atau kedaluwarsa).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Setiap respons server melarang caching.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Jumlah detik yang harus ditunggu sebelum mencoba lagi; nilainya sama dengan `retryAfter` di body.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Challenge bearer, dengan `error="invalid_token"` untuk token yang tidak valid.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: Server menutup koneksi setelah respons ini.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (dengan `field` jika ada field body yang bermasalah) atau `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Sebuah field melanggar skema
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Body bukan JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Kata sandi lemah
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: PGN yang tidak dapat dibaca
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (tidak ada header `Authorization`) atau `invalid_token` (token salah format, kedaluwarsa, dicabut, atau milik akun yang telah dihapus).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Tanpa sesi
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Token tidak valid
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: Permintaan ditolak.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Kata sandi salah saat autentikasi ulang
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Akun yang ditangguhkan
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found`, atau fitur yang dinonaktifkan server ini.'
      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`: body tidak tiba dalam 10 detik. Server menutup koneksi.'
      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: Permintaan bertentangan dengan keadaan server.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: username_taken
            message: This username is already taken.
    Gone:
      description: '`sso_expired`: percobaan masuk dengan Google atau tiketnya tidak dikenal, sudah dipakai, atau kedaluwarsa; mulai lagi proses masuk.'
      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`: body melebihi `HTTP_BODY_LIMIT` (16.384 byte secara default). Server menutup koneksi.'
      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`: target permintaan melebihi 4096 karakter.'
      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`: body bukan `application/json`, atau charset-nya bukan 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`: permainan memiliki lebih dari `GIF_MAX_PLIES` (600) setengah langkah.'
      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`: selesaikan challenge `pow` dan kirim lagi permintaan yang sama dengan `pow: { challenge, nonce }`. `reason` menyebutkan alasannya: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work`, atau `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` (batas laju, jatah akun, atau antrean hash kata sandi) atau `too_many_attempts` (pembatasan pada akun), dengan `retryAfter` dan 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: Batas laju
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Terlalu banyak kegagalan pada akun ini
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: kegagalan tak terduga. Server mencatatnya di log.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: internal_error
            message: Internal server error.
    BadGateway:
      description: '`sso_failed`: `state` atau `iss` yang salah, atau Google menolak kode tersebut atau mengirim token ID yang tidak lolos verifikasi (termasuk kode atau token ID dari klien yang lain, milik game atau milik situs web). Respons ini tidak pernah memuat teks dari 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`, atau `timeout`: coba lagi setelah `retryAfter` jika nilai itu diberikan.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Server sedang sibuk
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: Database tetap terkunci (saat pembacaan)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Server terlalu lama merespons
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: GIF animasi, sebagai lampiran.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` untuk permainan dari server ini, `attachment; filename="scacelith-game.gif"` untuk PGN yang dikirim dengan `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: Ukuran file dalam byte.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: Halaman HTML.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: Halaman HTML yang menjelaskan penolakan.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Formulir tidak tiba dalam 10 detik (halaman HTML). Server menutup koneksi.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: Halaman HTML yang menjelaskan konflik.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Formulir melebihi `HTTP_BODY_LIMIT` (halaman HTML). Server menutup koneksi.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Body bukan formulir (`application/x-www-form-urlencoded`) maupun JSON, atau charset-nya bukan UTF-8 (halaman HTML).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Batas laju (halaman HTML), dengan `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: Kegagalan tak terduga (halaman HTML berjudul "Server error", yaitu kesalahan server). Server mencatatnya di log.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Server sibuk (database tetap terkunci, atau server sangat tertinggal; dengan `Retry-After`) atau terlalu lama merespons (halaman HTML berjudul "Server error", yaitu kesalahan server).
      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: Satu-satunya bentuk dari setiap respons kesalahan API JSON.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: Kode kesalahan dalam `snake_case`. Klien memilih apa yang ditampilkan berdasarkan kode ini.
          examples:
            - rate_limited
        message:
          type: string
          description: Kalimat dalam bahasa Inggris, untuk log dan sebagai cadangan.
        retryAfter:
          type: integer
          minimum: 1
          description: Jumlah detik yang harus ditunggu sebelum mencoba lagi, hanya ada pada penolakan yang berakhir setelah beberapa waktu. Dalam hal itu respons juga membawa header `Retry-After` dengan nilai yang sama, kecuali 503 `busy` pada pembacaan riwayat, permainan, pemain, dan papan peringkat.
        field:
          type: string
          description: Field atau parameter kueri yang bermasalah (`invalid_request`, `invalid_option`, serta `invalid_cursor`, `invalid_limit`, dan `invalid_filter` dari `GET /account/games`). Field bertingkat ditulis dengan titik (`pow.nonce`).
        reason:
          type: string
          description: 'Alasannya: aturan yang dilanggar `weak_password`, atau alasan proof of work diminta atau ditolak (`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: 'Hanya `banned`: akhir penangguhan (epoch ms), `null` untuk penangguhan permanen.'
        line:
          type: integer
          minimum: 1
          description: 'Hanya `invalid_pgn`: baris tempat kesalahan, mulai dari 1.'
        column:
          type: integer
          minimum: 1
          description: 'Hanya `invalid_pgn`: kolom tempat kesalahan, mulai dari 1, dalam karakter.'
    PowChallenge:
      type: object
      description: Challenge proof of work (`pow` dari 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: Challenge yang ditandatangani, untuk dikirim kembali apa adanya.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Jumlah bit nol di depan yang harus dimiliki `SHA-256(challenge + ":" + nonce)`.
        expiresAt:
          type: integer
          format: int64
          description: Waktu challenge kedaluwarsa (epoch ms), 2 menit setelah diterbitkan.
    PowAnswer:
      type: object
      description: Jawaban atas challenge proof of work.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: '`challenge` dari respons 428, sebagaimana diberikan.'
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: String desimal dengan paling banyak 20 digit sedemikian rupa sehingga `SHA-256(challenge + ":" + nonce)` diawali `bits` bit nol.
    ServerInfo:
      type: object
      description: Hal yang dibutuhkan klien sebelum masuk atau terhubung.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Nama server (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: UUID yang diperoleh database saat pertama kali dijalankan. Nilainya tetap sama setelah server dimulai ulang. Nilainya `null` jika database tidak dapat memberikannya.
        motd:
          type: string
          description: Pesan hari ini (`SERVER_MOTD`), bisa kosong.
        protocol:
          type: object
          description: Protokol WebSocket (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: Versi protokol tertua yang didukung server.
            max:
              type: integer
              minimum: 1
              description: Versi protokol terbaru yang didukung server.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 'Sidik jari skema protokol (4 byte pertama SHA-256 dari bentuk kanoniknya, sebagai bilangan bulat tak bertanda: `0xc649140f`, 3326678031, untuk protokol minor 2); hanya informasi, tidak pernah dibandingkan.'
            subprotocol:
              type: string
              description: Token subprotokol WebSocket (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: Port WebSocket yang digunakan pemain (`PUBLIC_WS_PORT`, jika tidak ada `WS_PORT`, jika tidak ada juga `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Path WebSocket.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Apakah akun baru dapat dibuat.
        emailVerification:
          type: boolean
          description: Apakah akun baru harus mengonfirmasi alamatnya (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Apakah opsi masuk dengan Google tersedia untuk game.
            googleWeb:
              type: boolean
              description: Apakah opsi masuk dengan Google juga tersedia untuk situs web (`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: Autentikasi dua faktor selalu tersedia.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Jumlah bit proof of work yang dibutuhkan pendaftaran (0 jika tidak ada).
        categories:
          type: array
          description: Kontrol waktu resmi, yaitu yang dinilai (`RATED_CATEGORIES`), dalam urutan server. Kontrol waktu lainnya adalah `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Aturan yang dapat diperiksa klien sebelum mengirim formulir.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: Panjang minimum nama pengguna (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: Panjang maksimum nama pengguna (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: Ekspresi reguler yang harus dicocoki nama pengguna baru.
            passwordMinLength:
              type: integer
              description: Panjang minimum kata sandi, dalam karakter (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: Panjang maksimum kata sandi, dalam byte UTF-8.
            customTimeControls:
              type: boolean
              description: Apakah tantangan dan permainan pribadi boleh menggunakan kontrol waktu kustom.
            reportsPerDay:
              type: integer
              description: Jumlah laporan yang boleh diajukan seorang pemain per 24 jam (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: Ukuran maksimum pesan WebSocket yang boleh dikirim klien, dalam 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: Kontrol waktu resmi.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Id kategori, menit dan detik tambahan waktu (`3+2`).
        baseSec:
          type: number
          minimum: 0
          description: Waktu dasar, dalam detik.
        incSec:
          type: number
          minimum: 0
          description: Tambahan waktu per langkah, dalam detik.
    AccountView:
      type: object
      description: Akun sebagaimana dilihat pemainnya (`user` dari respons masuk dan dari `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: Id akun.
        username:
          type: string
          description: Nama pengguna.
        email:
          type: string
          format: email
          description: Alamat email.
        emailVerified:
          type: boolean
          description: Apakah alamat sudah dikonfirmasi.
        mfaEnabled:
          type: boolean
          description: Apakah autentikasi dua faktor aktif.
        googleLinked:
          type: boolean
          description: Apakah akun Google sudah tertaut.
        hasPassword:
          type: boolean
          description: '`false` untuk akun yang dibuat melalui Google dan belum menetapkan kata sandi.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Apakah tantangan langsung melalui nama diterima (lihat `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Waktu akun dibuat (epoch ms).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Waktu masuk terakhir (epoch ms), atau `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Alamat baru dari perubahan email yang masih menunggu tautannya dikonfirmasi, atau `null`.
    SessionAnswer:
      type: object
      description: Sesi baru.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Token sesi, untuk header `Authorization` dan WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: Akhir mutlak sesi (epoch ms); batas waktu tidak aktif dapat mengakhirinya lebih awal.
        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: Tahap kedua proses masuk dengan autentikasi dua faktor; lanjutkan dengan `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 tahap ini, untuk `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Sisa detik untuk menyelesaikan tahap ini.
    LoginAnswer:
      description: Sesi, atau tahap kedua proses masuk dengan autentikasi dua faktor.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Proses masuk pertama dengan Google; lanjutkan dengan `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: Tiket untuk `POST /auth/sso/complete`, berlaku selama 10 menit.
        suggestedUsername:
          type: string
          description: Nama pengguna yang dibuat dari nama Google atau alamat email, atau `""` jika tidak ada yang cocok.
    SsoNeedsPassword:
      type: object
      description: Akun yang memiliki kata sandi menggunakan alamat ini; lanjutkan dengan `POST /auth/sso/google/link`. Belum ada yang ditautkan.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Tiket untuk `POST /auth/sso/google/link`.
        username:
          type: string
          description: Nama pengguna akun tersebut (hanya orang yang telah membuktikan kepemilikan alamat itu kepada Google yang melihatnya).
        expiresIn:
          type: integer
          const: 600
          description: Jumlah detik tiket tetap berlaku.
    SsoFinishAnswer:
      description: Respons dari `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: Percobaan masuk dengan Google.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Percobaan tersebut, untuk `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: URL Google yang dibuka di browser sistem, setelah diperiksa.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state` yang harus dikirim kembali oleh Google.'
        expiresIn:
          type: integer
          const: 600
          description: Jumlah detik percobaan ini tetap berlaku.
    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: Nama pengguna; selain itu berlaku aturan server (lihat deskripsi).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Alamat email. Spasi di awal dan akhir dihapus, dan alamat disimpan dalam huruf kecil.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kata sandi; selain itu berlaku aturan kata sandi.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Nama pengguna, atau alamat email (teks apa pun yang berisi `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Opsional. Ditampilkan dalam daftar perangkat yang masuk (seperti `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: '`mfaToken` dari respons masuk (atau dari respons masuk dengan Google).'
        code:
          type: string
          maxLength: 32
          description: Opsional. Kode autentikator 6 digit, atau kode pemulihan.
        recoveryCode:
          type: string
          maxLength: 32
          description: Opsional. Kode pemulihan. Salah satu dari `code` dan `recoveryCode` diperlukan.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Alamat email.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Parameter `token` dari tautan pengaturan ulang.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kata sandi baru; aturan kata sandi pendaftaran berlaku.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Challenge PKCE S256 dari `codeVerifier` milik klien (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: Port listener klien di `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 dari `codeVerifier` milik halaman (`BASE64URL(SHA-256(codeVerifier))`).
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: '`attemptId` dari `POST /auth/sso/google/start`.'
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: Verifier PKCE; SHA-256-nya harus cocok dengan challenge yang diberikan ke `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state` sebagaimana dikirim kembali oleh Google.'
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: '`code` sebagaimana dikirim kembali oleh Google (ASCII yang dapat dicetak tanpa spasi).'
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Opsional. `iss` sebagaimana dikirim kembali oleh Google, jika Google mengirimnya.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`linkTicket` dari `finish`, berlaku selama 10 menit.'
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kata sandi akun.
        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` dari `finish`, berlaku selama 10 menit.'
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Nama pengguna; aturan pendaftaran berlaku.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Sesi-sesi aktif, diurutkan dari yang paling baru dipakai.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Sesi aktif (perangkat yang masuk).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: Id sesi, untuk `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: Waktu masuk (epoch ms).
        lastSeenAt:
          type: integer
          format: int64
          description: Waktu penggunaan terakhir (epoch ms), diperbarui paling sering sekali setiap 5 menit (lebih lambat saat server sangat tertinggal).
        expiresAt:
          type: integer
          format: int64
          description: Akhir mutlak (epoch ms); batas waktu tidak aktif dapat mengakhiri sesi lebih awal.
        clientLabel:
          type:
            - string
            - 'null'
          description: '`clientLabel` dari proses masuk, atau `null` jika tidak ada yang dikirim.'
        current:
          type: boolean
          description: Apakah ini sesi yang membuat permintaan.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Satu catatan per kategori tempat pemain pernah memainkan permainan dinilai.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Sanksi yang aktif.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` selama ditangguhkan, jika tidak `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Catatan rating untuk satu kategori.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: Id kategori.
        rating:
          type: integer
          description: Rating.
        games:
          type: integer
          minimum: 0
          description: Permainan yang dimainkan dalam kategori ini (terhitung atau tidak).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Rating tertinggi yang pernah dicapai.
        provisional:
          type: boolean
          description: '`true` selama catatan ini masih dalam fase tanpa rating atau memiliki kurang dari `PROVISIONAL_GAMES` permainan terhitung (game menampilkannya sebagai `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Jenis sanksi.
        reason:
          type:
            - string
            - 'null'
          description: Alasan yang diberikan, jika ada.
        startsAt:
          type: integer
          format: int64
          description: Waktu mulai (epoch ms).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Waktu berakhir (epoch ms), `null` jika sanksi bersifat permanen.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Akhir penangguhan (epoch ms), `null` jika permanen.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` untuk menerima tantangan langsung melalui nama, `none` untuk menolaknya.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kata sandi saat ini.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kata sandi baru; aturan kata sandi pendaftaran berlaku.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kata sandi akun.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Kode dari kunci rahasia yang tertunda, tepat 6 digit.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kata sandi akun.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Opsional; diperlukan dengan autentikasi dua faktor (atau `recoveryCode`). Kode autentikator, atau kode pemulihan.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Opsional. Kode pemulihan (`xxxx-xxxx-xx`; huruf besar/kecil, spasi, dan tanda hubung tidak berpengaruh).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kata sandi akun.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Kode autentikator (kode pemulihan ditolak).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Alamat baru; spasi di awal dan akhir dihapus dan hurufnya diubah menjadi huruf kecil, lalu alamat itu diperiksa seperti alamat pendaftaran.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kata sandi akun.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Opsional; diperlukan dengan autentikasi dua faktor (atau `recoveryCode`). Kode autentikator, atau kode pemulihan.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Opsional. Kode pemulihan.
    TotpSetup:
      type: object
      description: Kunci rahasia yang tertunda untuk aplikasi autentikator.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Kunci rahasia dalam base32, untuk diketikkan ke aplikasi.
        uri:
          type: string
          format: uri
          description: URI `otpauth://totp/...`, untuk ditampilkan sebagai kode QR.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Jumlah detik per interval.
    RecoveryCodeList:
      type: array
      description: 10 kode pemulihan, yang ditampilkan hanya kali ini. Setiap kode hanya berlaku sekali.
      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: Satu pihak dalam permainan.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Nama dalam catatan permainan (`deleted#<id>` untuk akun yang dihapus).
        rating:
          type:
            - integer
            - 'null'
          description: Rating saat mulai, `null` jika tidak diketahui.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Rating setelah permainan. Permainan dinilai selalu memilikinya; `null` hanya untuk permainan yang tidak dihitung untuk rating (santai, kustom, dibatalkan).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: Perubahan rating, `0` jika aturan rating membiarkan rating tetap seperti semula (skor nol menurut FIDE, lawan tanpa rating, permainan pertama dari pemain tanpa rating); `null` seperti `ratingAfter`.
    GameSummary:
      type: object
      description: Ringkasan permainan yang tersimpan.
      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 permainan.
        category:
          type: string
          description: Id kategori resmi (`3+2`), atau `custom`.
        rated:
          type: boolean
          description: Apakah permainan dihitung untuk rating.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Kontrol waktu dalam detik (`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` (lihat kode permainan).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Kode alasan berakhirnya permainan (lihat kode permainan).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Hasil, `*` untuk permainan yang dibatalkan.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Jumlah setengah langkah.
        startedAt:
          type: integer
          format: int64
          description: Waktu mulai (epoch ms).
        endedAt:
          type: integer
          format: int64
          description: Waktu berakhir (epoch ms).
    Termination:
      type: string
      description: Nama alasan berakhirnya permainan (lihat kode permainan); `Unknown` untuk kode yang tidak dikenal protokol.
      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: Ringkasan permainan dari sisi satu pemain.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Pihak pemain.
    HistoryGameSummary:
      description: Permainan dalam riwayat pemain yang sedang masuk.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Waktu dasar dalam milidetik.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Tambahan waktu dalam milidetik.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: Hasil dari sisi pemain.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Permainan di halaman ini, dari yang terbaru.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Id yang dikirim sebagai `before` untuk halaman berikutnya, atau `null` di halaman terakhir.
        total:
          type: integer
          minimum: 0
          description: Jumlah permainan yang cocok dengan filter, di semua halaman.
    MoveRecord:
      type: object
      description: Satu setengah langkah dalam permainan.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: Langkah dalam notasi UCI, dengan tambahan `n`, `b`, `r`, atau `q` untuk promosi.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Waktu yang dibebankan untuk langkah itu (ms), `null` jika catatan tidak memilikinya.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Jam pemain yang melangkah setelah langkah itu (ms), `null` jika catatan tidak memilikinya.
    PgnTagsView:
      type: object
      description: Tag PGN utama, untuk ditampilkan. `Termination` di sini adalah nama alasan berakhirnya permainan; file PGN menggunakan nilai standar PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` atau `<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: Tanggal mulai dalam 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: Rating saat mulai, atau `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Rating saat mulai, atau `"-"`.
          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: Catatan permainan beserta langkah dan jamnya. Memiliki field ringkasan permainan, tanpa `color` dan `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Waktu dasar dalam milidetik.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Tambahan waktu dalam milidetik.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: Nama dari `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: Id permainan yang menjadi asal tanding ulang ini, atau `null`.
            moves:
              type: array
              description: Satu entri per setengah langkah.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Hanya jika pemain pemilik token memainkan permainan ini; pihaknya.
            reportable:
              type: boolean
              description: Hanya jika pemain pemilik token memainkan permainan ini; `true` jika `POST /reports` akan menerima laporan atas lawan untuk permainan ini sekarang (permainan berakhir dalam 7 hari, kuota harian belum habis, dan lawan belum dilaporkan untuk permainan ini).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Nama pengguna, sebagaimana ditulis pemain.
        createdAt:
          type: integer
          format: int64
          description: Waktu akun dibuat (epoch ms).
        ratings:
          type: array
          description: Hanya kategori resmi, dalam urutan server.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Setiap permainan yang tersimpan, termasuk permainan santai dan yang dibatalkan.
            rated:
              type: integer
              minimum: 0
              description: Permainan dinilai, dijumlahkan dari catatan rating.
            wins:
              type: integer
              minimum: 0
            draws:
              type: integer
              minimum: 0
            losses:
              type: integer
              minimum: 0
    PlayerGamesPage:
      type: object
      required:
        - username
        - games
        - next
      properties:
        username:
          type: string
          description: Nama pengguna, sebagaimana ditulis pemain.
        games:
          type: array
          description: Permainan di halaman ini, dari yang terbaru.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Id permainan terakhir setiap kali halaman penuh (halaman berikutnya mungkin kosong), jika tidak `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: Id kategori.
        minGames:
          type: integer
          minimum: 0
          description: Jumlah permainan terhitung yang dibutuhkan suatu catatan agar tercantum (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Waktu papan peringkat dihitung (epoch ms).
        players:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LeaderboardEntry'
    LeaderboardEntry:
      type: object
      required:
        - rank
        - username
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
      properties:
        rank:
          type: integer
          minimum: 1
        username:
          type: string
        rating:
          type: integer
        games:
          type: integer
          minimum: 0
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
    GifRequest:
      type: object
      additionalProperties: false
      required:
        - pgn
      properties:
        pgn:
          type: string
          description: Teks PGN, paling banyak 65.536 byte UTF-8. Hanya permainan pertama yang digunakan.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Opsional. Ukuran gambar.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Opsional. Pihak yang berada di bagian bawah papan.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Opsional. Milidetik per langkah, angka JSON dengan nilai bulat.
        coords:
          type: boolean
          default: true
          description: Opsional. Apakah huruf lajur dan angka baris digambar di sekeliling papan.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: Permainan, sebagai bilangan bulat atau string berisi 1 hingga 16 digit.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: Nama pengguna lawan, seperti dalam catatan permainan atau seperti saat ini, tanpa memperhatikan huruf besar/kecil. Tidak boleh kosong.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: Hal yang dilaporkan.
        comment:
          type:
            - string
            - 'null'
          description: Opsional. Paling banyak 500 karakter setelah karakter kontrol (selain tab dan line feed) dihapus dan spasi di awal dan akhir teks dipangkas.
    AccountExport:
      type: object
      description: Semua yang disimpan server tentang akun (waktu dalam epoch ms).
      required:
        - format
        - version
        - exportedAt
        - server
        - notes
        - account
        - ratings
        - ratingRefunds
        - sessions
        - securityEvents
        - sanctions
        - conduct
        - reportsFiled
        - games
      properties:
        format:
          type: string
          const: scacelith-account-export
        version:
          type: integer
          const: 1
        exportedAt:
          type: integer
          format: int64
          description: Waktu ekspor dibuat.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Apa yang dimuat dan tidak dimuat file ini, dalam bahasa Inggris sederhana untuk pemain.
          items:
            type: string
        account:
          description: Tampilan akun dari `GET /account/me`, dengan `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: Alamat akun Google yang tertaut, atau `null`.
        ratings:
          type: array
          description: Catatan rating lengkap.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Poin rating yang dikembalikan setelah seorang lawan terbukti curang, dijumlahkan per hari UTC dan kategori, dari yang terbaru. Baik permainannya maupun pemain curangnya tidak disebutkan.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Setiap sesi yang tersimpan, dari yang terbaru, tanpa token apa pun. Pembersihan retensi menghapus sesi yang kedaluwarsa, serta sesi yang telah dikeluarkan, sehari setelah sesi itu dikeluarkan.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Peristiwa keamanan, dari yang terbaru, disimpan selama `RETENTION_SECURITY_DAYS` hari.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Setiap sanksi, termasuk yang sudah dicabut. Nama moderator tidak pernah disertakan.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Peristiwa perilaku yang dicatat untuk permainan pemain yang ditinggalkan, dibatalkan, atau yang langkah pertamanya tidak dijalankan tepat waktu, disimpan selama 30 hari.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Laporan yang dibuat pemain.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Jumlah permainan.
            list:
              type: array
              description: Setiap permainan, dari yang terbaru, sebagai ringkasan `GET /account/games`. Langkah-langkahnya tersedia di `GET /games/{id}` dan PGN-nya di `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Catatan rating lengkap.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Apakah pemain sudah keluar dari fase tanpa rating.
            countedGames:
              type: integer
              minimum: 0
              description: Permainan yang masuk ke perhitungan rating.
            updatedAt:
              type: integer
              format: int64
              description: Waktu catatan terakhir berubah.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: Pukul 00.00 UTC hari tersebut (epoch ms).
        category:
          type: string
        points:
          type: integer
          description: Poin yang dikembalikan pada hari itu dalam kategori itu.
    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: Waktu sesi dikeluarkan, atau `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: Alamat IP saat masuk, dihapus setelah `RETENTION_IP_DAYS` hari.
    SecurityEvent:
      type: object
      description: |-
        Peristiwa keamanan. `ip` hanya diberikan untuk tindakan yang dilakukan saat sedang masuk, dengan kata sandi akun (dan faktor kedua), atau dengan tautan yang dikirim ke alamat akun: `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`, dan `account_exported`; semua jenis lain memiliki `ip: null`, dan `ip` dihapus setelah `RETENTION_IP_DAYS` hari.

        `detail` hanya menyimpan field berikut: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` dan `method` (`password`, atau `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` dan `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. Peristiwa `moderator_action` hanya menyimpan `{ action }`, untuk tindakan `ban`, `unban`, `reset_mfa`, `verify_email`, dan `revoke_sessions` (tindakan moderator lainnya tidak disertakan). Semua jenis lain memiliki `detail: null`. Peristiwa `rating_refund` tidak disertakan: poinnya ada di `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Jenis peristiwa (`login`, `password_changed`...).
        at:
          type: integer
          format: int64
          description: Waktu terjadinya.
        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: Nama publik pemain yang dilaporkan saat ini.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Apakah laporan masih terbuka (apakah pemain yang dilaporkan dikenai sanksi tidak disebutkan).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Token tautan email (field tersembunyi dalam formulir halaman).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Token tautan pengaturan ulang (field tersembunyi dalam formulir).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kata sandi baru; aturan kata sandi pendaftaran berlaku.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kata sandi baru sekali lagi; harus sama.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: Halaman HTML dalam UTF-8 (`text/html; charset=utf-8`), tanpa JavaScript atau sumber daya eksternal.
