openapi: 3.1.1
info:
  title: Scacelith-palvelimen HTTPS-rajapinta
  version: "1.1.0"
  summary: Jokaisen Scacelith-palvelimen HTTPS-rajapinta, niin virallisen kuin yhteisön palvelimien.
  description: |-
    Jokainen Scacelith-palvelin, sekä virallinen `caissa.scacelith.com` että mikä tahansa yhteisön palvelin, tarjoaa tämän HTTPS-rajapinnan. Peli käyttää sitä kaikkeen itse pelin ulkopuoliseen: rekisteröitymiseen ja kirjautumiseen (kaksivaiheinen vahvistus ja Google mukaan lukien), tilisivuun, pelihistoriaan, PGN-latauksiin ja pelien animoituihin GIF-kuviin, kirjautuneisiin laitteisiin, tietojen lataamiseen, tilin poistamiseen ja ilmoituksiin.

    Reaaliaikainen pelaaminen (vastustajan haku, haasteet, siirrot, kellot) kulkee saman palvelimen WebSocketin (`wss://<host>/ws`) kautta, joka avataan tämän rajapinnan istuntotokenilla; se kuvataan tiedostossa `PROTOCOL.md`, ei tässä.

    Alla mainitut oletusarvot koskevat palvelinta, jonka määrityksiä ei ole muutettu; yhteisön palvelin voi muuttaa niitä (`CONFIG.md` nimeää jokaisen asetuksen).

    ## Perus-URL, portti ja siirtoyhteys

    - Virallinen palvelin: `https://caissa.scacelith.com/api/v1`, TCP-portti 443.
    - Yhteisön palvelin: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` on oletuksena 443; NATin tai välityspalvelimen takana pelaajien käyttämä portti on `PUBLIC_API_PORT`.
    - Pelin WebSocket on oletuksena samassa portissa (`WS_PORT` siirtää sen muualle; `GET /info` kertoo asiakkaalle, missä se on).
    - HTTP/1.1 TLS:n yli. Kun `TLS_MODE=proxy`, palvelimen edessä oleva käänteinen välityspalvelin päättää TLS-yhteyden. `TLS_MODE=off` (pelkkä HTTP) on tarkoitettu vain paikalliseen kehitykseen.
    - Sähköpostien linkeistä avattavat HTML-sivut (`/verify-email`, `/reset-password`, `/confirm-email-change`) ovat palvelimen juuressa, polun `/api/v1` ulkopuolella. Kuntotarkistuksen päätepisteet vastaavat sekä juuressa että polun `/api/v1` alla. Google-kirjautumisella ei ole sivua palvelimella: Google ohjaa selaimen takaisin itse peliin, osoitteeseen `127.0.0.1`, tai sen verkkosivuston sivulle, joka kirjautumisen aloitti (`GOOGLE_WEB_REDIRECT_URI`).
    - Loppukauttaviiva ohitetaan (`/api/v1/info/` on sama kuin `/api/v1/info`), ja polkuparametrien URL-koodaus puretaan (parametri, joka ei ole kelvollista URL-koodausta, saa vastauksen 400 `invalid_request`).

    ## Pyynnöt

    - **JSON-rungot.** `POST`-, `PUT`- ja `DELETE`-pyynnöt lähettävät JSON-objektin otsakkeella `Content-Type: application/json`. Muu `charset` kuin UTF-8 hylätään, samoin mikä tahansa muu sisältötyyppi (415 `unsupported_media_type`). Tyhjä runko tulkitaan objektiksi `{}`, ja juuri sitä parametrittomat päätepisteet odottavat (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Koko ja aika.** Rungon enimmäiskoko on `HTTP_BODY_LIMIT` tavua (oletuksena 16 384; päätepisteellä `POST /gif` on oma rajansa, 135 168 tavua): 413 `payload_too_large`. Rungon on saavuttava 10 sekunnin kuluessa: 408 `request_timeout`. Kummankin virheen jälkeen palvelin sulkee yhteyden.
    - **Tiukat skeemat.** Kenttä, jota päätepiste ei tunne, hylätään, kenttä, jota ei ole merkitty valinnaiseksi, on pakollinen, ja tyypit ja pituudet tarkistetaan. Kaikki nämä virheet saavat vastauksen 400 `invalid_request`, ja `field` nimeää kentän (sisäkkäinen kenttä pisteillä erotettuna, esimerkiksi `pow.nonce`). Merkkijonot eivät saa sisältää ohjausmerkkejä. Pituudet lasketaan UTF-16-koodiyksikköinä, joten Unicoden perustason (Basic Multilingual Plane) ulkopuolinen merkki (emoji) lasketaan kahdesti. Runko, joka ei ole JSONia, saa vastauksen 400 `invalid_json`. `POST /gif` ja `POST /reports` tarkistavat runkonsa itse (katso kunkin päätepisteen kuvaus).
    - **Kyselymerkkijonot.** Parametrin ensimmäinen esiintymä on voimassa, tuntemattomat parametrit ohitetaan, ja `+` puretaan välilyönniksi: peliaikaluokan ottavat päätepisteet hyväksyvät sekä muodon `3%2B2` että `3+2`.
    - **Metodit.** `HEAD` on `GET` ilman runkoa. `OPTIONS` olemassa olevaan polkuun saa vastauksen 204 ja `Allow`-otsakkeen (sekä CORS-preflight-pyynnön otsakkeet, katso alla). Mikä tahansa muu metodi, jota polulla ei ole, saa vastauksen 405 `method_not_allowed` ja otsakkeen `Allow`.
    - **Pyynnön kohde.** Se saa olla enintään 4096 merkkiä (414 `uri_too_long`). Pyyntö, jota ei voi jäsentää lainkaan tai jonka otsakeosa on liian suuri tai saapuu liian hitaasti, saa tyhjän vastauksen 400, 431 tai 408, ja yhteys suljetaan.
    - **CORS: sallittujen luettelo, oletuksena pois käytöstä.** Rajapinta palvelee peliä, joka ei lähetä `Origin`-otsaketta. Kun `CORS_ORIGINS` on tyhjä (oletus), mitään `Access-Control-*`-otsaketta ei koskaan lähetetä, joten mikään verkkosivu ei voi lukea vastausta. `CORS_ORIGINS` luettelee ne verkkoalkuperät, joiden sivut saavat kutsua rajapintaa selaimesta, kunkin täsmälleen siinä muodossa kuin selaimet lähettävät sen `Origin`-otsakkeessa: `https://host` tai `https://host:port`, pienillä kirjaimilla, ilman polkua, loppukauttaviivaa tai oletusporttia; `http://` vain osoitteille `localhost`, `127.0.0.1` ja `[::1]` (paikallinen kehitys); ei jokerimerkkejä. Virallinen palvelin asettaa verkkosivustoaan varten `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com`. Tällöin:
      - Jokainen vastaus pyyntöön, jonka polku on polun `/api/v1` alla, kuntotarkistuksen päätepisteitä lukuun ottamatta, niin onnistumiset kuin virheetkin (osoitekohtaisen kerroksen 429 mukaan lukien), sisältää otsakkeen `Vary: Origin`. Kun pyynnön `Origin` on tavu tavulta jokin luetelluista alkuperistä, vastaus sisältää myös otsakkeen `Access-Control-Allow-Origin: <that origin>` ja, paitsi preflight-pyynnön vastauksessa, otsakkeen `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, jotta sivu voi lukea virhekoodit, `Retry-After`-arvon sekä PGN- ja GIF-latausten tiedostonimet.
      - Luetellusta alkuperästä olemassa olevaan polkuun tehty preflight-pyyntö (`OPTIONS` ja `Access-Control-Request-Method`) saa vastauksen 204 ilman runkoa, otsakkeilla `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (`Allow`-otsakkeen metodit), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600` ja `Vary: Origin`. Se ei tarvitse tokenia, eikä sitä lasketa tilikohtaiseen budjettiin eikä päätepisteiden rajoituksiin; kuten mikä tahansa pyyntö, se ottaa yhden tokenin osoitekohtaisesta kerroksesta. Selain käyttää preflight-pyynnön tulosta uudelleen 10 minuutin ajan, mutta vain täsmälleen samalle URL:lle kyselymerkkijono mukaan lukien. Pyyntö, jossa ei ole `Authorization`-otsaketta eikä JSON-runkoa, kuten `GET /info` tai `GET /leaderboard`, ei tarvitse preflight-pyyntöä.
      - Mikä tahansa muu vastaus preflight-pyyntöön on epäonnistunut preflight, vaikka siinä olisi CORS-otsakkeet: 404 polulle, jota ei ole, tai osoitekohtaisen kerroksen 429. Selain ei silloin lähetä pyyntöä, ja sivu näkee verkkovirheen ilman vastausta tai sen `Retry-After`-arvoa.
      - Otsaketta `Access-Control-Allow-Credentials` ei koskaan lähetetä, eikä otsaketta `Access-Control-Allow-Origin: *`: sivu lähettää istuntotokeninsa otsakkeessa `Authorization: Bearer` kuten pelikin, eikä rajapinta aseta evästeitä. HTML-sivujen, kuntotarkistuksen päätepisteiden ja jäsennyskelvottomiin pyyntöihin annettujen tyhjien vastausten mukana ei lähetetä CORS-otsakkeita. WebSocket ei käytä CORSia: `WS_ALLOWED_ORIGINS` luettelee sivut, jotka saavat avata sen.
      - Luettelemattoman alkuperän sivu ei voi lukea mitään, eikä sen sivustojen välisiä kirjoituspyyntöjä koskaan lähetetä: JSON-rungot ja `Authorization` vaativat preflight-pyynnön, jonka vastauksessa ei ole kyseiselle alkuperälle otsaketta `Access-Control-Allow-Origin`. `Cross-Origin-Resource-Policy: same-origin` on mukana jokaisessa vastauksessa: se koskee vain `no-cors`-pyyntöjä (toinen sivusto upottaa vastauksen), ei koskaan luetellun sivun `cors`-pyyntöjä.

    ## Vastaukset ja virheet

    - Vastaukset ovat UTF-8-koodattua JSONia, paitsi PGN-lataus (`application/x-chess-pgn`), animoidut GIF-kuvat (`image/gif`) ja HTML-sivut. Ajat ovat millisekunteja hetkestä 1.1.1970 UTC. Tunnisteet (id) ovat kokonaislukuja. Pelien tunnisteissa on enintään 16 numeroa, mutta ne pysyvät pienempinä kuin 2^53, joten JSON-luku (double) esittää ne tarkasti.
    - Jokainen rajapinnan vastaus sisältää otsakkeet `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` ja `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (HTML-sivuilla on oma käytäntönsä). Kun `TLS_MODE=native`, mukana on myös `Strict-Transport-Security: max-age=31536000`. Vastauksissa, jotka kuuntelija kirjoittaa itse ennen rajapintaa (kuntotarkistuksen päätepisteiden `GET` ja `HEAD`, osoitekohtaisen kerroksen 429 sekä 500 `internal_error`, kun rajapinta epäonnistuu kokonaan), on samat otsakkeet paitsi `Cross-Origin-Resource-Policy`, ja niiden `Content-Type` on `application/json` ilman merkistöä (charset).
    - Vastaus on luettava 60 sekunnin kuluessa siitä hetkestä, kun palvelin on saanut sen valmiiksi, millä on merkitystä vain suurille vastauksille (GIF, tietojen vienti, pitkä PGN). Aikaa, jonka palvelin käyttää vastauksen valmisteluun, ei lasketa tähän eikä niihin 30 sekuntiin ilman yhtään saapuvaa tai lähtevää tavua, joiden jälkeen yhteys suljetaan.

    Virheillä on yksi muoto (skeema `Error`):

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

    `message` on tarkoitettu lokeihin ja varavaihtoehdoksi; asiakas valitsee näytettävän tekstin `error`-kentän perusteella. `retryAfter` (sekunteina) on mukana vain hylkäyksissä, jotka raukeavat ajan myötä, ja vastauksessa on silloin myös `Retry-After`-otsake samalla arvolla; ainoa poikkeus on historian, pelien, pelaajien ja tulostaulun lukupyyntöjen 503 `busy`, jossa on vain kenttä. Jotkin virheet lisäävät kenttiä: `field` (virheellinen syöte), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` ja `column` (`invalid_pgn`).

    Virheet, joita mikä tahansa päätepiste voi antaa:

    | Tila | `error` | Milloin |
    |---|---|---|
    | 400 | `invalid_request` | Runko rikkoo päätepisteen skeemaa (`field` kertoo kohdan), pyynnön kohde tai `Content-Length` on virheellisesti muodostettu, polkuparametri ei ole kelvollista URL-koodausta tai runko katkesi kesken. |
    | 400 | `invalid_json` | Runko ei ole JSONia. |
    | 401 | `unauthorized` | Pyynnössä ei ole `Authorization`-otsaketta, vaikka päätepiste vaatii istunnon. |
    | 401 | `invalid_token` | Token on virheellisesti muodostettu, vanhentunut tai mitätöity tai kuuluu poistetulle tilille; myös päätepisteissä, joissa istunto on valinnainen. |
    | 404 | `not_found` | Päätepistettä ei ole. Jotkin päätepisteet käyttävät sitä myös: peliä, pelaajaa tai istuntoa ei ole. |
    | 405 | `method_not_allowed` | Polku on olemassa muille metodeille (katso `Allow`). |
    | 408 | `request_timeout` | Runko ei saapunut 10 sekunnin kuluessa. |
    | 413 | `payload_too_large` | Runko ylittää rajan `HTTP_BODY_LIMIT` (päätepisteelle `POST /gif` 135 168 tavua). |
    | 414 | `uri_too_long` | Pyynnön kohde ylittää 4096 merkkiä. |
    | 415 | `unsupported_media_type` | Runko ei ole `application/json`, tai sen merkistö ei ole UTF-8. |
    | 429 | `rate_limited` | Nopeusrajoitus (katso alla): `retryAfter` sekä `Retry-After`-otsake. |
    | 500 | `internal_error` | Odottamaton virhe. Palvelin kirjaa sen lokiin. |
    | 503 | `timeout` | Palvelin ei vastannut 30 sekunnin kuluessa (viennille 60 sekuntia, GIF-kuville oletusasetuksilla 45 sekuntia). |
    | 503 | `server_busy` | Istunnon haku tai tilin muutos havaitsi tietokannan lukituksi (`retryAfter` 1), tai salasanatiivisteiden jono on täynnä (katso alla). Myös `retryAfter`-arvolla 2–5, kun palvelin on pahasti jäljessä: `POST`-, `PUT`-, `PATCH`- tai `DELETE`-pyynnöllä tietokannassa odottaa liian monta kirjoitusta; minkä tahansa pyynnön kohdalla liian moni aiempi pyyntö on yhä käynnissä aikakatkaisunsa jälkeen, tai istuntotokenia ei voitu tarkistaa 5 sekunnin kuluessa. Pyyntöä ei suoritettu, eivätkä sen nopeusrajoitukset vieneet mitään. |

    Lukupäätepisteet (pelihistoria, pelit, pelaajat, tulostaulu) ja vienti vastaavat 503 `busy` arvolla `retryAfter: 1`, kun tietokanta pysyi lukittuna.

    Päätepisteet, jotka tarkistavat tai tiivistävät salasanan, voivat vastata myös jommallakummalla näistä, kumpikin satunnaisella `retryAfter`-arvolla 5–15 sekuntia:

    - 503 `server_busy`: palvelimen salasanatiivisteiden jono (`PASSWORD_HASH_QUEUE_MAX`) on täynnä, tai odotusaika loppui.
    - 429 `rate_limited`: kun jono on puoliksi täynnä, tällä asiakkaalla (IPv4-osoite tai IPv6 /48) on jo `PASSWORD_HASH_WAITERS_PER_SOURCE` tiivistettä odottamassa. Tämä 429 palauttaa nopeusrajoitusten tokenit, jotka pyyntö otti.

    Mitään ei muutettu eikä epäonnistunutta yritystä laskettu (paitsi päätepisteessä `POST /auth/sso/google/link`, jonka lipun yrityskerta ja tilin epäonnistuminen kirjattiin ennen tiivistystä), ja nollauslinkki pysyy voimassa.

    HTML-sivut palauttavat virheensä HTML-sivuina samoilla tilakoodeilla.

    ## Todennus

    Istunnon vaativat päätepisteet ottavat bearer-tokenin `Authorization`-otsakkeessa (suojausmenetelmä `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Tokenin hankkiminen.** Token (`sct_` ja sen perässä 43 base64url-merkkiä) saadaan päätepisteestä `POST /auth/login` ja sen jälkeen päätepisteestä `POST /auth/login/mfa`, kun kaksivaiheinen vahvistus on käytössä, päätepisteistä `POST /auth/sso/google/finish` ja `POST /auth/sso/google/link` (Google-kirjautuminen) sekä päätepisteestä `POST /auth/sso/complete`. Jokainen niistä vastaa `{ token, expiresAt, user }`. Palvelin tallentaa tokenista vain SHA-256-tiivisteen.
    - **Pakollinen istunto.** Puuttuva otsake saa vastauksen 401 `unauthorized` ja otsakkeen `WWW-Authenticate: Bearer realm="scacelith"`. Virheellinen token saa vastauksen 401 `invalid_token` ja otsakkeen `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Asiakkaan tulisi unohtaa token, joka saa vastauksen `invalid_token`, ja kirjautua uudelleen sisään.
    - **Valinnainen istunto.** Päätepisteissä `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` ja `GET /players/{username}/games` istunto on valinnainen. Ilman otsaketta ne palauttavat julkisen näkymän; jos otsake lähetetään, sen tokenin on oltava kelvollinen.
    - **Varattu tietovarasto.** Kun tietokanta pysyy lukittuna tokenin tarkistuksen aikana, istunnon vaativa tai hyväksyvä päätepiste vastaa 503 `server_busy` arvolla `retryAfter: 1` (ja otsakkeella `Retry-After`); kun tarkistus kestää yli 5 sekuntia, se vastaa 503 `server_busy` `retryAfter`-arvolla 2–5. Kummassakin tapauksessa tokenia ei arvioitu: säilytä se ja lähetä pyyntö uudelleen.
    - **Elinaika.** Istunto päättyy sen mukaan, kumpi täyttyy ensin: `SESSION_MAX_DAYS` (90) päivää kirjautumisesta (tämä on `expiresAt`) tai `SESSION_IDLE_DAYS` (30) päivää ilman käyttöä. Jokainen käyttö siirtää käyttämättömyysrajaa eteenpäin; palvelin kirjoittaa uuden arvon enintään 5 minuutin välein taustalla. Kun sen tietokannassa odottaa paljon kirjoituksia, se lykkää tätä kirjoitusta myöhempään pyyntöön, paitsi viimeisen tunnin aikana ennen käyttämättömyysrajaa, joten käytössä oleva istunto ei vanhene. Tilillä on enintään `MAX_SESSIONS_PER_USER` (10) istuntoa: uusi kirjautuminen mitätöi vanhimmat tämän määrän ylittävät istunnot.
    - **Mitätöinti.** Uloskirjautuminen (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) mitätöi istuntoja; samoin tekevät salasanan vaihto (muut istunnot), salasanan nollaus ja tilin poisto (kaikki istunnot) sekä ylläpitäjä. Rajapinnan kautta tehty mitätöinti tulee voimaan heti, ja mitätöidyllä istunnolla avattu WebSocket suljetaan. Salasanan vaihto tai nollaus ja tilin poisto mitätöivät istunnot samassa transaktiossa kuin itse muutos: jos jokin niistä epäonnistuu, yhtään istuntoa ei ole mitätöity eikä mitään muutakaan ole muutettu. Palvelin pitää istuntohakuja välimuistissa 30 sekuntia, joten erillisenä prosessina toimivan ylläpitokomennon tekemä mitätöinti tulee voimaan 30 sekunnin kuluessa.
    - **Voimassaoloalue.** Token kuuluu yhdelle palvelimelle ja avaa myös sen WebSocketin. Älä koskaan lähetä sitä toiselle palvelimelle.

    ## Nopeusrajoitukset ja muut hidastukset

    Kukin rajoitus koskee yhtä kahdesta laajuudesta. **Asiakas:** IPv4-osoite tai IPv6 /64 (välityspalvelintilassa osoite tulee `X-Forwarded-For`-otsakkeesta, jonka on lähettänyt `TRUSTED_PROXIES`-luettelon osoite); jotkin rajoitukset laskevat lisäksi jokaisen IPv6 /48 -verkon kokonaisuutena kunkin sen /64-verkon lisäksi. **Pelaaja:** kirjautunut tili osoitteesta riippumatta; pelaajakohtaisesti laskettu rajoitus lasketaan päätepisteessä, jossa istunto on valinnainen, asiakaskohtaisesti, jos pyynnössä ei ole tokenia.

    Jokainen rajoitus on token bucket -säiliö, johon mahtuu `limit` pyyntöä ja joka täyttyy jatkuvasti tahdilla `limit / window`; `retryAfter` on aika seuraavaan tokeniin. *Jaetuksi* merkityt rajoitukset lasketaan lisäksi samanpituisessa liukuvassa ikkunassa, jotta mihinkään ikkunaan ei mahdu paljon yli `limit` pyyntöä. Jokainen rajoitus lasketaan koko palvelimen laajuisesti. Kun yksi päätepisteen rajoituksista hylkää pyynnön, sen muiden rajoitusten tälle pyynnölle ottamat tokenit palautetaan. Hylkäys saa vastauksen 429 `rate_limited`, jossa on `retryAfter` ja `Retry-After`.

    - **Osoitekohtainen kerros.** Jokainen pyyntö (mikä tahansa polku ja metodi, kuntotarkistuksen päätepisteet ja WebSocket-päivitykset mukaan lukien) ottaa ensin yhden tokenin asiakkaansa budjetista: `HTTP_RATE_PER_IP` (600) minuutissa puolen minuutin purskeella sekä `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) IPv6 /48 -verkolle. Asiakkaalla voi lisäksi olla käsittelyssä enintään `IP_MAX_INFLIGHT` (32 x `WORKERS`) pyyntöä (sen yli `retryAfter` 1). Asiakas, joka jatkaa hylkäysten jälkeen, estetään: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) hylkäystä minuutissa estää sen 1 minuutiksi, sitten 4, 16 ja 60 minuutiksi kunkin uuden eston kohdalla 6 tunnin sisällä. `auth`-, `auth_*`- ja `reauth`-rajoitusten hylkäys lasketaan viideksi; pelaajakohtaisesti laskettujen rajoitusten hylkäyksiä ei lasketa koskaan. `ABUSE_EXEMPT`-luettelon osoitteet ohittavat tämän kerroksen mutta eivät tilikohtaista budjettia eivätkä päätepisteiden rajoituksia.
    - **Tilikohtainen budjetti.** Jokainen pyyntö, jossa on kelvollinen istuntotoken, lasketaan myös sen tilin budjettiin: `USER_RATE_PER_MIN` (120) minuutissa kaikille päätepisteille yhteensä osoitteesta riippumatta, puolen minuutin purskeella.
    - **Päätepisteiden rajoitukset:**

    | Rajoitus | Oletus | Laskentayksikkö | Päätepisteet |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, jaettu | asiakas ja `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) IPv6 /48 -verkkoa kohden | `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) / tunti, jaettu | asiakas, kolminkertaisesti IPv6 /48 -verkkoa kohden | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / tunti, jaettu | asiakas, kolminkertaisesti IPv6 /48 -verkkoa kohden | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / tunti, jaettu | asiakas, kolminkertaisesti IPv6 /48 -verkkoa kohden | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 tuntia, jaettu | asiakas, kolminkertaisesti IPv6 /48 -verkkoa kohden | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / tunti, jaettu | asiakas, kolminkertaisesti IPv6 /48 -verkkoa kohden | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | `auth`-rajoituksen luvut, oma säiliö, jaettu | asiakas ja 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 min, jaettu | pelaaja | samat päätepisteet kuin `reauth`-rajoituksella |
    | `account` | 60 / min | pelaaja | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | pelaaja | `GET /account/games` |
    | `account_export` | 5 / tunti, jaettu | pelaaja | `POST /account/export` (tarkistetaan ennen `reauth`-rajoitusta; jokainen yritys lasketaan) |
    | `sessions` | 60 / min | pelaaja | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | pelaaja (asiakas, jos tokenia ei ole) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (yksi säiliö kaikille neljälle) |
    | `gif` | 30 / min | pelaaja | `GET /games/{id}/gif`, `POST /gif` (yksi säiliö molemmille) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min ja `GIF_USER_RENDERS_PER_HOUR` (30) / tunti, jaettu | pelaaja | samat kaksi, vain kun GIF on luotava (ei välimuistista) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min ja `GIF_IP_RENDERS_PER_HOUR` (120) / tunti, jaettu | asiakas (kaikki sen tilit yhteensä), kolminkertaisesti IPv6 /48 -verkkoa kohden | samat, kuten yllä |
    | `reports` | 30 / tunti | pelaaja | `POST /reports` |
    | `sso_start` | 30 / 10 min, jaettu | asiakas ja 90 IPv6 /48 -verkkoa kohden | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (yhdessä) |
    | `sso_finish` | 30 / min | asiakas | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | asiakas | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    Päätepisteillä `GET /info` ja `GET /leaderboard` ei ole omaa rajoitusta: vain osoitekohtainen kerros. Päätepiste, jolla on useita rajoituksia, tarkistaa ne kuvauksessaan annetussa järjestyksessä.

    Palvelin käsittelee pyynnön tässä järjestyksessä: osoitekohtainen kerros, sitten kuntotarkistuksen päätepisteet; päätepisteen tunnistus; tarkistukset, ettei palvelin ole pahasti jäljessä (503 `server_busy`): liian monta aiempaa pyyntöä yhä käynnissä aikakatkaisunsa jälkeen sekä `POST`-, `PUT`-, `PATCH`- tai `DELETE`-pyynnöllä tietokannan kirjoitusjono; todennus 5 sekunnin kuluessa (sen yli 503 `server_busy`); tilikohtainen budjetti, kun pyynnössä on istunto; päätepisteen rajoitukset; runko; itse päätepiste (GIF-päätepisteet laskevat renderöintirajoituksensa vain, kun niillä on GIF luotavana). Näin ollen tokeninsa vuoksi hylätty pyyntö ei kuluta yhtään päätepisteen tokenia, kun taas pyyntö, jonka runko on virheellinen, kuluttaa ne.

    Muut hidastukset, joista päätepisteet itse vastaavat:

    - **Epäonnistuneet kirjautumiset samalla kirjautumisnimellä** (käyttäjänimi tai sähköposti): `AUTH_FAILURES_PER_ACCOUNT` (5) epäonnistumisesta alkaen jokaisen yrityksen on odotettava kaksi kertaa niin kauan kuin edellisen (2 s, 4 s ja niin edelleen, enintään 15 minuuttia): 429 `too_many_attempts` ja `retryAfter`. Laskuri unohtaa epäonnistumiset tunnin kuluttua, jos uusia ei tule.
    - **Epäonnistuneet toiset tekijät kirjautumisessa:** sama sääntö tilin 5. väärästä koodista alkaen. Yksi kirjautumisvaihe hyväksyy enintään 5 väärää koodia.
    - **Yhden tilin toisen tekijän koodit:** enintään `AUTH_MFA_PER_ACCOUNT` (10) koodia (todennussovelluksen koodeja tai palautuskoodeja, oikeita tai vääriä) 15 minuutissa mistä tahansa osoitteesta, kirjautumisessa ja uudelleentodennuksissa; sen yli 429 `too_many_attempts` ennen koodin tarkistamista, joten palautuskoodi ei kulu.
    - **Tilin epäonnistuneet uudelleentodennukset** (väärä salasana tai koodi): sama sääntö (429 `too_many_attempts`), yhteinen kaikille päätepisteille, jotka tekevät uudelleentodennuksen.
    - **Sähköpostit:** yksi vahvistus- tai nollausviesti osoitetta kohden 5 minuutin välein (vastaus pysyy samana); yksi ”joku yritti käyttää osoitettasi” -ilmoitus osoitetta kohden tunnissa.
    - **Ilmoitukset:** `REPORTS_PER_DAY` (5) pelaajaa kohden 24 tunnissa: 429 `report_limit`.

    ## Työtodiste

    `POST /auth/register` vaatii aina työtodisteen (proof of work), kun `POW_REGISTER_BITS` on suurempi kuin 0 (oletuksena 18; `GET /info` antaa sen kenttänä `pow.register`). `POST /auth/login` ja `POST /auth/sso/google/link` (yksi haastetyyppi molemmille) vaativat sellaisen vain 5 minuutin ajan sen jälkeen, kun palvelin havaitsee epäonnistuneiden kirjautumisten aallon (`POW_LOGIN_TRIGGER_PER_MIN`, sitten `POW_LOGIN_BITS`); asiakas saa sen selville vastauksesta.

    1. Pyyntö ilman todistetta (tai hylätyllä todisteella) saa vastauksen 428 `pow_required`, jossa on `reason` ja `pow`-haaste `{ challenge, bits, expiresAt }`.
    2. Etsi nonce: enintään 20 numeron desimaalimerkkijono, jolla `SHA-256(challenge + ":" + nonce)` alkaa `bits` nollabitillä (ensimmäisen tavun merkitsevin bitti ensin). 18 bittiä vaatii keskimäärin noin 260 000 tiivistettä.
    3. Lähetä sama pyyntö uudelleen niin, että rungossa on `"pow": { "challenge": "...", "nonce": "123456" }`.

    Haaste on voimassa 2 minuuttia ja vain kerran, yhdelle päätepisteelle ja yhdelle asiakasverkolle (IPv4-osoite tai IPv6 /64). Se on allekirjoitettu, joten palvelin ei säilytä siitä mitään ennen kuin se palaa. `reason` kertoo, miksi todiste hylättiin: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` tai `replayed`.

    ## Uudelleentodennus

    Tilin muutokset kysyvät salasanaa uudelleen ja, kun kaksivaiheinen vahvistus on käytössä, toista tekijää:

    - vain salasana: `POST /account/password` ja `POST /account/mfa/totp/setup`;
    - salasana ja todennussovelluksen koodi, palautuskoodia ei hyväksytä: `POST /account/mfa/recovery-codes`;
    - salasana ja todennussovelluksen koodi tai palautuskoodi: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` ja `POST /account/delete`.

    Rungoissa `code` sisältää todennussovelluksen 6-numeroisen koodin ja `recoveryCode` palautuskoodin (`xxxx-xxxx-xx`; kirjainkoolla, välilyönneillä ja yhdysmerkeillä ei ole merkitystä). Palautuskoodin voi lähettää myös kentässä `code` silloin, kun palautuskoodit hyväksytään. Kukin koodi toimii kerran: käytetty todennussovelluksen koodi hylätään seuraavaan 30 sekunnin jaksoon asti, ja palautuskoodi on käytön jälkeen poissa.

    | Tila | `error` | Milloin |
    |---|---|---|
    | 403 | `invalid_password` | Väärä salasana, tai salasana nollattiin tai vaihdettiin pyynnön aikana (mitään ei muutettu). |
    | 403 | `mfa_code_required` | Kaksivaiheinen vahvistus on käytössä, eikä kumpaakaan kentistä `code` ja `recoveryCode` lähetetty. |
    | 403 | `invalid_code` | Väärä tai jo käytetty koodi. |
    | 400 | `password_not_set` | Pelkän Google-kirjautumisen tilillä ei ole vielä salasanaa (”Unohditko salasanan?” asettaa sen). |
    | 429 | `too_many_attempts` | Liian monta epäonnistumista tai kokeiltua koodia tällä tilillä. |
    | 503 / 429 | `server_busy` / `rate_limited` | Salasanatiivisteiden jono on ruuhkautunut. |

    Väärät salasanat ja koodit lasketaan tilin epäonnistumislaskuriin, ja ne kirjataan tietoturvatapahtumiksi.

    ## Metriikkaportti

    API-portin lisäksi palvelin vastaa pelkällä HTTP:llä metriikkaportissa (`METRICS_PORT`, 9464, sidottu osoitteeseen `METRICS_BIND`, oletuksena 127.0.0.1; pidä se yksityisenä). Se ei ole osa tätä rajapintaa: `GET /healthz` vastaa `ok`; `GET /readyz` vastaa `ready` siitä alkaen, kun käynnistys on valmis (jokainen shardi on toistanut lokinsa ja kuuntelijat on sidottu), sammutuksen alkuun asti, muuten 503 `not ready`; `GET /metrics` tarjoaa Prometheus-metriikat ja vaatii, jos `METRICS_TOKEN` on asetettu, otsakkeen `Authorization: Bearer <token>` täsmälleen tällä tokenilla.
  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-palvelimen lähdekoodi ja sen viitedokumentaatio (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Virallinen palvelin.
  - url: https://{host}:{port}/api/v1
    description: Mikä tahansa Scacelith-palvelin, esimerkiksi yhteisön palvelin.
    variables:
      host:
        default: caissa.scacelith.com
        description: Palvelimen julkinen isäntänimi (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: Julkinen API-portti (`PUBLIC_API_PORT`, muuten `API_PORT`).
tags:
  - name: server-info
    x-displayName: Palvelimen tiedot
    description: Mitä asiakkaan on tiedettävä ennen kuin se kirjautuu sisään tai muodostaa yhteyden.
  - name: health
    x-displayName: Kuntotarkistus
    description: |-
      Palvelimen elossaolo ja valmius valvontaa varten. Nämä päätepisteet vastaavat API-portissa ennen todennusta ja kaikkia päätepisteiden rajoituksia, mutta kuten mikä tahansa pyyntö, ne ottavat yhden tokenin osoitekohtaisesta kerroksesta; valvontaisäntä voidaan lisätä luetteloon `ABUSE_EXEMPT`. Kumpikin polku toimii jokaiselle päätepisteelle: palvelimen juuressa ja polun `/api/v1` alla.

      Metriikkaportilla on omat kuntotarkistuksen päätepisteensä, jotka kuvataan johdannossa.
  - name: auth
    x-displayName: Rekisteröityminen ja kirjautuminen
    description: |-
      Tilin luominen, kirjautuminen salasanalla (ja toisella tekijällä), vahvistus- ja salasanan nollausviestit.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` ja `POST /auth/sso/complete` palauttavat istunnon `{ token, expiresAt, user }` (tai ensimmäiset niistä toisen vaiheen).
  - name: google-sign-in
    x-displayName: Google-kirjautuminen
    description: |-
      Tarjolla, kun `GET /info` ilmoittaa `sso.google: true`; muuten jokainen alla oleva päätepiste vastaa 404 `sso_disabled`. Verkkosivusto kirjautuu samalla tavalla omalla asiakkaallaan, kun `sso.googleWeb` on tosi (alla, **Verkkosivustolla**). Peli kirjautuu järjestelmän selaimen kautta RFC 8252:n mukaisella asennettujen sovellusten kululla (asiakastyyppi ”Työpöytäsovellus”, Desktop app): Google ohjaa selaimen takaisin pelin kuuntelijalle osoitteessa `127.0.0.1`, ei koskaan tälle palvelimelle, eikä peli koskaan näe Google-tunnistetietoja.

      1. Asiakas kuuntelee osoitteessa `127.0.0.1:0` (järjestelmä valitsee portin) ja luo PKCE-parin: `codeVerifier`, jossa on 43–128 merkkiä joukosta `[A-Za-z0-9._~-]`, ja `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, jossa on 43 merkkiä ilman täytettä (padding).
      2. `POST /auth/sso/google/start` haasteella ja portilla palauttaa Googlen URL:n ja yrityksen `state`-arvon. Asiakas tarkistaa URL:n (katso alla) ja avaa sen selaimessa.
      3. Google ohjaa selaimen osoitteeseen `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Asiakas hyväksyy vain aloitusvastauksen `state`-arvon eikä lähetä mitään `error=`-uudelleenohjauksen jälkeen.
      4. `POST /auth/sso/google/finish` yrityksen tunnisteella, `codeVerifier`-arvolla, `state`-arvolla ja `code`-arvolla (sekä `iss`-arvolla, jos Google lähetti sen).
      5. Vastaus on istunto, kaksivaiheisen vahvistuksen vaihe (jatka päätepisteellä `POST /auth/login/mfa`), `needsUsername` uudelle pelaajalle (jatka päätepisteellä `POST /auth/sso/complete`) tai `needsPassword`, kun osoitetta käyttää tili, jolla on salasana (jatka päätepisteellä `POST /auth/sso/google/link`).

      **Alkuperätunniste.** Uudelleenohjaus-URI sisältää pelaajan lisäämän palvelimen tunnisteen, jotta peli hylkää URL:n, jonka jokin toinen palvelin on saanut omille pelaajilleen. Alkuperä on isäntänimi pienillä kirjaimilla (IPv6-literaali hakasulkeissa), `:` ja API-portti desimaalilukuna, aina kirjoitettuna, myös 443: palvelin käyttää arvoa `SERVER_PUBLIC_HOST` ja julkista API-porttiaan, peli osoitetta, johon se on yhdistetty. Tunniste on arvon SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + alkuperä) base64url-koodauksen (ilman täytettä) 22 ensimmäistä merkkiä. Uudelleenohjaus-URI on `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: aina IPv4-literaali ja pelin kuuntelijan portti (1024–65535). Palvelin ei koskaan ota URI:a, isäntää tai polkua asiakkaalta.

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

      Google-kirjautuminen toimii siis vain pelaajille, jotka lisäsivät palvelimen täsmälleen nimellä `SERVER_PUBLIC_HOST` ja sen julkisella API-portilla.

      **Mitä peli tarkistaa ennen selaimen avaamista.** `authUrl` alkaa täsmälleen merkkijonolla `https://accounts.google.com/o/oauth2/v2/auth?` ja on tulostettavaa ASCIIta, alle 4096 merkkiä, ja sen kyselyosassa on täsmälleen yksi `response_type=code`, täsmälleen yksi `redirect_uri`, joka on sama kuin URI, jonka peli laskee portistaan ja alkuperätunnisteestaan, täsmälleen yksi `state`, joka on sama kuin vastauksen `state`, sekä `code_challenge_method=S256` ja 43-merkkinen `code_challenge`. Muuten peli pysäyttää kuuntelijansa eikä avaa mitään. Se lähettää `finish`- ja `link`-pyynnöt vain palvelimelle, joka vastasi `start`-pyyntöön.

      **Verkkosivustolla.** Verkkosivusto (virallinen: <https://scacelith.com/>) kirjautuu saman Google-projektin asiakkaalla (`GOOGLE_WEB_CLIENT_ID`), jonka tyyppi on ”Verkkosovellus” (Web application). Google ohjaa selaimen takaisin yhdelle sivuston sivulle, jonka ylläpitäjä on määrittänyt (`GOOGLE_WEB_REDIRECT_URI`, joka on lueteltu asiakkaan kohdassa ”Authorized redirect URIs” eli valtuutetut uudelleenohjaus-URI:t), ja tämä sivu lähettää koodin `finish`-päätepisteeseen; palvelin ei tässäkään ota uudelleenohjaus-URI:a asiakkaalta.

      1. Sivu luo PKCE-parin kuten yllä, säilyttää verifierin välilehdessä (`sessionStorage`) ja lähettää haasteen kanssa pyynnön `POST /auth/sso/google/web/start`.
      2. Se tarkistaa `authUrl`-osoitteen kuten peli, paitsi että sen `redirect_uri`-arvon on oltava sivun oman alkuperän sivu, ja siirtyy sitten siihen. Mitään Googlen skriptiä ei ladata.
      3. Google ohjaa selaimen osoitteeseen `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. Sivu hyväksyy vain aloitusvastauksen `state`-arvon eikä lähetä mitään `error=`-uudelleenohjauksen jälkeen.
      4. `finish`, `link` ja `complete` seuraavat kuten pelillä. Yritys muistaa, että verkkosivusto aloitti sen: koodi vaihdetaan verkkosivuston asiakkaalla ja sivulla, ja ID-tokenin on nimettävä kyseinen asiakas (pelin asiakkaan koodi tai ID-token epäonnistuu virheellä 502 `sso_failed`, ja päinvastoin).

      Google-tili johtaa samaan Scacelith-tiliin sekä pelistä että verkkosivustolta: liitos tallentaa Googlen subject-tunnisteen (`sub`), joka yksilöi Google-tilin asiakkaasta riippumatta. Verkkosivuston alkuperän on oltava luettelossa `CORS_ORIGINS` (muuten `check-config` varoittaa).

      **Mihin tiliin Google-kirjautuminen johtaa:** tiliin, joka on jo liitetty kyseiseen Google-tiliin; muuten aktiiviseen tiliin, jolla on Googlen vahvistama osoite (jos tilillä on salasana, `finish` vastaa `needsPassword`, ja liitos tallennetaan vasta, kun sen salasana ja sitten sen toinen tekijä, jos se on käytössä, hyväksytään; ilman salasanaa 409 `sso_account_exists`); muuten uuteen tiliin (`needsUsername`). Google-tiliä ei koskaan liitetä olemassa olevaan tiliin pelkän osoitteen perusteella. Tilin osoitteeseen lähetetään sähköpostiviesti, kun Google-kirjautuminen luo tilin ja kun se lisätään olemassa olevaan tiliin.
  - name: sessions
    x-displayName: Istunnot
    description: Tilin kirjautuneet laitteet ja uloskirjautuminen.
  - name: account
    x-displayName: Tili
    description: Tilin näkymä, sen asetukset ja salasana.
  - name: two-step-verification
    x-displayName: Kaksivaiheinen vahvistus
    description: 'Todennussovellukset (TOTP, RFC 6238): SHA-1, 6 numeroa, 30 sekunnin jaksot ja yhden jakson toleranssi kumpaankin suuntaan, sekä kertakäyttöiset palautuskoodit.'
  - name: email-change
    x-displayName: Sähköpostiosoitteen vaihto
    description: Tilin osoitteen vaihtaminen, joka vahvistetaan uuteen osoitteeseen lähetetyllä linkillä.
  - name: data-export
    x-displayName: Tietojen vienti
    description: Kaikki, mitä palvelin säilyttää tilistä, yhtenä JSON-tiedostona.
  - name: account-deletion
    x-displayName: Tilin poisto
    description: Tilin poistaminen lopullisesti.
  - name: game-history
    x-displayName: Pelihistoria
    description: Kirjautuneen pelaajan omat pelit suodatettuina ja sivutettuina.
  - name: games
    x-displayName: Pelit ja PGN
    description: |-
      Pelitietueet siirtoineen ja kelloineen sekä niiden PGN-tiedostot.

      **Pelikoodit.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (vain päättyneet pelit tallennetaan). `result`: `1-0`, `0-1`, `1/2-1/2` tai `*` (peruttu). `reason` nimineen (`termination`) ja sanoineen, joihin PGN-tiedoston siirtoteksti päättyy (tiedostossa englanniksi, alla käännöksineen); koodit 7, 14 ja 21 ovat tasapelejä (pelaaja, jonka aika loppui, joka luovutti tai joka poistui pelistä, kohtasi vastustajan, joka ei voinut tehdä mattia: FIDE 6.9 ja 5.1.2; `DESIGN.md` 6.3 antaa säännön), eikä palvelin koskaan päätä peliä koodeilla 4 ja 13 (ne kuuluvat yhteiseen syyluetteloon):

      | Koodi | `termination` | Sanat PGN-tiedostossa |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (shakkimatti) |
      | 2 | `Resignation` | Resignation (luovutus) |
      | 3 | `Timeout` | Loss on time (aika loppui) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (toinen laiton siirto, tuomittu häviö) |
      | 5 | `Stalemate` | Stalemate (patti) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (kuollut asema, riittämätön materiaali) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (lippu putosi, mutta vastustaja ei voi tehdä mattia) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (viidesti toistunut asema) |
      | 9 | `SeventyFiveMoves` | 75-move rule (75 siirron sääntö) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (kolmesti toistunut asema, vaadittu) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (50 siirron sääntö, vaadittu) |
      | 12 | `Agreement` | Draw by agreement (sovittu tasapeli) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (toinen laiton siirto, mutta vastustaja ei voi tehdä mattia) |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate (luovutus, mutta vastustaja ei voi tehdä mattia) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (poistui pelistä, yhteys katki liian kauan) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (poistui pelistä, mutta vastustaja ei voi tehdä mattia) |
      | 22 | `Aborted` | Game aborted (peli peruttu) |
      | 23 | `NoShow` | Aborted: first move not played in time (peruttu: ensimmäistä siirtoa ei tehty ajoissa) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (tuomittu häviö reilun pelin rikkomuksesta) |
      | 25 | `ServerAborted` | Aborted by the server (palvelin perui pelin) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (peruttu: kummankin pelaajan yhteys katkesi) |

      Koodi 14 on uusi protokollan alaversiossa (minor) 1: sitä ennen kirjoitetun asiakkaan tulisi näyttää tuntematon koodi pelkkänä pelin päättymisenä ja sen tuloksena (`result`).
  - name: gifs
    x-displayName: Animoidut GIF-kuvat
    description: |-
      Peli animoituna GIF-kuvana säilytettäväksi tai jaettavaksi: lauta ylhäältä nähtynä, yksi kehys asemaa kohden alkuasemasta loppuasemaan, pelaajien nimet ja vahvuusluvut laudan yläpuolella, viimeisin siirto sen alapuolella ja viimeisessä kehyksessä tulos ja se, miten peli päättyi.

      **Kustannus, välimuisti ja kiintiöt.** GIF luodaan omassa renderöintisäikeessään, ei koskaan pelejä suorittavissa säikeissä, ja alimmalla suoritinprioriteetilla: `GIF_THREADS` säiettä (oletuksena `WORKERS`), jotka käynnistetään ensimmäisen GIF-kuvan myötä ja pysäytetään, kun yhtään GIF-kuvaa ei ole luotu minuuttiin. Enintään `GIF_QUEUE_MAX` (4 x `WORKERS`) GIF-kuvaa odottaa säiettä, kukin enintään `GIF_QUEUE_TIMEOUT_MS` (10 s); renderöinti saa kestää `GIF_RENDER_TIMEOUT_MS` (30 s). Palvelin säilyttää luomansa GIF-kuvat `GIF_CACHE_MB` megatavun (32 x `WORKERS`) välimuistissa, josta pisimpään käyttämättä ollut poistetaan ensin; välimuistista saatu GIF tai toista pyyntöä varten parhaillaan luotava GIF ei vaadi renderöintiä. Välimuistin avaimena on kaikki, mikä muuttaa kuvaa, nimet mukaan lukien.

      Jokainen pyyntö lasketaan `gif`-rajoitukseen (30 minuutissa pelaajaa kohden molemmille päätepisteille). Luotava GIF lasketaan lisäksi renderöintirajoituksiin: pelaajaa kohden `GIF_USER_RENDERS_PER_MIN` (4) minuutissa ja `GIF_USER_RENDERS_PER_HOUR` (30) tunnissa; asiakasta kohden, kaikki sen tilit yhteensä, `GIF_IP_RENDERS_PER_MIN` (12) minuutissa ja `GIF_IP_RENDERS_PER_HOUR` (120) tunnissa, sekä kolminkertaisesti IPv6 /48 -verkkoa kohden. Asiakkaan tulisi säilyttää lataamansa tiedosto eikä pyytää sitä uudelleen sekä odottaa `retryAfter`-ajan verran vastauksen 429 tai 503 jälkeen.

      Koot: `small` (32 px:n ruudut: 284 x 350 pikseliä), `medium` (48 px: 424 x 515), `large` (72 px: 628 x 762); ilman koordinaatteja 268 x 342, 400 x 503 ja 600 x 748. Alkuasema näkyy vähintään 1 s (viiveen verran, jos se on pidempi), loppuasema 3 s, ja GIF toistuu silmukkana; ensimmäisen kehyksen jälkeen tallennetaan vain se osa kuvasta, joka muuttuu. 40 siirron peli vie noin 135, 205 ja 325 KiB (small, medium, large), 150 siirron peli 0,5, 0,8 ja 1,2 MiB.
  - name: players
    x-displayName: Pelaajat
    description: Julkiset profiilit ja viimeisimmät pelit. Vain julkisia tietoja, ei koskaan sähköpostiosoitetta, istuntoa, seuraamusta tai luotettavuustasoa (integrity level). Poistetulla tilillä ei ole profiilia.
  - name: leaderboard
    x-displayName: Tulostaulu
    description: Kunkin virallisen luokan parhaat pelaajat.
  - name: reports
    x-displayName: Ilmoitukset
    description: Äskeisen pelin vastustajasta ilmoittaminen moderaattoreille.
  - name: pages
    x-displayName: HTML-sivut
    description: |-
      Selaimelle tarkoitetut sivut, jotka avataan sähköpostien linkeistä. Niiden linkit osoittavat osoitteeseen `https://<SERVER_PUBLIC_HOST>` (ja `:<PUBLIC_API_PORT>`, kun portti ei ole 443), polun `/api/v1` ulkopuolelle.

      - Sivut eivät suorita JavaScriptiä eivätkä lataa ulkoisia resursseja. Ne tarjoillaan otsakkeella `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - `GET` vain näyttää painikkeen tai lomakkeen, jotta linkin avaava sähköpostiskanneri ei käytä linkkiä loppuun. Muutos tapahtuu `POST`-pyynnöllä: lomake lähetetään muodossa `application/x-www-form-urlencoded` (myös JSON-runko hyväksytään), ja linkin token on piilokentässä. Kahdesti annettu kenttä hylätään.
      - Virheet (nopeusrajoitukset, virheelliset kentät) ovat nekin HTML-sivuja, joiden otsikko on ”Request refused” (pyyntö hylätty) tai, tilakoodista 500 alkaen, ”Server error” (palvelinvirhe). Vain virheet, jotka havaitaan ennen kuin sivu on tunnistettu (liian pitkä tai virheellisesti muodostettu pyynnön kohde, osoitekohtainen kerros), saavat vastauksen JSON-muodossa.
x-tagGroups:
  - name: server
    x-displayName: Palvelin
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Tilit
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Pelit
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Pelaajat ja ilmoitukset
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Selainsivut
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Hae palvelimen nimi, versiot, portit ja rekisteröitymisen säännöt
      description: |-
        Mitä asiakas tarvitsee ennen kuin se kirjautuu sisään tai muodostaa yhteyden: palvelimen nimi ja tunniste, WebSocket-protokollan versiot ja WebSocketin sijainti, rekisteröitymisen säännöt, viralliset peliajat sekä rajat, jotka asiakas voi tarkistaa ennen lomakkeen lähettämistä.

        **Rajoitukset:** vain osoitekohtainen kerros.
      security: []
      responses:
        '200':
          description: Palvelimen kuvaus.
          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`: osoitekohtainen kerros.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Luo tili
      description: |-
        Luo tilin: kun sen sähköpostiosoite on vahvistettu siihen lähetetyllä linkillä, tai heti, jos sähköpostivahvistusta ei käytetä.

        **Sähköpostivahvistuksen kanssa** (`REQUIRE_EMAIL_VERIFICATION`, oletus): 202 `verification_sent`. Tiliä ei vielä ole: rekisteröityminen odottaa 24 tuntia ja varaa käyttäjänimensä sillä välin. Osoitteeseen lähetetään linkki, joka on voimassa nämä 24 tuntia, enintään yksi osoitetta kohden 5 minuutin välein (`POST /auth/verify-email/resend` lähettää uuden); tili luodaan vahvistetulla osoitteella, kun linkkiä käytetään (sivun `/verify-email` painike), ja pelaaja voi sen jälkeen kirjautua sisään. Siihen asti kirjautuminen tällä käyttäjänimellä saa vastauksen `invalid_credentials` kuten tuntematon tili, eikä julkista profiilia ole. Uusi rekisteröityminen samalla osoitteella korvaa odottavan. Vastaus on sama, kun toinen tili jo käyttää osoitetta: linkkiä ei silloin lähetetä, vaan tilin omistaja saa ilmoituksen (enintään yhden tunnissa), ja käyttäjänimi varataan samalla tavalla, jotta mikään ei paljasta, onko osoitteella tiliä. Rekisteröityminen, jonka linkkiä ei käytetty, hylätään 24 tunnin kuluttua, ja sen käyttäjänimi vapautuu.

        **Ilman sähköpostivahvistusta** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, tili luodaan heti ja sillä voi kirjautua sisään.

        **Säännöt.** `username`: `USERNAME_MIN`–`USERNAME_MAX` merkkiä (3–20), kirjaimia, numeroita, `_` ja `-`, alkaa kirjaimella tai numerolla (`GET /info` antaa nämä kentässä `limits`); varatut nimet (`admin`, `moderator`, `deleted`...) ja jotkin etuliitteet hylätään; yksilöllinen kirjainkoosta riippumatta. `email`: alusta ja lopusta poistetaan tyhjät merkit, ja osoite tallennetaan pienillä kirjaimilla; pelkkää ASCIIta, ja verkkotunnuksessa on oltava piste. `password`: vähintään `PASSWORD_MIN_LENGTH` (10) merkkiä ja enintään 256 tavua UTF-8:aa; se ei saa sisältää käyttäjänimeä eikä sähköpostiosoitteen paikallista osaa, eikä se saa olla yleinen salasana.

        **Virheet** tässä tarkistusjärjestyksessä: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; salasanatiivisteiden jonon virheet; 409 `email_taken` (vain ilman sähköpostivahvistusta).

        **Rajoitukset:** `auth`, sitten `auth_register` (10 rekisteröitymistä tunnissa asiakasta kohden). **Työtodiste:** aina, kun `POW_REGISTER_BITS` on suurempi kuin 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Ensimmäinen yritys ilman työtodistetta
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Lähetetty uudelleen työtodisteen kanssa
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: Tili on luotu, ja sillä voi kirjautua sisään (tällä palvelimella ei ole sähköpostivahvistusta).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: Rekisteröityminen odottaa vahvistuslinkkiään (tai osoitteella on jo tili; vastaus ei kerro kumpi).
          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` (ja `field`), `invalid_json`, `invalid_username`, `invalid_email` tai `weak_password` ja `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` tai `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: rekisteröityminen on suljettu tällä palvelimella (`GET /info` ilmoittaa `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: jollakin tilillä on tämä käyttäjänimi, tai toisen osoitteen odottava rekisteröityminen on varannut sen. `email_taken`: toinen tili käyttää osoitetta (vain ilman sähköpostivahvistusta).'
        '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`- tai `auth_register`-rajoitus tai salasanatiivisteiden jono.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (salasanatiivisteiden jono, tai tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Kirjaudu sisään salasanalla
      description: |-
        Kirjautuu sisään käyttäjänimellä tai sähköpostiosoitteella ja salasanalla. Mahdollisia vastauksia on kaksi, kumpikin tilakoodilla 200: istunto tai, kun kaksivaiheinen vahvistus on käytössä, toinen vaihe, joka on suoritettava 5 minuutin kuluessa päätepisteellä `POST /auth/login/mfa`.

        Tuntematon tili, väärä salasana ja tili ilman salasanaa saavat saman vastauksen yhtä pitkän ajan jälkeen: 401 `invalid_credentials`. Tilin tarkistukset (`banned`, `email_unverified`) tehdään vasta oikean salasanan jälkeen. Samalla kirjautumisnimellä `AUTH_FAILURES_PER_ACCOUNT` (5) epäonnistumisesta alkaen jokaisen yrityksen on odotettava (429 `too_many_attempts`).

        **Rajoitus:** `auth`. **Työtodiste:** vain epäonnistuneiden kirjautumisten aallon aikana (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: Istunto tai kaksivaiheista vahvistusta käyttävän kirjautumisen toinen vaihe.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Kirjautunut sisään
                  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: Kaksivaiheinen vahvistus on käytössä
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: tuntematon tili, väärä salasana tai tili ilman salasanaa.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Vain oikean salasanan jälkeen: `banned` ja `until` (Unix-aika, ms; `null`, jos porttikielto on pysyvä) tai `email_unverified` (vanhempi tili, jonka osoitetta ei ole vielä vahvistettu).'
        '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` (tämän kirjautumisnimen epäonnistumisviive) tai `rate_limited` (`auth`-rajoitus, salasanatiivisteiden jono).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (salasanatiivisteiden jono, tai tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Viimeistele kirjautuminen toisella tekijällä
      description: |-
        Kaksivaiheista vahvistusta käyttävän kirjautumisen toinen vaihe sen jälkeen, kun `POST /auth/login` tai Google-kirjautuminen (`POST /auth/sso/google/finish` tai `POST /auth/sso/google/link`) vastasi `mfaRequired`. Lähetä `code` (todennussovelluksen 6-numeroinen koodi tai palautuskoodi) tai `recoveryCode`. Tässä käytetty palautuskoodi kuluu loppuun.

        Yksi vaihe hyväksyy enintään 5 väärää koodia; vaihe päättyy myös, kun salasana nollataan tai vaihdetaan. Google-liitoksen salasanavaiheen jälkeen liitos tallennetaan vasta, kun koodi hyväksytään tässä.

        **Rajoitukset:** `auth` sekä tilille enintään `AUTH_MFA_PER_ACCOUNT` (10) koodia 15 minuutissa mistä tahansa osoitteesta; sen yli koodia ei tarkisteta, joten palautuskoodi ei kulu.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Todennussovelluksen koodi
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Palautuskoodi
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Kirjautunut sisään.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: kumpaakaan kentistä `code` ja `recoveryCode` ei lähetetty, tai runko rikkoo skeemaa; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: vaihe vanheni, käytettiin tai päättyi 5 väärän koodin jälkeen, tai salasana on nollattu tai vaihdettu ensimmäisen vaiheen jälkeen (kirjaudu uudelleen sisään). `invalid_code`: väärä tai jo käytetty koodi.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (ja `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: vain Google-liitoksen salasanavaiheen jälkeen, kun Google-tili liitettiin sillä välin toiseen tiliin.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: vain Google-liitoksen salasanavaiheen jälkeen, kun tili muuttui sillä välin; aloita kirjautuminen alusta.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: tilin epäonnistumisviive, tai tilin `AUTH_MFA_PER_ACCOUNT` koodia viimeisten 15 minuutin ajalta on käytetty. `rate_limited`: `auth`-rajoitus.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` arvolla `retryAfter: 1`: tietokanta pysyi lukittuna (Google-liitoksen vaiheen jälkeen mitään ei liitetty: aloita kirjautuminen alusta). `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Kirjaa tämä istunto ulos
      description: |-
        Mitätöi käytetyn tokenin istunnon. Sillä avattu WebSocket suljetaan. Ei runkoa (tai `{}`).

        **Rajoitus:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Kirjauduttu ulos.
          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`: muu runko kuin `{}`; `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`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Kirjaa kaikki istunnot ulos
      description: |-
        Mitätöi tilin kaikki istunnot, myös tämän. Niillä avatut WebSocketit suljetaan. Ei runkoa (tai `{}`).

        **Rajoitus:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Kaikki istunnot on kirjattu ulos.
          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`: muu runko kuin `{}`; `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`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Lähetä vahvistuslinkki uudelleen
      description: |-
        Lähettää sähköpostin vahvistuslinkin uudelleen. Vastaus on 202 `accepted` osoitteesta riippumatta, jotta se ei koskaan paljasta, käyttääkö jokin tili tai rekisteröityminen osoitetta.

        Pyyntö vaikuttaa enintään kerran 5 minuutissa osoitetta kohden. Osoitteella odottava rekisteröityminen saa jälleen täydet 24 tuntia riippumatta siitä, käyttääkö toinen tili osoitetta, jotta sen käyttäjänimi pysyy varattuna yhtä kauan kummassakin tapauksessa. Linkki lähetetään vain tälle rekisteröitymiselle, kun osoitteella ei ole tiliä (uusi, 24 tuntia voimassa oleva linkki korvaa edellisen), tai aktiiviselle vahvistamattomalle tilille, jolla on tämä osoite.

        **Rajoitukset:** `auth`, sitten `auth_mail` (10 tunnissa asiakasta kohden).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Hyväksytty (osoitteesta riippumatta).
          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`- tai `auth_mail`-rajoitus (osoitteesta riippumatta).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` arvolla `retryAfter: 1`: tietokanta pysyi lukittuna, kun tiliä haettiin tai vahvistamattoman tilin linkkiä tallennettiin (odottavan rekisteröitymisen uusiminen tehdään parhaan yrityksen periaatteella eikä koskaan aiheuta pyynnön epäonnistumista); osoitteen 5 minuutin jakso alkaa silti, joten uusi pyyntö sen aikana ei tee mitään. `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Lähetä salasanan nollauslinkki
      description: |-
        Lähettää tunnin voimassa olevan salasanan nollauslinkin, joka avaa sivun `/reset-password`. Vastaus on 202 `accepted` osoitteesta riippumatta. Linkki lähetetään vain aktiiviselle tilille, enintään kerran 5 minuutissa osoitetta kohden. Pelkän Google-kirjautumisen tili asettaa ensimmäisen salasanansa tällä tavalla.

        Salasanan palautuksella on rajapinnan tiukimmat rajoitukset, jotka kaikki lasketaan koko palvelimen laajuisesti: 3 pyyntöä tunnissa (`AUTH_FORGOT_PER_HOUR`) ja 10 pyyntöä 24 tunnissa (`AUTH_FORGOT_PER_DAY`) asiakasta kohden (IPv4-osoite tai IPv6 /64), kolminkertaiset määrät IPv6 /48 -verkkoa kohden, ja lisäksi `auth`-rajoitus (20 pyyntöä 10 minuutissa) ja yksi sähköpostiviesti osoitetta kohden 5 minuutin välein. Kumpikaan vastauksista 202 ja 429 ei paljasta, käyttääkö jokin tili osoitetta. Uuden salasanan asettamisella on oma rajoituksensa (`auth_reset`).

        **Rajoitukset:** `auth`, `auth_forgot`, sitten `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Hyväksytty (osoitteesta riippumatta).
          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`- tai `auth_forgot_day`-rajoitus (osoitteesta riippumatta).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` arvolla `retryAfter: 1`: tietokanta pysyi lukittuna, kun tiliä haettiin tai linkkiä tallennettiin; osoitteen 5 minuutin jakso alkaa silti, joten uusi pyyntö sen aikana ei lähetä linkkiä. `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Aseta uusi salasana nollauslinkin tokenilla
      description: |-
        Asettaa uuden salasanan nollauslinkin tokenilla (sivu `/reset-password` tekee saman). Uusi salasana noudattaa rekisteröitymisen sääntöjä.

        Nollauksella on nämä vaikutukset yhdessä transaktiossa (kaikki tai ei mitään): se mitätöi kaikki istunnot ja peruu odottavan sähköpostiosoitteen vaihdon; tilin muut nollauslinkit lakkaavat toimimasta; osoite katsotaan vahvistetuksi (linkki todisti sen); omistaja saa sähköpostiviestin. Kaksivaiheiseen vahvistukseen ei kosketa. Salasanatiivisteiden jonon virheen, vastauksen 503 tai vastauksen 500 jälkeen mitään ei muutettu: vanha salasana ja istunnot säilyvät, ja linkki pysyy voimassa.

        **Rajoitukset:** `auth`, sitten `auth_reset` (10 tunnissa asiakasta kohden, 30 IPv6 /48 -verkkoa kohden, yhteinen sivun kanssa: jokainen yritys tiivistää salasanan).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Salasana on vaihdettu, ja kaikki istunnot on kirjattu ulos.
          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`: linkki on virheellinen, käytetty tai vanhentunut, tai se lähetettiin osoitteeseen, joka ei enää ole tilin osoite. `weak_password` (ja `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`- tai `auth_reset`-rajoitus tai salasanatiivisteiden jono.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: salasanatiivisteiden jono, tai tietokanta pysyi lukittuna (`retryAfter: 1`, mitään ei muutettu). `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Aloita Google-kirjautuminen
      description: |-
        Aloittaa Google-kirjautumisyrityksen PKCE-haasteelle ja pelin kuuntelijan portille osoitteessa `127.0.0.1`. Vastaus antaa järjestelmän selaimessa avattavan Googlen URL:n ja yrityksen `state`-arvon; yritys on voimassa 10 minuuttia.

        `authUrl` sisältää parametrit `client_id`, `redirect_uri` (muodostettu arvosta `redirectPort` ja palvelimen alkuperätunnisteesta), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (palvelimen oman, Googlea varten luodun verifierin S256) ja `code_challenge_method=S256` sekä `prompt=select_account`. Peli tarkistaa sen ennen kuin avaa sen (katso osion kuvaus).

        **Rajoitus:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: Yritys.
          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`: tämä palvelin ei tarjoa Google-kirjautumista.'
        '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`-rajoitus.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Aloita Google-kirjautuminen verkkosivustolla
      description: |-
        Aloittaa verkkosivuston Google-kirjautumisyrityksen sen sivun PKCE-haasteelle. Vastaus on sama kuin pelille: Googlen URL, johon siirrytään, ja yrityksen `state`-arvo; yritys on voimassa 10 minuuttia. Tarjolla, kun `GET /info` ilmoittaa `sso.googleWeb: true`.

        `authUrl` sisältää verkkosivuston `client_id`-arvon ja `redirect_uri=GOOGLE_WEB_REDIRECT_URI`, muut parametrit samoina kuin pelille. Sivu tarkistaa sen ennen kuin siirtyy siihen (katso **Verkkosivustolla** osion kuvauksessa).

        **Rajoitus:** `sso_start`, yhteinen pelin aloituksen kanssa.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      responses:
        '200':
          description: Yritys.
          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`: puuttuva tai virheellisesti muodostettu `codeChallenge` tai mikä tahansa muu kenttä (`redirectPort` mukaan lukien).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: tämä palvelin ei tarjoa Google-kirjautumista tai ei tarjoa sitä verkkosivustoille.'
        '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`-rajoitus (yhteinen pelin aloituksen kanssa).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Välitä Googlen vastaus palvelimelle
      description: |-
        Välittää palvelimelle `code`- ja `state`-arvot, jotka Google lähetti pelin kuuntelijalle tai verkkosivuston sivulle, yhdessä yrityksen tunnisteen ja PKCE-verifierin kanssa. Yrityksen tunniste on hyödytön ilman verifieriä, ja koodi on hyödytön ilman palvelimen omaa PKCE-verifieriä ja asiakassalaisuutta (client secret).

        Palvelin tarkistaa yrityksen (tuntematon, käytetty tai vanhentunut: 410), sitten verifierin (väärä verifier jättää yrityksen käyttökelpoiseksi), käyttää sitten yrityksen kerran, tarkistaa arvot `state` ja `iss`, vaihtaa koodin Googlen kanssa ja tarkistaa ID-tokenin. Vastaus (200) on jokin seuraavista:

        - `{ token, expiresAt, user }`: kirjautunut sisään liitettyyn tiliin;
        - `{ mfaRequired, mfaToken, expiresIn }`: jatka päätepisteellä `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: uusi tili; jatka päätepisteellä `POST /auth/sso/complete` 10 minuutin kuluessa. `suggestedUsername` muodostetaan Google-nimestä tai osoitteesta, ja se on `""`, kun mikään ei sovi;
        - `{ needsPassword, linkTicket, username, expiresIn }`: tilillä, jolla on tämä osoite, on salasana; jatka päätepisteellä `POST /auth/sso/google/link`. Mitään ei ole vielä liitetty.

        **Rajoitus:** `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: Istunto, toinen vaihe tai ensimmäisen Google-kirjautumisen seuraava vaihe.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Kirjautunut sisään liitettyyn tiliin
                  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: Kaksivaiheinen vahvistus on käytössä
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Uusi pelaaja
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Osoitetta käyttää tili, jolla on salasana
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: verifier ei vastaa yrityksen haastetta (yritys pysyy käyttökelpoisena). `sso_email_unverified`: Google ei ole vahvistanut osoitetta. `registration_closed`. `account_disabled`. Vain liitetylle tilille: `banned` (ja `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: tämä palvelin ei tarjoa Google-kirjautumista.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: osoitetta käyttää aktiivinen tili, jolla ei ole salasanaa (tiliä ei nimetä).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: tuntematon, käytetty tai vanhentunut yritys.'
        '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`-rajoitus.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Liitä Google olemassa olevaan tiliin sen salasanalla
      description: |-
        Kun `finish` on vastannut `needsPassword`: liittää Googlen olemassa olevaan tiliin tämän tilin salasanalla, joka kirjoitetaan pelissä. Vastaus (200) on istunto (liitos tallennetaan ja pelaaja kirjautuu sisään) tai, kun kaksivaiheinen vahvistus on käytössä, `{ mfaRequired, mfaToken, expiresIn }`: jatka päätepisteellä `POST /auth/login/mfa`, ja liitos tallennetaan vasta, kun koodi hyväksytään siellä.

        Väärä salasana jättää lipun käyttökelpoiseksi, yhteensä 5 yritykseen asti. Yrityskerta otetaan juuri ennen salasanan tarkistamista: 429 `too_many_attempts` tai 428 `pow_required` ei vie yhtään, kun taas salasanatiivisteiden jonon hylkäys (503 `server_busy`, 429 `rate_limited`) on jo vienyt yhden ja lasketaan tilin epäonnistumiseksi. Epäonnistumisviive on sama laskuri kuin päätepisteellä `POST /auth/login`.

        **Rajoitus:** `auth` (IPv6 /48 -laskentoineen). **Työtodiste:** kuten päätepisteellä `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: Istunto tai kirjautumisen toinen vaihe.
          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`: väärä salasana; lippu pysyy voimassa, kaikkiaan 5 yritykseen asti.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (ja `until`), vain oikean salasanan jälkeen.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: tämä palvelin ei tarjoa Google-kirjautumista.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: Google-tili liitettiin sillä välin toiseen tiliin.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: tuntematon, käytetty tai vanhentunut lippu, viides väärä salasana, tili, jonka tila tai osoite on muuttunut `finish`-kutsun jälkeen, tai tili, jonka tila, osoite, salasana tai kaksivaiheinen vahvistus muuttui liitosta tallennettaessa. Aloita kirjautuminen alusta.'
        '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` (tilin epäonnistumisviive) tai `rate_limited` (`auth`-rajoitus, salasanatiivisteiden jono).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: salasanatiivisteiden jono, tai tietokanta pysyi lukittuna (`retryAfter: 1`, mitään ei liitetty). `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Luo tili ensimmäisen Google-kirjautumisen yhteydessä
      description: |-
        Kun `finish` on vastannut `needsUsername`: luo tilin valitulla käyttäjänimellä (rekisteröitymisen säännöt pätevät) ja kirjaa sen sisään. Tilillä ei ole salasanaa: `hasPassword` on `false`, ja ”Unohditko salasanan?” (`POST /auth/password/forgot`) antaa sille salasanan.

        **Rajoitus:** `auth` (myös sen IPv6 /48 -verkkokohtainen laskenta).
      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: Tili on luotu ja kirjattu sisään.
          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`: tämä palvelin ei tarjoa Google-kirjautumista.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (käyttäjänimi on jo jonkin tilin käytössä, tai toisen osoitteen odottava rekisteröityminen on varannut sen), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: tuntematon, käytetty tai vanhentunut lippu.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `auth`-rajoitus.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Listaa kirjautuneet laitteet
      description: |-
        Tilin aktiiviset istunnot (kirjautuneet laitteet), viimeksi käytetty ensin. `current` merkitsee istunnon, joka tekee pyynnön. `lastSeenAt` päivitetään enintään 5 minuutin välein (myöhemmin, kun palvelin on pahasti jäljessä); `expiresAt` on ehdoton päättymishetki, ja käyttämättömyysraja voi päättää istunnon aiemmin.

        **Rajoitus:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Aktiiviset istunnot.
          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`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Kirjaa yksi laite ulos
      description: |-
        Kirjaa tilin yhden istunnon ulos; myös nykyisen istunnon voi kirjata ulos. Sillä avattu WebSocket suljetaan. Ei runkoa (tai `{}`).

        **Rajoitus:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Istunto on kirjattu ulos.
          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`: muu runko kuin `{}` tai `id`, joka ei ole kelvollista URL-koodausta; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: tällä tilillä ei ole aktiivista istuntoa, jolla on tämä tunniste.'
        '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`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Hae tili, sen vahvuusluvut ja voimassa olevat seuraamukset
      description: |-
        Tili sellaisena kuin sen pelaaja näkee: tilinäkymä (myös jokaisen kirjautumisvastauksen `user`), yksi vahvuuslukutietue kutakin peliaikaluokkaa kohden, jossa pelaaja on pelannut pisteytettyjä pelejä, voimassa olevat seuraamukset ja porttikielto. Huijauksentorjunnan luotettavuustasoa ei koskaan näytetä.

        **Rajoitus:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Tili.
          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` tai `invalid_token` (myös kun tili on poistettu).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `account`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Hyväksy tai hylkää suorat haasteet
      description: |-
        Määrittää, voivatko muut pelaajat haastaa tämän pelaajan nimellä. Arvolla `none` suorat haasteet hylätään: haastajalle kerrotaan, että pelaaja ei ole tavoitettavissa. Ei uudelleentodennusta.

        **Rajoitus:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Nyt voimassa olevat asetukset.
          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`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Vaihda salasana
      description: |-
        Vaihtaa salasanan. Nykyinen salasana tarvitaan, mutta toista tekijää ei, vaikka kaksivaiheinen vahvistus olisi käytössä. Uuden salasanan on noudatettava rekisteröitymisen sääntöjä (tarkistetaan nykyisen salasanan jälkeen).

        Vaihdolla on nämä vaikutukset yhdessä transaktiossa (kaikki tai ei mitään): se mitätöi kaikki muut istunnot (tämä istunto pysyy kirjautuneena), peruu odottavan sähköpostiosoitteen vaihdon, saa tilin salasanan nollauslinkit lakkaamaan toimimasta ja lähettää omistajalle sähköpostiviestin.

        **Uudelleentodennus:** vain salasana. **Rajoitukset:** `reauth`, sitten `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: Salasana on vaihdettu.
          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` (pelkän Google-kirjautumisen tili), `weak_password` (ja `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: väärä nykyinen salasana tai salasanan nollaus tai vaihto, joka tapahtui pyyntöä tarkistettaessa.'
        '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` (tilin epäonnistuneet uudelleentodennukset) tai `rate_limited` (`reauth`- tai `reauth_user`-rajoitus, tilikohtainen budjetti, salasanatiivisteiden jono).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: salasanatiivisteiden jono, tai tietokanta pysyi lukittuna (`retryAfter: 1`, mitään ei muutettu). `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Aloita kaksivaiheisen vahvistuksen käyttöönotto
      description: |-
        Tallentaa uuden odottavan salaisen avaimen, joka korvaa mahdollisen aiemman odottavan avaimen, ja palauttaa sen todennussovellusta varten (tekstinä ja `otpauth://`-URI:na, joka näytetään QR-koodina). Kaksivaiheinen vahvistus ei ole vielä käytössä: `POST /account/mfa/totp/enable` ottaa sen käyttöön tämän avaimen koodilla.

        **Uudelleentodennus:** vain salasana. **Rajoitukset:** `reauth`, sitten `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Odottava salainen avain.
          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` (pelkän Google-kirjautumisen tili), `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` (tarkistetaan ennen salasanaa).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (tilin epäonnistuneet uudelleentodennukset) tai `rate_limited` (`reauth`- tai `reauth_user`-rajoitus, tilikohtainen budjetti, salasanatiivisteiden jono).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (salasanatiivisteiden jono, tai tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Viimeistele kaksivaiheisen vahvistuksen käyttöönotto ja hae palautuskoodit
      description: |-
        Ottaa kaksivaiheisen vahvistuksen käyttöön odottavan salaisen avaimen koodilla (tasan 6 numeroa). Salasanaa ei kysytä tässä; se annettiin määritysvaiheessa. Vastauksessa on 10 palautuskoodia, jotka näytetään vain tämän kerran.

        **Rajoitukset:** `reauth`, sitten `reauth_user`; väärä koodi lasketaan tilin epäonnistuneisiin uudelleentodennuksiin.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Kaksivaiheinen vahvistus on käytössä.
          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` ei ole tasan 6 numeroa, tai runko rikkoo skeemaa; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: väärä koodi (tarkista laitteen kellonaika).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: odottavaa salaista avainta ei ole (kutsu ensin `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` (tilin epäonnistuneet uudelleentodennukset) tai `rate_limited` (`reauth`- tai `reauth_user`-rajoitus, tilikohtainen budjetti).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Poista kaksivaiheinen vahvistus käytöstä
      description: |-
        Poistaa kaksivaiheisen vahvistuksen käytöstä. Salainen avain ja palautuskoodit poistetaan, ja omistaja saa sähköpostiviestin.

        **Uudelleentodennus:** salasana sekä todennussovelluksen koodi tai palautuskoodi (joko `code` tai `recoveryCode` vaaditaan). **Rajoitukset:** `reauth`, sitten `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: Kaksivaiheinen vahvistus on poistettu käytöstä.
          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` (pelkän Google-kirjautumisen tili), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (ei `code`- eikä `recoveryCode`-kenttää; tarkistetaan ennen salasanaa), `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` (epäonnistuneita uudelleentodennuksia tai liian monta kokeiltua koodia tällä tilillä) tai `rate_limited` (`reauth`- tai `reauth_user`-rajoitus, tilikohtainen budjetti, salasanatiivisteiden jono).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (salasanatiivisteiden jono, tai tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Vaihda palautuskoodit
      description: |-
        Korvaa palautuskoodit 10 uudella; vanhat lakkaavat toimimasta.

        **Uudelleentodennus:** salasana ja todennussovelluksen koodi kentässä `code` (palautuskoodi hylätään vastauksella 403 `invalid_code`). **Rajoitukset:** `reauth`, sitten `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: Uudet palautuskoodit, jotka näytetään vain tämän kerran.
          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` (pelkän Google-kirjautumisen tili), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (myös palautuskoodille).'
        '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` (epäonnistuneita uudelleentodennuksia tai liian monta kokeiltua koodia tällä tilillä) tai `rate_limited` (`reauth`- tai `reauth_user`-rajoitus, tilikohtainen budjetti, salasanatiivisteiden jono).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (salasanatiivisteiden jono, tai tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Vaihda sähköpostiosoite
      description: |-
        Vaihtaa tilin sähköpostiosoitteen. `newEmail`-arvon alusta ja lopusta poistetaan välilyönnit ja se muutetaan pieniksi kirjaimiksi, minkä jälkeen se tarkistetaan kuten rekisteröitymisosoite.

        **Sähköpostivahvistuksen kanssa** (oletus): 202 `verification_sent`. Uuteen osoitteeseen lähetetään 24 tuntia voimassa oleva linkki; se avaa sivun `/confirm-email-change`, ja osoite vaihtuu vasta, kun pelaaja painaa sivun painiketta. Siihen asti `GET /account/me` näyttää uuden osoitteen kentässä `pendingEmail`. Uusi pyyntö korvaa odottavan; salasanan vaihto tai nollaus peruu sen. Samaan uuteen osoitteeseen lähtee enintään yksi linkki 5 minuutin välein, pyysipä kuka tahansa (pyyntö jo odottavasta vaihdosta säilyttää aiemmin lähetetyn linkin, joka pysyy voimassa); vastaus on sama. Nykyiseen osoitteeseen lähetetään viesti siitä, että vaihtoa peitettyyn osoitteeseen (`a***@example.org`) on pyydetty. Vastaus ja `pendingEmail` ovat samat, kun toinen tili käyttää jo uutta osoitetta: silloin linkkiä ei lähetetä, joten vaihto ei koskaan toteudu, ja sen sijaan kyseisen osoitteen omistaja saa viestin (enintään yhden tunnissa).

        Kun linkki vahvistetaan, osoite vaihtuu ja katsotaan vahvistetuksi, laitteet pysyvät kirjautuneina, aiemmin lähetetyt linkit (vahvistus, salasanan nollaus, muut vaihdot) lakkaavat toimimasta ja entiseen osoitteeseen lähetetään tieto, jossa uusi osoite on peitetty.

        **Ilman sähköpostivahvistusta** (`REQUIRE_EMAIL_VERIFICATION=false`): osoite vaihtuu heti (200 `email_changed`), ja entiseen osoitteeseen lähetetään tieto. Jos toinen tili käyttää osoitetta, vastaus on 409 `email_taken`, ja kyseisen osoitteen omistaja saa viestin.

        **Uudelleentodennus:** salasana ja, kun kaksivaiheinen vahvistus on käytössä, todennussovelluksen koodi tai palautuskoodi. `invalid_email` ja `same_email` tarkistetaan ennen salasanaa, joten niitä ei lasketa epäonnistumisiksi. **Rajoitukset:** `reauth`, sitten `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: Osoite vaihtui heti (tällä palvelimella ei ole sähköpostivahvistusta).
          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: Uusi osoite sellaisena kuin se on tallennettu.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Uuteen osoitteeseen lähetettiin vahvistuslinkki (tai osoite kuuluu toiselle tilille; vastaus ei kerro sitä).
          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` (tilin nykyinen osoite), `password_not_set` (pelkän Google-kirjautumisen tili), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (myös kun salasanan vaihto tai nollaus ehti pyynnön edelle: linkkiä ei lähetetä), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: vain ilman sähköpostivahvistusta.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (epäonnistuneita uudelleentodennuksia tai liian monta kokeiltua koodia tällä tilillä) tai `rate_limited` (`reauth`- tai `reauth_user`-rajoitus, tilikohtainen budjetti, salasanatiivisteiden jono).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: salasanatiivisteiden jono, tai tietokanta pysyi lukittuna (`retryAfter: 1`: mitään ei muutettu, ja saman pyynnön voi lähettää uudelleen). `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Lataa tilin tiedot
      description: |-
        Kaikki, mitä palvelin säilyttää tilistä, yhtenä tallennettavana JSON-tiedostona (`format` `scacelith-account-export`, `version` 1). Vienti kirjaa tietoturvatapahtuman (`account_exported`). Asiakirjan `notes`-taulukko kertoo pelaajalle selkeällä englannin kielellä, mitä on jätetty pois.

        **Vienti ei koskaan sisällä:** salasanan tiivistettä, kaksivaiheisen vahvistuksen salaista avainta eikä palautuskoodeja; mitään istunto- tai linkkitokenia tai sellaisen tiivistettä; huijauksentorjunnan tietoja (luotettavuustaso ja -pisteet, poikkeamat, pelien analyysi, ilmoituksen painoarvo); muiden pelaajien pelaajasta tekemiä ilmoituksia; moderaattorien henkilöllisyyksiä; muiden pelaajien yksityisiä tietoja (vastustajat näkyvät julkisella nimellään ja vahvuusluvullaan, mikään ei kerro, onko toiselle pelaajalle määrätty seuraamuksia, eikä mukana ole IP-osoitteita, jotka voivat kuulua toiselle henkilölle).

        **Uudelleentodennus:** salasana ja, kun kaksivaiheinen vahvistus on käytössä, todennussovelluksen koodi tai palautuskoodi. **Rajoitukset:** `account_export` (5 tunnissa pelaajaa kohden, koko palvelimen laajuisesti; jokainen yritys lasketaan, myös epäonnistuneet; tarkistetaan ensin), sitten `reauth` ja `reauth_user`. **Aikaraja:** 60 sekuntia.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Vientiasiakirja liitteenä.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Käyttäjänimen muut merkit kuin kirjaimet, numerot, `_`, `.` ja `-` muuttuvat merkiksi `_`.'
              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` (pelkän Google-kirjautumisen tili), `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` (epäonnistuneita uudelleentodennuksia tai liian monta kokeiltua koodia tällä tilillä) tai `rate_limited` (`account_export`-, `reauth`- tai `reauth_user`-rajoitus, tilikohtainen budjetti, salasanatiivisteiden jono).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (tietokanta pysyi lukittuna, `Retry-After: 1` -otsakkeen kanssa), `server_busy` (salasanatiivisteiden jono), `timeout` (60 sekuntia). Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Poista tili
      description: |-
        Poistaa tilin; tätä ei voi perua.

        - Kaikki istunnot mitätöidään heti: token saa siitä lähtien vastauksen 401 `invalid_token`.
        - Käyttäjänimeksi tulee `deleted#<id>` sekä tilissä että jokaisessa pelitietueessa.
        - Nämä pyyhitään: sähköpostiosoite, salasanan tiiviste, kaksivaiheisen vahvistuksen salainen avain ja palautuskoodit, istunnot ja linkkitokenit, Google-liitos, huijauksentorjunnan luotettavuustietue sekä tietoturvatapahtumien yhteydessä tallennetut IP-osoitteet.
        - Vahvuusluvut ja pelit säilytetään. Pelit pysyvät luettavina anonyymillä nimellä, ja `GET /players/{username}` vastaa entiselle nimelle 404.

        **Uudelleentodennus:** salasana ja, kun kaksivaiheinen vahvistus on käytössä, todennussovelluksen koodi tai palautuskoodi. **Rajoitukset:** `reauth`, sitten `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: Tili on poistettu.
          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` (pelkän Google-kirjautumisen tili), `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` (epäonnistuneita uudelleentodennuksia tai liian monta kokeiltua koodia tällä tilillä) tai `rate_limited` (`reauth`- tai `reauth_user`-rajoitus, tilikohtainen budjetti, salasanatiivisteiden jono).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (salasanatiivisteiden jono, tai tietokanta pysyi lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Listaa pelaajan pelit suodatettuina ja sivutettuina
      description: |-
        Kirjautuneen pelaajan pelit uusimmasta alkaen, suodatettuina ja sivutettuina, sekä suodattimeen täsmäävien pelien määrä. Kaikki kyselyparametrit ovat valinnaisia, ja tyhjä arvo tulkitaan puuttuvaksi.

        Sivutus: anna edellisen sivun `next` parametrina `before`; viimeisellä sivulla `next` on `null`. `total` laskee suodattimeen täsmäävät pelit kaikilta sivuilta.

        **Rajoitus:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: Virallisen peliaikaluokan tunniste (`3+2` tai `3%2B2`) tai `custom` kaikille peleille, joissa on muu peliaika.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Vain pisteytetyt (`true`) tai pisteyttämättömät (`false`) pelit.
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Vain voitetut, hävityt tai tasapeliin päättyneet pelit pelaajan näkökulmasta. Perutut pelit näkyvät vain ilman tätä suodatinta.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Yksi sivu historiasta.
          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` ei ole pelin tunniste), `invalid_limit` tai `invalid_filter` (`category`, `rated` tai `result`); kussakin on `field`, joka nimeää parametrin.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `account_games`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (tietokanta pysyi lukittuna; `retryAfter: 1` rungossa, ei `Retry-After`-otsaketta), `server_busy` (istunnon haku löysi tietokannan lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Hae pelitietue siirtoineen ja kelloineen
      description: |-
        Yksi pelitietue siirtoineen ja kelloineen. Ilman tokenia tai sellaisen pelaajan tokenilla, joka ei pelannut peliä, vastaus on julkinen. Kun tokenin pelaaja pelasi pelin, vastaukseen lisätään `you` ja `reportable`.

        **Rajoitus:** `public_read` (pelaajakohtainen tokenin kanssa, asiakaskohtainen ilman).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Pelitietue.
          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`: ei positiivinen enintään 16-numeroinen kokonaisluku (alle 2^53); `invalid_request`: ei kelvollista URL-koodausta.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: token lähetettiin, eikä se ole kelvollinen.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: peliä ei ole olemassa.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `public_read`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (tietokanta pysyi lukittuna; `retryAfter: 1` rungossa, ei `Retry-After`-otsaketta), `server_busy` (istunnon haku löysi tietokannan lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Lataa peli PGN-tiedostona
      description: |-
        Sama peli PGN-tiedostona: yksi peli `\n`-rivinvaihdoin ja siirtoteksti alle 80 sarakkeen riveinä. Vastaus on sama tokenin kanssa tai ilman.

        Tagit tässä järjestyksessä: `Event` (`<SERVER_NAME> rated <category>` tai `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (aloituspäivä UTC-aikana), `Round`, `White`, `Black`, `Result` (`*` perutulle pelille), `UTCDate` ja `UTCTime` (alkuhetki), `WhiteElo` ja `BlackElo` (vahvuusluvut alussa tai `-`), `WhiteRatingDiff` ja `BlackRatingDiff` (muutokset, kuten `+10` ja `-10`, jokaisessa pisteytetyssä pelissä, `+0`, kun vahvuuslukusäännöt jättävät vahvuusluvun ennalleen; pisteyttämättömässä, mukautetussa tai perutussa pelissä kumpaakaan ei ole), `TimeControl` (sekunteina), `Termination` (PGN-standardin arvo: `normal`; `time forfeit`, lipun putoaminen, myös kun peli päättyy tasapeliin; `abandoned`; `rules infraction`, toinen laiton siirto tai reilun pelin rikkomuksesta tuomittu häviö; `unterminated`, peruttu peli), `PlyCount`, `ScacelithGameId` (tunniste desimaalilukuna).

        Jokaisessa siirrossa on `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: siirron tekijän kello siirron jälkeen ja siirrolle laskettu aika kymmenesosasekunteina (katkaistuna). Arvo jätetään pois, kun se puuttuu tietueesta. Viimeisen siirron jälkeen tulee päättymissyy sanoin ja sitten tulos.

        **Rajoitus:** `public_read` (pelaajakohtainen tokenin kanssa, asiakaskohtainen ilman).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: PGN-tiedosto.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-<id>.pgn"`.'
              schema:
                type: string
                pattern: '^attachment; filename="scacelith-[1-9][0-9]{0,15}\.pgn"$'
              example: attachment; filename="scacelith-4100000000001.pgn"
          content:
            application/x-chess-pgn:
              schema:
                type: string
                description: 'PGN-teksti UTF-8-muodossa (`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`: ei positiivinen enintään 16-numeroinen kokonaisluku (alle 2^53); `invalid_request`: ei kelvollista URL-koodausta.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: token lähetettiin, eikä se ole kelvollinen.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: peliä ei ole olemassa.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `public_read`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: muun muassa silloin, kun tallennettuja siirtoja ei voi toistaa tallennettuun päättymiseen asti (palvelin kirjaa sen lokiin).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (tietokanta pysyi lukittuna; `retryAfter: 1` rungossa, ei `Retry-After`-otsaketta), `server_busy` (istunnon haku löysi tietokannan lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Lataa tämän palvelimen peli animoituna GIF-kuvana
      description: |-
        Peli animoituna GIF-kuvana. Nimet ja vahvuusluvut ovat pelitietueen mukaiset (vahvuusluvut alussa, poistettu tili muodossa `deleted#<id>`), samoin tulos ja päättymistapa (`Resignation`, `Loss on time`...).

        Tarkistukset tehdään tässä järjestyksessä: `gif_disabled`, kuvan asetukset (`size`, `orientation`, `delay`, `coords`), pelin tunniste, peli, sen pituus.

        **Istunto vaaditaan:** kiintiöt lasketaan tilikohtaisesti. **Rajoitukset:** `gif` jokaisessa pyynnössä; `gif_user_min`, `gif_user_hour`, `gif_ip_min` ja `gif_ip_hour` vain, kun GIF täytyy tehdä (katso osion kuvaus). **Aikaraja:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 sekuntia (oletuksena 45 sekuntia).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: 'Kuvan koko: `small` (32 px:n ruudut), `medium` (48 px) tai `large` (72 px).'
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Puoli, joka näytetään laudan alalaidassa.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Millisekunteja siirtoa kohden (1–6 numeroa kymmenjärjestelmässä).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Piirretäänkö linjojen kirjaimet ja rivien numerot laudan ympärille (`1`) vai ei (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option`, jossa on `field` (`size`, `orientation`, `delay` tai `coords`): sallittujen ulkopuolinen arvo. `invalid_game_id`. `invalid_request`: ei kelvollista URL-koodausta.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: peliä ei ole olemassa. `gif_disabled`: palvelin on poistanut GIF-kuvat käytöstä (`GIF_ENABLED=false`; `gif`-rajoituksen token palautetaan).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `gif`-rajoitus, renderöintirajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: GIF-kuvaa ei voitu tehdä (palvelin kirjaa syyn lokiin). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`, jossa on `retryAfter` (3–10 sekuntia) ja `Retry-After`: renderöintijono on täynnä, tai GIF odotti vapaata säiettä `GIF_QUEUE_TIMEOUT_MS` (10 sekuntia); kaikki pyynnön ottamat rajoitusten tokenit palautetaan. `busy`: tietokanta pysyi lukittuna (`retryAfter: 1` vain rungossa). `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Tee animoitu GIF mistä tahansa PGN-muodossa lähetetystä pelistä
      description: |-
        Sama kuva kuin `GET /games/{id}/gif` mille tahansa PGN-tekstinä lähetetylle pelille: pelisovelluksen tallentama peli, toisen sivuston vienti tai käsin kirjoitettu peli. Tekstistä käytetään vain ensimmäistä peliä.

        - PGN-lukija hyväksyy saman kuin pelisovelluksen oma lukija: kaikki tämän palvelimen kirjoittamat PGN:t ja muiden sivustojen tavalliset viennit (kommentit, muunnelmat, NAG-merkinnät ja kellomerkinnät ohitetaan; siirtonumerot ja SAN luetaan sallivasti; `FEN`-tagi antaa alkuaseman, ellei `SetUp` ole `"0"`).
        - Nimet ja vahvuusluvut otetaan tageista `White`, `Black`, `WhiteElo` ja `BlackElo`. Aksentilliset kirjaimet menettävät aksenttinsa, muut tulostettavan ASCIIn ulkopuoliset merkit muuttuvat merkiksi `?`, ja pitkät nimet katkaistaan (48 merkkiä). Tulos otetaan `Result`-tagista, muuten siirtotekstin lopusta. `Termination`-tagi näytetään, ellei se ole `normal`: silloin loppuasema kertoo tarinan (matti, patti).
        - Tämä päätepiste tarkistaa runkonsa itse: tuntematon kenttä tai puuttuva tai muu kuin merkkijonomuotoinen `pgn` saa vastauksen 400 `invalid_request`, jossa on `field`; virheellinen asetus saa vastauksen 400 `invalid_option` (`delayMs`-arvon on oltava JSON-luku, `coords`-arvon totuusarvo; `null` hylätään).

        **Istunto vaaditaan:** kiintiöt lasketaan tilikohtaisesti. **Rajoitukset:** kuten `GET /games/{id}/gif`. **Rungon kokoraja:** 135 168 tavua riippumatta `HTTP_BODY_LIMIT`-asetuksesta (PGN JSON-merkkijonona, sen escape-merkinnät mukaan lukien). **Aikaraja:** oletuksena 45 sekuntia.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Lyhyt käsin kirjoitettu peli ilman koordinaatteja
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: PGN-tiedosto kaikilla asetuksilla
                value:
                  pgn: |
                    [White "alice"]
                    [Black "bob"]
                    [WhiteElo "1500"]
                    [BlackElo "1520"]
                    [Result "1-0"]

                    1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0
                  size: small
                  orientation: black
                  delayMs: 800
                  coords: true
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`, jossa on `field` (tuntematon kenttä tai `pgn` puuttuu tai ei ole merkkijono; ilman `field`-kenttää, kun runko ei ole objekti); `invalid_json`; `invalid_option`, jossa on `field` (`size`, `orientation`, `delayMs` tai `coords`); `invalid_pgn`, jossa on `line` ja `column` (1:stä alkaen, sarakkeet merkkeinä) sekä lukijan `message`: laiton tai moniselitteinen siirto, rikkinäinen tagi, tuntematon variantti, yli 65 536 tavua.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: palvelin on poistanut GIF-kuvat käytöstä (`GIF_ENABLED=false`; `gif`-rajoituksen token palautetaan).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: runko on yli 135 168 tavua.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `gif`-rajoitus, renderöintirajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: GIF-kuvaa ei voitu tehdä (palvelin kirjaa syyn lokiin). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`, jossa on `retryAfter` (3–10 sekuntia) ja `Retry-After`: renderöintijono on täynnä, tai GIF odotti liian kauan vapaata säiettä; kaikki pyynnön ottamat rajoitusten tokenit palautetaan. `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Hae pelaajan julkinen profiili
      description: |-
        Pelaajan julkinen profiili: vahvuusluvut virallisissa peliaikaluokissa (palvelimen järjestyksessä) ja pelimäärät. `games.total` laskee kaikki tallennetut pelit, myös pisteyttämättömät ja perutut; `games.rated`, `wins`, `draws` ja `losses` summataan vahvuuslukutietueista, joten ne kattavat vain pisteytetyt pelit. Vastaus on sama tokenin kanssa tai ilman.

        **Rajoitus:** `public_read` (pelaajakohtainen tokenin kanssa, asiakaskohtainen ilman).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Profiili.
          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`: ei 2–24 merkkiä joukosta `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: token lähetettiin, eikä se ole kelvollinen.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: pelaajaa ei ole olemassa, tai tili on poistettu.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `public_read`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (tietokanta pysyi lukittuna; `retryAfter: 1` rungossa, ei `Retry-After`-otsaketta), `server_busy` (istunnon haku löysi tietokannan lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Listaa pelaajan viimeisimmät pelit
      description: |-
        Pelaajan viimeisimmät pelit uusimmasta alkaen, sivutettuina kuten historia mutta ilman suodattimia tai kokonaismäärää. `color` on tämän pelaajan puoli. `next` on viimeisen pelin tunniste aina, kun sivu on täynnä, joten seuraava sivu voi olla tyhjä. Pelaaja haetaan ensin: tuntematon pelaaja antaa vastauksen 404 kyselystä riippumatta.

        **Rajoitus:** `public_read` (pelaajakohtainen tokenin kanssa, asiakaskohtainen ilman).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Yksi sivu pelaajan peleistä.
          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` ei ole pelin tunniste), `invalid_limit` (nämä kaksi ilman `field`-kenttää).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: token lähetettiin, eikä se ole kelvollinen.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: pelaajaa ei ole olemassa, tai tili on poistettu.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: `public_read`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (tietokanta pysyi lukittuna; `retryAfter: 1` rungossa, ei `Retry-After`-otsaketta), `server_busy` (istunnon haku löysi tietokannan lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Hae peliaikaluokan parhaat pelaajat
      description: |-
        Virallisen peliaikaluokan 100 parasta vahvuuslukutietuetta, joissa on vähintään `minGames` (`PROVISIONAL_GAMES`) laskettua peliä; poistetut tilit ja todetut huijarit jätetään pois. Palvelin laskee kunkin tulostaulun uudelleen enintään 10 sekunnin välein; `updatedAt` kertoo, milloin. Kullekin peliaikaluokalle ajetaan vain yksi laskenta kerrallaan: sillä välin muut pyynnöt saavat edellisen listan tai odottavat laskentaa, jos listaa ei vielä ole.

        **Rajoitukset:** vain osoitekohtainen kerros.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Virallisen peliaikaluokan tunniste (`3+2` tai `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Pelaajien määrä, 1–100. Suurempi enintään 3-numeroinen luku tulkitaan arvoksi 100; tyhjä arvo tulkitaan puuttuvaksi.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: Tulostaulu.
          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` (puuttuu tai ei ole virallinen peliaikaluokka), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: osoitekohtainen kerros.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (tietokanta pysyi lukittuna; `retryAfter: 1` rungossa, ei `Retry-After`-otsaketta), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Ilmoita äskeisen pelin vastustajasta
      description: |-
        Ilmoittaa vastustajasta jossakin pelaajan omista peleistä, joka päättyi viimeisten 7 päivän aikana. Ilmoitus ei koskaan itsessään muuta vahvuuslukua, seuraamusta tai luotettavuustasoa. Se nostaa moderaattorien näkemää tarkastelun prioriteettia ja pyytää pelin moottorianalyysin, paitsi kun syy on `abuse`.

        Samasta vastustajasta samasta pelistä tehty ilmoitus saa saman vastauksen eikä muuta mitään; vastaus ei koskaan kerro mitään ilmoitetusta tilistä. `GET /games/{id}` kertoo pelin pelaajille etukäteen, otettaisiinko ilmoitus vastaan (`reportable`).

        Tämä päätepiste tarkistaa runkonsa itse järjestyksessä `gameId`, `reported`, `category`, `comment`: virhe saa vastauksen 400 `invalid_request` ilman `field`-kenttää. Tuntemattomat kentät ohitetaan.

        **Rajoitukset:** `reports` (pelaajakohtainen), sitten `REPORTS_PER_DAY` (5) ilmoitusta pelaajaa kohden 24 tunnissa (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: Ilmoitus on vastaanotettu (tai se oli jo tehty).
          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` (ilman `field`-kenttää), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: ei ilmoittajan vastustaja pelissä, joka päättyi viimeisten 7 päivän aikana.'
        '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` ja `Retry-After`-otsake): `REPORTS_PER_DAY` ilmoitusta viimeisten 24 tunnin aikana. `rate_limited`: `reports`-rajoitus tai tilikohtainen budjetti.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (istunnon haku löysi tietokannan lukittuna), `timeout`. Myös `server_busy`, jonka `retryAfter` on 2–5, kun palvelin on pahasti jäljessä (katso johdanto): pyyntöä ei suoritettu.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Virallinen palvelin (sivut ovat palvelimen juuressa, polun `/api/v1` ulkopuolella).
      - url: https://{host}:{port}
        description: Mikä tahansa Scacelith-palvelin (sivut ovat palvelimen juuressa, polun `/api/v1` ulkopuolella).
        variables:
          host:
            default: caissa.scacelith.com
            description: Palvelimen julkinen isäntänimi (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Julkinen API-portti (`PUBLIC_API_PORT`, muuten `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Näytä sähköpostin vahvistussivu
      description: |-
        Sähköpostin vahvistuslinkin sivu (rekisteröityminen tai uudelleen lähetetty vahvistusviesti). Se näyttää vain painikkeen ”Confirm my e-mail address” (”Vahvista sähköpostiosoitteeni”), jotta linkin avaava sähköpostiskanneri ei käytä linkkiä loppuun; painike lähettää lomakkeen osoitteeseen `POST /verify-email`.

        **Rajoitus:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Sivu vahvistuspainikkeineen.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linkki on virheellinen tai vanhentunut (HTML-sivu).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`page`-rajoitus (HTML-sivu) tai osoitekohtainen kerros (JSON `rate_limited`).'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Vahvista sähköpostiosoite
      description: |-
        Vahvistussivun lomake. Se vahvistaa osoitteen; uuden rekisteröitymisen kohdalla se luo samalla tilin, ja pelaaja voi kirjautua sisään.

        **Rajoitus:** `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: Osoite on vahvistettu (ja uuden rekisteröitymisen tili luotu).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linkki on virheellinen, käytetty tai vanhentunut, tai lomake on virheellinen (HTML-sivu).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Uuden rekisteröitymisen linkki, jonka käyttäjänimen tai osoitteen toinen tili on sillä välin ottanut; tiliä ei luoda (HTML-sivu).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`auth`-rajoitus (HTML-sivu) tai osoitekohtainen kerros (JSON `rate_limited`).'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Tietokanta pysyi lukittuna (`Retry-After: 1`), tai palvelin on pahasti jäljessä (`Retry-After` 2–5, katso johdanto): mitään ei muutettu, ja linkki toimii yhä. Myös käsittelijän aikakatkaisu.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Virallinen palvelin (sivut ovat palvelimen juuressa, polun `/api/v1` ulkopuolella).
      - url: https://{host}:{port}
        description: Mikä tahansa Scacelith-palvelin (sivut ovat palvelimen juuressa, polun `/api/v1` ulkopuolella).
        variables:
          host:
            default: caissa.scacelith.com
            description: Palvelimen julkinen isäntänimi (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Julkinen API-portti (`PUBLIC_API_PORT`, muuten `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Näytä salasanan nollauslomake
      description: |-
        Salasanan nollauslinkin sivu: uuden salasanan lomake (salasana kahdesti). Lomake lähetetään osoitteeseen `POST /reset-password`.

        **Rajoitus:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Uuden salasanan lomake.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linkki on virheellinen (myös kun se lähetettiin osoitteeseen, joka ei enää ole tilin osoite).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`page`-rajoitus (HTML-sivu) tai osoitekohtainen kerros (JSON `rate_limited`).'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Aseta uusi salasana nollauslomakkeella
      description: |-
        Nollaussivun lomake; se tekee saman kuin `POST /auth/password/reset` yhdessä transaktiossa: kaikki laitteet kirjataan ulos, odottava sähköpostiosoitteen vaihto perutaan, muut nollauslinkit lakkaavat toimimasta, osoite katsotaan vahvistetuksi ja omistaja saa sähköpostiviestin.

        Ensin tarkistetaan linkki, sitten että molemmat salasanat ovat samat ja sitten salasanasäännöt. Kun palvelin on ruuhkautunut, lomake palaa `Retry-After`-otsakkeen kanssa ja linkki pysyy voimassa.

        **Rajoitukset:** `auth`, sitten `auth_reset` (yhteinen päätepisteen `POST /auth/password/reset` kanssa).
      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: Salasana on vaihdettu, ja kaikki laitteet on kirjattu ulos.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Lomake uudelleen virheen kanssa (salasanat eroavat, heikko salasana), linkki on virheellinen tai lomake on virheellinen (HTML-sivu).
        '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`- tai `auth_reset`-rajoitus (HTML-sivu), lomake uudelleen `Retry-After`-otsakkeen kanssa, kun tällä asiakkaalla on liian monta salasanatiivistettä jonossa (linkki pysyy voimassa), tai osoitekohtainen kerros (JSON `rate_limited`).'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Lomake uudelleen `Retry-After`-otsakkeen kanssa, kun palvelin on ruuhkautunut (salasanatiivisteiden jono, tai tietokanta pysyi lukittuna); linkki pysyy voimassa. Kun palvelin on pahasti jäljessä (katso johdanto), sen sijaan ”Server error” -sivu (palvelinvirhe) `Retry-After`-otsakkeen (2–5) kanssa: mitään ei suoritettu, ja linkki pysyy voimassa. Myös käsittelijän aikakatkaisu.'
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Virallinen palvelin (sivut ovat palvelimen juuressa, polun `/api/v1` ulkopuolella).
      - url: https://{host}:{port}
        description: Mikä tahansa Scacelith-palvelin (sivut ovat palvelimen juuressa, polun `/api/v1` ulkopuolella).
        variables:
          host:
            default: caissa.scacelith.com
            description: Palvelimen julkinen isäntänimi (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Julkinen API-portti (`PUBLIC_API_PORT`, muuten `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Näytä sähköpostiosoitteen vaihdon vahvistussivu
      description: |-
        Sähköpostiosoitteen vaihtolinkin sivu: se näyttää uuden osoitteen ja tilin nimen sekä painikkeen ”Use this e-mail address” (”Käytä tätä sähköpostiosoitetta”), joka lähettää lomakkeen osoitteeseen `POST /confirm-email-change`.

        **Rajoitus:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Sivu, jossa on uusi osoite ja sen vahvistuspainike.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linkki on virheellinen tai vanhentunut (myös kun tilin osoite on muuttunut pyynnön jälkeen).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`page`-rajoitus (HTML-sivu) tai osoitekohtainen kerros (JSON `rate_limited`).'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Vahvista uusi sähköpostiosoite
      description: |-
        Sähköpostiosoitteen vaihtosivun lomake. Osoite vaihtuu ja katsotaan vahvistetuksi; laitteet pysyvät kirjautuneina; aiemmin lähetetyt linkit (vahvistus, salasanan nollaus, muut vaihdot) lakkaavat toimimasta; entiseen osoitteeseen lähetetään tieto, jossa uusi osoite on peitetty.

        **Rajoitus:** `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: Osoite on vaihdettu.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linkki on virheellinen, käytetty tai vanhentunut, tai lomake on virheellinen (HTML-sivu).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Toinen tili otti osoitteen sillä välin (HTML-sivu).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: '`auth`-rajoitus (HTML-sivu) tai osoitekohtainen kerros (JSON `rate_limited`).'
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Tietokanta pysyi lukittuna (`Retry-After: 1`), tai palvelin on pahasti jäljessä (`Retry-After` 2–5, katso johdanto): mitään ei muutettu, ja linkki toimii yhä. Myös käsittelijän aikakatkaisu.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Virallinen palvelin, juuressa.
      - url: https://caissa.scacelith.com/api/v1
        description: Virallinen palvelin, polun `/api/v1` alla.
      - url: https://{host}:{port}
        description: Mikä tahansa Scacelith-palvelin, juuressa.
        variables:
          host:
            default: caissa.scacelith.com
            description: Palvelimen julkinen isäntänimi (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Julkinen API-portti (`PUBLIC_API_PORT`, muuten `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Mikä tahansa Scacelith-palvelin, polun `/api/v1` alla.
        variables:
          host:
            default: caissa.scacelith.com
            description: Palvelimen julkinen isäntänimi (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Julkinen API-portti (`PUBLIC_API_PORT`, muuten `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Tarkista, että prosessi on käynnissä
      description: |-
        Vastaa 200 `ok`, kun prosessi on käynnissä. Myös `HEAD` toimii. Mikä tahansa muu metodi saa vastauksen 405 `method_not_allowed` ja otsakkeen `Allow: GET, HEAD` (myös `OPTIONS`).

        **Rajoitukset:** vain osoitekohtainen kerros.
      security: []
      responses:
        '200':
          description: Prosessi on käynnissä.
          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`: osoitekohtainen kerros.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Virallinen palvelin, juuressa.
      - url: https://caissa.scacelith.com/api/v1
        description: Virallinen palvelin, polun `/api/v1` alla.
      - url: https://{host}:{port}
        description: Mikä tahansa Scacelith-palvelin, juuressa.
        variables:
          host:
            default: caissa.scacelith.com
            description: Palvelimen julkinen isäntänimi (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Julkinen API-portti (`PUBLIC_API_PORT`, muuten `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Mikä tahansa Scacelith-palvelin, polun `/api/v1` alla.
        variables:
          host:
            default: caissa.scacelith.com
            description: Palvelimen julkinen isäntänimi (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Julkinen API-portti (`PUBLIC_API_PORT`, muuten `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Tarkista, että palvelin ottaa vastaan pelaajia
      description: |-
        Vastaa 200 `ready`, kun palvelin ottaa vastaan pelaajia, ja 503 `not_ready`, kun se käynnistyy tai pysähtyy. Myös `HEAD` toimii. Mikä tahansa muu metodi saa vastauksen 405 `method_not_allowed` ja otsakkeen `Allow: GET, HEAD` (myös `OPTIONS`).

        **Rajoitukset:** vain osoitekohtainen kerros.
      security: []
      responses:
        '200':
          description: Palvelin ottaa vastaan pelaajia.
          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`: osoitekohtainen kerros.'
        '503':
          description: Palvelin käynnistyy tai pysähtyy.
          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: |-
        Istuntotoken (`sct_` ja sen perässä 43 base64url-merkkiä) kirjautumisvastauksesta `Authorization`-otsakkeessa: `Authorization: Bearer <token>`. Etuliite on tasan `Bearer` (kirjainkoolla on merkitystä) ja yksi välilyönti; arvo, jossa sitä ei ole, tai token, joka ei ole 1–512 tulostettavaa ASCII-merkkiä, saa vastauksen 401 `invalid_token` kuten mikä tahansa muu virheellinen token. Tyhjä otsake tulkitaan puuttuvaksi otsakkeeksi.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: 'Pelin tunniste: positiivinen kokonaisluku kymmenjärjestelmässä, enintään 16 numeroa ilman etunollia, alle 2^53.'
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: Istunnon tunniste sellaisena kuin `GET /auth/sessions` sen antaa, ilman etunollia kirjoitettuna (`03` ei ole istunto 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Pelaajan käyttäjänimi kirjainkoosta riippumatta. Palvelin hyväksyy minkä tahansa nimen, joka on 2–24 merkkiä joukosta `[A-Za-z0-9_.-]` (väljin sääntö, jonka se on koskaan sallinut).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Pelin tunniste; vain vanhemmat pelit listataan. Anna edellisen sivun `next`. Tyhjä arvo tulkitaan puuttuvaksi.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Sivun koko, 1–50. Suurempi enintään 3-numeroinen luku tulkitaan arvoksi 50; tyhjä arvo tulkitaan puuttuvaksi.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Sähköpostilinkin token (43 base64url-merkkiä). Puuttuva tai väärä token näyttää sivun ”link invalid or expired” (linkki virheellinen tai vanhentunut).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Jokainen palvelimen vastaus kieltää välimuistiin tallentamisen.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Sekunnit, jotka on odotettava ennen uutta yritystä; sama arvo kuin rungon `retryAfter`.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Bearer-haaste; virheellisen tokenin kohdalla mukana on `error="invalid_token"`.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: Palvelin sulkee yhteyden tämän vastauksen jälkeen.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (ja `field`, kun virhe on rungon kentässä) tai `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Kenttä rikkoo skeemaa
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Runko ei ole JSONia
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Heikko salasana
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: PGN, jota ei voi lukea
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (ei `Authorization`-otsaketta) tai `invalid_token` (token on virheellisesti muotoiltu, vanhentunut, mitätöity tai kuuluu poistetulle tilille).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Ei istuntoa
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Virheellinen token
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: Pyyntö hylätään.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Väärä salasana uudelleentodennuksessa
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Porttikiellossa oleva tili
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found` tai toiminto, jonka tämä palvelin on poistanut käytöstä.'
      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`: runko ei saapunut 10 sekunnin kuluessa. Palvelin sulkee yhteyden.'
      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: Pyyntö on ristiriidassa palvelimen tilan kanssa.
      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-kirjautumisen yritys tai lippu on tuntematon, käytetty tai vanhentunut; aloita kirjautuminen alusta.'
      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`: runko ylittää `HTTP_BODY_LIMIT`-rajan (oletuksena 16 384 tavua). Palvelin sulkee yhteyden.'
      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`: pyynnön kohde ylittää 4096 merkkiä.'
      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`: runko ei ole `application/json`, tai sen merkistö ei ole 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`: pelissä on yli `GIF_MAX_PLIES` (600) puolisiirtoa.'
      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`: ratkaise `pow`-haaste ja lähetä sama pyyntö uudelleen kentän `pow: { challenge, nonce }` kanssa. `reason` kertoo syyn: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` tai `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` (nopeusrajoitus, tilikohtainen budjetti tai salasanatiivisteiden jono) tai `too_many_attempts` (tilin hidastus), ja vastauksessa `retryAfter` sekä `Retry-After`-otsake.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rateLimited:
              summary: Nopeusrajoitus
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Liian monta epäonnistumista tällä tilillä
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: odottamaton virhe. Palvelin kirjaa sen lokiin.'
      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`: väärä `state` tai `iss`, tai Google hylkäsi koodin tai lähetti ID-tokenin, joka ei läpäise tarkistusta (mukaan lukien toisen asiakkaan, pelin tai verkkosivuston, koodi tai ID-token). Vastaus ei koskaan sisällä Googlen tekstiä.'
      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` tai `timeout`: yritä uudelleen `retryAfter`-ajan kuluttua, jos se on annettu.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Palvelin on ruuhkautunut
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: Tietokanta pysyi lukittuna (luku)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Palvelin käytti liian kauan aikaa
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: Animoitu GIF liitteenä.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` tämän palvelimen pelille, `attachment; filename="scacelith-game.gif"` päätepisteellä `POST /gif` lähetetylle PGN:lle.'
          schema:
            type: string
            pattern: '^attachment; filename="scacelith-([1-9][0-9]{0,15}|game)\.gif"$'
          example: attachment; filename="scacelith-4100000000001.gif"
        Content-Length:
          description: Tiedoston koko tavuina.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: HTML-sivu.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: HTML-sivu, joka selittää hylkäyksen.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Lomake ei saapunut 10 sekunnin kuluessa (HTML-sivu). Palvelin sulkee yhteyden.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: HTML-sivu, joka selittää ristiriidan.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Lomake ylittää `HTTP_BODY_LIMIT`-rajan (HTML-sivu). Palvelin sulkee yhteyden.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Runko ei ole lomake (`application/x-www-form-urlencoded`) eikä JSON, tai sen merkistö ei ole UTF-8 (HTML-sivu).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Nopeusrajoitus (HTML-sivu) `Retry-After`-otsakkeen kanssa.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlInternalError:
      description: Odottamaton virhe (HTML-sivu, jonka otsikko on ”Server error”). Palvelin kirjaa sen lokiin.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Palvelin on ruuhkautunut (tietokanta pysyi lukittuna, tai palvelin on pahasti jäljessä; `Retry-After`-otsakkeen kanssa) tai se käytti liian kauan aikaa (HTML-sivu, jonka otsikko on ”Server error”).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
  schemas:
    Error:
      type: object
      description: JSON-rajapinnan kaikkien virhevastausten ainoa muoto.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: '`snake_case`-muotoinen virhekoodi. Asiakas valitsee sen perusteella, mitä näyttää.'
          examples:
            - rate_limited
        message:
          type: string
          description: Englanninkielinen lause, tarkoitettu lokeihin ja varavaihtoehdoksi.
        retryAfter:
          type: integer
          minimum: 1
          description: Sekunnit, jotka on odotettava ennen uutta yritystä; mukana vain hylkäyksissä, jotka raukeavat ajan myötä. Vastauksessa on silloin myös `Retry-After`-otsake samalla arvolla, paitsi historian, pelien, pelaajien ja tulostaulun lukujen 503 `busy` -vastauksessa.
        field:
          type: string
          description: Virheellinen kenttä tai kyselyparametri (`invalid_request`, `invalid_option` sekä päätepisteen `GET /account/games` virheet `invalid_cursor`, `invalid_limit` ja `invalid_filter`). Sisäkkäinen kenttä merkitään pisteillä (`pow.nonce`).
        reason:
          type: string
          description: 'Syy: sääntö, jota `weak_password` rikkoo, tai miksi työtodistetta pyydettiin tai se hylättiin (`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: 'Vain `banned`: porttikiellon päättymishetki (Unix-aika, ms), `null` pysyvälle porttikiellolle.'
        line:
          type: integer
          minimum: 1
          description: 'Vain `invalid_pgn`: virheen rivi, 1:stä alkaen.'
        column:
          type: integer
          minimum: 1
          description: 'Vain `invalid_pgn`: virheen sarake, 1:stä alkaen, merkkeinä.'
    PowChallenge:
      type: object
      description: Työtodistehaaste (428 `pow_required` -vastauksen `pow`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: Allekirjoitettu haaste, joka lähetetään takaisin sellaisenaan.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Alkunollabittien määrä, joka arvossa `SHA-256(challenge + ":" + nonce)` on oltava.
        expiresAt:
          type: integer
          format: int64
          description: Milloin haaste vanhenee (Unix-aika, ms), 2 minuuttia sen myöntämisen jälkeen.
    PowAnswer:
      type: object
      description: Vastaus työtodistehaasteeseen.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: 428-vastauksen `challenge` sellaisenaan kuin se annettiin.
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Enintään 20 numeron desimaalimerkkijono, jolla `SHA-256(challenge + ":" + nonce)` alkaa `bits` nollabitillä.
    ServerInfo:
      type: object
      description: Mitä asiakas tarvitsee ennen kuin se kirjautuu sisään tai muodostaa yhteyden.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Palvelimen nimi (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: UUID, jonka tietokanta saa ensimmäisellä käynnistyksellään. Se pysyy samana uudelleenkäynnistyksissä. Se on `null`, kun tietokanta ei pysty antamaan sitä.
        motd:
          type: string
          description: Päivän viesti (`SERVER_MOTD`), mahdollisesti tyhjä.
        protocol:
          type: object
          description: WebSocket-protokolla (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: Vanhin protokollaversio, jota palvelin tukee.
            max:
              type: integer
              minimum: 1
              description: Uusin protokollaversio, jota palvelin tukee.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 'Protokollaskeeman sormenjälki (sen kanonisen muodon SHA-256-tiivisteen 4 ensimmäistä tavua etumerkittömänä kokonaislukuna: `0xc649140f`, 3326678031, protokollan alaversiolle 2); vain tiedoksi, sitä ei koskaan verrata.'
            subprotocol:
              type: string
              description: WebSocket-aliprotokollan token (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: WebSocket-portti, jota pelaajat käyttävät (`PUBLIC_WS_PORT`, muuten `WS_PORT`, muuten `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: WebSocketin polku.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Voiko uusia tilejä luoda.
        emailVerification:
          type: boolean
          description: Vahvistavatko uudet tilit osoitteensa (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Tarjotaanko pelille Google-kirjautumista.
            googleWeb:
              type: boolean
              description: Tarjotaanko Google-kirjautumista myös verkkosivustolle (`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: Kaksivaiheinen vahvistus on aina käytettävissä.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Rekisteröitymisen vaatimat työtodistebitit (0, jos työtodistetta ei vaadita).
        categories:
          type: array
          description: Viralliset (pisteytetyt) peliajat (`RATED_CATEGORIES`) palvelimen järjestyksessä. Mikä tahansa muu peliaika on `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Säännöt, jotka asiakas voi tarkistaa ennen lomakkeen lähettämistä.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: Käyttäjänimen vähimmäispituus (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: Käyttäjänimen enimmäispituus (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: Säännöllinen lauseke, jota uuden käyttäjänimen on vastattava.
            passwordMinLength:
              type: integer
              description: Salasanan vähimmäispituus merkkeinä (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: Salasanan enimmäispituus UTF-8-tavuina.
            customTimeControls:
              type: boolean
              description: Voivatko haasteet ja yksityiset pelit käyttää mukautettuja peliaikoja.
            reportsPerDay:
              type: integer
              description: Kuinka monta ilmoitusta pelaaja voi tehdä 24 tunnin aikana (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: Suurin WebSocket-viesti, jonka asiakas voi lähettää, tavuina.
      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: Virallinen peliaika.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 'Peliaikaluokan tunniste: minuutit ja lisäajan sekunnit (`3+2`).'
        baseSec:
          type: number
          minimum: 0
          description: Perusaika sekunteina.
        incSec:
          type: number
          minimum: 0
          description: Lisäaika siirtoa kohden sekunteina.
    AccountView:
      type: object
      description: Tili sellaisena kuin sen pelaaja näkee (kirjautumisvastausten ja `GET /account/me` -vastauksen `user`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: Tilin tunniste.
        username:
          type: string
          description: Käyttäjänimi.
        email:
          type: string
          format: email
          description: Sähköpostiosoite.
        emailVerified:
          type: boolean
          description: Onko osoite vahvistettu.
        mfaEnabled:
          type: boolean
          description: Onko kaksivaiheinen vahvistus käytössä.
        googleLinked:
          type: boolean
          description: Onko Google-tili liitetty.
        hasPassword:
          type: boolean
          description: '`false` Googlen kautta luodulle tilille, jolle ei ole vielä asetettu salasanaa.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Hyväksytäänkö suorat haasteet nimellä (katso `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Milloin tili luotiin (Unix-aika, ms).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Viimeisin kirjautuminen (Unix-aika, ms) tai `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Linkkiään odottavan sähköpostiosoitteen vaihdon uusi osoite tai `null`.
    SessionAnswer:
      type: object
      description: Uusi istunto.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Istuntotoken `Authorization`-otsaketta ja WebSocketia varten.
        expiresAt:
          type: integer
          format: int64
          description: Istunnon ehdoton päättymishetki (Unix-aika, ms); käyttämättömyysraja voi päättää sen aiemmin.
        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: Kaksivaiheista vahvistusta käyttävän kirjautumisen toinen vaihe; jatka päätepisteellä `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: Vaiheen token päätepistettä `POST /auth/login/mfa` varten.
        expiresIn:
          type: integer
          const: 300
          description: Sekunteja jäljellä vaiheen suorittamiseen.
    LoginAnswer:
      description: Istunto tai kaksivaiheista vahvistusta käyttävän kirjautumisen toinen vaihe.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Ensimmäinen Google-kirjautuminen; jatka päätepisteellä `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: Lippu päätepistettä `POST /auth/sso/complete` varten, voimassa 10 minuuttia.
        suggestedUsername:
          type: string
          description: Google-nimestä tai osoitteesta muodostettu käyttäjänimi tai `""`, jos mikään ei sovi.
    SsoNeedsPassword:
      type: object
      description: Salasanallinen tili käyttää osoitetta; jatka päätepisteellä `POST /auth/sso/google/link`. Mitään ei ole vielä liitetty.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Lippu päätepistettä `POST /auth/sso/google/link` varten.
        username:
          type: string
          description: Kyseisen tilin käyttäjänimi (sen näkee vain joku, joka on todentanut osoitteen Googlelle).
        expiresIn:
          type: integer
          const: 600
          description: Sekunnit, jotka lippu pysyy voimassa.
    SsoFinishAnswer:
      description: Päätepisteen `POST /auth/sso/google/finish` vastaus.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
        - $ref: '#/components/schemas/SsoNeedsUsername'
        - $ref: '#/components/schemas/SsoNeedsPassword'
    SsoStartAnswer:
      type: object
      description: Google-kirjautumisyritys.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Yritys päätepistettä `POST /auth/sso/google/finish` varten.
        authUrl:
          type: string
          format: uri
          description: Järjestelmän selaimessa avattava Googlen URL-osoite, kun se on tarkistettu.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state`, joka Googlen on lähetettävä takaisin.'
        expiresIn:
          type: integer
          const: 600
          description: Sekunnit, jotka yritys pysyy voimassa.
    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: Käyttäjänimi; sitten palvelimen säännöt (katso kuvaus).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Sähköpostiosoite. Alusta ja lopusta poistetaan välilyönnit, ja se tallennetaan pienillä kirjaimilla.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Salasana; sitten salasanasäännöt.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Käyttäjänimi tai sähköpostiosoite (mikä tahansa teksti, jossa on `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Valinnainen. Näytetään kirjautuneiden laitteiden luettelossa (esimerkiksi `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: Kirjautumisvastauksen (tai Google-kirjautumisen vastauksen) `mfaToken`.
        code:
          type: string
          maxLength: 32
          description: Valinnainen. 6-numeroinen todennussovelluksen koodi tai palautuskoodi.
        recoveryCode:
          type: string
          maxLength: 32
          description: Valinnainen. Palautuskoodi. Joko `code` tai `recoveryCode` tarvitaan.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Sähköpostiosoite.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Nollauslinkin `token`-parametri.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Uusi salasana; rekisteröitymisen salasanasäännöt pätevät.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Asiakkaan `codeVerifier`-arvon S256-PKCE-haaste (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: Asiakkaan kuuntelijan portti osoitteessa `127.0.0.1`.
    SsoWebStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Sivun `codeVerifier`-arvon S256-PKCE-haaste (`BASE64URL(SHA-256(codeVerifier))`).
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: Päätepisteen `POST /auth/sso/google/start` palauttama `attemptId`.
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: PKCE-verifier; sen SHA-256-tiivisteen on vastattava päätepisteelle `start` annettua haastetta.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state` sellaisena kuin Google lähetti sen takaisin.'
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: '`code` sellaisena kuin Google lähetti sen takaisin (tulostettavaa ASCIIta ilman välilyöntejä).'
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Valinnainen. `iss` sellaisena kuin Google lähetti sen takaisin, jos lähetti.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: Päätepisteen `finish` palauttama `linkTicket`, voimassa 10 minuuttia.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Tilin salasana.
        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: Päätepisteen `finish` palauttama `ssoTicket`, voimassa 10 minuuttia.
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Käyttäjänimi; rekisteröitymisen säännöt pätevät.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Aktiiviset istunnot, viimeksi käytetty ensin.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Aktiivinen istunto (kirjautunut laite).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: Istunnon tunniste päätepistettä `DELETE /auth/sessions/{id}` varten.
        createdAt:
          type: integer
          format: int64
          description: Kirjautuminen (Unix-aika, ms).
        lastSeenAt:
          type: integer
          format: int64
          description: Viimeisin käyttö (Unix-aika, ms), päivitetään enintään 5 minuutin välein (myöhemmin, kun palvelin on pahasti jäljessä).
        expiresAt:
          type: integer
          format: int64
          description: Ehdoton päättymishetki (Unix-aika, ms); käyttämättömyysraja voi päättää istunnon aiemmin.
        clientLabel:
          type:
            - string
            - 'null'
          description: Kirjautumisen `clientLabel` tai `null`, jos sitä ei lähetetty.
        current:
          type: boolean
          description: Onko tämä pyynnön tekevä istunto.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Yksi tietue kutakin peliaikaluokkaa kohden, jossa pelaaja on pelannut pisteytettyjä pelejä.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Voimassa olevat seuraamukset.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` porttikiellon aikana, muuten `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Yhden peliaikaluokan vahvuuslukutietue.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: Peliaikaluokan tunniste.
        rating:
          type: integer
          description: Vahvuusluku.
        games:
          type: integer
          minimum: 0
          description: Peliaikaluokassa pelatut pelit (lasketut ja laskemattomat).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Korkein saavutettu vahvuusluku.
        provisional:
          type: boolean
          description: '`true`, kun vahvuusluku on vielä luokittelemattomassa vaiheessa tai siinä on alle `PROVISIONAL_GAMES` laskettua peliä (peli näyttää sen muodossa `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Seuraamuksen laji.
        reason:
          type:
            - string
            - 'null'
          description: Annettu syy, jos sellainen on.
        startsAt:
          type: integer
          format: int64
          description: Alkuhetki (Unix-aika, ms).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Päättymishetki (Unix-aika, ms), `null`, kun seuraamus on pysyvä.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Porttikiellon päättymishetki (Unix-aika, ms), `null`, kun porttikielto on pysyvä.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` hyväksyy suorat haasteet nimellä, `none` hylkää ne.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Nykyinen salasana.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Uusi salasana; rekisteröitymisen salasanasäännöt pätevät.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Tilin salasana.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Odottavan salaisen avaimen koodi, tasan 6 numeroa.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Tilin salasana.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Valinnainen; tarvitaan, kun kaksivaiheinen vahvistus on käytössä (tai `recoveryCode`). Todennussovelluksen koodi tai palautuskoodi.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Valinnainen. Palautuskoodi (`xxxx-xxxx-xx`; kirjainkoolla, välilyönneillä ja yhdysmerkeillä ei ole merkitystä).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Tilin salasana.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Todennussovelluksen koodi (palautuskoodi hylätään).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Uusi osoite; alusta ja lopusta poistetaan välilyönnit ja se muutetaan pieniksi kirjaimiksi, minkä jälkeen se tarkistetaan kuten rekisteröitymisosoite.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Tilin salasana.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Valinnainen; tarvitaan, kun kaksivaiheinen vahvistus on käytössä (tai `recoveryCode`). Todennussovelluksen koodi tai palautuskoodi.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Valinnainen. Palautuskoodi.
    TotpSetup:
      type: object
      description: Odottava salainen avain todennussovellusta varten.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Salainen avain base32-muodossa, sovellukseen kirjoitettavaksi.
        uri:
          type: string
          format: uri
          description: '`otpauth://totp/...`-URI, joka näytetään QR-koodina.'
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Sekunteja jaksoa kohden.
    RecoveryCodeList:
      type: array
      description: 10 palautuskoodia, jotka näytetään vain tämän kerran. Kukin toimii kerran.
      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: Yksi pelin osapuolista.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Nimi pelitietueessa (poistetulla tilillä `deleted#<id>`).
        rating:
          type:
            - integer
            - 'null'
          description: Vahvuusluku alussa, `null`, jos tuntematon.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Vahvuusluku pelin jälkeen. Pisteytetyssä pelissä se on aina; `null` vain pelille, joka ei vaikuta vahvuuslukuihin (pisteyttämätön, mukautettu, peruttu).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: Vahvuusluvun muutos, `0`, kun vahvuuslukusäännöt jättävät vahvuusluvun ennalleen (FIDEn nollatulos, luokittelematon vastustaja, luokittelemattoman pelaajan ensimmäiset pelit); `null` samoin kuin `ratingAfter`.
    GameSummary:
      type: object
      description: Tallennetun pelin yhteenveto.
      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: Pelin tunniste.
        category:
          type: string
          description: Virallisen peliaikaluokan tunniste (`3+2`) tai `custom`.
        rated:
          type: boolean
          description: Vaikuttaako peli vahvuuslukuihin.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Peliaika sekunteina (`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` (katso pelikoodit).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Päättymissyyn koodi (katso pelikoodit).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Tulos, perutulle pelille `*`.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Puolisiirtojen määrä.
        startedAt:
          type: integer
          format: int64
          description: Alkuhetki (Unix-aika, ms).
        endedAt:
          type: integer
          format: int64
          description: Päättymishetki (Unix-aika, ms).
    Termination:
      type: string
      description: Päättymissyyn nimi (katso pelikoodit); `Unknown` koodille, jota protokolla ei tunne.
      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: Pelin yhteenveto yhden pelaajan näkökulmasta.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Pelaajan puoli.
    HistoryGameSummary:
      description: Peli kirjautuneen pelaajan historiasta.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Perusaika millisekunteina.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Lisäaika millisekunteina.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: Lopputulos pelaajan näkökulmasta.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Sivun pelit uusimmasta alkaen.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Tunniste, joka annetaan parametrina `before` seuraavaa sivua varten, tai viimeisellä sivulla `null`.
        total:
          type: integer
          minimum: 0
          description: Suodattimeen täsmäävien pelien määrä kaikilla sivuilla yhteensä.
    MoveRecord:
      type: object
      description: Pelin yksi puolisiirto.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: Siirto UCI-notaatiolla; korotuksessa loppuun lisätään `n`, `b`, `r` tai `q`.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Siirrolle laskettu aika (ms), `null`, jos tietueessa ei ole sitä.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Siirron tekijän kello siirron jälkeen (ms), `null`, jos tietueessa ei ole sitä.
    PgnTagsView:
      type: object
      description: Tärkeimmät PGN-tagit näyttämistä varten. `Termination` on tässä päättymissyyn nimi; PGN-tiedosto käyttää PGN-standardin arvoja.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` tai `<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: Aloituspäivä UTC-aikana.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: Vahvuusluku alussa tai `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Vahvuusluku alussa tai `"-"`.
          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: Pelitietue siirtoineen ja kelloineen. Siinä on pelin yhteenvedon kentät ilman kenttiä `color` ja `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Perusaika millisekunteina.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Lisäaika millisekunteina.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: '`status`-arvon nimi.'
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: Sen pelin tunniste, jonka uusintapeli tämä on, tai `null`.
            moves:
              type: array
              description: Yksi merkintä puolisiirtoa kohden.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Vain kun tokenin pelaaja pelasi tämän pelin; hänen puolensa.
            reportable:
              type: boolean
              description: Vain kun tokenin pelaaja pelasi tämän pelin; `true`, kun `POST /reports` ottaisi nyt vastaan ilmoituksen vastustajasta tästä pelistä (peli päättyi 7 päivän sisällä, päiväkiintiötä ei ole käytetty loppuun, eikä vastustajasta ole jo ilmoitettu tästä pelistä).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Käyttäjänimi sellaisena kuin pelaaja sen kirjoitti.
        createdAt:
          type: integer
          format: int64
          description: Milloin tili luotiin (Unix-aika, ms).
        ratings:
          type: array
          description: Vain viralliset peliaikaluokat palvelimen järjestyksessä.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Kaikki tallennetut pelit, myös pisteyttämättömät ja perutut.
            rated:
              type: integer
              minimum: 0
              description: Pisteytetyt pelit vahvuuslukutietueista summattuina.
            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: Käyttäjänimi sellaisena kuin pelaaja sen kirjoitti.
        games:
          type: array
          description: Sivun pelit uusimmasta alkaen.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Viimeisen pelin tunniste aina, kun sivu on täynnä (seuraava sivu voi silloin olla tyhjä), muuten `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: Peliaikaluokan tunniste.
        minGames:
          type: integer
          minimum: 0
          description: Laskettujen pelien määrä, joka tietueella on oltava, jotta se listataan (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Milloin tulostaulu laskettiin (Unix-aika, ms).
        players:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LeaderboardEntry'
    LeaderboardEntry:
      type: object
      required:
        - rank
        - username
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
      properties:
        rank:
          type: integer
          minimum: 1
        username:
          type: string
        rating:
          type: integer
        games:
          type: integer
          minimum: 0
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
    GifRequest:
      type: object
      additionalProperties: false
      required:
        - pgn
      properties:
        pgn:
          type: string
          description: PGN-teksti, enintään 65 536 UTF-8-tavua. Vain ensimmäistä peliä käytetään.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Valinnainen. Kuvan koko.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Valinnainen. Puoli, joka näytetään laudan alalaidassa.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Valinnainen. Millisekunteja siirtoa kohden; JSON-luku, jonka arvo on kokonaisluku.
        coords:
          type: boolean
          default: true
          description: Valinnainen. Piirretäänkö linjojen kirjaimet ja rivien numerot laudan ympärille.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: Peli kokonaislukuna tai 1–16 numeron merkkijonona.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: Vastustajan käyttäjänimi sellaisena kuin se on pelitietueessa tai nyt, kirjainkoosta riippumatta. Se ei saa olla tyhjä.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: Mitä ilmoitus koskee.
        comment:
          type:
            - string
            - 'null'
          description: Valinnainen. Enintään 500 merkkiä sen jälkeen, kun ohjausmerkit (sarkainta ja rivinvaihtoa lukuun ottamatta) on poistettu ja tekstin alusta ja lopusta on poistettu välilyönnit.
    AccountExport:
      type: object
      description: Kaikki, mitä palvelin säilyttää tilistä (ajat Unix-aikana, ms).
      required:
        - format
        - version
        - exportedAt
        - server
        - notes
        - account
        - ratings
        - ratingRefunds
        - sessions
        - securityEvents
        - sanctions
        - conduct
        - reportsFiled
        - games
      properties:
        format:
          type: string
          const: scacelith-account-export
        version:
          type: integer
          const: 1
        exportedAt:
          type: integer
          format: int64
          description: Milloin vienti tehtiin.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Mitä tiedosto sisältää ja mitä se jättää pois, selkeällä englannin kielellä pelaajalle.
          items:
            type: string
        account:
          description: Päätepisteen `GET /account/me` tilinäkymä sekä `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: Liitetyn Google-tilin osoite tai `null`.
        ratings:
          type: array
          description: Täydelliset vahvuuslukutietueet.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Vahvuuslukupisteet, jotka palautettiin, kun vastustajan todettiin huijanneen, laskettuina yhteen UTC-päivittäin ja peliaikaluokittain, uusimmasta alkaen. Pelejä tai huijareita ei nimetä.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Kaikki tallennetut istunnot uusimmasta alkaen, ilman tokeneita. Säilytysajan siivous poistaa vanhentuneen istunnon ja uloskirjatun istunnon päivä uloskirjautumisen jälkeen.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Tietoturvatapahtumat uusimmasta alkaen; säilytetään `RETENTION_SECURITY_DAYS` päivää.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Kaikki seuraamukset, myös kumotut. Moderaattorin nimeä ei koskaan sisällytetä.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Pelaajan kesken jätetyistä, perutuista ja ilman ensimmäistä siirtoa jääneistä peleistä kirjatut käytöstapahtumat; säilytetään 30 päivää.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Pelaajan tekemät ilmoitukset.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Pelien määrä.
            list:
              type: array
              description: Kaikki pelit uusimmasta alkaen päätepisteen `GET /account/games` yhteenvetoina. Siirrot saa päätepisteestä `GET /games/{id}` ja PGN:n päätepisteestä `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Täydellinen vahvuuslukutietue.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Onko pelaaja jo ohittanut luokittelemattoman vaiheen.
            countedGames:
              type: integer
              minimum: 0
              description: Vahvuuslukuun lasketut pelit.
            updatedAt:
              type: integer
              format: int64
              description: Milloin tietue viimeksi muuttui.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: Päivän kello 00.00 UTC (Unix-aika, ms).
        category:
          type: string
        points:
          type: integer
          description: Sinä päivänä kyseisessä peliaikaluokassa palautetut pisteet.
    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: Milloin istunto kirjattiin ulos, tai `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: Kirjautumisen osoite; pyyhitään `RETENTION_IP_DAYS` päivän jälkeen.
    SecurityEvent:
      type: object
      description: |-
        Tietoturvatapahtuma. `ip` annetaan vain toimista, jotka tehtiin kirjautuneena, tilin salasanalla (ja toisella tekijällä) tai tilin osoitteeseen lähetetyllä linkillä: `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` ja `account_exported`; kaikilla muilla lajeilla on `ip: null`, ja `ip` pyyhitään `RETENTION_IP_DAYS` päivän jälkeen.

        `detail` säilyttää vain nämä kentät: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` ja `method` (`password` tai `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` ja `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. `moderator_action`-tapahtuma säilyttää vain kentän `{ action }` toiminnoille `ban`, `unban`, `reset_mfa`, `verify_email` ja `revoke_sessions` (muut moderaattoritoiminnot jätetään pois). Kaikilla muilla lajeilla on `detail: null`. `rating_refund`-tapahtumat jätetään pois: niiden pisteet ovat kentässä `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Tapahtuman laji (`login`, `password_changed`...).
        at:
          type: integer
          format: int64
          description: Milloin se tapahtui.
        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: Ilmoitetun pelaajan nykyinen julkinen nimi.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Onko ilmoitus yhä avoin (sitä, saiko ilmoitettu pelaaja seuraamuksen, ei kerrota).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Sähköpostilinkin token (sivun lomakkeen piilokenttä).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Nollauslinkin token (lomakkeen piilokenttä).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Uusi salasana; rekisteröitymisen salasanasäännöt pätevät.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Uusi salasana uudelleen; sen on oltava sama.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: UTF-8-muotoinen HTML-sivu (`text/html; charset=utf-8`) ilman JavaScriptiä tai ulkoisia resursseja.
