openapi: 3.1.1
info:
  title: ScacelithサーバーのHTTPS API
  version: "0.9.0"
  summary: 公式サーバーとコミュニティサーバーを含む、すべてのScacelithサーバーに共通のHTTPS API。
  description: |-
    公式の`caissa.scacelith.com`からコミュニティサーバーまで、すべてのScacelithサーバーがこのHTTPS APIを提供します。ゲームは、対局そのもの以外のすべてにこのAPIを使います。アカウント作成とログイン（二要素認証とGoogleログインを含む）、アカウントページ、対局履歴、PGNのダウンロードと対局のアニメーションGIF、ログイン中のデバイス、データのダウンロード、アカウントの削除、そして報告です。

    リアルタイムの対局（マッチメイキング、対局の申し込み、指し手、時計）は、同じサーバーのWebSocket（`wss://<host>/ws`）を通じて行われます。このWebSocketは、このAPIのセッショントークンで開きます。その仕様はここではなく`PROTOCOL.md`に記載されています。

    以下に示す既定値は、設定を変更していないサーバーのものです。コミュニティサーバーでは変更されている場合があります（すべての設定項目は`CONFIG.md`に記載されています）。

    ## ベースURL、ポート、トランスポート

    - 公式サーバー：`https://caissa.scacelith.com/api/v1`、TCPポート443。
    - コミュニティサーバー：`https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`。`API_PORT`の既定値は443です。NATやプロキシの背後では、プレイヤーが使うポートは`PUBLIC_API_PORT`です。
    - ゲームのWebSocketは、既定ではAPIと同じポートにあります（`WS_PORT`で変更できます。その場所は`GET /info`がクライアントに伝えます）。
    - TLS上のHTTP/1.1を使います。`TLS_MODE=proxy`では、サーバーの前段にあるリバースプロキシがTLSを終端します。`TLS_MODE=off`（平文のHTTP）はローカル開発専用です。
    - メールのリンクから開くHTMLページ（`/verify-email`、`/reset-password`、`/confirm-email-change`）は、`/api/v1`の外、サーバーのルートにあります。ヘルスチェックのエンドポイントは、ルートと`/api/v1`の下の両方で応答します。Googleログインにはサーバー上のページがありません。Googleはブラウザーをゲーム自体（`127.0.0.1`）に戻します。
    - 末尾のスラッシュは無視され（`/api/v1/info/`は`/api/v1/info`と同じです）、パスパラメーターはURLデコードされます（有効なURLエンコーディングでないパラメーターには400 `invalid_request`が返ります）。

    ## リクエスト

    - **JSONボディ**。`POST`、`PUT`、`DELETE`のリクエストは、`Content-Type: application/json`でJSONオブジェクトを送ります。UTF-8以外の`charset`は拒否され、ほかのコンテンツタイプも同様に拒否されます（415 `unsupported_media_type`）。空のボディは`{}`として扱われ、パラメーターのないエンドポイント（`POST /auth/logout`、`POST /auth/logout-all`、`DELETE /auth/sessions/{id}`）はこれを受け取ります。
    - **サイズと時間**。ボディの上限は`HTTP_BODY_LIMIT`バイトです（既定値は16,384。`POST /gif`には135,168バイトという独自の上限があります）。超えると413 `payload_too_large`が返ります。ボディは10秒以内に届く必要があり、届かなければ408 `request_timeout`が返ります。どちらのエラーの後も、サーバーは接続を閉じます。
    - **厳密なスキーマ**。エンドポイントが知らないフィールドは拒否され、任意と記されていないフィールドは必須で、型と長さも検査されます。これらのいずれかに違反すると400 `invalid_request`が返り、`field`がそのフィールドを示します（入れ子のフィールドは`pow.nonce`のようにドット区切りです）。文字列に制御文字を含めることはできません。長さはUTF-16のコード単位で数えるため、基本多言語面の外の文字（絵文字など）は2文字と数えられます。JSONでないボディには400 `invalid_json`が返ります。`POST /gif`と`POST /reports`は、ボディを独自に検査します（各エンドポイントを参照）。
    - **クエリ文字列**。パラメーターは最初に現れたものが有効で、未知のパラメーターは無視されます。また`+`はスペースにデコードされます。持ち時間カテゴリーを受け取るエンドポイントは、`3%2B2`と`3+2`の両方を受け付けます。
    - **メソッド**。`HEAD`はボディのない`GET`です。存在するパスへの`OPTIONS`は、`Allow`ヘッダー付きで204を返します。そのパスにないその他のメソッドには、`Allow`付きで405 `method_not_allowed`が返ります。
    - **リクエストターゲット**。4096文字を超えることはできません（414 `uri_too_long`）。まったく解析できないリクエスト、またはヘッダー部が大きすぎるか届くのが遅すぎるリクエストには、ボディが空の400、431または408が返り、接続が閉じられます。
    - **CORSなし**。このAPIはWebページではなくゲームのためのものです。`Access-Control-*`ヘッダーは一切送られないため、Webページはレスポンスを読み取れません。また、JSONボディしか受け付けないため、クロスサイトの書き込みにはプリフライトが必要になり、そのプリフライトは失敗します。

    ## レスポンスとエラー

    - レスポンスはUTF-8のJSONです。ただし、PGNのダウンロード（`application/x-chess-pgn`）、アニメーションGIF（`image/gif`）、HTMLページは除きます。時刻は1970-01-01 UTCからのミリ秒です。IDは整数です。対局IDは最大16桁ですが2^53未満に収まるため、JSONの数値（倍精度浮動小数点数）で正確に表せます。
    - APIのすべてのレスポンスには、`Cache-Control: no-store`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: no-referrer`、`X-Frame-Options: DENY`、`Cross-Origin-Resource-Policy: same-origin`、`Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`が付きます（HTMLページには独自のポリシーがあります）。`TLS_MODE=native`では、`Strict-Transport-Security: max-age=31536000`も付きます。
    - レスポンスは、サーバーが準備を終えた時点から60秒以内に読み取る必要があります。これが問題になるのは大きなレスポンス（GIF、データのエクスポート、長いPGN）だけです。サーバーがレスポンスの準備にかけた時間は、この60秒にも、送受信が1バイトもないまま30秒たつと接続が閉じられる制限にも数えられません。

    エラーの形式は1つだけです（`Error`スキーマ）：

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

    `message`はログ用およびフォールバック用です。クライアントは`error`をもとに表示内容を選びます。`retryAfter`（秒）は時間がたてば解除される拒否にのみ含まれ、その場合レスポンスには同じ値の`Retry-After`ヘッダーも付きます。唯一の例外は、履歴、対局、プレイヤー、ランキングの読み取りで返る503 `busy`で、こちらにはフィールドしかありません。一部のエラーにはフィールドが追加されます：`field`（無効な入力）、`reason`（`weak_password`、`pow_required`）、`pow`（`pow_required`）、`until`（`banned`）、`line`と`column`（`invalid_pgn`）。

    どのエンドポイントでも返りうるエラー：

    | ステータス | `error` | 条件 |
    |---|---|---|
    | 400 | `invalid_request` | ボディがエンドポイントのスキーマに違反している（場所は`field`が示します）、リクエストターゲットまたは`Content-Length`の形式が不正、パスパラメーターが有効なURLエンコーディングでない、またはボディが途中で切れている。 |
    | 400 | `invalid_json` | ボディがJSONではない。 |
    | 401 | `unauthorized` | セッションが必要なエンドポイントに`Authorization`ヘッダーがない。 |
    | 401 | `invalid_token` | トークンの形式が不正、期限切れ、失効済み、または削除されたアカウントのもの。セッションが任意のエンドポイントでも返ります。 |
    | 404 | `not_found` | そのようなエンドポイントがない。一部のエンドポイントは、対局、プレイヤー、セッションが存在しない場合にも使います。 |
    | 405 | `method_not_allowed` | そのパスはほかのメソッドでのみ存在する（`Allow`を参照）。 |
    | 408 | `request_timeout` | ボディが10秒以内に届かなかった。 |
    | 413 | `payload_too_large` | ボディが`HTTP_BODY_LIMIT`（`POST /gif`では135,168バイト）を超えている。 |
    | 414 | `uri_too_long` | リクエストターゲットが4096文字を超えている。 |
    | 415 | `unsupported_media_type` | ボディが`application/json`ではない、またはその文字セットがUTF-8ではない。 |
    | 429 | `rate_limited` | レート制限（後述）。`retryAfter`と`Retry-After`ヘッダーが付きます。 |
    | 500 | `internal_error` | 予期しない障害。サーバーがログに記録します。 |
    | 503 | `timeout` | サーバーが30秒以内に応答しなかった（既定の設定では、エクスポートは60秒、GIFは45秒）。 |
    | 503 | `server_busy` | セッションの照会またはアカウントの変更でデータベースがロックされていた（`retryAfter` 1）、またはパスワードハッシュのキューが満杯（後述）。 |

    読み取り系のエンドポイント（対局履歴、対局、プレイヤー、ランキング）とエクスポートは、データベースのロックが解除されなかった場合に`retryAfter: 1`付きで503 `busy`を返します。

    パスワードを検証またはハッシュ化するエンドポイントは、次のいずれかを返すこともあります。どちらも`retryAfter`は5～15秒のランダムな値です：

    - 503 `server_busy`：サーバーのパスワードハッシュのキュー（`PASSWORD_HASH_QUEUE_MAX`）が満杯か、待ち時間が尽きた。
    - 429 `rate_limited`：キューが半分埋まった状態で、このクライアント（IPv4アドレスまたはIPv6の/48）の待機中のハッシュがすでに`PASSWORD_HASH_WAITERS_PER_SOURCE`件ある。この429では、リクエストが消費したレート制限のトークンが返却されます。

    いずれの場合も何も変更されず、失敗した試行としても数えられません（ただし`POST /auth/sso/google/link`は例外で、チケットの試行とアカウントの失敗はハッシュの前に計上済みです）。リセットリンクも有効なままです。

    HTMLページは、エラーを同じステータスコードのHTMLページとして返します。

    ## 認証

    セッションが必要なエンドポイントは、`Authorization`ヘッダーでBearerトークンを受け取ります（`bearerAuth`スキーム）：

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **トークンの取得**。トークン（`sct_`に続く43文字のbase64url）は、`POST /auth/login`（二要素認証がオンの場合はその後の`POST /auth/login/mfa`）、`POST /auth/sso/google/finish`と`POST /auth/sso/google/link`（Googleログイン）、および`POST /auth/sso/complete`から得られます。いずれも`{ token, expiresAt, user }`を返します。サーバーはトークンのSHA-256しか保存しません。
    - **必須のセッション**。ヘッダーがない場合は、`WWW-Authenticate: Bearer realm="scacelith"`付きで401 `unauthorized`が返ります。無効なトークンには、`WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`付きで401 `invalid_token`が返ります。クライアントは`invalid_token`を受け取ったトークンを破棄し、ログインし直してください。
    - **任意のセッション**。`GET /games/{id}`、`GET /games/{id}/pgn`、`GET /players/{username}`、`GET /players/{username}/games`では、セッションは任意です。ヘッダーがなければ公開ビューを返します。ヘッダーを送る場合、そのトークンは有効でなければなりません。
    - **有効期間**。セッションは、ログインから`SESSION_MAX_DAYS`（90）日後（これが`expiresAt`です）と、`SESSION_IDLE_DAYS`（30）日間使われなかった時点の、どちらか早いほうで終了します。使うたびにアイドル期限は延長されますが、サーバーが新しい値を書き込むのは最大で5分に1回です。1つのアカウントが保持できるセッションは最大`MAX_SESSIONS_PER_USER`（10）個で、新たなログインでこの数を超えると、最も古いセッションが失効します。
    - **失効**。ログアウト（`POST /auth/logout`、`POST /auth/logout-all`、`DELETE /auth/sessions/{id}`）はセッションを失効させます。パスワードの変更（ほかのセッション）、パスワードのリセットとアカウントの削除（すべてのセッション）、そして管理者によっても失効します。APIからの失効はただちに有効になり、失効したセッションで開かれたWebSocketは閉じられます。サーバーはセッションの照会結果を30秒間キャッシュするため、別プロセスである管理コマンドによる失効は30秒以内に有効になります。
    - **スコープ**。トークンは1つのサーバーに属し、そのサーバーのWebSocketを開くのにも使えます。ほかのサーバーには決して送らないでください。

    ## レート制限とその他の制限

    制限は2つのスコープのいずれかに適用されます。**クライアント**：IPv4アドレス、またはIPv6の/64（プロキシモードでは、`TRUSTED_PROXIES`のアドレスから送られた`X-Forwarded-For`からアドレスを得ます）。一部の制限は、各/64ネットワークに加えて、IPv6の/48全体もまとめて数えます。**プレイヤー**：アドレスにかかわらず、ログインしているアカウント。セッションが任意のエンドポイントでプレイヤー単位に数える制限は、トークンのないリクエストではクライアント単位で数えます。

    各制限は、`limit`件のリクエストを保持し、`limit / window`の速度で連続的に補充されるトークンバケットです。`retryAfter`は次のトークンまでの時間です。*共有*と記された制限は、同じ長さのスライディングウィンドウでも数えられ、どのウィンドウにも`limit`件を大きく超えるリクエストが入らないようになっています。すべての制限はサーバー全体で数えられます。エンドポイントの制限の1つがリクエストを拒否すると、ほかの制限がそのリクエストのために消費したトークンは返却されます。拒否には`retryAfter`と`Retry-After`付きで429 `rate_limited`が返ります。

    - **アドレス単位の層**。すべてのリクエスト（あらゆるパスとメソッド。ヘルスチェックのエンドポイントとWebSocketのアップグレードを含む）は、まずクライアントの割り当てからトークンを1つ消費します。割り当ては`HTTP_RATE_PER_IP`（600）件/分でバーストは30秒分、IPv6の/48には`HTTP_RATE_PER_PREFIX`（4 × `HTTP_RATE_PER_IP`）です。また、1つのクライアントが同時に処理中にできるリクエストは最大`IP_MAX_INFLIGHT`（32 × `WORKERS`）件です（超えると`retryAfter` 1）。拒否された後もリクエストを続けるクライアントはブロックされます。1分間に`ABUSE_BLOCK_REFUSALS_PER_MIN`（600）回拒否されると1分間ブロックされ、6時間以内に再びブロックされるたびに4分、16分、60分と延びます。`auth`、`auth_*`、`reauth`の制限による拒否は5回分と数えられ、プレイヤー単位の制限による拒否は数えられません。`ABUSE_EXEMPT`に含まれるアドレスはこの層の対象外ですが、アカウント単位の割り当てとエンドポイントの制限は適用されます。
    - **アカウント単位の割り当て**。有効なセッショントークンを持つすべてのリクエストは、そのアカウントの割り当ても消費します。アドレスにかかわらず、全エンドポイント合計で`USER_RATE_PER_MIN`（120）件/分、バーストは30秒分です。
    - **エンドポイントごとの制限**：

    | 制限 | 既定値 | 数える単位 | エンドポイント |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP`（20）/ 10分、共有 | クライアント。IPv6の/48ごとに`AUTH_RATE_PER_PREFIX`（5 × `AUTH_RATE_PER_IP`） | `POST /auth/register`、`/auth/login`、`/auth/login/mfa`、`/auth/verify-email/resend`、`/auth/password/forgot`、`/auth/password/reset`、`/auth/sso/google/link`、`/auth/sso/complete`、および`POST /verify-email`、`/reset-password`、`/confirm-email-change` |
    | `auth_register` | `AUTH_REGISTER_PER_HOUR`（10）/ 1時間、共有 | クライアント。IPv6の/48ごとにその3倍 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR`（10）/ 1時間、共有 | クライアント。IPv6の/48ごとにその3倍 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR`（3）/ 1時間、共有 | クライアント。IPv6の/48ごとにその3倍 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY`（10）/ 24時間、共有 | クライアント。IPv6の/48ごとにその3倍 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR`（10）/ 1時間、共有 | クライアント。IPv6の/48ごとにその3倍 | `POST /auth/password/reset`、`POST /reset-password` |
    | `reauth` | `auth`と同じ数値、独自のバケット、共有 | クライアントとIPv6の/48 | パスワードを求めるアカウント変更（「再認証」を参照）と`POST /account/export` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER`（10）/ 10分、共有 | プレイヤー | `reauth`と同じエンドポイント |
    | `account` | 60 / 分 | プレイヤー | `GET /account/me`、`PUT /account/preferences` |
    | `account_games` | 60 / 分 | プレイヤー | `GET /account/games` |
    | `account_export` | 5 / 1時間、共有 | プレイヤー | `POST /account/export`（`reauth`より先に検査。すべての試行を数えます） |
    | `sessions` | 60 / 分 | プレイヤー | `POST /auth/logout`、`/auth/logout-all`、`GET /auth/sessions`、`DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / 分 | プレイヤー（トークンがなければクライアント） | `GET /players/{username}`、`/players/{username}/games`、`/games/{id}`、`/games/{id}/pgn`（4つで1つのバケット） |
    | `gif` | 30 / 分 | プレイヤー | `GET /games/{id}/gif`、`POST /gif`（2つで1つのバケット） |
    | `gif_user_min`、`gif_user_hour` | `GIF_USER_RENDERS_PER_MIN`（4）/ 分と`GIF_USER_RENDERS_PER_HOUR`（30）/ 1時間、共有 | プレイヤー | 同じ2つ。GIFを作成する必要がある場合のみ（キャッシュから返さない場合） |
    | `gif_ip_min`、`gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN`（12）/ 分と`GIF_IP_RENDERS_PER_HOUR`（120）/ 1時間、共有 | クライアント（そのすべてのアカウントの合計）。IPv6の/48ごとにその3倍 | 同上 |
    | `reports` | 30 / 1時間 | プレイヤー | `POST /reports` |
    | `sso_start` | 30 / 10分、共有 | クライアント。IPv6の/48ごとに90 | `POST /auth/sso/google/start` |
    | `sso_finish` | 30 / 分 | クライアント | `POST /auth/sso/google/finish` |
    | `page` | 60 / 分 | クライアント | `GET /verify-email`、`/reset-password`、`/confirm-email-change` |

    `GET /info`と`GET /leaderboard`には独自の制限はなく、アドレス単位の層だけが適用されます。複数の制限を持つエンドポイントは、その説明に記載された順に制限を検査します。

    サーバーはリクエストを次の順に処理します：アドレス単位の層、次にヘルスチェックのエンドポイント、エンドポイントの照合、認証、リクエストがセッションを持つ場合はアカウント単位の割り当て、エンドポイントの制限、ボディ、エンドポイント本体（GIFのエンドポイントは、作成すべきGIFがある場合にのみ描画の制限を消費します）。したがって、トークンが原因で拒否されたリクエストはエンドポイントのトークンを消費しませんが、ボディが無効なリクエストは消費します。

    エンドポイント自体が応答するその他の制限：

    - **1つのログイン名でのログイン失敗**（ユーザー名またはメールアドレス）：失敗が`AUTH_FAILURES_PER_ACCOUNT`（5）回に達すると、以降の試行ごとに前回の2倍の待ち時間が必要になります（2秒、4秒と続き、最大15分）。`retryAfter`付きで429 `too_many_attempts`が返ります。失敗のない状態が1時間続くと、カウンターはリセットされます。
    - **ログイン時の第二要素の失敗**：アカウントの5回目の誤ったコードから同じ規則が適用されます。1回のログイン手順で受け付ける誤ったコードは最大5回です。
    - **1つのアカウントの第二要素コード**：ログイン時と再認証時を合わせ、どのアドレスからでも、15分あたり最大`AUTH_MFA_PER_ACCOUNT`（10）個のコード（認証アプリのコードまたはリカバリーコード。正誤を問いません）。これを超えるとコードを検査する前に429 `too_many_attempts`が返るため、リカバリーコードは消費されません。
    - **アカウントの再認証の失敗**（誤ったパスワードまたはコード）：同じ規則（429 `too_many_attempts`）で、再認証を行うすべてのエンドポイントで共有されます。
    - **メール**：確認メールまたはリセットメールは、アドレスごとに5分に1通まで（レスポンスは変わりません）。"someone tried to use your address"（誰かがあなたのアドレスを使おうとしました）という通知は、アドレスごとに1時間に1通までです。
    - **報告**：プレイヤーごとに24時間で`REPORTS_PER_DAY`（5）件まで。超えると429 `report_limit`が返ります。

    ## プルーフ・オブ・ワーク

    `POW_REGISTER_BITS`が0より大きい場合（既定値は18。`GET /info`はこれを`pow.register`として返します）、`POST /auth/register`には常にプルーフ・オブ・ワークが必要です。`POST /auth/login`と`POST /auth/sso/google/link`（両者で同じ種類のチャレンジを使います）では、サーバーがログイン失敗の急増を検知してから5分間だけ必要になります（`POW_LOGIN_TRIGGER_PER_MIN`、その後`POW_LOGIN_BITS`）。必要かどうかは、クライアントがレスポンスから知ります。

    1. プルーフのない（または拒否されたプルーフ付きの）リクエストには、`reason`と`pow`チャレンジ`{ challenge, bits, expiresAt }`付きで428 `pow_required`が返ります。
    2. ナンスを見つけます。ナンスは最大20桁の10進数の文字列で、`SHA-256(challenge + ":" + nonce)`が`bits`個のゼロビットで始まるもの（先頭バイトの最上位ビットから順に数えます）です。18ビットでは、平均して約260,000回のハッシュ計算が必要です。
    3. ボディに`"pow": { "challenge": "...", "nonce": "123456" }`を入れて、同じリクエストをもう一度送ります。

    チャレンジは2分間、1回だけ、1つのエンドポイントと1つのクライアントネットワーク（IPv4アドレスまたはIPv6の/64）に対して有効です。チャレンジは署名されているため、戻ってくるまでサーバーはそれについて何も保持しません。`reason`はプルーフが拒否された理由を示します：`required`、`malformed`、`signature`、`endpoint`、`network`、`expired`、`bits`、`work`、`replayed`のいずれかです。

    ## 再認証

    アカウントの変更では、パスワードの再入力と、二要素認証がオンの場合は第二要素が求められます：

    - パスワードのみ：`POST /account/password`と`POST /account/mfa/totp/setup`。
    - パスワードと認証アプリのコード（リカバリーコードは不可）：`POST /account/mfa/recovery-codes`。
    - パスワードと、認証アプリのコードまたはリカバリーコード：`POST /account/mfa/totp/disable`、`POST /account/email`、`POST /account/export`、`POST /account/delete`。

    ボディでは、`code`に6桁の認証アプリのコードを、`recoveryCode`にリカバリーコード（`xxxx-xxxx-xx`。大文字と小文字、スペース、ハイフンは区別されません）を入れます。リカバリーコードを受け付ける場面では、リカバリーコードを`code`で送ることもできます。各コードは1回しか使えません。使用済みの認証アプリのコードは次の30秒ステップまで拒否され、リカバリーコードは一度使うと無効になります。

    | ステータス | `error` | 条件 |
    |---|---|---|
    | 403 | `invalid_password` | パスワードが誤っている。 |
    | 403 | `mfa_code_required` | 二要素認証がオンで、`code`も`recoveryCode`も送られていない。 |
    | 403 | `invalid_code` | コードが誤っているか、使用済み。 |
    | 400 | `password_not_set` | Googleでのみログインするアカウントに、まだパスワードがない（「パスワードをお忘れですか？」で設定できます）。 |
    | 429 | `too_many_attempts` | このアカウントで失敗した回数、または試したコードが多すぎる。 |
    | 503 / 429 | `server_busy` / `rate_limited` | パスワードハッシュのキューが混雑している。 |

    誤ったパスワードとコードは、アカウントの失敗カウンターに数えられ、セキュリティイベントとして記録されます。

    ## メトリクスポート

    サーバーはAPIポートのほかに、メトリクスポート（`METRICS_PORT`、9464。`METRICS_BIND`にバインドされ、既定値は127.0.0.1です。非公開にしてください）で平文のHTTPに応答します。これはこのAPIの一部ではありません。`GET /healthz`は`ok`を返します。`GET /readyz`は、起動が完了してから（すべてのシャードがジャーナルを再生し、リスナーがバインドされた後）シャットダウンが始まるまで`ready`を返し、それ以外のときは503 `not ready`を返します。`GET /metrics`はPrometheusのメトリクスを返し、`METRICS_TOKEN`が設定されている場合は、そのトークンと完全に一致する`Authorization: Bearer <token>`が必要です。
  contact:
    name: Scacelith
    url: https://github.com/DarkCenobyte/scacelith-chess-server
  license:
    name: GPL-3.0-or-later
    identifier: GPL-3.0-or-later
