openapi: 3.1.1
info:
  title: Scacelith 서버 HTTPS API
  version: "1.1.0"
  summary: 공식 서버와 커뮤니티 서버를 비롯한 모든 Scacelith 서버의 HTTPS API입니다.
  description: |-
    공식 서버인 `caissa.scacelith.com`과 모든 커뮤니티 서버를 비롯해, 모든 Scacelith 서버가 이 HTTPS API에 응답합니다. 게임 앱은 게임 진행 자체를 제외한 모든 것에 이 API를 사용합니다. 회원가입과 로그인(2단계 인증과 Google 로그인 포함), 계정 페이지, 게임 기록, 게임의 PGN 다운로드와 애니메이션 GIF, 로그인된 기기, 데이터 다운로드, 계정 삭제, 신고가 여기에 해당합니다.

    실시간 플레이(매치메이킹, 도전, 수, 시계)는 같은 서버의 WebSocket(`wss://<host>/ws`)을 거치며, 이 WebSocket은 이 API의 세션 토큰으로 엽니다. WebSocket은 여기가 아니라 `PROTOCOL.md`에서 설명합니다.

    아래의 기본값은 설정을 변경하지 않은 서버의 값입니다. 커뮤니티 서버는 이 값을 바꿀 수 있습니다(모든 설정은 `CONFIG.md`에 나와 있습니다).

    ## 기본 URL, 포트, 전송 방식

    - 공식 서버: `https://caissa.scacelith.com/api/v1`, TCP 포트 443.
    - 커뮤니티 서버: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT`의 기본값은 443입니다. NAT나 프록시 뒤에서는 플레이어가 사용하는 포트가 `PUBLIC_API_PORT`입니다.
    - 게임의 WebSocket은 기본적으로 같은 포트에 있습니다(`WS_PORT`로 옮길 수 있으며, 그 위치는 `GET /info`가 클라이언트에 알려 줍니다).
    - TLS 위의 HTTP/1.1을 사용합니다. `TLS_MODE=proxy`이면 서버 앞단의 리버스 프록시가 TLS를 종료합니다. `TLS_MODE=off`(일반 HTTP)는 로컬 개발 전용입니다.
    - 이메일의 링크로 여는 HTML 페이지(`/verify-email`, `/reset-password`, `/confirm-email-change`)는 `/api/v1` 바깥, 서버의 루트에 있습니다. 상태 확인 엔드포인트는 루트와 `/api/v1` 아래 양쪽에서 응답합니다. Google 로그인에는 서버의 페이지가 없습니다. Google은 브라우저를 `127.0.0.1`에 있는 게임 자체로, 또는 로그인을 시작한 웹사이트의 페이지(`GOOGLE_WEB_REDIRECT_URI`)로 돌려보냅니다.
    - 끝의 슬래시는 무시되며(`/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 코드 단위로 세므로, 기본 다국어 평면 밖의 문자(이모지)는 두 번 계산됩니다. JSON이 아닌 본문에는 400 `invalid_json`으로 응답합니다. `POST /gif`와 `POST /reports`는 본문을 직접 검사합니다(각 엔드포인트 참조).
    - **쿼리 문자열.** 매개변수가 여러 번 나오면 첫 번째만 유효하고, 알 수 없는 매개변수는 무시되며, `+`는 공백으로 디코딩됩니다. 그래서 시간 제한 카테고리를 받는 엔드포인트는 `3%2B2`와 `3+2`를 모두 받습니다.
    - **메서드.** `HEAD`는 본문이 없는 `GET`입니다. 존재하는 경로에 대한 `OPTIONS`는 `Allow` 헤더(및 아래에서 설명하는 CORS 프리플라이트의 헤더)와 함께 204로 응답합니다. 경로에 없는 그 밖의 메서드에는 `Allow`와 함께 405 `method_not_allowed`로 응답합니다.
    - **요청 대상.** 4096자를 넘을 수 없습니다(414 `uri_too_long`). 전혀 파싱할 수 없는 요청이나, 헤드가 너무 크거나 너무 느리게 도착하는 요청에는 빈 400, 431, 408 응답을 보내고 연결을 닫습니다.
    - **CORS: 허용 목록 방식, 기본값은 꺼짐.** API는 게임을 위해 동작하며, 게임은 `Origin`을 보내지 않습니다. `CORS_ORIGINS`가 비어 있으면(기본값) `Access-Control-*` 헤더를 전혀 보내지 않으므로 어떤 웹 페이지도 응답을 읽을 수 없습니다. `CORS_ORIGINS`에는 브라우저에서 API를 호출할 수 있는 페이지의 웹 오리진을 나열하며, 각 오리진은 브라우저가 `Origin`에 보내는 형태 그대로 씁니다. 즉 `https://host` 또는 `https://host:port` 형식으로, 소문자로, 경로나 끝의 슬래시나 기본 포트 없이 씁니다. `http://`는 `localhost`, `127.0.0.1`, `[::1]`(로컬 개발)에만 쓸 수 있으며, 와일드카드는 쓸 수 없습니다. 공식 서버는 웹사이트를 위해 `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com`을 설정합니다. 이때:
      - 상태 확인 엔드포인트를 제외한 `/api/v1` 아래 경로의 모든 응답은 성공이든 오류든(주소별 계층의 429 포함) `Vary: Origin`을 포함합니다. 요청의 `Origin`이 나열된 오리진 중 하나와 바이트 단위로 정확히 일치하면 `Access-Control-Allow-Origin: <that origin>`도 포함하고, 프리플라이트에 대한 응답이 아니면 `Access-Control-Expose-Headers: Retry-After, Content-Disposition`도 포함합니다. 그래서 페이지가 오류 코드, `Retry-After`, PGN과 GIF 다운로드의 파일 이름을 읽을 수 있습니다.
      - 나열된 오리진에서 존재하는 경로로 보낸 프리플라이트(`Access-Control-Request-Method`가 있는 `OPTIONS`)는 본문 없이 `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`(`Allow`의 메서드), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600`, `Vary: Origin`과 함께 204로 응답합니다. 프리플라이트에는 토큰이 필요 없고 계정 할당량에도, 엔드포인트 제한에도 계산되지 않지만, 다른 모든 요청과 마찬가지로 주소별 계층의 토큰을 하나 소비합니다. 브라우저는 프리플라이트를 10분 동안 재사용하지만, 쿼리 문자열까지 똑같은 URL에 대해서만 재사용합니다. `GET /info`나 `GET /leaderboard`처럼 `Authorization`도 JSON 본문도 없는 요청에는 프리플라이트가 필요 없습니다.
      - 프리플라이트에 대한 그 밖의 응답은 CORS 헤더가 있더라도 실패한 프리플라이트입니다. 존재하지 않는 경로에 대한 404나 주소별 계층의 429가 그렇습니다. 이 경우 브라우저는 요청을 보내지 않으며, 페이지는 응답도 그 `Retry-After`도 받지 못한 채 네트워크 오류를 보게 됩니다.
      - `Access-Control-Allow-Credentials`와 `Access-Control-Allow-Origin: *`는 절대 보내지 않습니다. 페이지는 게임과 마찬가지로 세션 토큰을 `Authorization: Bearer`로 보내며, API는 쿠키를 설정하지 않습니다. HTML 페이지, 상태 확인 엔드포인트, 읽을 수 없는 요청에 대한 빈 응답에는 CORS 헤더가 없습니다. WebSocket은 CORS를 사용하지 않으며, WebSocket을 열 수 있는 페이지는 `WS_ALLOWED_ORIGINS`에 나열합니다.
      - 나열되지 않은 오리진의 페이지는 아무것도 읽을 수 없고, 그 페이지의 교차 사이트 쓰기 요청은 전송되지 않습니다. JSON 본문과 `Authorization`에는 프리플라이트가 필요한데, 그 응답에는 해당 오리진에 대한 `Access-Control-Allow-Origin`이 없기 때문입니다. `Cross-Origin-Resource-Policy: same-origin`은 모든 응답에 그대로 남습니다. 이 헤더는 `no-cors` 요청(다른 사이트가 응답을 삽입하는 경우)에만 적용되며, 나열된 페이지의 `cors` 요청에는 절대 적용되지 않습니다.

    ## 응답과 오류

    - 응답은 UTF-8 JSON입니다. 단, PGN 다운로드(`application/x-chess-pgn`), 애니메이션 GIF(`image/gif`), HTML 페이지는 예외입니다. 시각은 1970-01-01 UTC 이후의 밀리초입니다. ID는 정수입니다. 게임 ID는 최대 16자리이지만 2^53보다 작으므로 JSON 숫자(double)로 정확히 표현됩니다.
    - 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`도 포함됩니다. 리스너가 API보다 먼저 직접 작성하는 응답(상태 확인 엔드포인트의 `GET`과 `HEAD`, 주소별 계층의 429, API가 완전히 실패했을 때의 500 `internal_error`)에는 `Cross-Origin-Resource-Policy`를 제외한 같은 헤더가 포함되며, 그 `Content-Type`은 charset이 없는 `application/json`입니다.
    - 응답은 서버가 응답을 준비해 둔 시점부터 60초 안에 읽어야 하며, 이는 큰 응답(GIF, 데이터 내보내기, 긴 PGN)에서만 의미가 있습니다. 서버가 응답을 준비하는 데 걸리는 시간은 이 60초에도, 주고받는 바이트가 하나도 없으면 연결을 닫는 30초에도 포함되지 않습니다.

    오류의 형태는 한 가지입니다(`Error` 스키마):

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

    `message`는 로그용이자 대체 표시용입니다. 클라이언트는 `error`를 보고 무엇을 표시할지 정합니다. `retryAfter`(초)는 시간이 지나면 풀리는 거부에만 있으며, 이때 응답에는 같은 값의 `Retry-After` 헤더도 포함됩니다. 유일한 예외는 게임 기록, 게임, 플레이어, 리더보드 조회의 503 `busy`로, 이 응답에는 필드만 있습니다. 일부 오류에는 필드가 추가됩니다. `field`(잘못된 입력), `reason`(`weak_password`, `pow_required`), `pow`(`pow_required`), `until`(`banned`), `line`과 `column`(`invalid_pgn`).

    모든 엔드포인트에서 발생할 수 있는 오류:

    | 상태 코드 | `error` | 발생 조건 |
    |---|---|---|
    | 400 | `invalid_request` | 본문이 엔드포인트의 스키마를 위반했거나(`field`가 위치를 알려 줌), 요청 대상이나 `Content-Length`의 형식이 잘못되었거나, 경로 매개변수가 올바른 URL 인코딩이 아니거나, 본문이 잘렸습니다. |
    | 400 | `invalid_json` | 본문이 JSON이 아닙니다. |
    | 401 | `unauthorized` | 세션이 필요한 엔드포인트에 `Authorization` 헤더가 없습니다. |
    | 401 | `invalid_token` | 토큰의 형식이 잘못되었거나, 만료되었거나, 폐기되었거나, 삭제된 계정의 토큰입니다. 세션이 선택 사항인 엔드포인트에서도 발생합니다. |
    | 404 | `not_found` | 해당 엔드포인트가 없습니다. 일부 엔드포인트는 해당 게임, 플레이어, 세션이 없을 때에도 사용합니다. |
    | 405 | `method_not_allowed` | 경로가 다른 메서드로만 존재합니다(`Allow` 참조). |
    | 408 | `request_timeout` | 본문이 10초 안에 도착하지 않았습니다. |
    | 413 | `payload_too_large` | 본문이 `HTTP_BODY_LIMIT`보다 큽니다(`POST /gif`는 135,168바이트). |
    | 414 | `uri_too_long` | 요청 대상이 4096자를 넘습니다. |
    | 415 | `unsupported_media_type` | 본문이 `application/json`이 아니거나 charset이 UTF-8이 아닙니다. |
    | 429 | `rate_limited` | 속도 제한(아래 참조). `retryAfter`와 `Retry-After` 헤더가 함께 옵니다. |
    | 500 | `internal_error` | 예상치 못한 오류입니다. 서버가 로그에 기록합니다. |
    | 503 | `timeout` | 서버가 30초 안에 응답하지 않았습니다(기본 설정에서 내보내기는 60초, GIF는 45초). |
    | 503 | `server_busy` | 세션 조회나 계정 변경 중에 데이터베이스가 잠겨 있었거나(`retryAfter` 1), 비밀번호 해시 대기열이 가득 찼습니다(아래 참조). 서버 처리가 크게 밀려 있을 때도 `retryAfter` 2~5와 함께 발생합니다. `POST`, `PUT`, `PATCH`, `DELETE`의 경우 데이터베이스에 대기 중인 쓰기가 너무 많을 때, 모든 요청의 경우 타임아웃을 넘기고도 아직 실행 중인 이전 요청이 너무 많거나 세션 토큰을 5초 안에 확인하지 못했을 때입니다. 요청은 실행되지 않았으며, 속도 제한의 토큰도 소비되지 않았습니다. |

    조회 엔드포인트(게임 기록, 게임, 플레이어, 리더보드)와 내보내기는 데이터베이스가 계속 잠겨 있으면 `retryAfter: 1`과 함께 503 `busy`로 응답합니다.

    비밀번호를 확인하거나 해시하는 엔드포인트는 다음 중 하나로 응답할 수도 있으며, 둘 다 5~15초 사이의 임의의 `retryAfter`가 붙습니다:

    - 503 `server_busy`: 서버의 비밀번호 해시 대기열(`PASSWORD_HASH_QUEUE_MAX`)이 가득 찼거나 대기 시간이 다 되었습니다.
    - 429 `rate_limited`: 대기열이 절반 이상 찬 상태에서 이 클라이언트(IPv4 주소 또는 IPv6 /48)의 해시가 이미 `PASSWORD_HASH_WAITERS_PER_SOURCE`개 대기 중입니다. 이 429는 요청이 소비한 속도 제한 토큰을 돌려줍니다.

    아무것도 변경되지 않았고 실패한 시도로 계산되지도 않았으며(단, `POST /auth/sso/google/link`는 예외로, 해시 전에 티켓 시도 횟수와 계정 실패 횟수가 이미 차감됨), 재설정 링크도 계속 유효합니다.

    HTML 페이지는 오류를 같은 상태 코드의 HTML 페이지로 응답합니다.

    ## 인증

    세션이 필요한 엔드포인트는 `Authorization` 헤더로 Bearer 토큰을 받습니다(`bearerAuth` 스킴):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **토큰 받기.** 토큰(`sct_` 뒤에 base64url 문자 43개)은 `POST /auth/login`(2단계 인증이 켜져 있으면 이어서 `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`에서는 세션이 선택 사항입니다. 헤더가 없으면 공개 정보로 응답하고, 헤더를 보내면 그 토큰이 유효해야 합니다.
    - **저장소 혼잡.** 토큰을 확인하는 동안 데이터베이스가 계속 잠겨 있으면, 세션이 필요하거나 세션을 받는 엔드포인트는 `retryAfter: 1`(및 `Retry-After`)과 함께 503 `server_busy`로 응답합니다. 확인에 5초 넘게 걸리면 `retryAfter` 2~5와 함께 503 `server_busy`로 응답합니다. 어느 경우든 토큰이 판정된 것은 아니므로, 토큰을 그대로 두고 요청을 다시 보내세요.
    - **수명.** 세션은 로그인 후 `SESSION_MAX_DAYS`(90)일(이것이 `expiresAt`)과 사용하지 않은 채 `SESSION_IDLE_DAYS`(30)일 중 먼저 도래하는 시점에 끝납니다. 사용할 때마다 유휴 기한이 뒤로 밀리며, 서버는 새 값을 백그라운드에서 최대 5분에 한 번 기록합니다. 데이터베이스에 대기 중인 쓰기가 많은 동안에는 이 기록을 이후의 요청으로 미루지만, 유휴 기한 전 마지막 한 시간 안에는 미루지 않으므로 사용 중인 세션은 만료되지 않습니다. 한 계정은 세션을 최대 `MAX_SESSIONS_PER_USER`(10)개까지 유지하며, 새로 로그인하면 그 수를 넘는 가장 오래된 세션이 폐기됩니다.
    - **폐기.** 로그아웃(`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`)은 세션을 폐기합니다. 비밀번호 변경(다른 세션), 비밀번호 재설정과 계정 삭제(모든 세션), 관리자도 세션을 폐기합니다. API를 통한 폐기는 즉시 적용되며, 폐기된 세션으로 연 WebSocket은 닫힙니다. 비밀번호 변경이나 재설정, 계정 삭제는 변경 자체와 같은 트랜잭션 안에서 세션을 폐기합니다. 그중 하나가 실패하면 세션은 폐기되지 않았고 다른 것도 전혀 변경되지 않은 것입니다. 서버는 세션 조회 결과를 30초 동안 캐시하므로, 별도 프로세스인 관리자 명령에 의한 폐기는 30초 안에 적용됩니다.
    - **범위.** 토큰은 한 서버에 속하며 그 서버의 WebSocket도 엽니다. 절대로 다른 서버에 보내지 마세요.

    ## 속도 제한과 기타 스로틀링

    제한은 두 가지 범위 중 하나에 적용됩니다. **클라이언트:** IPv4 주소 하나 또는 IPv6 /64 하나(프록시 모드에서는 `TRUSTED_PROXIES` 주소가 보낸 `X-Forwarded-For`에서 주소를 가져옴). 일부 제한은 각 /64 네트워크와 별도로 IPv6 /48 전체도 함께 집계합니다. **플레이어:** 주소와 관계없이 로그인한 계정. 세션이 선택 사항인 엔드포인트에서 플레이어별로 집계하는 제한은, 토큰이 없는 요청에 대해서는 클라이언트별로 집계합니다.

    각 제한은 요청 `limit`개를 담는 토큰 버킷이며, `limit / window`의 속도로 계속 다시 채워집니다. `retryAfter`는 다음 토큰까지 남은 시간입니다. *공유*로 표시된 제한은 같은 길이의 슬라이딩 윈도우로도 집계되므로, 어떤 구간에도 `limit`개보다 훨씬 많은 요청이 들어가지 않습니다. 모든 제한은 서버 전체 기준으로 집계됩니다. 엔드포인트의 제한 중 하나가 요청을 거부하면, 그 요청에 대해 다른 제한이 소비한 토큰은 반환됩니다. 거부되면 `retryAfter` 및 `Retry-After`와 함께 429 `rate_limited`로 응답합니다.

    - **주소별 계층.** 모든 요청(경로와 메서드 무관, 상태 확인 엔드포인트와 WebSocket 업그레이드 포함)은 먼저 클라이언트 할당량에서 토큰 하나를 소비합니다. 분당 `HTTP_RATE_PER_IP`(600)개, 버스트는 30초 분량이며, IPv6 /48에는 `HTTP_RATE_PER_PREFIX`(4 x `HTTP_RATE_PER_IP`)가 적용됩니다. 또한 한 클라이언트가 동시에 진행할 수 있는 요청은 최대 `IP_MAX_INFLIGHT`(32 x `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 x `AUTH_RATE_PER_IP`) | `POST /auth/register`, `/auth/login`, `/auth/login/mfa`, `/auth/verify-email/resend`, `/auth/password/forgot`, `/auth/password/reset`, `/auth/sso/google/link`, `/auth/sso/complete`; `POST /verify-email`, `/reset-password`, `/confirm-email-change` |
    | `auth_register` | `AUTH_REGISTER_PER_HOUR`(10) / 시간, 공유 | 클라이언트, IPv6 /48당 그 3배 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR`(10) / 시간, 공유 | 클라이언트, IPv6 /48당 그 3배 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR`(3) / 시간, 공유 | 클라이언트, IPv6 /48당 그 3배 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY`(10) / 24시간, 공유 | 클라이언트, IPv6 /48당 그 3배 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR`(10) / 시간, 공유 | 클라이언트, IPv6 /48당 그 3배 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | `auth`와 같은 수치, 별도 버킷, 공유 | 클라이언트와 IPv6 /48 | `POST /account/password`, `/account/mfa/totp/setup`, `/account/mfa/totp/enable`, `/account/mfa/totp/disable`, `/account/mfa/recovery-codes`, `/account/email`, `/account/export`, `/account/delete` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER`(10) / 10분, 공유 | 플레이어 | `reauth`와 같은 엔드포인트 |
    | `account` | 60 / 분 | 플레이어 | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / 분 | 플레이어 | `GET /account/games` |
    | `account_export` | 5 / 시간, 공유 | 플레이어 | `POST /account/export`(`reauth`보다 먼저 확인, 모든 시도가 집계됨) |
    | `sessions` | 60 / 분 | 플레이어 | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / 분 | 플레이어(토큰이 없으면 클라이언트) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn`(네 엔드포인트가 버킷 하나를 공유) |
    | `gif` | 30 / 분 | 플레이어 | `GET /games/{id}/gif`, `POST /gif`(두 엔드포인트가 버킷 하나를 공유) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN`(4) / 분 및 `GIF_USER_RENDERS_PER_HOUR`(30) / 시간, 공유 | 플레이어 | 위의 두 엔드포인트, GIF를 새로 만들어야 할 때만(캐시에서 가져올 때는 제외) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN`(12) / 분 및 `GIF_IP_RENDERS_PER_HOUR`(120) / 시간, 공유 | 클라이언트(그 클라이언트의 모든 계정 합산), IPv6 /48당 그 3배 | 위와 같은 엔드포인트, 같은 조건 |
    | `reports` | 30 / 시간 | 플레이어 | `POST /reports` |
    | `sso_start` | 30 / 10분, 공유 | 클라이언트, 그리고 IPv6 /48당 90 | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start`(합산) |
    | `sso_finish` | 30 / 분 | 클라이언트 | `POST /auth/sso/google/finish` |
    | `page` | 60 / 분 | 클라이언트 | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info`와 `GET /leaderboard`에는 자체 제한이 없고 주소별 계층만 적용됩니다. 제한이 여러 개인 엔드포인트는 설명에 적힌 순서대로 제한을 확인합니다.

    서버는 요청을 다음 순서로 처리합니다: 주소별 계층, 그다음 상태 확인 엔드포인트; 엔드포인트 매칭; 서버 처리가 크게 밀려 있지 않은지 확인(503 `server_busy`): 타임아웃을 넘기고도 아직 실행 중인 이전 요청이 너무 많은지, 그리고 `POST`, `PUT`, `PATCH`, `DELETE`이면 데이터베이스의 쓰기 적체; 5초 이내의 인증(넘으면 503 `server_busy`); 요청에 세션이 있으면 계정 할당량; 엔드포인트의 제한; 본문; 엔드포인트 자체(GIF 엔드포인트는 만들 GIF가 있을 때만 렌더링 제한을 적용). 따라서 토큰 때문에 거부된 요청은 엔드포인트의 토큰을 전혀 소비하지 않고, 본문이 잘못된 요청은 소비합니다.

    엔드포인트가 직접 응답하는 그 밖의 스로틀링:

    - **한 로그인 이름에 대한 로그인 실패**(사용자 이름 또는 이메일): `AUTH_FAILURES_PER_ACCOUNT`(5)번 실패한 뒤부터는 매 시도마다 직전보다 두 배 오래 기다려야 합니다(2초, 4초 하는 식으로 최대 15분). 이때 `retryAfter`와 함께 429 `too_many_attempts`로 응답합니다. 실패 없이 한 시간이 지나면 카운터가 초기화됩니다.
    - **로그인 시 2차 인증 실패:** 계정의 5번째 잘못된 코드부터 같은 규칙이 적용됩니다. 로그인 단계 하나에서는 잘못된 코드를 최대 5개까지 받습니다.
    - **한 계정의 2차 인증 코드:** 주소와 관계없이, 로그인과 재인증에서 15분당 최대 `AUTH_MFA_PER_ACCOUNT`(10)개의 코드(인증 앱 코드나 복구 코드, 맞든 틀리든)를 받습니다. 이를 넘으면 코드를 확인하기 전에 429 `too_many_attempts`로 응답하므로 복구 코드가 소모되지 않습니다.
    - **계정의 재인증 실패**(잘못된 비밀번호나 코드): 같은 규칙(429 `too_many_attempts`)이며, 재인증하는 모든 엔드포인트가 이 규칙을 공유합니다.
    - **이메일:** 확인 메일이나 재설정 메일은 주소당 5분에 하나(응답은 똑같음), “누군가 이 주소를 사용하려고 했습니다” 알림은 주소당 한 시간에 하나입니다.
    - **신고:** 플레이어당 24시간에 `REPORTS_PER_DAY`(5)건. 넘으면 429 `report_limit`입니다.

    ## 작업 증명

    `POST /auth/register`는 `POW_REGISTER_BITS`가 0보다 크면(기본값 18, `GET /info`가 `pow.register`로 알려 줌) 항상 작업 증명이 필요합니다. `POST /auth/login`과 `POST /auth/sso/google/link`(두 엔드포인트에 같은 종류의 챌린지)는 서버가 로그인 실패가 몰려드는 것을 감지한 뒤 5분 동안만 작업 증명이 필요합니다(`POW_LOGIN_TRIGGER_PER_MIN`, 그다음 `POW_LOGIN_BITS`). 클라이언트는 응답을 보고 이를 알게 됩니다.

    1. 증명이 없는(또는 거부된 증명이 있는) 요청에는 428 `pow_required`로 응답하며, 응답에는 `reason`과 `pow` 챌린지(`{ challenge, bits, expiresAt }`)가 들어 있습니다.
    2. nonce를 찾습니다. nonce는 최대 20자리의 10진수 문자열로, `SHA-256(challenge + ":" + nonce)`가 0인 비트 `bits`개로 시작해야 합니다(첫 바이트의 최상위 비트부터). 18비트에는 평균 약 260,000번의 해시가 필요합니다.
    3. 본문에 `"pow": { "challenge": "...", "nonce": "123456" }`를 넣어 같은 요청을 다시 보냅니다.

    챌린지는 2분 동안 한 번만, 하나의 엔드포인트와 하나의 클라이언트 네트워크(IPv4 주소 또는 IPv6 /64)에 대해서만 유효합니다. 챌린지는 서명되어 있으므로, 서버는 챌린지가 돌아올 때까지 그에 관해 아무것도 저장하지 않습니다. `reason`은 증명이 거부된 이유를 알려 줍니다. `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work`, `replayed` 중 하나입니다.

    ## 재인증

    계정을 변경할 때는 비밀번호를 다시 요구하며, 2단계 인증이 켜져 있으면 2차 인증도 요구합니다:

    - 비밀번호만: `POST /account/password`, `POST /account/mfa/totp/setup`;
    - 비밀번호와 인증 앱 코드(복구 코드는 거부됨): `POST /account/mfa/recovery-codes`;
    - 비밀번호와 인증 앱 코드 또는 복구 코드: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export`, `POST /account/delete`.

    본문에서 `code`에는 6자리 인증 앱 코드를, `recoveryCode`에는 복구 코드(`xxxx-xxxx-xx`. 대소문자, 공백, 대시는 상관없음)를 넣습니다. 복구 코드를 받는 곳에서는 복구 코드를 `code`에 넣어 보내도 됩니다. 각 코드는 한 번만 쓸 수 있습니다. 사용한 인증 앱 코드는 다음 30초 단계까지 거부되며, 복구 코드는 한 번 사용하면 사라집니다.

    | 상태 코드 | `error` | 발생 조건 |
    |---|---|---|
    | 403 | `invalid_password` | 비밀번호가 틀렸거나, 요청이 실행되는 동안 비밀번호가 재설정 또는 변경되었습니다(아무것도 변경되지 않음). |
    | 403 | `mfa_code_required` | 2단계 인증이 켜져 있는데 `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: |-
      계정 만들기, 비밀번호(및 2차 인증)로 로그인하기, 확인 메일과 비밀번호 재설정 메일.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link`, `POST /auth/sso/complete`는 세션 `{ token, expiresAt, user }`로 응답합니다(앞쪽의 일부 엔드포인트는 두 번째 단계로 응답하기도 함).
  - name: google-sign-in
    x-displayName: Google 로그인
    description: |-
      `GET /info`가 `sso.google: true`를 알려 줄 때 제공되며, 그렇지 않으면 아래의 모든 엔드포인트가 404 `sso_disabled`로 응답합니다. `sso.googleWeb`이 true이면 웹사이트도 자체 클라이언트로 같은 방식으로 로그인합니다(아래 **웹사이트에서**). 게임은 RFC 8252의 설치형 앱 흐름(“데스크톱 앱” 클라이언트)에 따라 시스템 브라우저를 통해 로그인합니다. Google은 브라우저를 이 서버가 아니라 `127.0.0.1`에 있는 게임의 리스너로 돌려보내며, 게임은 Google 자격 증명을 전혀 보지 않습니다.

      1. 클라이언트는 `127.0.0.1:0`에서 대기하고(포트는 시스템이 고름) PKCE 쌍을 만듭니다. `[A-Za-z0-9._~-]` 문자 43~128자로 된 `codeVerifier`와, 패딩 없이 43자인 `codeChallenge = BASE64URL(SHA-256(codeVerifier))`입니다.
      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. 응답은 세션, 2단계 인증 단계(`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`가 정확히 하나, 게임이 자신의 포트와 오리진 태그로 계산한 URI와 같은 `redirect_uri`가 정확히 하나, 응답의 `state`와 같은 `state`가 정확히 하나 있고, `code_challenge_method=S256`과 43자인 `code_challenge`가 있어야 합니다. 그렇지 않으면 게임은 리스너를 멈추고 아무것도 열지 않습니다. 게임은 `start`에 응답한 서버에만 `finish`와 `link`를 보냅니다.

      **웹사이트에서.** 웹사이트(공식 웹사이트: <https://scacelith.com/>)는 같은 Google 프로젝트의 “웹 애플리케이션” 클라이언트(`GOOGLE_WEB_CLIENT_ID`)로 로그인합니다. Google은 브라우저를 운영자가 설정한 사이트의 한 페이지(`GOOGLE_WEB_REDIRECT_URI`, 클라이언트의 “승인된 리디렉션 URI”에 등록됨)로 돌려보내고, 그 페이지가 코드를 `finish`로 보냅니다. 여기서도 서버는 클라이언트로부터 리디렉션 URI를 받지 않습니다.

      1. 페이지는 위와 같이 PKCE 쌍을 만들고, 검증자를 탭에 보관하며(`sessionStorage`), 챌린지를 담아 `POST /auth/sso/google/web/start`를 보냅니다.
      2. 게임과 마찬가지로 `authUrl`을 검사하되, `redirect_uri`가 자기 오리진의 페이지여야 한다는 점만 다릅니다. 그런 다음 그 URL로 이동합니다. Google 스크립트는 전혀 불러오지 않습니다.
      3. Google은 브라우저를 `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`로 보냅니다. 페이지는 시작 응답의 `state`만 받아들이며, `error=` 리디렉션 뒤에는 아무것도 보내지 않습니다.
      4. `finish`, `link`, `complete`는 게임과 똑같이 이어집니다. 시도는 웹사이트가 시작했다는 것을 기억합니다. 코드는 웹사이트의 클라이언트와 페이지로 교환되며, ID 토큰에는 그 클라이언트가 지정되어 있어야 합니다(게임 클라이언트의 코드나 ID 토큰은 502 `sso_failed`로 실패하며, 반대의 경우도 마찬가지).

      Google 계정은 게임에서든 웹사이트에서든 같은 Scacelith 계정에 도달합니다. 연결에는 Google의 subject(`sub`)가 저장되며, 이 값은 클라이언트와 관계없이 Google 계정을 식별합니다. 웹사이트의 오리진은 `CORS_ORIGINS`에 있어야 합니다(없으면 `check-config`가 경고합니다).

      **Google 로그인이 도달하는 계정:** 그 Google 계정에 이미 연결된 계정. 없으면 Google이 확인한 주소를 가진 활성 계정(비밀번호가 있으면 `finish`가 `needsPassword`로 응답하며, 그 비밀번호와, 켜져 있다면 2차 인증까지 통과해야 연결이 저장됨. 비밀번호가 없으면 409 `sso_account_exists`). 그것도 없으면 새 계정(`needsUsername`). Google 계정이 주소만으로 기존 계정에 연결되는 일은 없습니다. Google 로그인으로 계정이 만들어질 때와 기존 계정에 Google 로그인이 추가될 때, 그 계정의 주소로 메일이 발송됩니다.
  - name: sessions
    x-displayName: 세션
    description: 계정의 로그인된 기기와 로그아웃.
  - name: account
    x-displayName: 계정
    description: 계정 뷰, 설정, 비밀번호.
  - name: two-step-verification
    x-displayName: 2단계 인증
    description: 인증 앱(TOTP, RFC 6238). SHA-1, 6자리, 30초 단계를 사용하며 앞뒤로 한 단계의 오차를 허용합니다. 여기에 일회용 복구 코드가 더해집니다.
  - name: email-change
    x-displayName: 이메일 주소 변경
    description: 계정의 주소를 변경하며, 새 주소로 보낸 링크로 확인합니다.
  - name: data-export
    x-displayName: 데이터 내보내기
    description: 서버가 계정에 관해 보관하는 모든 것을 JSON 파일 하나로 제공합니다.
  - name: account-deletion
    x-displayName: 계정 삭제
    description: 계정을 영구히 삭제합니다.
  - name: game-history
    x-displayName: 게임 기록
    description: 로그인한 플레이어 자신의 게임을 필터링하고 페이지로 나누어 제공합니다.
  - name: games
    x-displayName: 게임과 PGN
    description: |-
      수와 시계 기록을 담은 게임 기록과 그 PGN 파일.

      **게임 코드.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted`(끝난 게임만 저장됨). `result`: `1-0`, `0-1`, `1/2-1/2`, 또는 `*`(중단). `reason`과 그 이름(`termination`), 그리고 PGN 파일의 수 기록 끝에 붙는 문구(파일에서는 영어이며, 아래에 번역을 함께 적음)는 다음과 같습니다. 코드 7, 14, 21은 무승부입니다(시간이 다 되었거나, 기권했거나, 게임을 떠난 플레이어의 상대가 체크메이트할 수 없었던 경우: FIDE 6.9 및 5.1.2, 규칙은 `DESIGN.md` 6.3에 있음). 서버는 코드 4와 13으로 게임을 끝내지 않습니다(이 코드는 공용 사유 목록에 속함):

      | 코드 | `termination` | PGN의 문구 |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (체크메이트) |
      | 2 | `Resignation` | Resignation (기권) |
      | 3 | `Timeout` | Loss on time (시간 초과 패배) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (두 번째 불법 수, 몰수패) |
      | 5 | `Stalemate` | Stalemate (스테일메이트) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (데드 포지션, 기물 부족) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (시간 초과지만 상대가 체크메이트할 수 없음) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (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 (두 번째 불법 수지만 상대가 체크메이트할 수 없음) |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate (기권했지만 상대가 체크메이트할 수 없음) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (게임을 떠남, 너무 오래 연결 끊김) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (게임을 떠났지만 상대가 체크메이트할 수 없음) |
      | 22 | `Aborted` | Game aborted (게임 중단) |
      | 23 | `NoShow` | Aborted: first move not played in time (중단: 제한 시간 안에 첫 수를 두지 않음) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (몰수패, 페어플레이 위반) |
      | 25 | `ServerAborted` | Aborted by the server (서버가 중단함) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (중단: 두 플레이어 모두 연결 끊김) |

      코드 14는 프로토콜 마이너 버전 1에서 새로 추가되었습니다. 그 이전에 작성된 클라이언트는 알 수 없는 코드를 `result`와 함께 일반적인 게임 종료로 표시해야 합니다.
  - name: gifs
    x-displayName: 애니메이션 GIF
    description: |-
      보관하거나 공유할 수 있도록 게임을 애니메이션 GIF로 만듭니다. 위에서 내려다본 체스판, 시작부터 최종 포지션까지 포지션마다 한 프레임, 체스판 위에 플레이어의 이름과 레이팅, 체스판 아래에 마지막 수, 그리고 마지막 프레임에 결과와 게임이 끝난 방식이 들어갑니다.

      **비용, 캐시, 할당량.** GIF는 게임을 실행하는 스레드가 아닌 별도의 렌더링 스레드에서, 가장 낮은 CPU 우선순위로 만들어집니다. 스레드는 `GIF_THREADS`개(기본값 `WORKERS`)이며, 첫 GIF와 함께 시작되고 GIF 없이 1분이 지나면 멈춥니다. 최대 `GIF_QUEUE_MAX`(4 x `WORKERS`)개의 GIF가 스레드를 기다릴 수 있으며, 각각 최대 `GIF_QUEUE_TIMEOUT_MS`(10초)까지 기다립니다. 렌더링 한 번은 최대 `GIF_RENDER_TIMEOUT_MS`(30초)까지 걸릴 수 있습니다. 서버는 만든 GIF를 `GIF_CACHE_MB` MB(32 x `WORKERS`) 크기의 캐시에 보관하며, 가장 오랫동안 사용되지 않은 것부터 지웁니다. 캐시에 있는 GIF나 다른 요청을 위해 만들어지는 중인 GIF는 렌더링 비용이 들지 않습니다. 캐시 키에는 이름을 포함해 그림을 바꾸는 모든 요소가 들어갑니다.

      모든 요청은 `gif`에 집계됩니다(두 엔드포인트 합쳐 플레이어당 분당 30회). 새로 만들어야 하는 GIF는 렌더링 제한에도 집계됩니다. 플레이어당 분당 `GIF_USER_RENDERS_PER_MIN`(4)개와 시간당 `GIF_USER_RENDERS_PER_HOUR`(30)개, 클라이언트당(그 클라이언트의 모든 계정 합산) 분당 `GIF_IP_RENDERS_PER_MIN`(12)개와 시간당 `GIF_IP_RENDERS_PER_HOUR`(120)개, IPv6 /48당 그 3배입니다. 클라이언트는 같은 파일을 다시 요청하지 말고 내려받은 파일을 보관해야 하며, 429나 503을 받으면 `retryAfter`만큼 기다려야 합니다.

      크기: `small`(칸 32px: 284 x 350픽셀), `medium`(48px: 424 x 515), `large`(72px: 628 x 762). 좌표가 없으면 268 x 342, 400 x 503, 600 x 748입니다. 시작 포지션은 최소 1초(지연 값이 더 길면 그 값) 동안, 최종 포지션은 3초 동안 표시되며, GIF는 반복 재생됩니다. 첫 프레임 이후에는 그림에서 바뀌는 부분만 저장됩니다. 40수 게임은 약 135, 205, 325KiB(small, medium, large), 150수 게임은 0.5, 0.8, 1.2MiB입니다.
  - 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 본문도 허용)이며, 링크의 토큰은 숨은 필드에 들어 있습니다. 같은 필드가 두 번 있으면 거부됩니다.
      - 오류(속도 제한, 잘못된 필드)도 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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: 계정 만들기
      description: |-
        계정을 만듭니다. 이메일 주소로 보낸 링크로 주소가 확인되면 만들어지며, 이메일 확인이 없으면 즉시 만들어집니다.

        **이메일 확인이 있는 경우**(`REQUIRE_EMAIL_VERIFICATION`, 기본값): 202 `verification_sent`. 아직 계정은 존재하지 않습니다. 가입 신청은 24시간 동안 대기하며, 그동안 사용자 이름을 선점해 둡니다. 그 24시간 동안 유효한 링크가 주소로 발송되며, 주소당 5분에 최대 한 번입니다(`POST /auth/verify-email/resend`로 새 링크를 보냄). 링크를 사용하면(`/verify-email` 페이지의 버튼) 주소가 확인된 상태로 계정이 만들어지고, 그때부터 플레이어가 로그인할 수 있습니다. 그전까지 그 사용자 이름으로 로그인하면 알 수 없는 계정과 마찬가지로 `invalid_credentials`로 응답하며, 공개 프로필도 존재하지 않습니다. 같은 주소로 새로 가입 신청을 하면 대기 중인 신청을 대체합니다. 다른 계정이 이미 그 주소를 사용하고 있을 때도 응답은 같습니다. 이때는 링크를 보내지 않고 대신 그 주소의 주인에게 알림을 보내며(최대 한 시간에 한 번), 사용자 이름도 같은 방식으로 선점해 두므로, 그 주소에 계정이 있는지 알 수 있는 단서가 전혀 없습니다. 링크가 사용되지 않은 가입 신청은 24시간 뒤 삭제되며, 그 사용자 이름은 다시 사용할 수 있게 됩니다.

        **이메일 확인이 없는 경우**(`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`. 계정이 즉시 만들어지고 로그인할 수 있습니다.

        **규칙.** `username`: `USERNAME_MIN`~`USERNAME_MAX`자(3~20자)의 문자, 숫자, `_`, `-`로 이루어지며 문자나 숫자로 시작해야 합니다(`GET /info`가 `limits`로 알려 줌). 예약된 이름(`admin`, `moderator`, `deleted` 등)과 일부 접두사는 거부되며, 대소문자를 구분하지 않고 고유해야 합니다. `email`: 앞뒤 공백을 제거하고 소문자로 저장하며, 점이 들어간 도메인을 가진 일반 ASCII여야 합니다. `password`: 최소 `PASSWORD_MIN_LENGTH`(10)자, 최대 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`(클라이언트당 시간당 가입 10회). **작업 증명:** `POW_REGISTER_BITS`가 0보다 크면 항상 필요합니다.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: 첫 시도, 작업 증명 없음
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: 작업 증명을 담아 다시 보냄
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: 계정이 만들어졌으며 로그인할 수 있습니다(이 서버에는 이메일 확인이 없음).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: 가입 신청이 확인 링크를 기다리고 있습니다(또는 그 주소에 이미 계정이 있음. 응답으로는 구별할 수 없음).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationSentStatus'
              example:
                status: verification_sent
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`(`field` 포함), `invalid_json`, `invalid_username`, `invalid_email`, 또는 `weak_password`(`reason`: `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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: 비밀번호로 로그인
      description: |-
        사용자 이름 또는 이메일 주소와 비밀번호로 로그인합니다. 가능한 응답은 두 가지이며, 둘 다 상태 코드 200입니다. 세션, 또는 2단계 인증이 켜져 있으면 `POST /auth/login/mfa`로 5분 안에 완료해야 하는 두 번째 단계입니다.

        알 수 없는 계정, 틀린 비밀번호, 비밀번호가 없는 계정은 같은 시간이 지난 뒤 같은 응답을 받습니다. 401 `invalid_credentials`입니다. 계정 검사(`banned`, `email_unverified`)는 비밀번호가 맞은 뒤에만 이루어집니다. 한 로그인 이름에서 `AUTH_FAILURES_PER_ACCOUNT`(5)번 실패한 뒤부터는 시도할 때마다 기다려야 합니다(429 `too_many_attempts`).

        **제한:** `auth`. **작업 증명:** 로그인 실패가 몰려드는 동안에만 필요합니다(428 `pow_required`).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
            example:
              login: alice
              password: correct horse battery
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: 세션, 또는 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: 2단계 인증이 켜져 있음
                  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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: 2차 인증으로 로그인 완료
      description: |-
        `POST /auth/login` 또는 Google 로그인(`POST /auth/sso/google/finish` 또는 `POST /auth/sso/google/link`)이 `mfaRequired`로 응답한 뒤의, 2단계 인증을 사용하는 로그인의 두 번째 단계입니다. `code`(6자리 인증 앱 코드 또는 복구 코드)나 `recoveryCode`를 보내세요. 여기서 사용한 복구 코드는 사라집니다.

        한 단계에서는 잘못된 코드를 최대 5개까지 받으며, 비밀번호가 재설정되거나 변경되어도 단계가 끝납니다. Google 연결의 비밀번호 단계 뒤에는, 여기서 코드가 통과해야만 연결이 저장됩니다.

        **제한:** `auth`, 그리고 주소와 관계없이 계정당 15분에 최대 `AUTH_MFA_PER_ACCOUNT`(10)개의 코드. 이를 넘으면 코드를 확인하지 않으므로 복구 코드가 소모되지 않습니다.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: 인증 앱 코드
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: 복구 코드
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: 로그인되었습니다.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: `code`도 `recoveryCode`도 보내지 않았거나 본문이 스키마를 위반했습니다. `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: 단계가 만료되었거나, 이미 사용되었거나, 잘못된 코드 5개로 끝났거나, 첫 단계 이후 비밀번호가 재설정 또는 변경되었습니다(다시 로그인하세요). `invalid_code`: 틀렸거나 이미 사용된 코드.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned`(`until` 포함), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: Google 연결의 비밀번호 단계 뒤에만 해당하며, 그사이에 그 Google 계정이 다른 계정에 연결되었습니다.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: Google 연결의 비밀번호 단계 뒤에만 해당하며, 그사이에 계정이 변경되었습니다. 로그인을 처음부터 다시 시작하세요.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: 계정의 실패 대기 시간이거나, 최근 15분 동안의 `AUTH_MFA_PER_ACCOUNT`개 코드를 모두 썼습니다. `rate_limited`: `auth` 제한.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`retryAfter: 1`인 `server_busy`: 데이터베이스가 계속 잠겨 있었습니다(Google 연결 단계 뒤였다면 아무것도 연결되지 않았으니 로그인을 처음부터 다시 시작하세요). `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: 확인 링크 다시 보내기
      description: |-
        이메일 확인 링크를 다시 보냅니다. 주소와 관계없이 응답은 202 `accepted`이므로, 그 주소를 계정이나 가입 신청이 사용하는지는 절대 드러나지 않습니다.

        요청은 주소당 5분에 최대 한 번만 동작합니다. 그 주소로 대기 중인 가입 신청은 다른 계정이 그 주소를 사용하든 아니든 24시간을 새로 얻으므로, 어느 경우든 사용자 이름이 같은 기간 동안 선점됩니다. 링크는 주소에 계정이 없을 때의 그 가입 신청(24시간 유효한 새 링크가 이전 링크를 대체), 또는 그 주소를 가진 확인되지 않은 활성 계정에 대해서만 발송됩니다.

        **제한:** `auth`, 그다음 `auth_mail`(클라이언트당 시간당 10회).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: 접수되었습니다(주소와 관계없음).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedStatus'
              example:
                status: accepted
        '400':
          $ref: '#/components/responses/BadRequest'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `auth` 또는 `auth_mail` 제한(주소와 관계없음).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`retryAfter: 1`인 `server_busy`: 계정을 조회하거나 확인되지 않은 계정의 링크를 저장하는 동안 데이터베이스가 계속 잠겨 있었습니다(대기 중인 가입 신청의 갱신은 가능한 범위에서만 수행되며 요청을 실패시키지 않음). 그래도 그 주소의 5분은 시작되므로, 그 안에 보낸 새 요청은 아무 일도 하지 않습니다. `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: 비밀번호 재설정 링크 보내기
      description: |-
        한 시간 동안 유효하며 `/reset-password` 페이지를 여는 비밀번호 재설정 링크를 보냅니다. 주소와 관계없이 응답은 202 `accepted`입니다. 링크는 활성 계정에만, 주소당 5분에 최대 한 번 발송됩니다. Google 전용 계정은 이 방법으로 첫 비밀번호를 설정합니다.

        비밀번호 찾기에는 API에서 가장 엄격한 제한이 적용되며, 모두 서버 전체 기준으로 집계됩니다. 클라이언트(IPv4 주소 또는 IPv6 /64)당 시간당 3회(`AUTH_FORGOT_PER_HOUR`)와 24시간당 10회(`AUTH_FORGOT_PER_DAY`), IPv6 /48당 그 3배이며, 여기에 `auth` 제한(10분당 20회)과 주소당 5분에 메일 한 통 제한이 더해집니다. 202로도 429로도 그 주소를 사용하는 계정이 있는지 알 수 없습니다. 새 비밀번호 설정에는 별도의 제한(`auth_reset`)이 있습니다.

        **제한:** `auth`, `auth_forgot`, 그다음 `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: 접수되었습니다(주소와 관계없음).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedStatus'
              example:
                status: accepted
        '400':
          $ref: '#/components/responses/BadRequest'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `auth`, `auth_forgot` 또는 `auth_forgot_day` 제한(주소와 관계없음).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`retryAfter: 1`인 `server_busy`: 계정을 조회하거나 링크를 저장하는 동안 데이터베이스가 계속 잠겨 있었습니다. 그래도 그 주소의 5분은 시작되므로, 그 안에 보낸 새 요청은 링크를 보내지 않습니다. `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: 재설정 링크의 토큰으로 새 비밀번호 설정
      description: |-
        재설정 링크의 토큰으로 새 비밀번호를 설정합니다(`/reset-password` 페이지도 같은 일을 합니다). 새 비밀번호에는 가입 규칙이 적용됩니다.

        재설정은 하나의 트랜잭션 안에서(전부 적용되거나 하나도 적용되지 않음) 다음 효과를 냅니다. 모든 세션을 폐기하고 대기 중인 이메일 변경을 취소합니다. 계정의 다른 재설정 링크는 더 이상 동작하지 않습니다. 주소는 확인된 것으로 간주됩니다(링크가 증명함). 계정 주인에게 메일이 발송됩니다. 2단계 인증은 건드리지 않습니다. 비밀번호 해시 대기열 오류, 503, 500 뒤에는 아무것도 변경되지 않은 것입니다. 기존 비밀번호와 세션이 유지되고 링크도 계속 유효합니다.

        **제한:** `auth`, 그다음 `auth_reset`(클라이언트당 시간당 10회, IPv6 /48당 30회, 페이지와 공유: 시도할 때마다 비밀번호를 해시함).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: 비밀번호가 변경되었고 모든 세션이 로그아웃되었습니다.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: password_reset
              example:
                status: password_reset
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_token`: 링크가 유효하지 않거나, 이미 사용되었거나, 만료되었거나, 계정이 더 이상 사용하지 않는 주소로 발송된 링크입니다. `weak_password`(`reason` 포함). `invalid_request`, `invalid_json`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `auth` 또는 `auth_reset` 제한, 또는 비밀번호 해시 대기열.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: 비밀번호 해시 대기열, 또는 데이터베이스가 계속 잠겨 있었음(`retryAfter: 1`, 아무것도 변경되지 않음). `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /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_method=S256`과 함께 `code_challenge`(서버가 Google용으로 가진 자체 검증자의 S256), `prompt=select_account`가 들어 있습니다. 게임은 이 URL을 열기 전에 검사합니다(태그 설명 참조).

        **제한:** `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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: 웹사이트에서 Google 로그인 시작
      description: |-
        웹사이트 페이지의 PKCE 챌린지로 웹사이트의 Google 로그인 시도를 시작합니다. 응답은 게임의 경우와 같습니다. 이동할 Google URL과 이 시도의 `state`이며, 시도는 10분 동안 유효합니다. `GET /info`가 `sso.googleWeb: true`를 알려 줄 때 제공됩니다.

        `authUrl`에는 웹사이트의 `client_id`와 `redirect_uri=GOOGLE_WEB_REDIRECT_URI`가 들어 있으며, 그 밖의 매개변수는 게임과 같습니다. 페이지는 이 URL로 이동하기 전에 검사합니다(태그 설명의 **웹사이트에서** 참조).

        **제한:** `sso_start`(게임의 시작과 공유).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      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-web.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fscacelith.com%2Faccount%2Fgoogle%2F&response_type=code&scope=openid%20email%20profile&state=yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ&nonce=gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU&code_challenge=ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc&code_challenge_method=S256&prompt=select_account
                state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
                expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: `codeChallenge`가 없거나 형식이 잘못되었거나, 그 밖의 필드(`redirectPort` 포함)가 있습니다.'
        '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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Google의 응답을 서버에 전달
      description: |-
        Google이 게임의 리스너나 웹사이트의 페이지로 보낸 `code`와 `state`를 시도 ID 및 PKCE 검증자와 함께 서버에 전달합니다. 시도 ID는 검증자가 없으면 쓸모가 없고, 코드는 서버 자체의 PKCE 검증자와 클라이언트 시크릿이 없으면 쓸모가 없습니다.

        서버는 시도를 확인하고(알 수 없거나, 이미 사용되었거나, 만료되었으면 410), 이어서 검증자를 확인하며(틀린 검증자는 시도를 계속 사용할 수 있는 상태로 둠), 그다음 시도를 한 번 사용하고, `state`와 `iss`를 확인하고, Google과 코드를 교환하고, ID 토큰을 검증합니다. 응답(200)은 다음 중 하나입니다:

        - `{ token, expiresAt, user }`: 연결된 계정에 로그인됨.
        - `{ mfaRequired, mfaToken, expiresIn }`: `POST /auth/login/mfa`로 계속.
        - `{ needsUsername, ssoTicket, suggestedUsername }`: 새 계정. 10분 안에 `POST /auth/sso/complete`로 계속. `suggestedUsername`은 Google 이름이나 주소에서 만들며, 알맞은 것이 없으면 `""`입니다.
        - `{ needsPassword, linkTicket, username, expiresIn }`: 그 주소를 가진 계정에 비밀번호가 있음. `POST /auth/sso/google/link`로 계속. 아직 아무것도 연결되지 않았습니다.

        **제한:** `sso_finish`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoFinishRequest'
            example:
              attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
              codeVerifier: Sb5SaoX0ByHGjJ0XQkEqaaPaJYx2BId4ncTT8tLM6W8
              state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
              code: 4/0AVMBsJhR2x7cKq9vT1pLmN3oW8yZ5aB6dE7fG8hJ
              iss: https://accounts.google.com
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: 세션, 두 번째 단계, 또는 첫 Google 로그인의 다음 단계.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: 연결된 계정에 로그인됨
                  value:
                    token: sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
                    expiresAt: 1798658839708
                    user:
                      id: 1
                      username: alice
                      email: alice@example.org
                      emailVerified: true
                      mfaEnabled: false
                      googleLinked: true
                      hasPassword: false
                      acceptChallenges: all
                      createdAt: 1790882839743
                      lastLoginAt: 1790882839708
                      pendingEmail: null
                mfaRequired:
                  summary: 2단계 인증이 켜져 있음
                  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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: 비밀번호로 기존 계정에 Google 연결
      description: |-
        `finish`가 `needsPassword`로 응답한 뒤에 사용합니다: 게임에서 입력한 기존 계정의 비밀번호로 그 계정에 Google을 연결합니다. 응답(200)은 세션(연결이 저장되고 플레이어가 로그인됨)이거나, 2단계 인증이 켜져 있으면 `{ mfaRequired, mfaToken, expiresIn }`입니다. 이 경우 `POST /auth/login/mfa`로 계속하며, 거기서 코드가 통과해야만 연결이 저장됩니다.

        비밀번호가 틀려도 티켓은 계속 사용할 수 있으며, 모두 합쳐 5번까지 시도할 수 있습니다. 시도 횟수는 비밀번호를 확인하기 직전에 차감됩니다. 429 `too_many_attempts`나 428 `pow_required`는 시도 횟수를 차감하지 않지만, 비밀번호 해시 대기열에 의한 거부(503 `server_busy`, 429 `rate_limited`)는 이미 한 번을 차감했으며 계정의 실패로 계산됩니다. 실패 대기 시간은 `POST /auth/login`과 같은 카운터를 사용합니다.

        **제한:** `auth`(IPv6 /48 단위 집계 포함). **작업 증명:** `POST /auth/login`과 같습니다.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoLinkRequest'
            example:
              linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
              password: correct horse battery
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: 세션, 또는 로그인의 두 번째 단계.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: 틀린 비밀번호. 티켓은 유지되며, 모두 합쳐 5번까지 시도할 수 있습니다.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned`(`until` 포함). 비밀번호가 맞은 경우에만 해당합니다.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: 이 서버는 Google 로그인을 제공하지 않습니다.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: 그사이에 Google 계정이 다른 계정에 연결되었습니다.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: 알 수 없거나 이미 사용되었거나 만료된 티켓, 5번째로 틀린 비밀번호, `finish` 이후 상태나 주소가 바뀐 계정, 또는 연결을 저장하는 동안 상태, 주소, 비밀번호, 2단계 인증 중 하나가 바뀐 계정. 로그인을 처음부터 다시 시작하세요.'
        '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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: 로그인된 기기 목록 조회
      description: |-
        계정의 활성 세션(로그인된 기기)을 가장 최근에 사용한 것부터 반환합니다. `current`는 요청을 보낸 세션을 표시합니다. `lastSeenAt`은 최대 5분에 한 번 갱신되며(서버 처리가 크게 밀려 있는 동안에는 더 늦게), `expiresAt`은 절대 만료 시각이고, 유휴 기한에 따라 세션이 그보다 일찍 끝날 수 있습니다.

        **제한:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: 활성 세션.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionList'
              example:
                sessions:
                  - id: 3
                    createdAt: 1790882839708
                    lastSeenAt: 1790882839708
                    expiresAt: 1798658839708
                    clientLabel: Laptop
                    current: false
                  - id: 1
                    createdAt: 1790882839708
                    lastSeenAt: 1790882839708
                    expiresAt: 1798658839708
                    clientLabel: Scacelith 1.4 (Windows)
                    current: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `sessions` 제한 또는 계정 할당량.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`(데이터베이스가 계속 잠겨 있었음), `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: 기기 하나 로그아웃
      description: |-
        계정의 세션 하나를 로그아웃시킵니다. 현재 세션도 로그아웃할 수 있습니다. 그 세션으로 연 WebSocket은 닫힙니다. 본문은 없습니다(또는 `{}`).

        **제한:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: 세션이 로그아웃되었습니다.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: revoked
              example:
                status: revoked
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: `{}` 이외의 본문, 또는 올바른 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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: 계정, 레이팅, 적용 중인 제재 조회
      description: |-
        플레이어 본인에게 보이는 계정: 계정 뷰(모든 로그인 응답의 `user`이기도 함), 플레이어가 레이팅 게임을 둔 카테고리마다 하나씩의 레이팅 기록, 적용 중인 제재, 정지 정보. 안티치트의 무결성 수준은 절대 표시되지 않습니다.

        **제한:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: 계정.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountMe'
              example:
                user:
                  id: 1
                  username: alice
                  email: alice@example.org
                  emailVerified: true
                  mfaEnabled: true
                  googleLinked: false
                  hasPassword: true
                  acceptChallenges: all
                  createdAt: 1790882871478
                  lastLoginAt: 1790882902200
                  pendingEmail: alice.new@example.org
                ratings:
                  - category: '3+2'
                    rating: 1510
                    games: 2
                    wins: 1
                    draws: 1
                    losses: 0
                    peak: 1510
                    provisional: true
                sanctions: []
                ban: null
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`unauthorized` 또는 `invalid_token`(계정이 삭제된 경우 포함).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `account` 제한 또는 계정 할당량.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`(데이터베이스가 계속 잠겨 있었음), `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: 비밀번호 변경
      description: |-
        비밀번호를 변경합니다. 현재 비밀번호가 필요하지만, 2단계 인증이 켜져 있어도 2차 인증은 필요하지 않습니다. 새 비밀번호는 가입 규칙을 따릅니다(현재 비밀번호 다음에 검사됨).

        변경은 하나의 트랜잭션에서(전부 적용되거나 하나도 적용되지 않음) 다음 효과를 냅니다: 다른 모든 세션을 폐기하고(이 세션은 로그인 상태 유지), 대기 중인 이메일 변경을 취소하며, 계정의 비밀번호 재설정 링크를 더 이상 쓸 수 없게 하고, 소유자에게 메일을 보냅니다.

        **재인증:** 비밀번호만. **제한:** `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`: 비밀번호 해시 대기열, 또는 데이터베이스가 계속 잠겨 있었음(`retryAfter: 1`, 아무것도 변경되지 않음). `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: 2단계 인증 켜기 시작
      description: |-
        대기 중인 새 인증 앱 비밀 키를 저장하고(이전에 대기 중이던 비밀 키는 대체됨), 인증 앱에서 쓸 수 있도록 반환합니다(텍스트, 그리고 QR 코드로 표시할 `otpauth://` URI로). 2단계 인증은 아직 켜지지 않습니다: `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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: 2단계 인증 켜기 완료 및 복구 코드 받기
      description: |-
        대기 중인 비밀 키의 코드(정확히 6자리)로 2단계 인증을 켭니다. 여기서는 비밀번호를 묻지 않습니다. 설정을 시작할 때 이미 입력했기 때문입니다. 응답에는 복구 코드 10개가 들어 있으며, 이번 한 번만 표시됩니다.

        **제한:** `reauth`, 그다음 `reauth_user`. 틀린 코드는 계정의 재인증 실패로 집계됩니다.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: 2단계 인증이 켜졌습니다.
          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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: 2단계 인증 끄기
      description: |-
        2단계 인증을 끕니다. 비밀 키와 복구 코드가 삭제되고, 소유자에게 메일이 갑니다.

        **재인증:** 비밀번호, 그리고 인증 앱 코드 또는 복구 코드(`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: 2단계 인증이 꺼졌습니다.
          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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: 복구 코드 교체
      description: |-
        복구 코드를 새 코드 10개로 교체합니다. 이전 코드는 더 이상 쓸 수 없습니다.

        **재인증:** 비밀번호, 그리고 `code`에 넣은 인증 앱 코드(복구 코드는 403 `invalid_code`로 거부됨). **제한:** `reauth`, 그다음 `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecoveryCodesRequest'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: 새 복구 코드. 이번 한 번만 표시됩니다.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - recoveryCodes
                properties:
                  recoveryCodes:
                    $ref: '#/components/schemas/RecoveryCodeList'
              example:
                recoveryCodes:
                  - j7v5-3ezx-zn
                  - 4kqm-8w2p-hd
                  - x0ra-c6tn-5g
                  - mb3s-9yzj-k1
                  - 2hvd-pq7e-w8
                  - c5ng-0tka-xr
                  - zz4y-b1me-7q
                  - 8pjh-6dsw-3n
                  - t9xc-2gfk-0v
                  - q6wa-ner5-jy
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`password_not_set`(Google 전용 계정), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code`(복구 코드를 보낸 경우 포함).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_not_enabled`.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`(재인증 실패, 또는 이 계정에서 시도한 코드가 너무 많음) 또는 `rate_limited`(`reauth` 또는 `reauth_user` 제한, 계정 할당량, 비밀번호 해시 대기열).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`(비밀번호 해시 대기열, 또는 데이터베이스가 계속 잠겨 있었음), `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: 이메일 주소 변경
      description: |-
        계정의 이메일 주소를 변경합니다. `newEmail`은 앞뒤 공백을 제거하고 소문자로 바꾼 뒤, 가입 시 주소와 같은 방식으로 검사합니다.

        **이메일 확인이 있는 경우**(기본값): 202 `verification_sent`. 24시간 동안 유효한 링크가 새 주소로 발송됩니다. 이 링크는 `/confirm-email-change`를 열며, 플레이어가 그 페이지의 버튼을 눌러야만 주소가 바뀝니다. 그때까지 `GET /account/me`는 새 주소를 `pendingEmail`로 보여 줍니다. 새 요청은 대기 중인 요청을 대체하고, 비밀번호 변경이나 재설정은 대기 중인 요청을 취소합니다. 누가 요청하든 같은 새 주소로는 5분에 최대 한 개의 링크만 발송됩니다(이미 대기 중인 변경을 다시 요청하면 앞서 보낸 링크가 그대로 유지되며 계속 유효함). 응답은 동일합니다. 현재 주소로는 마스킹된 주소(`a***@example.org`)로의 변경이 요청되었다는 알림이 갑니다. 다른 계정이 이미 새 주소를 사용하고 있어도 응답과 `pendingEmail`은 동일합니다. 이 경우 링크가 발송되지 않으므로 그 변경은 절대 완료되지 않으며, 대신 그 주소의 소유자에게 알림이 갑니다(1시간에 최대 한 번).

        링크가 확인되면 주소가 바뀌고 확인된 것으로 간주되며, 기기들은 로그인 상태를 유지하고, 앞서 보낸 링크(확인, 비밀번호 재설정, 다른 변경)는 더 이상 쓸 수 없게 되며, 이전 주소로 새 주소를 마스킹한 알림이 갑니다.

        **이메일 확인이 없는 경우**(`REQUIRE_EMAIL_VERIFICATION=false`): 주소가 즉시 바뀌고(200 `email_changed`), 이전 주소로 알림이 갑니다. 다른 계정이 그 주소를 사용하고 있으면 응답은 409 `email_taken`이고, 그 주소의 소유자에게 알림이 갑니다.

        **재인증:** 비밀번호, 그리고 2단계 인증이 켜져 있으면 인증 앱 코드 또는 복구 코드. `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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: 계정 데이터 다운로드
      description: |-
        서버가 계정에 관해 보관하는 모든 정보를 저장용 JSON 파일 하나로 반환합니다(`format`은 `scacelith-account-export`, `version`은 1). 내보내기를 하면 보안 이벤트(`account_exported`)가 기록됩니다. 문서의 `notes` 배열은 무엇이 빠져 있는지를 플레이어에게 쉬운 영어로 알려 줍니다.

        **내보내기에 절대 포함되지 않는 것:** 비밀번호 해시, 2단계 인증 비밀 키와 복구 코드. 모든 세션 토큰이나 링크 토큰, 또는 그 해시. 안티치트 데이터(무결성 수준과 점수, 이상 징후, 게임 분석, 신고의 가중치). 다른 플레이어가 이 플레이어에 대해 한 신고. 모더레이터의 신원. 다른 플레이어의 비공개 데이터(상대는 공개 이름과 레이팅으로만 나타나고, 다른 플레이어가 제재를 받았는지는 어디에도 드러나지 않으며, 다른 사람의 것일 수 있는 IP 주소는 포함되지 않음).

        **재인증:** 비밀번호, 그리고 2단계 인증이 켜져 있으면 인증 앱 코드 또는 복구 코드. **제한:** `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초). 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: 계정 삭제
      description: |-
        계정을 삭제합니다. 되돌릴 수 없습니다.

        - 모든 세션이 즉시 폐기됩니다: 그 이후로 토큰에는 401 `invalid_token`이 반환됩니다.
        - 사용자 이름은 계정과 모든 게임 기록에서 `deleted#<id>`로 바뀝니다.
        - 다음 정보가 지워집니다: 이메일 주소, 비밀번호 해시, 2단계 인증 비밀 키와 복구 코드, 세션과 링크 토큰, Google 계정 연결, 안티치트의 무결성 기록, 보안 이벤트와 함께 저장된 IP 주소.
        - 레이팅과 게임은 유지됩니다. 게임은 익명 이름으로 계속 열람할 수 있으며, `GET /players/{username}`은 이전 이름에 대해 404로 응답합니다.

        **재인증:** 비밀번호, 그리고 2단계 인증이 켜져 있으면 인증 앱 코드 또는 복구 코드. **제한:** `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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: 플레이어의 게임 목록 조회(필터링, 페이지 단위)
      description: |-
        로그인한 플레이어의 게임을 최신순으로, 필터링하고 페이지 단위로 나누어 반환하며, 필터에 맞는 게임 수도 함께 반환합니다. 모든 쿼리 매개변수는 선택 사항이며, 빈 값은 없는 것으로 간주합니다.

        페이지 넘기기: 이전 페이지의 `next`를 `before`로 전달하세요. 마지막 페이지에서는 `next`가 `null`입니다. `total`은 모든 페이지에 걸쳐 필터에 맞는 게임 수입니다.

        **제한:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: 공식 카테고리 id(`3+2` 또는 `3%2B2`), 또는 그 밖의 시간 제한으로 둔 모든 게임을 뜻하는 `custom`.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: 레이팅 게임(`true`)만 또는 캐주얼 게임(`false`)만.
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: 플레이어 입장에서 이긴 게임, 진 게임 또는 비긴 게임만. 중단된 게임은 이 필터가 없을 때만 나타납니다.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: 게임 기록의 한 페이지.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HistoryPage'
              example:
                games:
                  - id: 4100000000001
                    category: '3+2'
                    rated: true
                    timeControl: '180+2'
                    white:
                      name: alice
                      rating: 1500
                      ratingAfter: 1510
                      ratingDiff: 10
                    black:
                      name: bob
                      rating: 1520
                      ratingAfter: 1510
                      ratingDiff: -10
                    color: white
                    status: 1
                    reason: 2
                    result: 1-0
                    termination: Resignation
                    plies: 41
                    startedAt: 1790620205000
                    endedAt: 1790620611000
                    baseMs: 180000
                    incMs: 2000
                    outcome: win
                next: null
                total: 1
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_cursor`(`before`가 게임 id가 아님), `invalid_limit` 또는 `invalid_filter`(`category`, `rated` 또는 `result`). 각각 해당 매개변수를 가리키는 `field`가 포함됩니다.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `account_games` 제한 또는 계정 할당량.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`(데이터베이스가 계속 잠겨 있었음. 본문에 `retryAfter: 1`이 있고 `Retry-After` 헤더는 없음), `server_busy`(세션 조회 중 데이터베이스가 잠겨 있었음), `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: 수와 시계를 포함한 게임 기록 조회
      description: |-
        수와 시계를 포함한 게임 기록 하나. 토큰이 없거나 그 게임을 두지 않은 플레이어의 토큰이면 공개 응답을 반환합니다. 토큰의 플레이어가 그 게임을 두었다면 응답에 `you`와 `reportable`이 추가됩니다.

        **제한:** `public_read`(토큰이 있으면 플레이어별, 없으면 클라이언트별).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: 게임 기록.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GameRecord'
              example:
                id: 4100000000001
                category: '3+2'
                rated: true
                timeControl: '180+2'
                white:
                  name: alice
                  rating: 1500
                  ratingAfter: 1510
                  ratingDiff: 10
                black:
                  name: bob
                  rating: 1520
                  ratingAfter: 1510
                  ratingDiff: -10
                status: 1
                reason: 2
                result: 1-0
                termination: Resignation
                plies: 2
                startedAt: 1790620205000
                endedAt: 1790620611000
                baseMs: 180000
                incMs: 2000
                statusName: WhiteWins
                rematchOf: null
                moves:
                  - uci: e2e4
                    spentMs: 0
                    clockMs: 180000
                  - uci: e7e5
                    spentMs: 1700
                    clockMs: 180300
                pgn:
                  Event: Scacelith rated 3+2
                  Site: caissa.scacelith.com
                  Date: '2026.09.28'
                  Round: '-'
                  White: alice
                  Black: bob
                  Result: 1-0
                  WhiteElo: 1500
                  BlackElo: 1520
                  TimeControl: '180+2'
                  Termination: Resignation
                  PlyCount: 2
                you: white
                reportable: true
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_game_id`: 16자리 이하의 양의 정수(2^53 미만)가 아님. `invalid_request`: 올바른 URL 인코딩이 아님.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: 토큰을 보냈지만 유효하지 않음.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: 해당 게임이 없음.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `public_read` 제한 또는 계정 할당량.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`(데이터베이스가 계속 잠겨 있었음. 본문에 `retryAfter: 1`이 있고 `Retry-After` 헤더는 없음), `server_busy`(세션 조회 중 데이터베이스가 잠겨 있었음), `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: 게임을 PGN 파일로 다운로드
      description: |-
        같은 게임을 PGN 파일로 반환합니다: 줄 끝이 `\n`인 게임 하나이며, 수 기록은 한 줄이 80열 미만입니다. 응답은 토큰이 있든 없든 같습니다.

        태그(이 순서대로): `Event`(`<SERVER_NAME> rated <category>` 또는 `<SERVER_NAME> casual <category>`), `Site`(`SERVER_PUBLIC_HOST`), `Date`(UTC 기준 시작 날짜), `Round`, `White`, `Black`, `Result`(중단된 게임은 `*`), `UTCDate`와 `UTCTime`(시작 시각), `WhiteElo`와 `BlackElo`(시작 시점의 레이팅, 또는 `-`), `WhiteRatingDiff`와 `BlackRatingDiff`(`+10`, `-10` 같은 변동값. 모든 레이팅 게임에 있으며, 레이팅 규칙에 따라 레이팅이 그대로 유지되면 `+0`. 캐주얼 게임, 사용자 지정 시간 제한 게임, 중단된 게임에는 둘 다 없음), `TimeControl`(초 단위), `Termination`(PGN 표준 값: `normal`. `time forfeit`는 시간 초과(플래그 폴)이며 무승부로 끝난 경우도 포함. `abandoned`. `rules infraction`은 두 번째 불법 수 또는 페어플레이 위반에 따른 몰수패. `unterminated`는 중단된 게임), `PlyCount`, `ScacelithGameId`(십진수 id).

        각 수에는 `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`가 붙습니다: 수를 둔 쪽의 그 수 이후 시계와 그 수에 소비된 것으로 계산된 시간이며, 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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /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~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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /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`가 포함된 `invalid_option`(`size`, `orientation`, `delayMs` 또는 `coords`). `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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: 플레이어의 최근 게임 목록 조회
      description: |-
        플레이어의 최근 게임을 최신순으로 반환합니다. 게임 기록 목록처럼 페이지 단위로 나뉘지만 필터와 총계는 없습니다. `color`는 이 플레이어의 진영입니다. 페이지가 가득 차면 `next`는 항상 마지막 게임의 id이므로, 다음 페이지가 비어 있을 수 있습니다. 플레이어를 먼저 조회하므로, 알 수 없는 플레이어는 쿼리 내용과 관계없이 404입니다.

        **제한:** `public_read`(토큰이 있으면 플레이어별, 없으면 클라이언트별).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: 플레이어 게임 목록의 한 페이지.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerGamesPage'
              example:
                username: alice
                games:
                  - id: 4100000000001
                    category: '3+2'
                    rated: true
                    timeControl: '180+2'
                    white:
                      name: alice
                      rating: 1500
                      ratingAfter: 1510
                      ratingDiff: 10
                    black:
                      name: bob
                      rating: 1520
                      ratingAfter: 1510
                      ratingDiff: -10
                    color: white
                    status: 1
                    reason: 2
                    result: 1-0
                    termination: Resignation
                    plies: 41
                    startedAt: 1790620205000
                    endedAt: 1790620611000
                next: 4100000000001
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`, `invalid_cursor`(`before`가 게임 id가 아님), `invalid_limit`(이 두 가지는 `field` 없음).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: 토큰을 보냈지만 유효하지 않음.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: 해당 플레이어가 없거나 삭제된 계정임.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `public_read` 제한 또는 계정 할당량.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`(데이터베이스가 계속 잠겨 있었음. 본문에 `retryAfter: 1`이 있고 `Retry-After` 헤더는 없음), `server_busy`(세션 조회 중 데이터베이스가 잠겨 있었음), `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: 카테고리의 상위 플레이어 조회
      description: |-
        공식 카테고리에서 집계된 게임이 `minGames`(`PROVISIONAL_GAMES`)판 이상인 레이팅 기록 중 상위 100개. 삭제된 계정과 부정행위가 확인된 플레이어는 제외됩니다. 서버는 각 리더보드를 최대 10초에 한 번 다시 계산하며, 그 시각은 `updatedAt`에 나옵니다. 한 카테고리의 계산은 한 번에 하나만 실행됩니다: 그동안 다른 요청은 이전 목록을 받거나, 아직 목록이 없으면 계산이 끝나기를 기다립니다.

        **제한:** 주소별 계층만.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: 공식 카테고리 id(`3+2` 또는 `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: 플레이어 수(1~100). 3자리 이하의 더 큰 수는 100으로 간주하며, 빈 값은 없는 것으로 간주합니다.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: 리더보드.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Leaderboard'
              example:
                category: '3+2'
                minGames: 30
                updatedAt: 1790882839828
                players:
                  - rank: 1
                    username: bob
                    rating: 1874
                    games: 212
                    wins: 120
                    draws: 30
                    losses: 62
                    peak: 1901
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_category`(없거나 공식 카테고리가 아님), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: 주소별 계층.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy`(데이터베이스가 계속 잠겨 있었음. 본문에 `retryAfter: 1`이 있고 `Retry-After` 헤더는 없음), `timeout`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /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`. 또한 서버 처리가 크게 밀려 있으면 `retryAfter`가 2~5인 `server_busy`(소개 참조): 이때 요청은 실행되지 않았습니다.'
  /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`) 서버 처리가 크게 밀려 있음(`Retry-After` 2~5, 소개 참조): 아무것도 바뀌지 않았으며 링크는 여전히 유효합니다. 핸들러 타임아웃 시에도 이 상태로 응답합니다.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: 공식 서버(페이지는 `/api/v1` 바깥의 루트에 있음).
      - url: https://{host}:{port}
        description: 모든 Scacelith 서버(페이지는 `/api/v1` 바깥의 루트에 있음).
        variables:
          host:
            default: caissa.scacelith.com
            description: 서버의 공개 호스트 이름(`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: 공개 API 포트(`PUBLIC_API_PORT`, 없으면 `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: 비밀번호 재설정 양식 표시
      description: |-
        비밀번호 재설정 링크의 페이지: 새 비밀번호 양식(비밀번호를 두 번 입력). 양식은 `POST /reset-password`로 전송됩니다.

        **제한:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: 새 비밀번호 양식.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: 링크가 유효하지 않음(계정이 더 이상 쓰지 않는 주소로 발송된 링크인 경우 포함).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`page` 제한(HTML 페이지) 또는 주소별 계층(JSON `rate_limited`).'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: 재설정 양식으로 새 비밀번호 설정
      description: |-
        재설정 페이지의 양식. `POST /auth/password/reset`과 같은 일을 하나의 트랜잭션에서 수행합니다: 모든 기기가 로그아웃되고, 대기 중인 이메일 변경이 취소되며, 다른 재설정 링크는 더 이상 쓸 수 없게 되고, 주소는 확인된 것으로 간주되며, 소유자에게 메일이 갑니다.

        먼저 링크를 검사하고, 그다음 두 비밀번호가 같은지, 그다음 비밀번호 규칙을 검사합니다. 서버가 바쁘면 `Retry-After`와 함께 양식이 다시 표시되며, 링크는 계속 유효합니다.

        **제한:** `auth`, 그다음 `auth_reset`(`POST /auth/password/reset`과 공유).
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/ResetPasswordForm'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
              confirmPassword: a much better passphrase
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordForm'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
              confirmPassword: a much better passphrase
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: 비밀번호가 변경되었고, 모든 기기가 로그아웃되었습니다.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: 오류와 함께 다시 표시된 양식(비밀번호가 서로 다름, 약한 비밀번호), 유효하지 않은 링크 또는 유효하지 않은 양식(HTML 페이지).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`auth` 또는 `auth_reset` 제한(HTML 페이지), 이 클라이언트의 대기 중인 비밀번호 해시가 너무 많을 때 `Retry-After`와 함께 다시 표시되는 양식(링크는 계속 유효함), 또는 주소별 계층(JSON `rate_limited`).'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: '서버가 바쁠 때(비밀번호 해시 대기열, 또는 데이터베이스가 계속 잠겨 있음) `Retry-After`와 함께 다시 표시되는 양식. 링크는 계속 유효합니다. 서버 처리가 크게 밀려 있을 때는(소개 참조) 대신 `Retry-After`(2~5)가 포함된 “Server error”(서버 오류) 페이지: 아무것도 실행되지 않았으며 링크는 계속 유효합니다. 핸들러 타임아웃 시에도 이 상태로 응답합니다.'
  /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`) 서버 처리가 크게 밀려 있음(`Retry-After` 2~5, 소개 참조): 아무것도 바뀌지 않았으며 링크는 여전히 유효합니다. 핸들러 타임아웃 시에도 이 상태로 응답합니다.'
  /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_` 뒤에 base64url 문자 43개)을 `Authorization` 헤더에 넣습니다: `Authorization: Bearer <token>`. 접두사는 정확히 `Bearer`(대소문자 구분)와 공백 하나입니다. 접두사가 없는 값, 또는 출력 가능한 ASCII 문자 1~512자가 아닌 토큰에는 다른 유효하지 않은 토큰과 마찬가지로 401 `invalid_token`으로 응답합니다. 빈 헤더는 헤더가 없는 것으로 간주합니다.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: '게임 id: 앞에 0이 없는 16자리 이하의 양의 십진 정수이며, 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. 앞에 0을 붙이지 않고 씁니다(`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: 이메일 링크의 토큰(base64url 문자 43개). 없거나 잘못되면 “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`이 아니거나, charset이 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 토큰을 보냈습니다(다른 클라이언트, 즉 게임 클라이언트나 웹사이트 클라이언트의 코드나 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도 아니거나, charset이 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)`가 가져야 하는 선행 0 비트의 수.'
        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)`가 0 비트 `bits`개로 시작하게 만드는, 20자리 이하의 십진수 문자열.'
    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바이트를 부호 없는 정수로 나타낸 값. 프로토콜 마이너 버전 2에서는 `0xc649140f`, 즉 3326678031). 참고용이며 비교에 쓰이지 않습니다.'
            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
            - googleWeb
          properties:
            google:
              type: boolean
              description: 게임 앱에 Google 로그인을 제공하는지 여부.
            googleWeb:
              type: boolean
              description: 웹사이트에도 Google 로그인을 제공하는지 여부(`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: 2단계 인증은 항상 사용할 수 있습니다.
        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: 3326678031
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
          googleWeb: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: 공식 시간 제한.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 카테고리 id. 분과 초 단위 추가 시간으로 이루어집니다(`3+2`).
        baseSec:
          type: number
          minimum: 0
          description: 기본 시간(초).
        incSec:
          type: number
          minimum: 0
          description: 수마다 더해지는 추가 시간(초).
    AccountView:
      type: object
      description: 플레이어 본인에게 보이는 계정(로그인 응답과 `GET /account/me`의 `user`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: 계정 id.
        username:
          type: string
          description: 사용자 이름.
        email:
          type: string
          format: email
          description: 이메일 주소.
        emailVerified:
          type: boolean
          description: 주소가 확인되었는지 여부.
        mfaEnabled:
          type: boolean
          description: 2단계 인증이 켜져 있는지 여부.
        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: 시도. `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`에서 대기하는 클라이언트 리스너의 포트.'
    SsoWebStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: 페이지 `codeVerifier`의 S256 PKCE 챌린지(`BASE64URL(SHA-256(codeVerifier))`).
    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 검증자(verifier). 그 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: '`DELETE /auth/sessions/{id}`에 쓰는 세션 id.'
        createdAt:
          type: integer
          format: int64
          description: 로그인 시각(에포크 밀리초).
        lastSeenAt:
          type: integer
          format: int64
          description: 마지막 사용 시각(에포크 밀리초). 최대 5분에 한 번 갱신됩니다(서버 처리가 크게 밀려 있는 동안에는 더 늦게).
        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: 플레이어가 레이팅 게임을 둔 카테고리마다 기록 하나.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: 적용 중인 제재.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '정지된 동안에는 `{ until }`, 그렇지 않으면 `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: 한 카테고리의 레이팅 기록.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: 카테고리 id.
        rating:
          type: integer
          description: 레이팅.
        games:
          type: integer
          minimum: 0
          description: 이 카테고리에서 둔 게임 수(집계 여부와 관계없이).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: 도달한 최고 레이팅.
        provisional:
          type: boolean
          description: '레이팅이 아직 미산정 상태이거나 집계된 게임이 `PROVISIONAL_GAMES`판 미만인 동안 `true`(게임 앱에서는 `1510?`처럼 표시됨).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: 제재 종류.
        reason:
          type:
            - string
            - 'null'
          description: 제시된 사유(있는 경우).
        startsAt:
          type: integer
          format: int64
          description: 시작 시각(에포크 밀리초).
        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: 선택 사항. 2단계 인증이 켜져 있으면 필요합니다(또는 `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: 선택 사항. 2단계 인증이 켜져 있으면 필요합니다(또는 `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: 한 단계의 길이(초).
    RecoveryCodeList:
      type: array
      description: 복구 코드 10개. 이번 한 번만 표시됩니다. 각 코드는 한 번만 쓸 수 있습니다.
      minItems: 10
      maxItems: 10
      items:
        type: string
        pattern: '^[0-9a-hjkmnp-tv-z]{4}-[0-9a-hjkmnp-tv-z]{4}-[0-9a-hjkmnp-tv-z]{2}$'
    PlayerSide:
      type: object
      description: 게임의 한쪽 진영.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: 게임 기록상의 이름(삭제된 계정은 `deleted#<id>`).
        rating:
          type:
            - integer
            - 'null'
          description: 시작 시점의 레이팅. 알 수 없으면 `null`.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: 게임 후의 레이팅. 레이팅 게임에는 항상 있으며, 레이팅에 반영되지 않는 게임(캐주얼, 사용자 지정 시간 제한, 중단)에서만 `null`입니다.
        ratingDiff:
          type:
            - integer
            - 'null'
          description: 레이팅 변동. 레이팅 규칙에 따라 레이팅이 그대로 유지되면 `0`(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
        - ResignationVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: 한 플레이어 입장에서 본 게임 요약.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: 플레이어의 진영.
    HistoryGameSummary:
      description: 로그인한 플레이어의 게임 기록에 있는 게임 하나.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: 기본 시간(밀리초).
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: 추가 시간(밀리초).
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: 플레이어 입장에서 본 결과.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: 이 페이지의 게임(최신순).
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: 다음 페이지를 위해 `before`로 전달할 id. 마지막 페이지에서는 `null`.
        total:
          type: integer
          minimum: 0
          description: 모든 페이지에 걸쳐 필터에 맞는 게임 수.
    MoveRecord:
      type: object
      description: 게임의 한 반수.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: UCI 표기법으로 나타낸 수. 프로모션이면 `n`, `b`, `r` 또는 `q`가 붙습니다.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: 이 수에 소비된 것으로 계산된 시간(ms). 기록에 없으면 `null`.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: 수를 둔 쪽의 그 수 이후 시계(ms). 기록에 없으면 `null`.
    PgnTagsView:
      type: object
      description: 표시용 주요 PGN 태그. 여기서 `Termination`은 종료 사유의 이름이며, PGN 파일은 PGN 표준 값을 사용합니다.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` 또는 `<SERVER_NAME> casual <category>`.'
        Site:
          type: string
          description: '`SERVER_PUBLIC_HOST`.'
        Date:
          type: string
          pattern: '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$'
          description: UTC 기준 시작 날짜.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: 시작 시점의 레이팅 또는 `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: 시작 시점의 레이팅 또는 `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        TimeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
        Termination:
          $ref: '#/components/schemas/Termination'
        PlyCount:
          type: integer
          minimum: 0
    GameRecord:
      description: 수와 시계를 포함한 게임 기록. 게임 요약의 필드를 가지며, `color`와 `outcome`은 없습니다.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: 기본 시간(밀리초).
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: 추가 시간(밀리초).
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: '`status`의 이름.'
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: 이 게임이 재대결이면 원래 게임의 id, 아니면 `null`.
            moves:
              type: array
              description: 반수마다 항목 하나.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: 토큰의 플레이어가 이 게임을 둔 경우에만 있음. 그 플레이어의 진영.
            reportable:
              type: boolean
              description: 토큰의 플레이어가 이 게임을 둔 경우에만 있음. 지금 `POST /reports`가 이 게임의 상대에 대한 신고를 받아들일 수 있으면 `true`(게임이 7일 이내에 끝났고, 하루 한도를 다 쓰지 않았으며, 이 게임으로 상대를 이미 신고하지 않은 경우).
    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: 선택 사항. 수당 밀리초. 정숫값을 가진 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: 저장된 모든 세션(최신순, 토큰은 일절 포함하지 않음). 보존 기간 정리 작업은 만료된 세션을 삭제하며, 로그아웃된 세션은 로그아웃 하루 뒤에 삭제합니다.
          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`는 로그인 상태에서, 계정의 비밀번호(와 2차 인증)로, 또는 계정 주소로 발송된 링크로 수행한 작업에만 제공됩니다: `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`).