externalDocs:
  description: Scacelithサーバーのソースコードとリファレンスドキュメント（API.md、PROTOCOL.md、CONFIG.md）。
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: 公式サーバー。
  - url: https://{host}:{port}/api/v1
    description: コミュニティサーバーなど、任意のScacelithサーバー。
    variables:
      host:
        default: caissa.scacelith.com
        description: サーバーの公開ホスト名（`SERVER_PUBLIC_HOST`）。
      port:
        default: "443"
        description: 公開APIポート（`PUBLIC_API_PORT`、未設定なら`API_PORT`）。
tags:
  - name: server-info
    x-displayName: サーバー情報
    description: ログインや接続の前に、クライアントが知っておくべき情報。
  - name: health
    x-displayName: ヘルスチェック
    description: |-
      監視のための、サーバーの死活状態と準備完了状態の確認。APIポートで、認証とすべてのエンドポイントの制限より前に応答しますが、ほかのリクエストと同様にアドレス単位の層のトークンを消費します。監視用のホストは`ABUSE_EXEMPT`に登録できます。各エンドポイントは、サーバーのルートと`/api/v1`の下の両方のパスで動作します。

      メトリクスポートには独自のヘルスチェックのエンドポイントがあり、冒頭の説明に記載されています。
  - name: auth
    x-displayName: 登録とログイン
    description: |-
      アカウントの作成、パスワード（と第二要素）によるログイン、確認メールとパスワードリセットのメール。

      `POST /auth/login`、`POST /auth/login/mfa`、`POST /auth/sso/google/finish`、`POST /auth/sso/google/link`、`POST /auth/sso/complete`は、セッション`{ token, expiresAt, user }`を返します（はじめのいくつかは、代わりにログインの第2段階を返すこともあります）。
  - name: google-sign-in
    x-displayName: Googleログイン
    description: |-
      `GET /info`が`sso.google: true`を示す場合に提供されます。そうでなければ、以下のすべてのエンドポイントは404 `sso_disabled`を返します。ゲームは、RFC 8252のインストール型アプリのフロー（「デスクトップアプリ」タイプのクライアント）により、システムのブラウザーを通じてログインします。Googleはブラウザーを、このサーバーではなく、`127.0.0.1`で待ち受けるゲームのリスナーに戻します。ゲームがGoogleの認証情報を目にすることはありません。

      1. クライアントは`127.0.0.1:0`で待ち受け（ポートはシステムが選びます）、PKCEのペアを作ります。43～128文字の`[A-Za-z0-9._~-]`からなる`codeVerifier`と、`codeChallenge = BASE64URL(SHA-256(codeVerifier))`です。後者は43文字で、パディングはありません。
      2. チャレンジとポートを指定した`POST /auth/sso/google/start`が、GoogleのURLとこの試行の`state`を返します。クライアントはURLを検査し（後述）、ブラウザーで開きます。
      3. Googleはブラウザーを`http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`に送ります。クライアントは開始時のレスポンスの`state`だけを受け付け、`error=`のリダイレクトの後には何も送りません。
      4. 試行のID、`codeVerifier`、`state`、`code`（Googleが送ってきた場合は`iss`も）を指定して、`POST /auth/sso/google/finish`を呼び出します。
      5. レスポンスは、セッション、二要素認証の段階（`POST /auth/login/mfa`に進みます）、新しいプレイヤーの場合の`needsUsername`（`POST /auth/sso/complete`に進みます）、またはパスワードを持つアカウントがそのアドレスを使っている場合の`needsPassword`（`POST /auth/sso/google/link`に進みます）のいずれかです。

      **オリジンタグ**。リダイレクトURIには、プレイヤーが追加したサーバーのタグが含まれます。これにより、別のサーバーが自分のプレイヤー向けに取得したURLを、ゲームが拒否できます。オリジンは、小文字のホスト（IPv6リテラルは角括弧で囲みます）、`:`、10進数のAPIポートをつなげたもので、ポートは443も含めて常に書きます。サーバーは`SERVER_PUBLIC_HOST`と公開APIポートを、ゲームは接続先のアドレスを使います。タグは、SHA-256(UTF-8の`"scacelith-sso-origin-v1\n"` + オリジン)のbase64url（パディングなし）の先頭22文字です。リダイレクトURIは`"http://127.0.0.1:" + port + "/oauth2/google/" + tag`です。常にIPv4リテラルを使い、ポートはゲームのリスナーのポート（1024～65535）です。サーバーがクライアントからURI、ホスト、パスを受け取ることはありません。

      | オリジン | タグ |
      |---|---|
      | `play.scacelith.example:443` | `IhcScoV7eDOzTEcSnqPUPt` |
      | `localhost:8443` | `TFGx7zQ_8QlGZW5zpqznCr` |
      | `[::1]:8443` | `XToJm0DG5PjciEVmZa9Cho` |
      | `127.0.0.1:50443` | `3r653wM5ZjYsHcAJljmCwY` |

      したがって、Googleログインが使えるのは、サーバーを`SERVER_PUBLIC_HOST`と公開APIポートのとおりに追加したプレイヤーだけです。

      **ブラウザーを開く前にゲームが検査すること**。`authUrl`は正確に`https://accounts.google.com/o/oauth2/v2/auth?`で始まり、4096文字未満の印字可能なASCIIで構成され、そのクエリには、`response_type=code`がちょうど1つ、ゲームが自身のポートとオリジンタグから計算したURIに等しい`redirect_uri`がちょうど1つ、レスポンスの`state`に等しい`state`がちょうど1つ、そして`code_challenge_method=S256`と43文字の`code_challenge`が含まれていなければなりません。そうでなければ、ゲームはリスナーを停止し、何も開きません。ゲームは`finish`と`link`を、`start`に応答したサーバーにだけ送信します。

      **Googleログインでどのアカウントに入るか**：そのGoogleアカウントとすでに連携しているアカウント。なければ、Googleが確認したアドレスを持つ有効なアカウント（パスワードがある場合、`finish`は`needsPassword`を返し、パスワード、続いて二要素認証がオンならその第二要素が通った時点で初めて連携が保存されます。パスワードがない場合は409 `sso_account_exists`）。それもなければ新しいアカウント（`needsUsername`）。Googleアカウントが、アドレスだけを根拠に既存のアカウントと連携されることはありません。Googleログインによってアカウントが作成されたとき、および既存のアカウントにGoogleログインが追加されたときには、そのアカウントのアドレスにメールが送られます。
  - name: sessions
    x-displayName: セッション
    description: アカウントのログイン中のデバイスと、ログアウト。
  - name: account
    x-displayName: アカウント
    description: アカウント情報、その設定とパスワード。
  - name: two-step-verification
    x-displayName: 二要素認証
    description: 認証アプリ（TOTP、RFC 6238。SHA-1、6桁、30秒ステップ、前後1ステップの許容幅）と、使い捨てのリカバリーコード。
  - name: email-change
    x-displayName: メールアドレスの変更
    description: アカウントのメールアドレスの変更。新しいアドレスに送られるリンクで確認します。
  - name: data-export
    x-displayName: データのエクスポート
    description: サーバーがアカウントについて保持しているすべての情報を、1つのJSONファイルで。
  - name: account-deletion
    x-displayName: アカウントの削除
    description: アカウントの完全な削除。
  - name: game-history
    x-displayName: 対局履歴
    description: ログイン中のプレイヤー自身の対局（絞り込みとページ分割付き）。
  - name: games
    x-displayName: 対局とPGN
    description: |-
      指し手と時計を含む対局の記録と、そのPGNファイル。

      **対局のコード**。`status`：1 `WhiteWins`、2 `BlackWins`、3 `Draw`、4 `Aborted`（保存されるのは終了した対局だけです）。`result`：`1-0`、`0-1`、`1/2-1/2`、または`*`（中止）。`reason`とその名前（`termination`）、およびPGNファイルの棋譜テキストの末尾に書かれる文言は下表のとおりです。コード7と21は引き分けです（時間切れになった、または対局を放棄したプレイヤーの相手が、チェックメイトできなかった場合）。また、サーバーがコード4と13で対局を終えることはありません（これらは共通の終局理由リストに含まれているだけです）：

      | コード | `termination` | PGN内の文言（訳） |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate（チェックメイト） |
      | 2 | `Resignation` | Resignation（投了） |
      | 3 | `Timeout` | Loss on time（時間切れ負け） |
      | 4 | `IllegalMoves` | Second illegal move (forfeit)（2回目の反則手による負け） |
      | 5 | `Stalemate` | Stalemate（ステイルメイト） |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material)（駒不足によるデッドポジション） |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate（時間切れ、ただし相手はメイトできない） |
      | 8 | `FivefoldRepetition` | Fivefold repetition（5回同一局面） |
      | 9 | `SeventyFiveMoves` | 75-move rule（75手ルール） |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed)（3回同一局面の申告） |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed)（50手ルールの申告） |
      | 12 | `Agreement` | Draw by agreement（合意による引き分け） |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate（2回目の反則手、ただし相手はメイトできない） |
      | 20 | `Abandonment` | Abandoned (disconnected for too long)（長時間の接続切れによる対局放棄） |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate（対局放棄、ただし相手はメイトできない） |
      | 22 | `Aborted` | Game aborted（対局中止） |
      | 23 | `NoShow` | Aborted: first move not played in time（中止：時間内に最初の一手が指されなかった） |
      | 24 | `Forfeit` | Forfeit (fair play violation)（フェアプレー違反による不戦敗） |
      | 25 | `ServerAborted` | Aborted by the server（サーバーにより中止） |
      | 26 | `BothDisconnected` | Aborted: both players disconnected（中止：両者の接続が切断） |
  - name: gifs
    x-displayName: アニメーションGIF
    description: |-
      保存や共有のための、対局のアニメーションGIF。真上から見た盤面を、開始局面から最終局面まで1局面につき1フレームで表示し、盤の上に対局者の名前とレーティング、盤の下に最後の指し手、最終フレームには結果と終局の理由を示します。

      **コスト、キャッシュ、割り当て**。GIFは、対局を処理するスレッドではなく専用の描画スレッドで、最低のCPU優先度で作成されます。スレッドは`GIF_THREADS`個（既定値は`WORKERS`）で、最初のGIFとともに起動し、GIFのない状態が1分続くと停止します。スレッドを待てるGIFは最大`GIF_QUEUE_MAX`（4 × `WORKERS`）個で、それぞれ最大`GIF_QUEUE_TIMEOUT_MS`（10秒）まで待ちます。1回の描画には最大`GIF_RENDER_TIMEOUT_MS`（30秒）かけられます。サーバーは作成したGIFを`GIF_CACHE_MB` MB（32 × `WORKERS`）のキャッシュに保持し、最も長く使われていないものから破棄します。キャッシュにあるGIFや、別のリクエストのために作成中のGIFは、描画の回数に数えられません。キャッシュのキーには、名前を含め、画像を変えるすべての要素が含まれます。

      すべてのリクエストは`gif`に数えられます（2つのエンドポイント合計で、プレイヤーごとに毎分30件）。作成が必要なGIFは描画の制限にも数えられます。プレイヤーごとに毎分`GIF_USER_RENDERS_PER_MIN`（4）件と毎時`GIF_USER_RENDERS_PER_HOUR`（30）件、クライアントごと（そのすべてのアカウントの合計）に毎分`GIF_IP_RENDERS_PER_MIN`（12）件と毎時`GIF_IP_RENDERS_PER_HOUR`（120）件、IPv6の/48ごとにはその3倍です。クライアントは、ダウンロードしたファイルを再度要求せずに保持し、429または503の後は`retryAfter`だけ待ってください。

      サイズ：`small`（マス32 px：284 × 350ピクセル）、`medium`（48 px：424 × 515）、`large`（72 px：628 × 762）。座標なしでは268 × 342、400 × 503、600 × 748です。開始局面は少なくとも1秒（指定したフレーム間隔のほうが長ければその時間）、最終局面は3秒表示され、GIFはループします。最初のフレームの後は、画像の変化した部分だけが保存されます。ファイルサイズは小・中・大の順に、40手の対局でおよそ135、205、325 KiB、150手の対局で0.5、0.8、1.2 MiBです。
  - name: players
    x-displayName: プレイヤー
    description: 公開プロフィールと最近の対局。公開データのみで、メールアドレス、セッション、制裁、インテグリティレベルは決して含まれません。削除されたアカウントにはプロフィールがありません。
  - name: leaderboard
    x-displayName: ランキング
    description: 各公式カテゴリーの上位プレイヤー。
  - name: reports
    x-displayName: 報告
    description: 最近の対局の相手を、モデレーターに報告します。
  - name: pages
    x-displayName: HTMLページ
    description: |-
      メールのリンクから開く、ブラウザー向けのページ。リンク先は`/api/v1`の外の`https://<SERVER_PUBLIC_HOST>`です（443以外の場合は`:<PUBLIC_API_PORT>`付き）。

      - ページはJavaScriptを実行せず、外部リソースも読み込みません。`Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`付きで配信されます。
      - `GET`はボタンまたはフォームを表示するだけなので、メールスキャナーがリンクを開いてもリンクは消費されません。変更は`POST`で行われます。`application/x-www-form-urlencoded`として送られるフォームで（JSONボディも受け付けます）、リンクのトークンは隠しフィールドに入ります。同じフィールドが2回指定されると拒否されます。
      - エラー（レート制限、無効なフィールド）もHTMLページで、タイトルは"Request refused"（リクエスト拒否）、500以上では"Server error"（サーバーエラー）です。ページの照合より前に見つかった失敗（長すぎるか形式が不正なリクエストターゲット、アドレス単位の層）だけは、JSONで返ります。
x-tagGroups:
  - name: server
    x-displayName: サーバー
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: アカウント関連
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: 対局
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: プレイヤーと報告
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: ブラウザー向けページ
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: サーバーの名前、バージョン、ポート、登録ルールを取得
      description: |-
        ログインや接続の前にクライアントが必要とする情報：サーバーの名前とID、WebSocketプロトコルのバージョンとWebSocketの場所、登録ルール、公式の持ち時間、そしてクライアントがフォームを送信する前に確認できる制限。

        **制限**：アドレス単位の層のみ。
      security: []
      responses:
        '200':
          description: サーバーの情報。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServerInfo'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：アドレス単位の層。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`。'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: アカウントを作成
      description: |-
        アカウントを作成します。メールアドレスに送られたリンクでアドレスが確認された時点で作成されるか、メール確認なしの場合はただちに作成されます。

        **メール確認あり**（`REQUIRE_EMAIL_VERIFICATION`、既定）：202 `verification_sent`。この時点ではまだアカウントは存在しません。仮登録が24時間確認を待ち、その間ユーザー名を確保します。この24時間有効なリンクがアドレスに送られます（アドレスごとに5分に1通まで。`POST /auth/verify-email/resend`で新しいリンクを送れます）。リンクが使われた時点（`/verify-email`ページのボタン）で、アドレスが確認済みのアカウントが作成され、プレイヤーはログインできるようになります。それまでは、そのユーザー名でのログインには未知のアカウントと同じく`invalid_credentials`が返り、公開プロフィールも存在しません。同じアドレスで新たに登録すると、確認待ちの仮登録は置き換えられます。別のアカウントがすでにそのアドレスを使っている場合もレスポンスは同じです。その場合リンクは送られず、代わりにそのアカウントの所有者に通知が届き（1時間に1通まで）、ユーザー名も同様に確保されるため、そのアドレスにアカウントがあるかどうかは何からもわかりません。リンクが使われなかった仮登録は24時間後に破棄され、そのユーザー名は再び使えるようになります。

        **メール確認なし**（`REQUIRE_EMAIL_VERIFICATION=false`）：201 `ready`。アカウントはただちに作成され、ログインできます。

        **ルール**。`username`：`USERNAME_MIN`～`USERNAME_MAX`文字（3～20）の英字、数字、`_`、`-`で、英字または数字で始まること（`GET /info`はこれらを`limits`として返します）。予約名（`admin`、`moderator`、`deleted`など）と一部の接頭辞は拒否されます。大文字と小文字を区別せずに一意であること。`email`：前後の空白を除き、小文字で保存されます。ドットを含むドメインを持つ、ASCII文字だけのアドレスであること。`password`：`PASSWORD_MIN_LENGTH`（10）文字以上、UTF-8で256バイト以下。ユーザー名やメールアドレスのローカル部を含まず、よく使われるパスワードでないこと。

        **エラー**（この順に検査）：403 `registration_closed`、400 `invalid_username`、400 `invalid_email`、400 `weak_password`、409 `username_taken`、428 `pow_required`、パスワードハッシュのキューのエラー、409 `email_taken`（メール確認なしの場合のみ）。

        **制限**：`auth`、次に`auth_register`（クライアントごとに1時間あたり10件の登録）。**プルーフ・オブ・ワーク**：`POW_REGISTER_BITS`が0より大きい場合は常に必要。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: 1回目の試行（プルーフ・オブ・ワークなし）
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: プルーフ・オブ・ワークを付けて再送信
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: アカウントが作成され、ログインできます（このサーバーではメール確認なし）。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: 仮登録が確認リンクを待っています（または、そのアドレスにはすでにアカウントがあります。レスポンスからはどちらかわかりません）。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationSentStatus'
              example:
                status: verification_sent
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`（`field`付き）、`invalid_json`、`invalid_username`、`invalid_email`、または`reason`付きの`weak_password`（`too_short`、`too_long`、`contains_username`、`contains_email`、`too_common`）。'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`：このサーバーでは登録を受け付けていません（`GET /info`が`registration: closed`を示します）。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`：そのユーザー名を持つアカウントがあるか、別のアドレスの確認待ちの仮登録がそのユーザー名を確保している。`email_taken`：別のアカウントがそのアドレスを使っている（メール確認なしの場合のみ）。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '428':
          $ref: '#/components/responses/PowRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`auth`または`auth_register`の制限、またはパスワードハッシュのキュー。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（パスワードハッシュのキュー、またはデータベースのロックが解除されなかった）、`timeout`。'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: パスワードでログイン
      description: |-
        ユーザー名またはメールアドレスとパスワードでログインします。レスポンスは2通りで、どちらもステータス200です：セッション、または二要素認証がオンの場合は、5分以内に`POST /auth/login/mfa`で完了させる第2段階です。

        未知のアカウント、誤ったパスワード、パスワードのないアカウントには、同じ時間をかけて同じレスポンス（401 `invalid_credentials`）が返ります。アカウントの検査（`banned`、`email_unverified`）は、パスワードが正しい場合にのみ行われます。1つのログイン名で`AUTH_FAILURES_PER_ACCOUNT`（5）回失敗すると、以降の試行ごとに待ち時間が必要になります（429 `too_many_attempts`）。

        **制限**：`auth`。**プルーフ・オブ・ワーク**：ログイン失敗の急増中のみ（428 `pow_required`）。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
            example:
              login: alice
              password: correct horse battery
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: セッション、または二要素認証付きログインの第2段階。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: ログイン完了
                  value:
                    token: sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
                    expiresAt: 1798658839708
                    user:
                      id: 1
                      username: alice
                      email: alice@example.org
                      emailVerified: true
                      mfaEnabled: false
                      googleLinked: false
                      hasPassword: true
                      acceptChallenges: all
                      createdAt: 1790882839743
                      lastLoginAt: 1790882839708
                      pendingEmail: null
                mfaRequired:
                  summary: 二要素認証がオン
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`：未知のアカウント、誤ったパスワード、またはパスワードのないアカウント。'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'パスワードが正しい場合のみ：`until`（エポックからのミリ秒。無期限の利用停止では`null`）付きの`banned`、または`email_unverified`（アドレスがまだ確認されていない古いアカウント）。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '428':
          $ref: '#/components/responses/PowRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（このログイン名の失敗による待ち時間）、または`rate_limited`（`auth`の制限、パスワードハッシュのキュー）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（パスワードハッシュのキュー、またはデータベースのロックが解除されなかった）、`timeout`。'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: 第二要素でログインを完了
      description: |-
        `POST /auth/login`またはGoogleログイン（`POST /auth/sso/google/finish`または`POST /auth/sso/google/link`）が`mfaRequired`を返した後の、二要素認証付きログインの第2段階です。`code`（6桁の認証アプリのコード、またはリカバリーコード）か`recoveryCode`を送ります。ここで使ったリカバリーコードは無効になります。

        1つの段階で受け付ける誤ったコードは最大5回です。パスワードがリセットまたは変更された場合も、その段階は終了します。Googleアカウント連携のパスワード段階の後では、ここでコードが通った時点で初めて連携が保存されます。

        **制限**：`auth`、およびアカウントごとに、どのアドレスからでも15分あたり最大`AUTH_MFA_PER_ACCOUNT`（10）個のコード。これを超えるとコードは検査されないため、リカバリーコードは消費されません。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: 認証アプリのコード
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: リカバリーコード
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: ログインしました。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`：`code`も`recoveryCode`も送られていない、またはボディがスキーマに違反している。`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`：段階が期限切れ、使用済み、または5回の誤ったコードで終了した、あるいは第1段階の後にパスワードがリセットまたは変更された（ログインし直してください）。`invalid_code`：誤ったコード、または使用済みのコード。'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned`（`until`付き）、`email_unverified`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`：Googleアカウント連携のパスワード段階の後のみ。その間に、Googleアカウントが別のアカウントと連携された。'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`：Googleアカウント連携のパスワード段階の後のみ。その間にアカウントが変更された。ゲームから最初からやり直してください。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`：アカウントの失敗による待ち時間、または直近15分間の`AUTH_MFA_PER_ACCOUNT`個のコードを使い切った。`rate_limited`：`auth`の制限。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`retryAfter: 1`付きの`server_busy`：データベースのロックが解除されなかった（Googleアカウント連携の段階の後であれば、何も連携されていません。ゲームから最初からやり直してください）。`timeout`。'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: このセッションからログアウト
      description: |-
        使用したトークンのセッションを失効させます。そのセッションで開かれたWebSocketは閉じられます。ボディはなし（または`{}`）。

        **制限**：`sessions`。
      security:
        - bearerAuth: []
      responses:
        '200':
          description: ログアウトしました。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoggedOutStatus'
              example:
                status: logged_out
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`：`{}`以外のボディ。`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`sessions`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（データベースのロックが解除されなかった）、`timeout`。'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: すべてのセッションからログアウト
      description: |-
        このセッションを含め、アカウントのすべてのセッションを失効させます。それらのセッションで開かれたWebSocketは閉じられます。ボディはなし（または`{}`）。

        **制限**：`sessions`。
      security:
        - bearerAuth: []
      responses:
        '200':
          description: すべてのセッションがログアウトしました。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoggedOutStatus'
              example:
                status: logged_out
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`：`{}`以外のボディ。`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`sessions`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（データベースのロックが解除されなかった）、`timeout`。'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: 確認リンクを再送
      description: |-
        メールアドレス確認のリンクを再送します。アドレスにかかわらずレスポンスは202 `accepted`なので、アカウントや仮登録がそのアドレスを使っているかどうかは決してわかりません。

        リクエストが実際に処理されるのは、アドレスごとに5分に1回までです。そのアドレスで確認待ちの仮登録は、別のアカウントがそのアドレスを使っているかどうかにかかわらず、改めて24時間の期限を得ます。これにより、どちらの場合もユーザー名が同じ期間だけ確保されます。リンクが送られるのは、そのアドレスにアカウントがない場合のその仮登録（24時間有効な新しいリンクが以前のリンクに置き換わります）か、そのアドレスを持つ有効で未確認のアカウントに対してだけです。

        **制限**：`auth`、次に`auth_mail`（クライアントごとに1時間あたり10件）。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: 受け付けました（アドレスにかかわらず）。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedStatus'
              example:
                status: accepted
        '400':
          $ref: '#/components/responses/BadRequest'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`auth`または`auth_mail`の制限（アドレスにかかわらず）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`retryAfter: 1`付きの`server_busy`：アカウントの照会中にデータベースのロックが解除されなかった（確認待ちの仮登録の更新はベストエフォートで、リクエストを失敗させることはありません）。`timeout`。'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: パスワードリセットのリンクを送信
      description: |-
        `/reset-password`ページを開く、1時間有効なパスワードリセットのリンクを送ります。アドレスにかかわらず、レスポンスは202 `accepted`です。リンクは有効なアカウントにのみ、アドレスごとに5分に1回まで送られます。Googleでのみログインするアカウントは、この方法で最初のパスワードを設定します。

        パスワードの再設定にはAPIで最も厳しい制限があり、すべてサーバー全体で数えられます。クライアント（IPv4アドレスまたはIPv6の/64）ごとに1時間あたり3件（`AUTH_FORGOT_PER_HOUR`）と24時間あたり10件（`AUTH_FORGOT_PER_DAY`）、IPv6の/48ごとにはその3倍で、さらに`auth`の制限（10分あたり20件）と、アドレスごとに5分に1通というメールの制限も加わります。202からも429からも、アカウントがそのアドレスを使っているかどうかはわかりません。新しいパスワードの設定には独自の制限（`auth_reset`）があります。

        **制限**：`auth`、`auth_forgot`、次に`auth_forgot_day`。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: 受け付けました（アドレスにかかわらず）。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedStatus'
              example:
                status: accepted
        '400':
          $ref: '#/components/responses/BadRequest'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`auth`、`auth_forgot`、`auth_forgot_day`のいずれかの制限（アドレスにかかわらず）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`retryAfter: 1`付きの`server_busy`：データベースのロックが解除されなかった。`timeout`。'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: リセットリンクのトークンで新しいパスワードを設定
      description: |-
        リセットリンクのトークンで新しいパスワードを設定します（`/reset-password`ページも同じことを行います）。新しいパスワードは登録時のルールに従います。

        リセットにより、すべてのセッションが失効し、保留中のメールアドレス変更は取り消されます。アカウントのほかのリセットリンクは使えなくなり、アドレスは確認済みとみなされ（リンクがそれを証明したため）、所有者にはメールが届きます。二要素認証は変更されません。パスワードハッシュのキューのエラーや503の後も、リンクは有効なままです。

        **制限**：`auth`、次に`auth_reset`（クライアントごとに1時間あたり10件、IPv6の/48ごとに30件。ページと共有。各試行でパスワードをハッシュ化するため）。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: パスワードが変更され、すべてのセッションがログアウトしました。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: password_reset
              example:
                status: password_reset
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_token`：リンクが無効、使用済み、期限切れ、またはアカウントがもう使っていないアドレスに送られたもの。`weak_password`（`reason`付き）。`invalid_request`、`invalid_json`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`auth`または`auth_reset`の制限、またはパスワードハッシュのキュー。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`：パスワードハッシュのキュー、またはデータベースのロックが解除されなかった（`retryAfter: 1`。何も変更されていません）。`timeout`。'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Googleログインを開始
      description: |-
        PKCEのチャレンジと、`127.0.0.1`で待ち受けるゲームのリスナーのポートに対して、Googleログインの試行を開始します。レスポンスは、システムのブラウザーで開くGoogleのURLと、この試行の`state`を返します。試行は10分間有効です。

        `authUrl`には、`client_id`、`redirect_uri`（`redirectPort`とサーバーのオリジンタグから構築）、`response_type=code`、`scope=openid email profile`、`state`、`nonce`、`code_challenge`（サーバーがGoogle用に持つ独自のベリファイアのS256）と`code_challenge_method=S256`、そして`prompt=select_account`が含まれます。ゲームは開く前にこれを検査します（タグの説明を参照）。

        **制限**：`sso_start`。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: 試行の情報。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoStartAnswer'
              example:
                attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
                authUrl: https://accounts.google.com/o/oauth2/v2/auth?client_id=1234567890-abc.apps.googleusercontent.com&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2Foauth2%2Fgoogle%2FIhcScoV7eDOzTEcSnqPUPt&response_type=code&scope=openid%20email%20profile&state=yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ&nonce=gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU&code_challenge=ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc&code_challenge_method=S256&prompt=select_account
                state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
                expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`：このサーバーではGoogleログインを提供していません。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`sso_start`の制限。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（データベースのロックが解除されなかった）、`timeout`。'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Googleからの応答をサーバーに渡す
      description: |-
        Googleがゲームのリスナーに送った`code`と`state`を、試行のIDとPKCEのベリファイアとともにサーバーに渡します。試行のIDはベリファイアがなければ役に立たず、コードはサーバー自身のPKCEのベリファイアとクライアントシークレットがなければ役に立ちません。

        サーバーは試行（未知、使用済み、期限切れなら410）、次にベリファイア（誤っていても試行は引き続き使えます）を検査し、その後試行を1回分消費して、`state`と`iss`を検査し、Googleとコードを交換してIDトークンを検証します。レスポンス（200）は次のいずれかです：

        - `{ token, expiresAt, user }`：連携済みのアカウントにログインしました。
        - `{ mfaRequired, mfaToken, expiresIn }`：`POST /auth/login/mfa`に進みます。
        - `{ needsUsername, ssoTicket, suggestedUsername }`：新しいアカウント。10分以内に`POST /auth/sso/complete`に進みます。`suggestedUsername`はGoogleの名前またはアドレスから作られ、適したものがなければ`""`です。
        - `{ needsPassword, linkTicket, username, expiresIn }`：そのアドレスのアカウントにパスワードがあります。`POST /auth/sso/google/link`に進みます。この時点ではまだ何も連携されていません。

        **制限**：`sso_finish`。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoFinishRequest'
            example:
              attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
              codeVerifier: Sb5SaoX0ByHGjJ0XQkEqaaPaJYx2BId4ncTT8tLM6W8
              state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
              code: 4/0AVMBsJhR2x7cKq9vT1pLmN3oW8yZ5aB6dE7fG8hJ
              iss: https://accounts.google.com
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: セッション、第2段階、または初回のGoogleログインの次の段階。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: 連携済みのアカウントにログイン
                  value:
                    token: sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
                    expiresAt: 1798658839708
                    user:
                      id: 1
                      username: alice
                      email: alice@example.org
                      emailVerified: true
                      mfaEnabled: false
                      googleLinked: true
                      hasPassword: false
                      acceptChallenges: all
                      createdAt: 1790882839743
                      lastLoginAt: 1790882839708
                      pendingEmail: null
                mfaRequired:
                  summary: 二要素認証がオン
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: 新しいプレイヤー
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: パスワードを持つアカウントがそのアドレスを使用中
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`：ベリファイアが試行のチャレンジと一致しない（試行は引き続き使えます）。`sso_email_unverified`：Googleがアドレスを確認していない。`registration_closed`。`account_disabled`。連携済みのアカウントの場合のみ：`banned`（`until`付き）、`email_unverified`。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`：このサーバーではGoogleログインを提供していません。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`：パスワードのない有効なアカウントがそのアドレスを使っている（どのアカウントかは示されません）。'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`：未知、使用済み、または期限切れの試行。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`sso_finish`の制限。'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（データベースのロックが解除されなかった）、`timeout`。'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: パスワードを使ってGoogleを既存のアカウントと連携
      description: |-
        `finish`が`needsPassword`を返した後に使います。ゲームで入力されたそのアカウントのパスワードを使って、Googleを既存のアカウントと連携します。レスポンス（200）はセッション（連携が保存され、プレイヤーはログイン済み）か、二要素認証がオンの場合は`{ mfaRequired, mfaToken, expiresIn }`です。後者の場合は`POST /auth/login/mfa`に進み、そこでコードが通った時点で初めて連携が保存されます。

        パスワードが誤っていてもチケットは引き続き使えますが、試行は合計5回までです。試行はパスワードを検査する直前に1回分消費されます。429 `too_many_attempts`や428 `pow_required`では消費されませんが、パスワードハッシュのキューによる拒否（503 `server_busy`、429 `rate_limited`）ではすでに1回分が消費されており、アカウントの失敗として数えられます。失敗による待ち時間は、`POST /auth/login`と同じカウンターを使います。

        **制限**：`auth`（IPv6の/48単位のカウントを含む）。**プルーフ・オブ・ワーク**：`POST /auth/login`と同様。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoLinkRequest'
            example:
              linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
              password: correct horse battery
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: セッション、またはログインの第2段階。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`：誤ったパスワード。チケットは、合計5回の試行まで有効なままです。'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned`（`until`付き）。パスワードが正しい場合のみ。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`：このサーバーではGoogleログインを提供していません。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`：その間に、Googleアカウントが別のアカウントと連携された。'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`：未知、使用済み、または期限切れのチケット、5回目の誤ったパスワード、`finish`の後に状態またはアドレスが変わったアカウント、あるいは連携の保存中に状態、アドレス、パスワード、二要素認証のいずれかが変わったアカウント。ゲームから最初からやり直してください。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '428':
          $ref: '#/components/responses/PowRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（アカウントの失敗による待ち時間）、または`rate_limited`（`auth`の制限、パスワードハッシュのキュー）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`：パスワードハッシュのキュー、またはデータベースのロックが解除されなかった（`retryAfter: 1`。何も連携されていません）。`timeout`。'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: 初回のGoogleログインでアカウントを作成
      description: |-
        `finish`が`needsUsername`を返した後に使います。選んだユーザー名（登録時のルールが適用されます）でアカウントを作成し、ログインさせます。このアカウントにはパスワードがありません。`hasPassword`は`false`で、「パスワードをお忘れですか？」（`POST /auth/password/forgot`）でパスワードを設定できます。

        **制限**：`auth`（IPv6の/48単位のカウントを含む）。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoCompleteRequest'
            example:
              ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
              username: alice
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: アカウントが作成され、ログインしました。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`、`invalid_request`、`invalid_json`。'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`：このサーバーではGoogleログインを提供していません。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`（そのユーザー名を持つアカウントがあるか、別のアドレスの確認待ちの仮登録がそのユーザー名を確保している）、`sso_already_linked`、`email_taken`。'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`：未知、使用済み、または期限切れのチケット。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`auth`の制限。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（データベースのロックが解除されなかった）、`timeout`。'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: ログイン中のデバイスを一覧表示
      description: |-
        アカウントの有効なセッション（ログイン中のデバイス）を、最近使われた順に返します。`current`は、リクエストを行っているセッションを示します。`lastSeenAt`の更新は最大で5分に1回です。`expiresAt`は絶対的な終了時刻で、アイドル期限によってそれより早くセッションが終了することもあります。

        **制限**：`sessions`。
      security:
        - bearerAuth: []
      responses:
        '200':
          description: 有効なセッション。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionList'
              example:
                sessions:
                  - id: 3
                    createdAt: 1790882839708
                    lastSeenAt: 1790882839708
                    expiresAt: 1798658839708
                    clientLabel: Laptop
                    current: false
                  - id: 1
                    createdAt: 1790882839708
                    lastSeenAt: 1790882839708
                    expiresAt: 1798658839708
                    clientLabel: Scacelith 1.4 (Windows)
                    current: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`sessions`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（データベースのロックが解除されなかった）、`timeout`。'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: 1台のデバイスをログアウト
      description: |-
        アカウントのセッションを1つログアウトさせます。現在のセッションもログアウトできます。そのセッションで開かれたWebSocketは閉じられます。ボディはなし（または`{}`）。

        **制限**：`sessions`。
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: セッションをログアウトさせました。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: revoked
              example:
                status: revoked
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`：`{}`以外のボディ、または有効なURLエンコーディングでない`id`。`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：このアカウントには、このIDの有効なセッションがない。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`sessions`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（データベースのロックが解除されなかった）、`timeout`。'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: アカウント、レーティング、有効な制裁を取得
      description: |-
        プレイヤー本人から見たアカウント：アカウント情報（すべてのログインのレスポンスの`user`と同じもの）、プレイヤーがレーティング戦を指したカテゴリーごとに1つのレーティング記録、有効な制裁、利用停止。アンチチートのインテグリティレベルは決して表示されません。

        **制限**：`account`。
      security:
        - bearerAuth: []
      responses:
        '200':
          description: アカウント。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountMe'
              example:
                user:
                  id: 1
                  username: alice
                  email: alice@example.org
                  emailVerified: true
                  mfaEnabled: true
                  googleLinked: false
                  hasPassword: true
                  acceptChallenges: all
                  createdAt: 1790882871478
                  lastLoginAt: 1790882902200
                  pendingEmail: alice.new@example.org
                ratings:
                  - category: '3+2'
                    rating: 1510
                    games: 2
                    wins: 1
                    draws: 1
                    losses: 0
                    peak: 1510
                    provisional: true
                sanctions: []
                ban: null
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`unauthorized`、または`invalid_token`（アカウントが削除された場合も）。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`account`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（データベースのロックが解除されなかった）、`timeout`。'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: 直接の対局申し込みを受け付けるか拒否するかを設定
      description: |-
        ほかのプレイヤーが、名前を指定してこのプレイヤーに対局を申し込めるかどうかを設定します。`none`の場合、直接の申し込みは拒否され、申し込んだ側にはそのプレイヤーが対局できないと伝えられます。再認証は不要です。

        **制限**：`account`。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: 現在有効な設定。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - preferences
                properties:
                  preferences:
                    $ref: '#/components/schemas/Preferences'
              example:
                preferences:
                  acceptChallenges: none
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`account`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（データベースのロックが解除されなかった）、`timeout`。'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: パスワードを変更
      description: |-
        パスワードを変更します。現在のパスワードが必要ですが、二要素認証がオンでも第二要素は不要です。新しいパスワードは登録時のルールに従います（現在のパスワードの後に検査されます）。

        変更により、ほかのすべてのセッションが失効し（このセッションはログインしたままです）、保留中のメールアドレス変更は取り消され、アカウントのパスワードリセットのリンクは使えなくなり、所有者にメールが送られます。

        **再認証**：パスワードのみ。**制限**：`reauth`、次に`reauth_user`。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordChangeRequest'
            example:
              currentPassword: correct horse battery
              newPassword: a much better passphrase
      responses:
        '200':
          description: パスワードを変更しました。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: password_changed
              example:
                status: password_changed
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set`（Googleでのみログインするアカウント）、`weak_password`（`reason`付き）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`：現在のパスワードが誤っている、またはリクエストの検査中にパスワードのリセットや変更が行われた。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（アカウントの再認証の失敗）、または`rate_limited`（`reauth`または`reauth_user`の制限、アカウント単位の割り当て、パスワードハッシュのキュー）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（パスワードハッシュのキュー、またはデータベースのロックが解除されなかった）、`timeout`。'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: 二要素認証の有効化を開始
      description: |-
        保留中の新しい認証アプリ用シークレットを保存し（以前の保留中のシークレットは置き換えられます）、認証アプリ向けに返します（テキストと、QRコードとして表示する`otpauth://` URIの両方で）。二要素認証はまだオンになっていません。このシークレットのコードを使って、`POST /account/mfa/totp/enable`でオンにします。

        **再認証**：パスワードのみ。**制限**：`reauth`、次に`reauth_user`。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: 保留中のシークレット。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TotpSetup'
              example:
                secret: OCKJVMPMMKMPLBSIYLN6QQRMKIPC2VLH
                uri: otpauth://totp/Scacelith:alice?secret=OCKJVMPMMKMPLBSIYLN6QQRMKIPC2VLH&issuer=Scacelith&algorithm=SHA1&digits=6&period=30
                algorithm: SHA1
                digits: 6
                period: 30
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set`（Googleでのみログインするアカウント）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`（パスワードより先に検査）。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（アカウントの再認証の失敗）、または`rate_limited`（`reauth`または`reauth_user`の制限、アカウント単位の割り当て、パスワードハッシュのキュー）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（パスワードハッシュのキュー、またはデータベースのロックが解除されなかった）、`timeout`。'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: 二要素認証の有効化を完了し、リカバリーコードを取得
      description: |-
        保留中のシークレットのコード（ちょうど6桁）で二要素認証をオンにします。パスワードはセットアップ時に入力済みなので、ここでは求められません。レスポンスには10個のリカバリーコードが含まれ、表示されるのはこの1回だけです。

        **制限**：`reauth`、次に`reauth_user`。誤ったコードは、アカウントの再認証の失敗として数えられます。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: 二要素認証がオンになりました。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - recoveryCodes
                properties:
                  status:
                    const: mfa_enabled
                  recoveryCodes:
                    $ref: '#/components/schemas/RecoveryCodeList'
              example:
                status: mfa_enabled
                recoveryCodes:
                  - j7v5-3ezx-zn
                  - 4kqm-8w2p-hd
                  - x0ra-c6tn-5g
                  - mb3s-9yzj-k1
                  - 2hvd-pq7e-w8
                  - c5ng-0tka-xr
                  - zz4y-b1me-7q
                  - 8pjh-6dsw-3n
                  - t9xc-2gfk-0v
                  - q6wa-ner5-jy
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`：`code`がちょうど6桁ではない、またはボディがスキーマに違反している。`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`：誤ったコード（デバイスの時刻を確認してください）。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`。`mfa_setup_required`：保留中のシークレットがない（先に`POST /account/mfa/totp/setup`を呼び出してください）。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（アカウントの再認証の失敗）、または`rate_limited`（`reauth`または`reauth_user`の制限、アカウント単位の割り当て）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（データベースのロックが解除されなかった）、`timeout`。'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: 二要素認証をオフにする
      description: |-
        二要素認証をオフにします。シークレットとリカバリーコードは削除され、所有者にメールが送られます。

        **再認証**：パスワードと、認証アプリのコードまたはリカバリーコード（`code`か`recoveryCode`のどちらかが必須）。**制限**：`reauth`、次に`reauth_user`。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: 二要素認証がオフになりました。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: mfa_disabled
              example:
                status: mfa_disabled
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set`（Googleでのみログインするアカウント）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required`（`code`も`recoveryCode`もない。パスワードより先に検査）、`invalid_password`、`invalid_code`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_not_enabled`。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（再認証の失敗、またはこのアカウントで試したコードが多すぎる）、または`rate_limited`（`reauth`または`reauth_user`の制限、アカウント単位の割り当て、パスワードハッシュのキュー）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（パスワードハッシュのキュー、またはデータベースのロックが解除されなかった）、`timeout`。'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: リカバリーコードを再発行
      description: |-
        リカバリーコードを10個の新しいコードに置き換えます。古いコードは使えなくなります。

        **再認証**：パスワードと、`code`に入れた認証アプリのコード（リカバリーコードは403 `invalid_code`で拒否されます）。**制限**：`reauth`、次に`reauth_user`。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecoveryCodesRequest'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: 新しいリカバリーコード。表示されるのはこの1回だけです。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - recoveryCodes
                properties:
                  recoveryCodes:
                    $ref: '#/components/schemas/RecoveryCodeList'
              example:
                recoveryCodes:
                  - j7v5-3ezx-zn
                  - 4kqm-8w2p-hd
                  - x0ra-c6tn-5g
                  - mb3s-9yzj-k1
                  - 2hvd-pq7e-w8
                  - c5ng-0tka-xr
                  - zz4y-b1me-7q
                  - 8pjh-6dsw-3n
                  - t9xc-2gfk-0v
                  - q6wa-ner5-jy
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set`（Googleでのみログインするアカウント）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`、`mfa_code_required`、`invalid_code`（リカバリーコードの場合も）。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_not_enabled`。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（再認証の失敗、またはこのアカウントで試したコードが多すぎる）、または`rate_limited`（`reauth`または`reauth_user`の制限、アカウント単位の割り当て、パスワードハッシュのキュー）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（パスワードハッシュのキュー、またはデータベースのロックが解除されなかった）、`timeout`。'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: メールアドレスを変更
      description: |-
        アカウントのメールアドレスを変更します。`newEmail`は前後の空白が除かれて小文字に変換された後、登録時のアドレスと同様に検査されます。

        **メール確認あり**（既定）：202 `verification_sent`。24時間有効なリンクが新しいアドレスに送られます。リンクは`/confirm-email-change`を開き、プレイヤーがそのページのボタンを押した時点で初めてアドレスが変更されます。それまでは、`GET /account/me`が新しいアドレスを`pendingEmail`として示します。新しいリクエストは保留中のリクエストを置き換え、パスワードの変更やリセットは保留中のリクエストを取り消します。同じ新しいアドレスに送られるリンクは、誰が要求しても5分に1通までです（すでに保留中の変更と同じ要求では、先に送られたリンクがそのまま有効です）。レスポンスは変わりません。現在のアドレスには、マスクされたアドレス（`a***@example.org`）への変更が要求されたという通知が届きます。別のアカウントがすでに新しいアドレスを使っている場合も、レスポンスと`pendingEmail`は同じです。その場合リンクは送られないため変更は完了せず、代わりにそのアドレスの所有者に通知が届きます（1時間に1通まで）。

        リンクで確認されると、アドレスが変更されて確認済みとみなされ、デバイスはログインしたままとなり、以前に送られたリンク（確認、パスワードリセット、ほかの変更）は使えなくなり、以前のアドレスには新しいアドレスをマスクした通知が届きます。

        **メール確認なし**（`REQUIRE_EMAIL_VERIFICATION=false`）：アドレスはただちに変更され（200 `email_changed`）、以前のアドレスに通知が届きます。別のアカウントがそのアドレスを使っている場合は409 `email_taken`が返り、そのアドレスの所有者に通知が届きます。

        **再認証**：パスワードと、二要素認証がオンの場合は認証アプリのコードまたはリカバリーコード。`invalid_email`と`same_email`はパスワードより先に検査されるため、失敗には数えられません。**制限**：`reauth`、次に`reauth_user`。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailChangeRequest'
            example:
              newEmail: alice.new@example.org
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: アドレスがただちに変更されました（このサーバーではメール確認なし）。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - email
                properties:
                  status:
                    const: email_changed
                  email:
                    type: string
                    format: email
                    description: 保存された新しいアドレス。
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: 確認リンクが新しいアドレスに送られました（または、そのアドレスは別のアカウントのものです。レスポンスからはどちらかわかりません）。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationSentStatus'
              example:
                status: verification_sent
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_email`、`same_email`（アカウントの現在のアドレス）、`password_not_set`（Googleでのみログインするアカウント）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`（パスワードの変更やリセットがリクエストより先に完了した場合も。リンクは送られません）、`mfa_code_required`、`invalid_code`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`：メール確認なしの場合のみ。'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（再認証の失敗、またはこのアカウントで試したコードが多すぎる）、または`rate_limited`（`reauth`または`reauth_user`の制限、アカウント単位の割り当て、パスワードハッシュのキュー）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`：パスワードハッシュのキュー、またはデータベースのロックが解除されなかった（`retryAfter: 1`。何も変更されておらず、同じリクエストを再送できます）。`timeout`。'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: アカウントのデータをダウンロード
      description: |-
        サーバーがアカウントについて保持しているすべての情報を、保存用の1つのJSONファイルとして返します（`format`は`scacelith-account-export`、`version`は1）。エクスポートはセキュリティイベント（`account_exported`）として記録されます。ドキュメントの`notes`配列は、何が除かれているかを平易な英語でプレイヤーに説明します。

        **エクスポートに決して含まれないもの**：パスワードハッシュ、二要素認証のシークレットとリカバリーコード。あらゆるセッションやリンクのトークン、およびそのハッシュ。アンチチートのデータ（インテグリティレベルとスコア、異常、対局の解析、報告の重み）。ほかのプレイヤーがこのプレイヤーについて行った報告。モデレーターの身元。ほかのプレイヤーの非公開データ（対戦相手は公開名とレーティングで示され、ほかのプレイヤーが制裁を受けたかどうかを示すものはなく、他人のものである可能性のあるIPアドレスは含まれません）。

        **再認証**：パスワードと、二要素認証がオンの場合は認証アプリのコードまたはリカバリーコード。**制限**：`account_export`（サーバー全体で、プレイヤーごとに1時間あたり5件。失敗も含めすべての試行を数え、最初に検査されます）、次に`reauth`と`reauth_user`。**時間制限**：60秒。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: エクスポートのドキュメント（添付ファイルとして）。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`。ユーザー名に含まれる英字、数字、`_`、`.`、`-`以外の文字は`_`に置き換えられます。'
              schema:
                type: string
                pattern: '^attachment; filename="scacelith-account-[A-Za-z0-9_.-]+\.json"$'
              example: attachment; filename="scacelith-account-alice.json"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountExport'
              example:
                format: scacelith-account-export
                version: 1
                exportedAt: 1790882839708
                server:
                  name: Scacelith
                  host: caissa.scacelith.com
                notes:
                  - This file holds the data Scacelith keeps about your account. Times are milliseconds since 1970-01-01 UTC.
                account:
                  id: 1
                  username: alice
                  email: alice@example.org
                  emailVerified: true
                  pendingEmail: null
                  mfaEnabled: false
                  googleLinked: false
                  googleEmail: null
                  hasPassword: true
                  acceptChallenges: all
                  createdAt: 1790882839743
                  lastLoginAt: 1790882839708
                ratings:
                  - category: '3+2'
                    rating: 1510
                    games: 2
                    wins: 1
                    draws: 1
                    losses: 0
                    peak: 1510
                    provisional: true
                    rated: true
                    countedGames: 2
                    updatedAt: 1790882839809
                ratingRefunds:
                  - day: 1790812800000
                    category: '3+2'
                    points: 9
                sessions:
                  - id: 1
                    createdAt: 1790882839708
                    lastSeenAt: 1790882839708
                    expiresAt: 1798658839708
                    revokedAt: null
                    clientLabel: Scacelith 1.4 (Windows)
                    ip: 203.0.113.7
                securityEvents:
                  - kind: login
                    at: 1790882839708
                    ip: 203.0.113.7
                    detail:
                      method: password
                sanctions: []
                conduct:
                  - kind: abort
                    at: 1790800000000
                reportsFiled:
                  - gameId: 4100000000001
                    reported: bob
                    category: other
                    comment: rude
                    createdAt: 1790882840000
                    status: open
                games:
                  total: 1
                  list:
                    - id: 4100000000001
                      category: '3+2'
                      rated: true
                      timeControl: '180+2'
                      white:
                        name: alice
                        rating: 1500
                        ratingAfter: 1510
                        ratingDiff: 10
                      black:
                        name: bob
                        rating: 1520
                        ratingAfter: 1510
                        ratingDiff: -10
                      color: white
                      status: 1
                      reason: 2
                      result: 1-0
                      termination: Resignation
                      plies: 41
                      startedAt: 1790620205000
                      endedAt: 1790620611000
                      baseMs: 180000
                      incMs: 2000
                      outcome: win
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set`（Googleでのみログインするアカウント）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`、`mfa_code_required`、`invalid_code`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（再認証の失敗、またはこのアカウントで試したコードが多すぎる）、または`rate_limited`（`account_export`、`reauth`、`reauth_user`のいずれかの制限、アカウント単位の割り当て、パスワードハッシュのキュー）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（データベースのロックが解除されなかった。`Retry-After: 1`ヘッダー付き）、`server_busy`（パスワードハッシュのキュー）、`timeout`（60秒）。'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: アカウントを削除
      description: |-
        アカウントを削除します。元に戻すことはできません。

        - すべてのセッションがただちに失効します。以降、そのトークンには401 `invalid_token`が返ります。
        - アカウントとすべての対局記録で、ユーザー名は`deleted#<id>`になります。
        - 次のものが消去されます：メールアドレス、パスワードハッシュ、二要素認証のシークレットとリカバリーコード、セッションとリンクのトークン、Googleアカウントとの連携、アンチチートのインテグリティ記録、セキュリティイベントとともに保存されたIPアドレス。
        - レーティングと対局は保持されます。対局は匿名の名前で引き続き閲覧でき、以前の名前に対する`GET /players/{username}`は404を返します。

        **再認証**：パスワードと、二要素認証がオンの場合は認証アプリのコードまたはリカバリーコード。**制限**：`reauth`、次に`reauth_user`。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: アカウントを削除しました。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: deleted
              example:
                status: deleted
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set`（Googleでのみログインするアカウント）、`invalid_request`、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`、`mfa_code_required`、`invalid_code`。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`（再認証の失敗、またはこのアカウントで試したコードが多すぎる）、または`rate_limited`（`reauth`または`reauth_user`の制限、アカウント単位の割り当て、パスワードハッシュのキュー）。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（パスワードハッシュのキュー、またはデータベースのロックが解除されなかった）、`timeout`。'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: プレイヤーの対局を一覧表示（絞り込みとページ分割付き）
      description: |-
        ログイン中のプレイヤーの対局を新しい順に、絞り込みとページ分割を適用して、条件に一致する対局の数とともに返します。クエリパラメーターはすべて任意で、空の値は指定なしとみなされます。

        ページ送り：前のページの`next`を`before`として渡します。最後のページでは`next`は`null`です。`total`は、全ページを通じて条件に一致する対局の数です。

        **制限**：`account_games`。
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: 公式カテゴリーのID（`3+2`、または`3%2B2`）、またはそれ以外の持ち時間のすべての対局を表す`custom`。
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: レーティング戦（`true`）またはカジュアル戦（`false`）の対局のみ。
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: プレイヤーから見て勝った、負けた、または引き分けた対局のみ。中止された対局は、この絞り込みがない場合にだけ表示されます。
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: 履歴の1ページ。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HistoryPage'
              example:
                games:
                  - id: 4100000000001
                    category: '3+2'
                    rated: true
                    timeControl: '180+2'
                    white:
                      name: alice
                      rating: 1500
                      ratingAfter: 1510
                      ratingDiff: 10
                    black:
                      name: bob
                      rating: 1520
                      ratingAfter: 1510
                      ratingDiff: -10
                    color: white
                    status: 1
                    reason: 2
                    result: 1-0
                    termination: Resignation
                    plies: 41
                    startedAt: 1790620205000
                    endedAt: 1790620611000
                    baseMs: 180000
                    incMs: 2000
                    outcome: win
                next: null
                total: 1
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_cursor`（`before`が対局IDではない）、`invalid_limit`、または`invalid_filter`（`category`、`rated`、`result`）。いずれも、`field`がパラメーターを示します。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`account_games`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（データベースのロックが解除されなかった。ボディに`retryAfter: 1`があり、`Retry-After`ヘッダーはありません）、`server_busy`（セッションの照会でデータベースがロックされていた）、`timeout`。'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: 指し手と時計を含む対局の記録を取得
      description: |-
        指し手と時計を含む、1局の対局記録。トークンがない場合、またはその対局を指していないプレイヤーのトークンの場合は、公開版のレスポンスが返ります。トークンのプレイヤーがその対局を指した場合は、レスポンスに`you`と`reportable`が加わります。

        **制限**：`public_read`（トークンがあればプレイヤー単位、なければクライアント単位）。
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: 対局の記録。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GameRecord'
              example:
                id: 4100000000001
                category: '3+2'
                rated: true
                timeControl: '180+2'
                white:
                  name: alice
                  rating: 1500
                  ratingAfter: 1510
                  ratingDiff: 10
                black:
                  name: bob
                  rating: 1520
                  ratingAfter: 1510
                  ratingDiff: -10
                status: 1
                reason: 2
                result: 1-0
                termination: Resignation
                plies: 2
                startedAt: 1790620205000
                endedAt: 1790620611000
                baseMs: 180000
                incMs: 2000
                statusName: WhiteWins
                rematchOf: null
                moves:
                  - uci: e2e4
                    spentMs: 0
                    clockMs: 180000
                  - uci: e7e5
                    spentMs: 1700
                    clockMs: 180300
                pgn:
                  Event: Scacelith rated 3+2
                  Site: caissa.scacelith.com
                  Date: '2026.09.28'
                  Round: '-'
                  White: alice
                  Black: bob
                  Result: 1-0
                  WhiteElo: 1500
                  BlackElo: 1520
                  TimeControl: '180+2'
                  Termination: Resignation
                  PlyCount: 2
                you: white
                reportable: true
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_game_id`：最大16桁（2^53未満）の正の整数ではない。`invalid_request`：有効なURLエンコーディングではない。'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`：トークンが送られたが、有効ではない。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：その対局は存在しない。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`public_read`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（データベースのロックが解除されなかった。ボディに`retryAfter: 1`があり、`Retry-After`ヘッダーはありません）、`server_busy`（セッションの照会でデータベースがロックされていた）、`timeout`。'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: 対局をPGNファイルとしてダウンロード
      description: |-
        同じ対局をPGNファイルとして返します。改行コードが`\n`の1局分の対局で、棋譜テキストは1行80桁未満です。レスポンスはトークンの有無にかかわらず同じです。

        タグは次の順に並びます：`Event`（`<SERVER_NAME> rated <category>`または`<SERVER_NAME> casual <category>`）、`Site`（`SERVER_PUBLIC_HOST`）、`Date`（UTCでの開始日）、`Round`、`White`、`Black`、`Result`（中止された対局では`*`）、`UTCDate`と`UTCTime`（開始時点）、`WhiteElo`と`BlackElo`（開始時点のレーティング、または`-`）、`WhiteRatingDiff`と`BlackRatingDiff`（`+10`や`-10`のような変動。すべてのレーティング戦に付き、レーティングの規則によってレーティングが変わらなかった場合は`+0`。カジュアル戦、カスタムの持ち時間の対局、中止された対局にはどちらもありません）、`TimeControl`（秒単位）、`Termination`（PGN標準の値：`normal`。`time forfeit`は時間切れで、引き分けに終わった場合も含みます。`abandoned`。`rules infraction`は2回目の反則手またはフェアプレー違反による不戦敗。`unterminated`は中止された対局）、`PlyCount`、`ScacelithGameId`（10進数のID）。

        各指し手には`{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`が付きます。指した側の指し手後の時計の残り時間と、その手に費やした時間で、単位は0.1秒（切り捨て）です。記録に値がない場合は省略されます。最後の指し手の後には、終局理由の文言、続いて結果が書かれます。

        **制限**：`public_read`（トークンがあればプレイヤー単位、なければクライアント単位）。
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: PGNファイル。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-<id>.pgn"`。'
              schema:
                type: string
                pattern: '^attachment; filename="scacelith-[1-9][0-9]{0,15}\.pgn"$'
              example: attachment; filename="scacelith-4100000000001.pgn"
          content:
            application/x-chess-pgn:
              schema:
                type: string
                description: 'UTF-8のPGNテキスト（`Content-Type: application/x-chess-pgn; charset=utf-8`）。'
              example: |
                [Event "Scacelith rated 3+2"]
                [Site "caissa.scacelith.com"]
                [Date "2026.09.28"]
                [Round "-"]
                [White "alice"]
                [Black "bob"]
                [Result "1-0"]
                [UTCDate "2026.09.28"]
                [UTCTime "18:30:05"]
                [WhiteElo "1500"]
                [BlackElo "1520"]
                [WhiteRatingDiff "+10"]
                [BlackRatingDiff "-10"]
                [TimeControl "180+2"]
                [Termination "normal"]
                [PlyCount "2"]
                [ScacelithGameId "4100000000001"]

                1. e4 {[%clk 0:03:00.0] [%emt 0:00:00.0]} 1... e5 {[%clk 0:03:00.3]
                [%emt 0:00:01.7]} {Resignation} 1-0
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_game_id`：最大16桁（2^53未満）の正の整数ではない。`invalid_request`：有効なURLエンコーディングではない。'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`：トークンが送られたが、有効ではない。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：その対局は存在しない。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`public_read`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`：たとえば、保存された指し手を再生しても保存された終局に到達できない場合（サーバーがログに記録します）。'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（データベースのロックが解除されなかった。ボディに`retryAfter: 1`があり、`Retry-After`ヘッダーはありません）、`server_busy`（セッションの照会でデータベースがロックされていた）、`timeout`。'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: このサーバーの対局をアニメーションGIFとしてダウンロード
      description: |-
        対局をアニメーションGIFとして返します。名前とレーティングは対局記録のもので（開始時点のレーティング。削除されたアカウントは`deleted#<id>`）、結果と終局理由（`Resignation`、`Loss on time`など）も同様です。

        検査は次の順に行われます：`gif_disabled`、画像のオプション（`size`、`orientation`、`delay`、`coords`）、対局ID、対局、その長さ。

        **セッション必須**：割り当てはアカウント単位で数えられます。**制限**：すべてのリクエストで`gif`。GIFを作成する必要がある場合のみ`gif_user_min`、`gif_user_hour`、`gif_ip_min`、`gif_ip_hour`（タグの説明を参照）。**時間制限**：`GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5秒（既定では45秒）。
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: 画像のサイズ。`small`（マス32 px）、`medium`（48 px）、`large`（72 px）のいずれか。
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: 盤の下側（手前）に置く側。
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: 1手あたりのミリ秒（10進数で1～6桁）。
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: 盤の周囲にファイルの文字とランクの数字を描くか（`1`）、描かないか（`0`）。
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`field`（`size`、`orientation`、`delay`、`coords`）付きの`invalid_option`：許可されていない値。`invalid_game_id`。`invalid_request`：有効なURLエンコーディングではない。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：その対局は存在しない。`gif_disabled`：サーバーがGIFを無効にしている（`GIF_ENABLED=false`。`gif`のトークンは返却されます）。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`gif`の制限、描画の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`：GIFを作成できなかった（理由はサーバーがログに記録します）。`internal_error`。'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`retryAfter`（3～10秒）と`Retry-After`付きの`server_busy`：描画キューが満杯、またはGIFが空きスレッドを`GIF_QUEUE_TIMEOUT_MS`（10秒）待った。リクエストが消費した制限のトークンはすべて返却されます。`busy`：データベースのロックが解除されなかった（`retryAfter: 1`はボディのみ）。`timeout`。'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: PGNとして送った任意の対局のアニメーションGIFを作成
      description: |-
        PGNテキストとして送った任意の対局について、`GET /games/{id}/gif`と同じ画像を作成します。ゲームが保存した対局、ほかのサイトからのエクスポート、手入力した対局などに使えます。テキスト内の最初の対局だけが使われます。

        - PGNリーダーは、ゲーム自体のリーダーと同じものを受け付けます。このサーバーが書き出すすべてのPGNと、ほかのサイトの一般的なエクスポートです（コメント、変化、NAG、時計の注釈は読み飛ばされ、手番号とSANは寛容に読み取られます。`SetUp`が`"0"`でない限り、`FEN`タグが開始局面を指定します）。
        - 名前とレーティングは、`White`、`Black`、`WhiteElo`、`BlackElo`タグから取られます。アクセント付きの文字はアクセントが除かれ、それ以外で印字可能なASCIIでない文字は`?`になり、長い名前は切り詰められます（48文字）。結果は`Result`タグから、なければ棋譜テキストの末尾から取られます。`Termination`タグは、`normal`でない限り表示されます。表示されない場合は、最終局面が結末を物語ります（チェックメイト、ステイルメイトなど）。
        - このエンドポイントはボディを独自に検査します。未知のフィールドがある場合、または`pgn`がないか文字列でない場合は、`field`付きで400 `invalid_request`が返ります。誤ったオプションには400 `invalid_option`が返ります（`delayMs`はJSONの数値、`coords`は真偽値でなければならず、`null`は拒否されます）。

        **セッション必須**：割り当てはアカウント単位で数えられます。**制限**：`GET /games/{id}/gif`と同様。**ボディの上限**：`HTTP_BODY_LIMIT`にかかわらず135,168バイト（JSON文字列としてのPGN。エスケープを含みます）。**時間制限**：既定では45秒。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: 手入力した短い対局（座標なし）
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: すべてのオプションを指定したPGNファイル
                value:
                  pgn: |
                    [White "alice"]
                    [Black "bob"]
                    [WhiteElo "1500"]
                    [BlackElo "1520"]
                    [Result "1-0"]

                    1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0
                  size: small
                  orientation: black
                  delayMs: 800
                  coords: true
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`field`付きの`invalid_request`（未知のフィールド、または`pgn`がないか文字列でない。ボディがオブジェクトでない場合は`field`なし）。`invalid_json`。`field`（`size`、`orientation`、`delayMs`、`coords`）付きの`invalid_option`。`line`と`column`（1始まり。列は文字単位）とリーダーの`message`付きの`invalid_pgn`：反則手またはあいまいな指し手、壊れたタグ、未知のバリアント、65,536バイト超。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`：サーバーがGIFを無効にしている（`GIF_ENABLED=false`。`gif`のトークンは返却されます）。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`：135,168バイトを超えるボディ。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`gif`の制限、描画の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`：GIFを作成できなかった（理由はサーバーがログに記録します）。`internal_error`。'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`retryAfter`（3～10秒）と`Retry-After`付きの`server_busy`：描画キューが満杯、またはGIFが空きスレッドを長く待ちすぎた。リクエストが消費した制限のトークンはすべて返却されます。`timeout`。'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: プレイヤーの公開プロフィールを取得
      description: |-
        プレイヤーの公開プロフィール：公式カテゴリーごとのレーティング（サーバーの順序で）と対局数。`games.total`は、カジュアル戦や中止された対局を含む、保存されたすべての対局を数えます。`games.rated`、`wins`、`draws`、`losses`はレーティング記録の合計なので、レーティング戦だけが対象です。レスポンスはトークンの有無にかかわらず同じです。

        **制限**：`public_read`（トークンがあればプレイヤー単位、なければクライアント単位）。
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: プロフィール。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerProfile'
              example:
                username: alice
                createdAt: 1790882839743
                ratings:
                  - category: '3+2'
                    rating: 1510
                    provisional: true
                    games: 2
                    wins: 1
                    draws: 1
                    losses: 0
                    peak: 1510
                games:
                  total: 3
                  rated: 2
                  wins: 1
                  draws: 1
                  losses: 0
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`：`[A-Za-z0-9_.-]`の2～24文字ではない。'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`：トークンが送られたが、有効ではない。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：そのプレイヤーは存在しない、または削除されたアカウント。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`public_read`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（データベースのロックが解除されなかった。ボディに`retryAfter: 1`があり、`Retry-After`ヘッダーはありません）、`server_busy`（セッションの照会でデータベースがロックされていた）、`timeout`。'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: プレイヤーの最近の対局を一覧表示
      description: |-
        プレイヤーの最近の対局を新しい順に返します。履歴と同じようにページ分割されますが、絞り込みと総数はありません。`color`はこのプレイヤーの色です。ページが満杯のときは常に`next`が最後の対局のIDになるため、次のページが空の場合もあります。プレイヤーが最初に照会されるため、未知のプレイヤーにはクエリの内容にかかわらず404が返ります。

        **制限**：`public_read`（トークンがあればプレイヤー単位、なければクライアント単位）。
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: プレイヤーの対局の1ページ。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerGamesPage'
              example:
                username: alice
                games:
                  - id: 4100000000001
                    category: '3+2'
                    rated: true
                    timeControl: '180+2'
                    white:
                      name: alice
                      rating: 1500
                      ratingAfter: 1510
                      ratingDiff: 10
                    black:
                      name: bob
                      rating: 1520
                      ratingAfter: 1510
                      ratingDiff: -10
                    color: white
                    status: 1
                    reason: 2
                    result: 1-0
                    termination: Resignation
                    plies: 41
                    startedAt: 1790620205000
                    endedAt: 1790620611000
                next: 4100000000001
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`、`invalid_cursor`（`before`が対局IDではない）、`invalid_limit`（後の2つは`field`なし）。'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`：トークンが送られたが、有効ではない。'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`：そのプレイヤーは存在しない、または削除されたアカウント。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：`public_read`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（データベースのロックが解除されなかった。ボディに`retryAfter: 1`があり、`Retry-After`ヘッダーはありません）、`server_busy`（セッションの照会でデータベースがロックされていた）、`timeout`。'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: カテゴリーの上位プレイヤーを取得
      description: |-
        公式カテゴリーで、集計対象の対局が`minGames`（`PROVISIONAL_GAMES`）局以上あるレーティング記録の上位100件。削除されたアカウントと、不正が確認されたプレイヤーは除かれます。サーバーは各ランキングを最大で10秒に1回計算し直します。その時刻は`updatedAt`が示します。

        **制限**：アドレス単位の層のみ。
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: 公式カテゴリーのID（`3+2`、または`3%2B2`）。
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: プレイヤーの人数（1～100）。それより大きい3桁までの数は100とみなされ、空の値は指定なしとみなされます。
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: ランキング。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Leaderboard'
              example:
                category: '3+2'
                minGames: 30
                updatedAt: 1790882839828
                players:
                  - rank: 1
                    username: bob
                    rating: 1874
                    games: 212
                    wins: 120
                    draws: 30
                    losses: 62
                    peak: 1901
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_category`（指定がない、または公式カテゴリーではない）、`invalid_limit`。'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：アドレス単位の層。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`（データベースのロックが解除されなかった。ボディに`retryAfter: 1`があり、`Retry-After`ヘッダーはありません）、`timeout`。'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: 最近の対局の相手を報告
      description: |-
        プレイヤー自身の対局のうち、直近7日以内に終了した対局の相手を報告します。報告それ自体がレーティング、制裁、インテグリティレベルを変えることはありません。報告は、モデレーターが見る審査の優先度を上げ、`abuse`以外の場合は、その対局のエンジン解析を要求します。

        同じ対局で同じ相手を再度報告しても、同じレスポンスが返り、何も変わりません。レスポンスが報告されたアカウントについて何かを伝えることはありません。報告が受け付けられるかどうかは、`GET /games/{id}`が対局者に事前に伝えます（`reportable`）。

        このエンドポイントは、ボディを`gameId`、`reported`、`category`、`comment`の順に独自に検査します。失敗すると、`field`なしで400 `invalid_request`が返ります。未知のフィールドは無視されます。

        **制限**：`reports`（プレイヤー単位）、次にプレイヤーごとに24時間あたり`REPORTS_PER_DAY`（5）件の報告（429 `report_limit`）。
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportRequest'
            example:
              gameId: 4100000000001
              reported: bob
              category: cheating
              comment: engine-like play
      responses:
        '202':
          description: 報告を受け付けました（または、すでに受け付け済みです）。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: received
              example:
                status: received
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`（`field`なし）、`invalid_json`。'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`：直近7日以内に終了した対局における、報告者の対戦相手ではない。'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`report_limit`（`retryAfter: 3600`、`Retry-After`ヘッダー付き）：直近24時間に`REPORTS_PER_DAY`件の報告を行った。`rate_limited`：`reports`の制限、またはアカウント単位の割り当て。'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`（セッションの照会でデータベースがロックされていた）、`timeout`。'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: 公式サーバー（ページは`/api/v1`の外、ルートにあります）。
      - url: https://{host}:{port}
        description: 任意のScacelithサーバー（ページは`/api/v1`の外、ルートにあります）。
        variables:
          host:
            default: caissa.scacelith.com
            description: サーバーの公開ホスト名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公開APIポート（`PUBLIC_API_PORT`、未設定なら`API_PORT`）。
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: メールアドレス確認ページを表示
      description: |-
        メールアドレス確認リンク（登録時、または再送された確認メール）のページ。"Confirm my e-mail address"（メールアドレスを確認）ボタンを表示するだけなので、メールスキャナーがリンクを開いてもリンクは消費されません。ボタンはフォームを`POST /verify-email`に送信します。

        **制限**：`page`。
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: 確認ボタンのあるページ。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: リンクが無効または期限切れ（HTMLページ）。
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`page`の制限（HTMLページ）、またはアドレス単位の層（JSONの`rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: メールアドレスを確認
      description: |-
        確認ページのフォーム。アドレスを確認します。新しい仮登録の場合はこの時点でアカウントが作成され、プレイヤーはログインできるようになります。

        **制限**：`auth`。
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
          application/json:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: アドレスが確認されました（新しい仮登録の場合はアカウントも作成されました）。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: リンクが無効、使用済み、または期限切れ、あるいはフォームが無効（HTMLページ）。
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: その間にユーザー名またはアドレスを別のアカウントに取られた、新しい仮登録のリンク。アカウントは作成されません（HTMLページ）。
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`auth`の制限（HTMLページ）、またはアドレス単位の層（JSONの`rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'データベースのロックが解除されなかった（`Retry-After: 1`）。何も変更されておらず、リンクは引き続き使えます。ハンドラーのタイムアウトの場合もこのステータスです。'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: 公式サーバー（ページは`/api/v1`の外、ルートにあります）。
      - url: https://{host}:{port}
        description: 任意のScacelithサーバー（ページは`/api/v1`の外、ルートにあります）。
        variables:
          host:
            default: caissa.scacelith.com
            description: サーバーの公開ホスト名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公開APIポート（`PUBLIC_API_PORT`、未設定なら`API_PORT`）。
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: パスワードリセットのフォームを表示
      description: |-
        パスワードリセットリンクのページ：新しいパスワードのフォーム（パスワードを2回入力）。フォームは`POST /reset-password`に送信されます。

        **制限**：`page`。
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: 新しいパスワードのフォーム。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: リンクが無効（アカウントがもう使っていないアドレスに送られたリンクの場合も）。
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`page`の制限（HTMLページ）、またはアドレス単位の層（JSONの`rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: リセットフォームから新しいパスワードを設定
      description: |-
        リセットページのフォーム。`POST /auth/password/reset`と同じ処理を行います：すべてのデバイスがログアウトされ、保留中のメールアドレス変更は取り消され、ほかのリセットリンクは使えなくなり、アドレスは確認済みとみなされ、所有者にメールが届きます。

        まずリンク、次に2つのパスワードが一致すること、最後にパスワードのルールが検査されます。サーバーが混雑している場合は`Retry-After`付きでフォームが再表示され、リンクは有効なままです。

        **制限**：`auth`、次に`auth_reset`（`POST /auth/password/reset`と共有）。
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/ResetPasswordForm'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
              confirmPassword: a much better passphrase
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordForm'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
              confirmPassword: a much better passphrase
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: パスワードが変更され、すべてのデバイスがログアウトしました。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: エラー付きで再表示されたフォーム（パスワードが一致しない、弱いパスワード）、リンクが無効、またはフォームが無効（HTMLページ）。
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`auth`または`auth_reset`の制限（HTMLページ）、このクライアントの待機中のパスワードハッシュが多すぎる場合に`Retry-After`付きで再表示されたフォーム（リンクは有効なままです）、またはアドレス単位の層（JSONの`rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: サーバーが混雑している場合（パスワードハッシュのキュー、またはデータベースのロックが解除されなかった）に`Retry-After`付きで再表示されたフォーム。リンクは有効なままです。ハンドラーのタイムアウトの場合もこのステータスです。
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: 公式サーバー（ページは`/api/v1`の外、ルートにあります）。
      - url: https://{host}:{port}
        description: 任意のScacelithサーバー（ページは`/api/v1`の外、ルートにあります）。
        variables:
          host:
            default: caissa.scacelith.com
            description: サーバーの公開ホスト名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公開APIポート（`PUBLIC_API_PORT`、未設定なら`API_PORT`）。
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: メールアドレス変更の確認ページを表示
      description: |-
        メールアドレス変更リンクのページ。新しいアドレスとアカウント名を表示し、フォームを`POST /confirm-email-change`に送信する"Use this e-mail address"（このメールアドレスを使用）ボタンがあります。

        **制限**：`page`。
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: 新しいアドレスと確認ボタンのあるページ。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: リンクが無効または期限切れ（リクエストの後にアカウントのアドレスが変わった場合も）。
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`page`の制限（HTMLページ）、またはアドレス単位の層（JSONの`rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: 新しいメールアドレスを確認
      description: |-
        メールアドレス変更ページのフォーム。アドレスが変更されて確認済みとみなされ、デバイスはログインしたままとなり、以前に送られたリンク（確認、パスワードリセット、ほかの変更）は使えなくなり、以前のアドレスには新しいアドレスをマスクした通知が届きます。

        **制限**：`auth`。
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: lZ50AvPxUwbGqQCwWPIO28ONray7SzW0Cl_sLImwF3Q
          application/json:
            schema:
              $ref: '#/components/schemas/LinkTokenForm'
            example:
              token: lZ50AvPxUwbGqQCwWPIO28ONray7SzW0Cl_sLImwF3Q
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: アドレスが変更されました。
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: リンクが無効、使用済み、または期限切れ、あるいはフォームが無効（HTMLページ）。
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: その間に別のアカウントがそのアドレスを使い始めた（HTMLページ）。
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`auth`の制限（HTMLページ）、またはアドレス単位の層（JSONの`rate_limited`）。'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'データベースのロックが解除されなかった（`Retry-After: 1`）。何も変更されておらず、リンクは引き続き使えます。ハンドラーのタイムアウトの場合もこのステータスです。'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: 公式サーバー（ルート）。
      - url: https://caissa.scacelith.com/api/v1
        description: 公式サーバー（`/api/v1`の下）。
      - url: https://{host}:{port}
        description: 任意のScacelithサーバー（ルート）。
        variables:
          host:
            default: caissa.scacelith.com
            description: サーバーの公開ホスト名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公開APIポート（`PUBLIC_API_PORT`、未設定なら`API_PORT`）。
      - url: https://{host}:{port}/api/v1
        description: 任意のScacelithサーバー（`/api/v1`の下）。
        variables:
          host:
            default: caissa.scacelith.com
            description: サーバーの公開ホスト名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公開APIポート（`PUBLIC_API_PORT`、未設定なら`API_PORT`）。
    get:
      operationId: getLiveness
      tags:
        - health
      summary: プロセスが動作していることを確認
      description: |-
        プロセスが動作している間、200 `ok`を返します。`HEAD`も使えます。その他のメソッド（`OPTIONS`を含む）には、`Allow: GET, HEAD`付きで405 `method_not_allowed`が返ります。

        **制限**：アドレス単位の層のみ。
      security: []
      responses:
        '200':
          description: プロセスは動作しています。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ok
              example:
                status: ok
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：アドレス単位の層。'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: 公式サーバー（ルート）。
      - url: https://caissa.scacelith.com/api/v1
        description: 公式サーバー（`/api/v1`の下）。
      - url: https://{host}:{port}
        description: 任意のScacelithサーバー（ルート）。
        variables:
          host:
            default: caissa.scacelith.com
            description: サーバーの公開ホスト名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公開APIポート（`PUBLIC_API_PORT`、未設定なら`API_PORT`）。
      - url: https://{host}:{port}/api/v1
        description: 任意のScacelithサーバー（`/api/v1`の下）。
        variables:
          host:
            default: caissa.scacelith.com
            description: サーバーの公開ホスト名（`SERVER_PUBLIC_HOST`）。
          port:
            default: "443"
            description: 公開APIポート（`PUBLIC_API_PORT`、未設定なら`API_PORT`）。
    get:
      operationId: getReadiness
      tags:
        - health
      summary: サーバーがプレイヤーを受け付けていることを確認
      description: |-
        サーバーがプレイヤーを受け付けているときは200 `ready`を、起動中または停止中は503 `not_ready`を返します。`HEAD`も使えます。その他のメソッド（`OPTIONS`を含む）には、`Allow: GET, HEAD`付きで405 `method_not_allowed`が返ります。

        **制限**：アドレス単位の層のみ。
      security: []
      responses:
        '200':
          description: サーバーはプレイヤーを受け付けています。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`：アドレス単位の層。'
        '503':
          description: サーバーは起動中または停止中です。
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: not_ready
              example:
                status: not_ready
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sct_[A-Za-z0-9_-]{43}
      description: |-
        ログインのレスポンスで得たセッショントークン（`sct_`に続く43文字のbase64url）を、`Authorization`ヘッダーに`Authorization: Bearer <token>`の形で入れます。接頭辞は正確に`Bearer`（大文字と小文字を区別）と1つのスペースです。これがない値や、印字可能なASCII文字1～512文字でないトークンには、ほかの無効なトークンと同様に401 `invalid_token`が返ります。空のヘッダーはヘッダーなしとみなされます。
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: 対局ID。先頭にゼロのない最大16桁の正の10進整数で、2^53未満です。
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: '`GET /auth/sessions`が返すセッションID。先頭にゼロを付けずに書きます（`03`はセッション3ではありません）。'
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: プレイヤーのユーザー名（大文字と小文字は区別しません）。サーバーは`[A-Za-z0-9_.-]`の2～24文字からなる任意の名前を受け付けます（これまでに許可したことのある最も広いルールです）。
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: 対局ID。これより古い対局だけが一覧に含まれます。前のページの`next`を渡します。空の値は指定なしとみなされます。
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: ページサイズ（1～50）。それより大きい3桁までの数は50とみなされ、空の値は指定なしとみなされます。
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: メールのリンクのトークン（43文字のbase64url）。ないか誤っている場合は、"link invalid or expired"（リンクが無効または期限切れ）のページが表示されます。
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: サーバーのすべてのレスポンスはキャッシュを禁止します。
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: 再試行までに待つ秒数。ボディの`retryAfter`と同じ値です。
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Bearer認証のチャレンジ。無効なトークンの場合は`error="invalid_token"`付き。
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: サーバーはこのレスポンスの後に接続を閉じます。
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request`（ボディのフィールドに問題がある場合は`field`付き）または`invalid_json`。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: フィールドがスキーマに違反
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: ボディがJSONではない
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: 弱いパスワード
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: 読み取れないPGN
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized`（`Authorization`ヘッダーがない）または`invalid_token`（トークンの形式が不正、期限切れ、失効済み、または削除されたアカウントのもの）。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: セッションなし
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: 無効なトークン
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: リクエストは拒否されました。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: 再認証時の誤ったパスワード
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: 利用停止中のアカウント
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found`、またはこのサーバーで無効にされている機能。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: not_found
            message: No such game.
    RequestTimeout:
      description: '`request_timeout`：ボディが10秒以内に届かなかった。サーバーは接続を閉じます。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: request_timeout
            message: The request body took too long.
    Conflict:
      description: リクエストがサーバーの状態と競合しています。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: username_taken
            message: This username is already taken.
    Gone:
      description: '`sso_expired`：Googleログインの試行またはチケットが未知、使用済み、または期限切れ。ゲームから最初からやり直してください。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: sso_expired
            message: This sign-in has expired; start again from Scacelith.
    PayloadTooLarge:
      description: '`payload_too_large`：ボディが`HTTP_BODY_LIMIT`（既定値は16,384バイト）を超えている。サーバーは接続を閉じます。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: payload_too_large
            message: The body must not exceed 16384 bytes.
    UriTooLong:
      description: '`uri_too_long`：リクエストターゲットが4096文字を超えている。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: uri_too_long
            message: The URL is too long.
    UnsupportedMediaType:
      description: '`unsupported_media_type`：ボディが`application/json`ではない、またはその文字セットがUTF-8ではない。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: unsupported_media_type
            message: Content-Type must be application/json.
    UnprocessableContent:
      description: '`game_too_long`：対局の手数が`GIF_MAX_PLIES`（600）半手を超えている。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: game_too_long
            message: The game is too long for a GIF (742 plies, at most 600).
    PowRequired:
      description: '`pow_required`：`pow`チャレンジを解き、`pow: { challenge, nonce }`を付けて同じリクエストをもう一度送ってください。理由は`reason`が示します：`required`、`malformed`、`signature`、`endpoint`、`network`、`expired`、`bits`、`work`、`replayed`のいずれかです。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: pow_required
            message: Proof of work required.
            reason: required
            pow:
              challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
              bits: 18
              expiresAt: 1790882991200
    TooManyRequests:
      description: '`rate_limited`（レート制限、アカウント単位の割り当て、またはパスワードハッシュのキュー）または`too_many_attempts`（アカウントに対する制限）。`retryAfter`と`Retry-After`ヘッダー付き。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rateLimited:
              summary: レート制限
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: このアカウントでの失敗が多すぎる
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`：予期しない障害。サーバーがログに記録します。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: internal_error
            message: Internal server error.
    BadGateway:
      description: '`sso_failed`：`state`または`iss`が誤っている、あるいはGoogleがコードを拒否したか、検証できないIDトークンを送ってきた。Googleからのテキストが含まれることはありません。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: sso_failed
            message: Google sign-in could not be completed.
    ServiceUnavailable:
      description: '`server_busy`、`busy`、または`timeout`：`retryAfter`が示されている場合は、その時間がたってから再試行してください。'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: サーバーが混雑している
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: データベースのロックが解除されなかった（読み取り）
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: サーバーの処理に時間がかかりすぎた
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: アニメーションGIF（添付ファイルとして）。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: 'このサーバーの対局では`attachment; filename="scacelith-<id>.gif"`、`POST /gif`で送ったPGNでは`attachment; filename="scacelith-game.gif"`。'
          schema:
            type: string
            pattern: '^attachment; filename="scacelith-([1-9][0-9]{0,15}|game)\.gif"$'
          example: attachment; filename="scacelith-4100000000001.gif"
        Content-Length:
          description: ファイルのサイズ（バイト）。
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: HTMLページ。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: 拒否の理由を説明するHTMLページ。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: フォームが10秒以内に届かなかった（HTMLページ）。サーバーは接続を閉じます。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: 競合を説明するHTMLページ。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: フォームが`HTTP_BODY_LIMIT`を超えている（HTMLページ）。サーバーは接続を閉じます。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: ボディがフォーム（`application/x-www-form-urlencoded`）でもJSONでもない、またはその文字セットがUTF-8ではない（HTMLページ）。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: レート制限（HTMLページ）。`Retry-After`付き。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlInternalError:
      description: 予期しない障害。タイトルが"Server error"（サーバーエラー）のHTMLページです。サーバーがログに記録します。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: サーバーが混雑している（データベースのロックが解除されなかった。`Retry-After`付き）、または処理に時間がかかりすぎた。タイトルが"Server error"（サーバーエラー）のHTMLページです。
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
  schemas:
    Error:
      type: object
      description: JSON APIのすべてのエラーレスポンスに共通の形式。
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: '`snake_case`のエラーコード。クライアントはこれをもとに表示内容を選びます。'
          examples:
            - rate_limited
        message:
          type: string
          description: 英語の文。ログ用およびフォールバック用です。
        retryAfter:
          type: integer
          minimum: 1
          description: 再試行までに待つ秒数。時間がたてば解除される拒否にのみ含まれます。その場合、レスポンスには同じ値の`Retry-After`ヘッダーも付きます。ただし、履歴、対局、プレイヤー、ランキングの読み取りで返る503 `busy`は例外です。
        field:
          type: string
          description: 問題のあるフィールドまたはクエリパラメーター（`invalid_request`、`invalid_option`、および`GET /account/games`の`invalid_cursor`、`invalid_limit`、`invalid_filter`）。入れ子のフィールドはドット区切りです（`pow.nonce`）。
        reason:
          type: string
          description: '理由：`weak_password`が違反したルール、またはプルーフ・オブ・ワークが要求された理由や拒否された理由（`pow_required`）。'
          enum:
            - too_short
            - too_long
            - contains_username
            - contains_email
            - too_common
            - required
            - malformed
            - signature
            - endpoint
            - network
            - expired
            - bits
            - work
            - replayed
        pow:
          $ref: '#/components/schemas/PowChallenge'
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: '`banned`のみ：利用停止の終了時刻（エポックからのミリ秒）。無期限の利用停止では`null`。'
        line:
          type: integer
          minimum: 1
          description: '`invalid_pgn`のみ：エラーの行（1始まり）。'
        column:
          type: integer
          minimum: 1
          description: '`invalid_pgn`のみ：エラーの列（1始まり、文字単位）。'
    PowChallenge:
      type: object
      description: プルーフ・オブ・ワークのチャレンジ（428 `pow_required`の`pow`）。
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: 署名されたチャレンジ。そのまま送り返します。
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: '`SHA-256(challenge + ":" + nonce)`の先頭に必要なゼロビットの数。'
        expiresAt:
          type: integer
          format: int64
          description: チャレンジの有効期限（エポックからのミリ秒）。発行から2分後です。
    PowAnswer:
      type: object
      description: プルーフ・オブ・ワークのチャレンジへの解答。
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: 428のレスポンスで受け取った`challenge`（そのままの値）。
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: '`SHA-256(challenge + ":" + nonce)`が`bits`個のゼロビットで始まるような、最大20桁の10進数の文字列。'
    ServerInfo:
      type: object
      description: ログインや接続の前にクライアントが必要とする情報。
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: サーバーの名前（`SERVER_NAME`）。
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: データベースが初回起動時に取得するUUID。再起動しても変わりません。データベースから取得できない場合は`null`です。
        motd:
          type: string
          description: お知らせメッセージ（`SERVER_MOTD`）。空の場合もあります。
        protocol:
          type: object
          description: WebSocketプロトコル（`PROTOCOL.md`）。
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: サーバーが対応する最も古いプロトコルバージョン。
            max:
              type: integer
              minimum: 1
              description: サーバーが対応する最も新しいプロトコルバージョン。
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: プロトコルスキーマのフィンガープリント（正規形のSHA-256の先頭4バイトを符号なし整数にしたもの）。参考情報です。
            subprotocol:
              type: string
              description: WebSocketのサブプロトコルのトークン（`Sec-WebSocket-Protocol`）。
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: プレイヤーが使うWebSocketのポート（`PUBLIC_WS_PORT`、未設定なら`WS_PORT`、それも未設定なら`API_PORT`）。
        wsPath:
          type: string
          const: /ws
          description: WebSocketのパス。
        registration:
          type: string
          enum:
            - open
            - closed
          description: 新しいアカウントを作成できるかどうか。
        emailVerification:
          type: boolean
          description: 新しいアカウントがアドレスを確認する必要があるかどうか（`REQUIRE_EMAIL_VERIFICATION`）。
        sso:
          type: object
          required:
            - google
          properties:
            google:
              type: boolean
              description: Googleログインを提供しているかどうか。
        mfa:
          type: boolean
          const: true
          description: 二要素認証は常に利用できます。
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: 登録に必要なプルーフ・オブ・ワークのビット数（不要なら0）。
        categories:
          type: array
          description: 公式の（レーティング戦の）持ち時間（`RATED_CATEGORIES`）を、サーバーの順序で並べたもの。それ以外の持ち時間はすべて`custom`です。
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: クライアントがフォームを送信する前に確認できるルール。
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: ユーザー名の最小文字数（`USERNAME_MIN`）。
            usernameMax:
              type: integer
              description: ユーザー名の最大文字数（`USERNAME_MAX`）。
            usernamePattern:
              type: string
              description: 新しいユーザー名が一致しなければならない正規表現。
            passwordMinLength:
              type: integer
              description: パスワードの最小文字数（`PASSWORD_MIN_LENGTH`）。
            passwordMaxBytes:
              type: integer
              description: パスワードの最大長（UTF-8のバイト数）。
            customTimeControls:
              type: boolean
              description: 対局の申し込みとプライベート対局で、カスタムの持ち時間を使えるかどうか。
            reportsPerDay:
              type: integer
              description: プレイヤーが24時間あたりに行える報告の件数（`REPORTS_PER_DAY`）。
            wsMaxMessageBytes:
              type: integer
              description: クライアントが送信できるWebSocketメッセージの最大サイズ（バイト）。
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 97842216
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: 公式の持ち時間。
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: カテゴリーのID。分と加算秒数で表します（`3+2`）。
        baseSec:
          type: number
          minimum: 0
          description: 基本の持ち時間（秒）。
        incSec:
          type: number
          minimum: 0
          description: 1手ごとの加算時間（秒）。
    AccountView:
      type: object
      description: プレイヤー本人から見たアカウント（ログインのレスポンスと`GET /account/me`の`user`）。
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: アカウントID。
        username:
          type: string
          description: ユーザー名。
        email:
          type: string
          format: email
          description: メールアドレス。
        emailVerified:
          type: boolean
          description: アドレスが確認済みかどうか。
        mfaEnabled:
          type: boolean
          description: 二要素認証がオンかどうか。
        googleLinked:
          type: boolean
          description: Googleアカウントと連携しているかどうか。
        hasPassword:
          type: boolean
          description: 'Google経由で作成され、まだパスワードを設定していないアカウントでは`false`。'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: 名前を指定した直接の対局申し込みを受け付けるかどうか（`PUT /account/preferences`を参照）。
        createdAt:
          type: integer
          format: int64
          description: アカウントの作成日時（エポックからのミリ秒）。
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: 最後のログイン日時（エポックからのミリ秒）、または`null`。
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: リンクによる確認待ちのメールアドレス変更の、新しいアドレス。なければ`null`。
    SessionAnswer:
      type: object
      description: 新しいセッション。
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: セッショントークン。`Authorization`ヘッダーとWebSocketで使います。
        expiresAt:
          type: integer
          format: int64
          description: セッションの絶対的な終了時刻（エポックからのミリ秒）。アイドル期限によって、それより早く終了することもあります。
        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: 二要素認証付きログインの第2段階。`POST /auth/login/mfa`に進みます。
      required:
        - mfaRequired
        - mfaToken
        - expiresIn
      properties:
        mfaRequired:
          type: boolean
          const: true
        mfaToken:
          type: string
          pattern: '^mfa_[A-Za-z0-9_-]{43}$'
          description: この段階のトークン。`POST /auth/login/mfa`で使います。
        expiresIn:
          type: integer
          const: 300
          description: 段階を完了するまでの残り秒数。
    LoginAnswer:
      description: セッション、または二要素認証付きログインの第2段階。
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: 初回のGoogleログイン。`POST /auth/sso/complete`に進みます。
      required:
        - needsUsername
        - ssoTicket
        - suggestedUsername
      properties:
        needsUsername:
          type: boolean
          const: true
        ssoTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: '`POST /auth/sso/complete`用のチケット。10分間有効です。'
        suggestedUsername:
          type: string
          description: Googleの名前またはアドレスから作られたユーザー名。適したものがなければ`""`。
    SsoNeedsPassword:
      type: object
      description: パスワードを持つアカウントがそのアドレスを使っています。`POST /auth/sso/google/link`に進みます。この時点ではまだ何も連携されていません。
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: '`POST /auth/sso/google/link`用のチケット。'
        username:
          type: string
          description: そのアカウントのユーザー名（Googleに対してそのアドレスの所有を証明した人だけが目にします）。
        expiresIn:
          type: integer
          const: 600
          description: チケットが有効な残り秒数。
    SsoFinishAnswer:
      description: '`POST /auth/sso/google/finish`のレスポンス。'
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
        - $ref: '#/components/schemas/SsoNeedsUsername'
        - $ref: '#/components/schemas/SsoNeedsPassword'
    SsoStartAnswer:
      type: object
      description: Googleログインの試行。
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: 試行のID。`POST /auth/sso/google/finish`で使います。
        authUrl:
          type: string
          format: uri
          description: 検査した後にシステムのブラウザーで開く、GoogleのURL。
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Googleが送り返すべき`state`。
        expiresIn:
          type: integer
          const: 600
          description: 試行が有効な残り秒数。
    VerificationSentStatus:
      type: object
      required:
        - status
      properties:
        status:
          const: verification_sent
    AcceptedStatus:
      type: object
      required:
        - status
      properties:
        status:
          const: accepted
    LoggedOutStatus:
      type: object
      required:
        - status
      properties:
        status:
          const: logged_out
    RegisterRequest:
      type: object
      additionalProperties: false
      required:
        - username
        - email
        - password
      properties:
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: ユーザー名。サーバーのルールに従います（説明を参照）。
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: メールアドレス。前後の空白が除かれ、小文字で保存されます。
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: パスワード。パスワードのルールに従います。
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: ユーザー名、またはメールアドレス（`@`を含む任意のテキスト）。
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: 任意。ログイン中のデバイスの一覧に表示されます（`Scacelith 1.4 (Windows)`など）。
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: ログインのレスポンス（またはGoogleログインのレスポンス）の`mfaToken`。
        code:
          type: string
          maxLength: 32
          description: 任意。6桁の認証アプリのコード、またはリカバリーコード。
        recoveryCode:
          type: string
          maxLength: 32
          description: 任意。リカバリーコード。`code`と`recoveryCode`のどちらかが必要です。
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: メールアドレス。
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: リセットリンクの`token`パラメーター。
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: 新しいパスワード。登録時のパスワードのルールが適用されます。
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: クライアントの`codeVerifier`から作るS256のPKCEチャレンジ（`BASE64URL(SHA-256(codeVerifier))`）。
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: '`127.0.0.1`で待ち受けるクライアントのリスナーのポート。'
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: '`POST /auth/sso/google/start`で得た`attemptId`。'
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: PKCEのベリファイア。そのSHA-256が、`start`に渡したチャレンジと一致しなければなりません。
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Googleが送り返したままの`state`。
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: Googleが送り返したままの`code`（スペースを含まない印字可能なASCII）。
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: 任意。Googleが送り返した場合は、そのままの`iss`。
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`finish`で得た`linkTicket`。10分間有効です。'
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: アカウントのパスワード。
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    SsoCompleteRequest:
      type: object
      additionalProperties: false
      required:
        - ssoTicket
        - username
      properties:
        ssoTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`finish`で得た`ssoTicket`。10分間有効です。'
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: ユーザー名。登録時のルールが適用されます。
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: 有効なセッション（最近使われた順）。
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: 有効なセッション（ログイン中のデバイス）。
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: セッションID。`DELETE /auth/sessions/{id}`で使います。
        createdAt:
          type: integer
          format: int64
          description: ログイン日時（エポックからのミリ秒）。
        lastSeenAt:
          type: integer
          format: int64
          description: 最後に使われた日時（エポックからのミリ秒）。更新は最大で5分に1回です。
        expiresAt:
          type: integer
          format: int64
          description: 絶対的な終了時刻（エポックからのミリ秒）。アイドル期限によって、それより早くセッションが終了することもあります。
        clientLabel:
          type:
            - string
            - 'null'
          description: ログイン時の`clientLabel`。送られなかった場合は`null`。
        current:
          type: boolean
          description: リクエストを行っているセッションかどうか。
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: プレイヤーがレーティング戦を指したカテゴリーごとに1つの記録。
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: 有効な制裁。
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '利用停止中は`{ until }`、それ以外は`null`。'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: 1つのカテゴリーのレーティング記録。
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: カテゴリーのID。
        rating:
          type: integer
          description: レーティング。
        games:
          type: integer
          minimum: 0
          description: そのカテゴリーで指した対局数（集計対象かどうかを問いません）。
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: 到達した最高レーティング。
        provisional:
          type: boolean
          description: '未レーティングの段階にあるか、集計対象の対局が`PROVISIONAL_GAMES`局未満のあいだは`true`（ゲームでは`1510?`のように表示されます）。'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: 制裁の種類。
        reason:
          type:
            - string
            - 'null'
          description: 示された理由（ある場合）。
        startsAt:
          type: integer
          format: int64
          description: 開始日時（エポックからのミリ秒）。
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: 終了日時（エポックからのミリ秒）。無期限の制裁では`null`。
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: 利用停止の終了日時（エポックからのミリ秒）。無期限の場合は`null`。
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '名前を指定した直接の対局申し込みを受け付けるなら`all`、拒否するなら`none`。'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: 現在のパスワード。
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: 新しいパスワード。登録時のパスワードのルールが適用されます。
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: アカウントのパスワード。
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: 保留中のシークレットのコード（ちょうど6桁）。
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: アカウントのパスワード。
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: 任意。二要素認証がオンの場合は必要です（または`recoveryCode`）。認証アプリのコード、またはリカバリーコード。
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: 任意。リカバリーコード（`xxxx-xxxx-xx`。大文字と小文字、スペース、ハイフンは区別されません）。
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: アカウントのパスワード。
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: 認証アプリのコード（リカバリーコードは拒否されます）。
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: 新しいアドレス。前後の空白が除かれて小文字に変換された後、登録時のアドレスと同様に検査されます。
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: アカウントのパスワード。
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: 任意。二要素認証がオンの場合は必要です（または`recoveryCode`）。認証アプリのコード、またはリカバリーコード。
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: 任意。リカバリーコード。
    TotpSetup:
      type: object
      description: 認証アプリ用の保留中のシークレット。
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: アプリに入力するための、base32のシークレット。
        uri:
          type: string
          format: uri
          description: QRコードとして表示するための`otpauth://totp/...` URI。
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: 1ステップの秒数。
    RecoveryCodeList:
      type: array
      description: 10個のリカバリーコード。表示されるのはこの1回だけです。各コードは1回だけ使えます。
      minItems: 10
      maxItems: 10
      items:
        type: string
        pattern: '^[0-9a-hjkmnp-tv-z]{4}-[0-9a-hjkmnp-tv-z]{4}-[0-9a-hjkmnp-tv-z]{2}$'
    PlayerSide:
      type: object
      description: 対局の一方の側。
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: 対局記録上の名前（削除されたアカウントは`deleted#<id>`）。
        rating:
          type:
            - integer
            - 'null'
          description: 開始時点のレーティング。不明な場合は`null`。
        ratingAfter:
          type:
            - integer
            - 'null'
          description: 対局後のレーティング。レーティング戦には常にあります。`null`になるのは、レーティングに反映されない対局（カジュアル、カスタム、中止）だけです。
        ratingDiff:
          type:
            - integer
            - 'null'
          description: レーティングの変動。レーティングの規則によってレーティングが変わらない場合（FIDEのゼロスコア規定、未レーティングの相手、未レーティングのプレイヤーの最初の対局）は`0`。`null`になる条件は`ratingAfter`と同じです。
    GameSummary:
      type: object
      description: 保存された対局の概要。
      required:
        - id
        - category
        - rated
        - timeControl
        - white
        - black
        - status
        - reason
        - result
        - termination
        - plies
        - startedAt
        - endedAt
      properties:
        id:
          type: integer
          format: int64
          minimum: 1
          maximum: 9007199254740991
          description: 対局ID。
        category:
          type: string
          description: 公式カテゴリーのID（`3+2`）、または`custom`。
        rated:
          type: boolean
          description: 対局がレーティングに反映されるかどうか。
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 秒単位の持ち時間（`180+2`）。
        white:
          $ref: '#/components/schemas/PlayerSide'
        black:
          $ref: '#/components/schemas/PlayerSide'
        status:
          type: integer
          enum:
            - 1
            - 2
            - 3
            - 4
          description: '1 `WhiteWins`、2 `BlackWins`、3 `Draw`、4 `Aborted`（対局のコードを参照）。'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: 終局理由のコード（対局のコードを参照）。
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: 結果。中止された対局では`*`。
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: 手数（半手単位）。
        startedAt:
          type: integer
          format: int64
          description: 開始日時（エポックからのミリ秒）。
        endedAt:
          type: integer
          format: int64
          description: 終了日時（エポックからのミリ秒）。
    Termination:
      type: string
      description: 終局理由の名前（対局のコードを参照）。プロトコルが知らないコードの場合は`Unknown`。
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: 一方のプレイヤーから見た対局の概要。
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: プレイヤーの色。
    HistoryGameSummary:
      description: ログイン中のプレイヤーの履歴にある対局。
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: 基本の持ち時間（ミリ秒）。
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: 加算時間（ミリ秒）。
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: プレイヤーから見た勝敗。
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: このページの対局（新しい順）。
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: 次のページのために`before`として渡すID。最後のページでは`null`。
        total:
          type: integer
          minimum: 0
          description: 全ページを通じて条件に一致する対局の数。
    MoveRecord:
      type: object
      description: 対局の1半手。
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: UCI表記の指し手。プロモーションでは`n`、`b`、`r`、`q`が付きます。
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: その手に費やした時間（ミリ秒）。記録にない場合は`null`。
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: 指した側の、指し手後の時計の残り時間（ミリ秒）。記録にない場合は`null`。
    PgnTagsView:
      type: object
      description: 表示用の主なPGNタグ。ここでの`Termination`は終局理由の名前です。PGNファイルではPGN標準の値を使います。
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>`または`<SERVER_NAME> casual <category>`。'
        Site:
          type: string
          description: '`SERVER_PUBLIC_HOST`。'
        Date:
          type: string
          pattern: '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$'
          description: UTCでの開始日。
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: 開始時点のレーティング、または`"-"`。
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: 開始時点のレーティング、または`"-"`。
          oneOf:
            - type: integer
            - type: string
              const: '-'
        TimeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
        Termination:
          $ref: '#/components/schemas/Termination'
        PlyCount:
          type: integer
          minimum: 0
    GameRecord:
      description: 指し手と時計を含む対局の記録。対局の概要のフィールドを持ちますが、`color`と`outcome`はありません。
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: 基本の持ち時間（ミリ秒）。
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: 加算時間（ミリ秒）。
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: '`status`の名前。'
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: この対局が再戦である場合の元の対局のID、または`null`。
            moves:
              type: array
              description: 1半手につき1エントリー。
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: トークンのプレイヤーがこの対局を指した場合のみ。そのプレイヤーの色。
            reportable:
              type: boolean
              description: トークンのプレイヤーがこの対局を指した場合のみ。この対局の相手についての報告を現時点で`POST /reports`が受け付ける場合は`true`（対局が7日以内に終了し、1日の上限を使い切っておらず、この対局で相手をまだ報告していない）。
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: プレイヤーが書いたとおりのユーザー名。
        createdAt:
          type: integer
          format: int64
          description: アカウントの作成日時（エポックからのミリ秒）。
        ratings:
          type: array
          description: 公式カテゴリーのみ（サーバーの順序で）。
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: カジュアル戦や中止された対局を含む、保存されたすべての対局。
            rated:
              type: integer
              minimum: 0
              description: レーティング戦の数（レーティング記録の合計）。
            wins:
              type: integer
              minimum: 0
            draws:
              type: integer
              minimum: 0
            losses:
              type: integer
              minimum: 0
    PlayerGamesPage:
      type: object
      required:
        - username
        - games
        - next
      properties:
        username:
          type: string
          description: プレイヤーが書いたとおりのユーザー名。
        games:
          type: array
          description: このページの対局（新しい順）。
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: ページが満杯のときは常に最後の対局のID（その場合、次のページが空のこともあります）。それ以外は`null`。
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: カテゴリーのID。
        minGames:
          type: integer
          minimum: 0
          description: 記録が掲載されるのに必要な、集計対象の対局数（`PROVISIONAL_GAMES`）。
        updatedAt:
          type: integer
          format: int64
          description: ランキングが計算された日時（エポックからのミリ秒）。
        players:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LeaderboardEntry'
    LeaderboardEntry:
      type: object
      required:
        - rank
        - username
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
      properties:
        rank:
          type: integer
          minimum: 1
        username:
          type: string
        rating:
          type: integer
        games:
          type: integer
          minimum: 0
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
    GifRequest:
      type: object
      additionalProperties: false
      required:
        - pgn
      properties:
        pgn:
          type: string
          description: PGNテキスト（UTF-8で最大65,536バイト）。最初の対局だけが使われます。
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: 任意。画像のサイズ。
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: 任意。盤の下側（手前）に置く側。
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: 任意。1手あたりのミリ秒。整数値のJSONの数値です。
        coords:
          type: boolean
          default: true
          description: 任意。盤の周囲にファイルの文字とランクの数字を描くかどうか。
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: 対局。整数、または1～16桁の数字の文字列で指定します。
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: 対戦相手のユーザー名。対局記録上の名前でも現在の名前でもよく、大文字と小文字は区別しません。空白のみにはできません。
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: 報告の内容の種類。
        comment:
          type:
            - string
            - 'null'
          description: 任意。制御文字（タブと改行を除く）を取り除き、前後の空白を除いた後で最大500文字。
    AccountExport:
      type: object
      description: サーバーがアカウントについて保持しているすべての情報（時刻はエポックからのミリ秒）。
      required:
        - format
        - version
        - exportedAt
        - server
        - notes
        - account
        - ratings
        - ratingRefunds
        - sessions
        - securityEvents
        - sanctions
        - conduct
        - reportsFiled
        - games
      properties:
        format:
          type: string
          const: scacelith-account-export
        version:
          type: integer
          const: 1
        exportedAt:
          type: integer
          format: int64
          description: エクスポートの作成日時。
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`。'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`。'
        notes:
          type: array
          description: ファイルに含まれるものと除かれるものの、プレイヤー向けの平易な英語による説明。
          items:
            type: string
        account:
          description: '`GET /account/me`のアカウント情報に`googleEmail`を加えたもの。'
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: 連携しているGoogleアカウントのアドレス、または`null`。
        ratings:
          type: array
          description: 完全なレーティング記録。
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: 対戦相手の不正が判明した後に返還されたレーティングポイント。UTCの日とカテゴリーごとに合計され、新しい順に並びます。対局も、不正を行ったプレイヤーも示されません。
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: 保存されているすべてのセッション（新しい順。トークンは含みません）。保持期間による削除処理で、期限切れのセッションは削除され、ログアウトしたセッションはログアウトの1日後に削除されます。
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: セキュリティイベント（新しい順）。`RETENTION_SECURITY_DAYS`日間保持されます。
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: 解除されたものを含む、すべての制裁。モデレーターの名前は決して含まれません。
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: プレイヤーが放棄した対局、中止した対局、最初の一手を指さなかった対局について記録された行動イベント。30日間保持されます。
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: プレイヤーが行った報告。
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: 対局数。
            list:
              type: array
              description: すべての対局（新しい順）を、`GET /account/games`の概要の形式で示します。指し手は`GET /games/{id}`、PGNは`GET /games/{id}/pgn`で取得できます。
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: 完全なレーティング記録。
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: プレイヤーが未レーティングの段階を抜けたかどうか。
            countedGames:
              type: integer
              minimum: 0
              description: レーティングの計算に入った対局の数。
            updatedAt:
              type: integer
              format: int64
              description: 記録が最後に変更された日時。
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: その日の00:00 UTC（エポックからのミリ秒）。
        category:
          type: string
        points:
          type: integer
          description: その日にそのカテゴリーで返還されたポイント。
    ExportSession:
      type: object
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - revokedAt
        - clientLabel
        - ip
      properties:
        id:
          type: integer
          format: int64
        createdAt:
          type: integer
          format: int64
        lastSeenAt:
          type: integer
          format: int64
        expiresAt:
          type: integer
          format: int64
        revokedAt:
          type:
            - integer
            - 'null'
          format: int64
          description: セッションがログアウトされた日時、または`null`。
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: ログイン時のアドレス。`RETENTION_IP_DAYS`日後に消去されます。
    SecurityEvent:
      type: object
      description: |-
        セキュリティイベント。`ip`が示されるのは、ログイン中に行われた操作、アカウントのパスワード（と第二要素）を使って行われた操作、またはアドレスにメールで送られたリンクで行われた操作だけです：`register`、`email_verified`、`login`、`sso_login`、`sso_linked`、`sso_account_created`、`recovery_code_used`、`password_reset`、`password_changed`、`reauth_failed`、`mfa_setup_started`、`mfa_enabled`、`mfa_disabled`、`recovery_codes_regenerated`、`session_revoked`、`sessions_revoked_all`、`email_change_requested`、`email_changed`、`email_change_refused`、`account_exported`。その他の種類はすべて`ip: null`で、`ip`は`RETENTION_IP_DAYS`日後に消去されます。

        `detail`が保持するのは次のフィールドだけです：`login`は`method`、`sso_login`と`sso_account_created`は`provider`、`sso_linked`は`provider`と`method`（`password`または`password+totp`）、`login_failed`は`failures`、`login_lockout`は`retryAfterMs`、`mfa_failed`は`attempts`、`recovery_code_used`は`remaining`、`reauth_failed`は`factor`、`session_revoked`と`sessions_revoked_all`は`reason`、`email_change_refused`は`reason`、`sanction_auto`は`kind`、`gameId`、`until`。`moderator_action`イベントは、`ban`、`unban`、`reset_mfa`、`verify_email`、`revoke_sessions`の操作についてのみ、`{ action }`だけを保持します（モデレーターのその他の操作は除かれます）。その他の種類はすべて`detail: null`です。`rating_refund`イベントは除かれます。そのポイントは`ratingRefunds`にあります。
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: イベントの種類（`login`、`password_changed`など）。
        at:
          type: integer
          format: int64
          description: 発生日時。
        ip:
          type:
            - string
            - 'null'
        detail:
          type:
            - object
            - 'null'
    ExportSanction:
      type: object
      required:
        - id
        - kind
        - reason
        - source
        - gameId
        - startsAt
        - endsAt
        - createdAt
        - liftedAt
      properties:
        id:
          type: integer
          format: int64
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
        reason:
          type:
            - string
            - 'null'
        source:
          type: string
          enum:
            - auto
            - moderator
        gameId:
          type:
            - integer
            - 'null'
          format: int64
        startsAt:
          type: integer
          format: int64
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
        createdAt:
          type: integer
          format: int64
        liftedAt:
          type:
            - integer
            - 'null'
          format: int64
    ConductEvent:
      type: object
      required:
        - kind
        - at
      properties:
        kind:
          type: string
          enum:
            - abandon
            - abort
            - noshow
        at:
          type: integer
          format: int64
    FiledReport:
      type: object
      required:
        - gameId
        - reported
        - category
        - comment
        - createdAt
        - status
      properties:
        gameId:
          type:
            - integer
            - 'null'
          format: int64
        reported:
          type: string
          description: 報告されたプレイヤーの現在の公開名。
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: 報告がまだ処理中かどうか（報告されたプレイヤーが制裁を受けたかどうかは示されません）。
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: メールのリンクのトークン（ページのフォームの隠しフィールド）。
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: リセットリンクのトークン（フォームの隠しフィールド）。
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: 新しいパスワード。登録時のパスワードのルールが適用されます。
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: 確認のために再入力する新しいパスワード。同じでなければなりません。
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: JavaScriptや外部リソースを含まない、UTF-8のHTMLページ（`text/html; charset=utf-8`）。
