openapi: 3.1.1
info:
  title: HTTPS-API van de Scacelith-server
  version: "1.1.0"
  summary: 'De HTTPS-API van elke Scacelith-server: de officiële en de communityservers.'
  description: |-
    Elke Scacelith-server, de officiële `caissa.scacelith.com` en elke communityserver, biedt deze HTTPS-API aan. Het spel gebruikt die voor alles buiten de partij zelf: registratie en inloggen (inclusief tweestapsverificatie en Google), de accountpagina, de partijgeschiedenis, PGN-downloads en geanimeerde GIF's van partijen, ingelogde apparaten, het downloaden van de gegevens, het verwijderen van het account, en meldingen.

    Het live spelen (matchmaking, uitdagingen, zetten, klokken) loopt via de WebSocket van dezelfde server (`wss://<host>/ws`), geopend met een sessietoken van deze API; dat wordt beschreven in `PROTOCOL.md`, niet hier.

    De standaardwaarden hieronder zijn die van een server met een ongewijzigde configuratie; een communityserver kan ze wijzigen (`CONFIG.md` noemt elke instelling).

    ## Basis-URL, poort en transport

    - Officiële server: `https://caissa.scacelith.com/api/v1`, TCP-poort 443.
    - Een communityserver: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` is standaard 443; achter een NAT of een proxy is de poort die spelers gebruiken `PUBLIC_API_PORT`.
    - De WebSocket van het spel staat standaard op dezelfde poort (`WS_PORT` verplaatst hem; `GET /info` vertelt de client waar hij staat).
    - HTTP/1.1 over TLS. Met `TLS_MODE=proxy` termineert een reverse proxy vóór de server de TLS-verbinding. `TLS_MODE=off` (gewone HTTP) is alleen bedoeld voor lokale ontwikkeling.
    - De HTML-pagina's die via de links in e-mails worden geopend (`/verify-email`, `/reset-password`, `/confirm-email-change`), staan in de root van de server, buiten `/api/v1`. De endpoints voor statuscontrole antwoorden zowel in de root als onder `/api/v1`. Inloggen met Google heeft geen pagina op de server: Google stuurt de browser terug naar het spel zelf, op `127.0.0.1`, of naar een pagina van de website die het inloggen is begonnen (`GOOGLE_WEB_REDIRECT_URI`).
    - Een slash aan het eind wordt genegeerd (`/api/v1/info/` is `/api/v1/info`), en padparameters worden URL-gedecodeerd (een parameter die geen geldige URL-codering is, levert 400 `invalid_request` op).

    ## Verzoeken

    - **JSON-body's.** `POST`-, `PUT`- en `DELETE`-verzoeken bevatten een JSON-object met `Content-Type: application/json`. Een andere `charset` dan UTF-8 wordt geweigerd, net als elk ander inhoudstype (415 `unsupported_media_type`). Een lege body telt als `{}`, en dat is wat de endpoints zonder parameters verwachten (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Grootte en tijd.** Een body is beperkt tot `HTTP_BODY_LIMIT` bytes (standaard 16.384; `POST /gif` heeft een eigen limiet van 135.168 bytes): 413 `payload_too_large`. Hij moet binnen 10 seconden aankomen: 408 `request_timeout`. Na elk van beide fouten sluit de server de verbinding.
    - **Strikte schema's.** Een veld dat het endpoint niet kent, wordt geweigerd, een veld dat niet als optioneel is gemarkeerd, is verplicht, en typen en lengtes worden gecontroleerd. Elk van deze fouten levert 400 `invalid_request` op, met `field` dat het veld noemt (met punten voor een genest veld, zoals `pow.nonce`). Strings mogen geen stuurtekens bevatten. Lengtes worden geteld in UTF-16-code-eenheden, dus een teken buiten het Basic Multilingual Plane (een emoji) telt dubbel. Een body die geen JSON is, levert 400 `invalid_json` op. `POST /gif` en `POST /reports` controleren hun body zelf (zie het betreffende endpoint).
    - **Querystrings.** Het eerste voorkomen van een parameter telt, onbekende parameters worden genegeerd, en een `+` wordt gedecodeerd als spatie: de endpoints die een speeltempocategorie aannemen, accepteren zowel `3%2B2` als `3+2`.
    - **Methoden.** `HEAD` is `GET` zonder de body. `OPTIONS` op een bestaand pad krijgt 204 als antwoord met een `Allow`-header (en de headers van een CORS-preflight, zie hieronder). Elke andere methode die het pad niet heeft, levert 405 `method_not_allowed` op met `Allow`.
    - **Verzoekdoel.** Dit mag niet langer zijn dan 4096 tekens (414 `uri_too_long`). Een verzoek dat helemaal niet kan worden geparseerd, of waarvan het kopgedeelte te groot is of te langzaam aankomt, krijgt een leeg antwoord 400, 431 of 408 en de verbinding wordt gesloten.
    - **CORS: een allowlist, standaard uit.** De API bedient het spel, dat geen `Origin` stuurt. Met een lege `CORS_ORIGINS` (de standaard) wordt nooit een `Access-Control-*`-header verstuurd, dus kan geen enkele webpagina een antwoord lezen. `CORS_ORIGINS` somt de web-origins op waarvan de pagina's de API vanuit een browser mogen aanroepen, elk precies zoals browsers die in `Origin` sturen: `https://host` of `https://host:port`, in kleine letters, zonder pad, slash aan het eind of standaardpoort; `http://` alleen voor `localhost`, `127.0.0.1` en `[::1]` (lokale ontwikkeling); geen wildcard. De officiële server stelt `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com` in voor zijn website. Dan geldt:
      - Elk antwoord voor een pad onder `/api/v1`, behalve de endpoints voor statuscontrole, bij succes en bij fouten (inclusief de 429 van de laag per adres), bevat `Vary: Origin`. Wanneer de `Origin` van het verzoek byte voor byte een van de opgesomde origins is, bevat het ook `Access-Control-Allow-Origin: <that origin>` en, behalve in het antwoord op een preflight, `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, zodat de pagina de foutcodes, `Retry-After` en de bestandsnamen van de PGN- en GIF-downloads kan lezen.
      - Een preflight (`OPTIONS` met `Access-Control-Request-Method`) van een opgesomde origin op een bestaand pad krijgt 204 als antwoord, zonder body, met `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (de methoden van `Allow`), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600` en `Vary: Origin`. Hij heeft geen token nodig en telt niet mee voor een accountbudget of een endpointlimiet; zoals elk verzoek neemt hij een token van de laag per adres. Een browser hergebruikt een preflight 10 minuten lang, maar alleen voor precies die URL, inclusief querystring. Een verzoek zonder `Authorization` en zonder JSON-body, zoals `GET /info` of `GET /leaderboard`, heeft geen preflight nodig.
      - Elk ander antwoord op een preflight is een mislukte preflight, ook met de CORS-headers: een 404 voor een pad dat niet bestaat, of de 429 van de laag per adres. De browser verstuurt het verzoek dan niet, en de pagina ziet een netwerkfout, zonder het antwoord of de `Retry-After` ervan.
      - `Access-Control-Allow-Credentials` wordt nooit verstuurd, en `Access-Control-Allow-Origin: *` evenmin: een pagina stuurt haar sessietoken in `Authorization: Bearer`, zoals het spel doet, en de API zet geen cookie. De HTML-pagina's, de endpoints voor statuscontrole en de lege antwoorden op onleesbare verzoeken bevatten geen CORS-header. De WebSocket gebruikt geen CORS: `WS_ALLOWED_ORIGINS` somt de pagina's op die hem mogen openen.
      - Een pagina van een origin die niet is opgesomd, leest niets, en haar cross-site schrijfverzoeken worden nooit verstuurd: JSON-body's en `Authorization` vereisen een preflight, waarvan het antwoord geen `Access-Control-Allow-Origin` voor die origin bevat. `Cross-Origin-Resource-Policy: same-origin` blijft op elk antwoord staan: die header geldt alleen voor `no-cors`-verzoeken (een andere site die een antwoord insluit), nooit voor de `cors`-verzoeken van een opgesomde pagina.

    ## Antwoorden en fouten

    - Antwoorden zijn JSON in UTF-8, behalve de PGN-download (`application/x-chess-pgn`), de geanimeerde GIF's (`image/gif`) en de HTML-pagina's. Tijden zijn milliseconden sinds 1970-01-01 UTC. Id's zijn gehele getallen. Partij-id's hebben maximaal 16 cijfers maar blijven onder 2^53, zodat een JSON-getal (een double) ze exact kan bevatten.
    - Elk antwoord van de API bevat `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` en `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (de HTML-pagina's hebben een eigen beleid). Met `TLS_MODE=native` bevat het ook `Strict-Transport-Security: max-age=31536000`. De antwoorden die de listener zelf schrijft, vóór de API (de `GET` en `HEAD` van de endpoints voor statuscontrole, de 429 van de laag per adres, en een 500 `internal_error` wanneer de API volledig faalt), bevatten dezelfde headers behalve `Cross-Origin-Resource-Policy`, en hun `Content-Type` is `application/json` zonder charset.
    - Een antwoord moet worden gelezen binnen 60 seconden na het moment waarop de server het klaar heeft, wat alleen uitmaakt voor de grote antwoorden (een GIF, de gegevensexport, een lange PGN). De tijd die de server nodig heeft om een antwoord voor te bereiden, telt niet mee voor deze termijn en ook niet voor de 30 seconden zonder inkomende of uitgaande byte waarna een verbinding wordt gesloten.

    Fouten hebben één vorm (het schema `Error`):

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

    `message` is bedoeld voor logs en als terugvaloptie; een client kiest op basis van `error` wat hij toont. `retryAfter` (seconden) staat alleen bij weigeringen die na verloop van tijd aflopen, en het antwoord bevat dan ook een `Retry-After`-header met dezelfde waarde; de enige uitzondering is de 503 `busy` bij het lezen van de geschiedenis, partijen, spelers en de ranglijst, die alleen het veld heeft. Sommige fouten voegen velden toe: `field` (ongeldige invoer), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` en `column` (`invalid_pgn`).

    Fouten die elk endpoint kan geven:

    | Status | `error` | Wanneer |
    |---|---|---|
    | 400 | `invalid_request` | De body overtreedt het schema van het endpoint (`field` zegt waar), het verzoekdoel of `Content-Length` is misvormd, een padparameter is geen geldige URL-codering, of de body is afgebroken. |
    | 400 | `invalid_json` | De body is geen JSON. |
    | 401 | `unauthorized` | Geen `Authorization`-header bij een endpoint dat een sessie vereist. |
    | 401 | `invalid_token` | Het token is misvormd, verlopen of ingetrokken, of hoort bij een verwijderd account; ook bij endpoints waar de sessie optioneel is. |
    | 404 | `not_found` | Dit endpoint bestaat niet. Sommige endpoints gebruiken het ook: die partij, speler of sessie bestaat niet. |
    | 405 | `method_not_allowed` | Het pad bestaat voor andere methoden (zie `Allow`). |
    | 408 | `request_timeout` | De body is niet binnen 10 seconden aangekomen. |
    | 413 | `payload_too_large` | De body overschrijdt `HTTP_BODY_LIMIT` (135.168 bytes voor `POST /gif`). |
    | 414 | `uri_too_long` | Het verzoekdoel is langer dan 4096 tekens. |
    | 415 | `unsupported_media_type` | De body is geen `application/json`, of de charset ervan is niet UTF-8. |
    | 429 | `rate_limited` | Een rate limit (zie hieronder): `retryAfter` plus een `Retry-After`-header. |
    | 500 | `internal_error` | Een onverwachte fout. De server logt die. |
    | 503 | `timeout` | De server heeft niet binnen 30 seconden geantwoord (60 seconden voor de export, 45 seconden voor de GIF's met de standaardinstellingen). |
    | 503 | `server_busy` | Bij het opzoeken van een sessie of een accountwijziging was de database vergrendeld (`retryAfter` 1), of de wachtrij voor wachtwoordhashes is vol (zie hieronder). Ook, met een `retryAfter` van 2 tot 5, wanneer de server ver achterloopt: bij een `POST`, `PUT`, `PATCH` of `DELETE` heeft de database te veel wachtende schrijfbewerkingen; bij elk verzoek zijn te veel eerdere verzoeken na hun time-out nog bezig, of kon het sessietoken niet binnen 5 seconden worden gecontroleerd. Het verzoek is niet uitgevoerd, en de rate limits ervan hebben niets afgenomen. |

    De lees-endpoints (partijgeschiedenis, partijen, spelers, ranglijst) en de export geven 503 `busy` met `retryAfter: 1` wanneer de database vergrendeld bleef.

    Endpoints die een wachtwoord controleren of hashen, kunnen ook een van deze antwoorden geven, beide met een willekeurige `retryAfter` van 5 tot 15 seconden:

    - 503 `server_busy`: de wachtrij voor wachtwoordhashes van de server (`PASSWORD_HASH_QUEUE_MAX`) is vol, of de wachttijd is verstreken.
    - 429 `rate_limited`: de wachtrij is minstens half vol en deze client (een IPv4-adres of een IPv6-/48-netwerk) heeft al `PASSWORD_HASH_WAITERS_PER_SOURCE` wachtende hashes. Deze 429 geeft de rate-limit-tokens terug die het verzoek had genomen.

    Er is niets gewijzigd en er is geen mislukte poging geteld (behalve bij `POST /auth/sso/google/link`, waarvan de ticketpoging en de mislukking van het account al vóór het hashen waren afgeboekt), en een resetlink blijft geldig.

    De HTML-pagina's geven hun fouten als HTML-pagina's met dezelfde statuscodes.

    ## Authenticatie

    De endpoints die een sessie vereisen, nemen een bearer-token aan in de `Authorization`-header (het schema `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Een token krijgen.** Een token (`sct_` gevolgd door 43 base64url-tekens) komt van `POST /auth/login`, daarna `POST /auth/login/mfa` wanneer tweestapsverificatie aanstaat, van `POST /auth/sso/google/finish` en `POST /auth/sso/google/link` (inloggen met Google), en van `POST /auth/sso/complete`. Elk daarvan antwoordt met `{ token, expiresAt, user }`. De server bewaart alleen een SHA-256 van het token.
    - **Verplichte sessie.** Een ontbrekende header levert 401 `unauthorized` op met `WWW-Authenticate: Bearer realm="scacelith"`. Een ongeldig token levert 401 `invalid_token` op met `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Een client hoort een token dat `invalid_token` krijgt te vergeten en opnieuw in te loggen.
    - **Optionele sessie.** Bij `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` en `GET /players/{username}/games` is de sessie optioneel. Zonder de header geven ze de openbare weergave; als er een header wordt verstuurd, moet het token ervan geldig zijn.
    - **Bezette opslag.** Wanneer de database vergrendeld blijft terwijl een token wordt gecontroleerd, antwoordt een endpoint dat een sessie vereist of accepteert met 503 `server_busy` met `retryAfter: 1` (en `Retry-After`); wanneer de controle langer dan 5 seconden duurt, antwoordt het met 503 `server_busy` met een `retryAfter` van 2 tot 5. In beide gevallen is er over het token geen oordeel geveld: bewaar het en verstuur het verzoek opnieuw.
    - **Levensduur.** Een sessie eindigt bij het eerste van beide: `SESSION_MAX_DAYS` (90) na het inloggen (dit is `expiresAt`) en `SESSION_IDLE_DAYS` (30) zonder gebruik. Elk gebruik schuift de inactiviteitslimiet op; de server schrijft de nieuwe waarde hoogstens eens per 5 minuten weg, op de achtergrond. Zolang de database veel wachtende schrijfbewerkingen heeft, stelt de server dat wegschrijven uit tot een later verzoek, behalve in het laatste uur vóór de inactiviteitslimiet, zodat een sessie die in gebruik is niet verloopt. Een account houdt hoogstens `MAX_SESSIONS_PER_USER` (10) sessies: opnieuw inloggen trekt de oudste sessies boven dat aantal in.
    - **Intrekking.** Uitloggen (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) trekt sessies in; dat doen ook een wachtwoordwijziging (de andere sessies), een wachtwoordreset en het verwijderen van het account (alle sessies), en een beheerder. Een intrekking via de API gaat meteen in, en de WebSocket die met een ingetrokken sessie is geopend, wordt gesloten. Een wachtwoordwijziging of -reset en het verwijderen van het account trekken sessies in binnen dezelfde transactie als de wijziging zelf: als een daarvan mislukt, is er geen sessie ingetrokken en is er ook niets anders gewijzigd. De server cachet het opzoeken van sessies 30 seconden, dus een intrekking door het beheercommando, een apart proces, gaat binnen 30 seconden in.
    - **Bereik.** Een token hoort bij één server en opent ook de WebSocket daarvan. Stuur het nooit naar een andere server.

    ## Rate limits en andere beperkingen

    Limieten gelden voor een van twee bereiken. **Client:** een IPv4-adres, of een IPv6-/64-netwerk (in proxymodus komt het adres uit `X-Forwarded-For`, verstuurd door een `TRUSTED_PROXIES`-adres); sommige limieten tellen daarnaast elk IPv6-/48-netwerk als geheel, bovenop elk van zijn /64-netwerken. **Speler:** het ingelogde account, ongeacht het adres; een limiet die per speler telt op een endpoint waar de sessie optioneel is, telt per client voor een verzoek zonder token.

    Elke limiet is een token bucket die `limit` verzoeken bevat en continu wordt bijgevuld met `limit / window`; `retryAfter` is de tijd tot het volgende token. Limieten die als *gedeeld* zijn gemarkeerd, worden daarnaast geteld over een schuivend venster van dezelfde lengte, zodat geen enkel venster veel meer dan `limit` verzoeken bevat. Elke limiet wordt voor de hele server geteld. Wanneer een van de limieten van een endpoint een verzoek weigert, worden de tokens die de andere limieten voor dat verzoek hadden genomen, teruggegeven. Een weigering levert 429 `rate_limited` op met `retryAfter` en `Retry-After`.

    - **Laag per adres.** Elk verzoek (elk pad en elke methode, inclusief de endpoints voor statuscontrole en WebSocket-upgrades) neemt eerst één token uit het budget van zijn client: `HTTP_RATE_PER_IP` (600) per minuut met een burst van een halve minuut, en `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) voor een IPv6-/48-netwerk. Een client mag daarnaast hoogstens `IP_MAX_INFLIGHT` (32 x `WORKERS`) verzoeken tegelijk in behandeling hebben (daarboven `retryAfter` 1). Een client die na zijn weigeringen doorgaat, wordt geblokkeerd: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) weigeringen in één minuut blokkeren hem 1 minuut, daarna 4, 16 en 60 minuten bij elke nieuwe blokkade binnen 6 uur. Een weigering door de limieten `auth`, `auth_*` en `reauth` telt 5 keer; limieten die per speler tellen, tellen hier nooit mee. Adressen in `ABUSE_EXEMPT` slaan deze laag over, maar niet het accountbudget of de endpointlimieten.
    - **Accountbudget.** Elk verzoek met een geldig sessietoken telt ook mee voor zijn account: `USER_RATE_PER_MIN` (120) per minuut voor alle endpoints samen, ongeacht het adres, met een burst van een halve minuut.
    - **Endpointlimieten:**

    | Limiet | Standaard | Geteld per | Endpoints |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, gedeeld | client, en `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) per IPv6-/48-netwerk | `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) / uur, gedeeld | client, 3 keer zoveel per IPv6-/48-netwerk | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / uur, gedeeld | client, 3 keer zoveel per IPv6-/48-netwerk | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / uur, gedeeld | client, 3 keer zoveel per IPv6-/48-netwerk | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 uur, gedeeld | client, 3 keer zoveel per IPv6-/48-netwerk | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / uur, gedeeld | client, 3 keer zoveel per IPv6-/48-netwerk | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | de getallen van `auth`, een eigen bucket, gedeeld | client en IPv6-/48-netwerk | `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, gedeeld | speler | dezelfde endpoints als `reauth` |
    | `account` | 60 / min | speler | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | speler | `GET /account/games` |
    | `account_export` | 5 / uur, gedeeld | speler | `POST /account/export` (gecontroleerd vóór `reauth`; elke poging telt) |
    | `sessions` | 60 / min | speler | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | speler (client zonder token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (één bucket voor alle vier) |
    | `gif` | 30 / min | speler | `GET /games/{id}/gif`, `POST /gif` (één bucket voor beide) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min en `GIF_USER_RENDERS_PER_HOUR` (30) / uur, gedeeld | speler | dezelfde twee, alleen wanneer de GIF moet worden gemaakt (niet uit de cache) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min en `GIF_IP_RENDERS_PER_HOUR` (120) / uur, gedeeld | client (al zijn accounts samen), 3 keer zoveel per IPv6-/48-netwerk | dezelfde, zoals hierboven |
    | `reports` | 30 / uur | speler | `POST /reports` |
    | `sso_start` | 30 / 10 min, gedeeld | client, en 90 per IPv6-/48-netwerk | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (samen) |
    | `sso_finish` | 30 / min | client | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | client | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` en `GET /leaderboard` hebben geen eigen limiet: alleen de laag per adres. Een endpoint met meerdere limieten controleert ze in de volgorde die in zijn beschrijving staat.

    De server behandelt een verzoek in deze volgorde: de laag per adres, daarna de endpoints voor statuscontrole; het bepalen van het endpoint; de controles dat de server niet ver achterloopt (503 `server_busy`): te veel eerdere verzoeken die na hun time-out nog bezig zijn en, bij een `POST`, `PUT`, `PATCH` of `DELETE`, de achterstand aan schrijfbewerkingen van de database; de authenticatie, binnen 5 seconden (daarna 503 `server_busy`); het accountbudget, wanneer het verzoek een sessie bevat; de limieten van het endpoint; de body; het endpoint zelf (de GIF-endpoints nemen hun renderlimieten alleen wanneer ze een GIF moeten maken). Een verzoek dat om zijn token wordt geweigerd, verbruikt dus geen tokens van het endpoint, en een verzoek met een ongeldige body verbruikt ze wel.

    Andere beperkingen, die de endpoints zelf afhandelen:

    - **Mislukte inlogpogingen op één inlognaam** (gebruikersnaam of e-mailadres): vanaf `AUTH_FAILURES_PER_ACCOUNT` (5) mislukkingen moet elke poging twee keer zo lang wachten als de vorige (2 s, 4 s, enzovoort, tot 15 minuten): 429 `too_many_attempts` met `retryAfter`. De teller vergeet ze na een uur zonder mislukkingen.
    - **Mislukte tweede factoren bij het inloggen:** dezelfde regel vanaf de 5e onjuiste code van het account. Eén inlogstap accepteert hoogstens 5 onjuiste codes.
    - **Codes voor de tweede factor van één account:** hoogstens `AUTH_MFA_PER_ACCOUNT` (10) codes (authenticator- of herstelcodes, juist of onjuist) per 15 minuten, vanaf elk adres, bij het inloggen en bij herauthenticaties; daarboven 429 `too_many_attempts` voordat de code wordt gecontroleerd, zodat een herstelcode niet wordt verbruikt.
    - **Mislukte herauthenticaties van een account** (onjuist wachtwoord of onjuiste code): dezelfde regel (429 `too_many_attempts`), gedeeld door alle endpoints die opnieuw authenticeren.
    - **E-mails:** één bevestigings- of resetmail per adres per 5 minuten (het antwoord blijft hetzelfde); één bericht "iemand heeft geprobeerd je adres te gebruiken" per adres per uur.
    - **Meldingen:** `REPORTS_PER_DAY` (5) per speler in 24 uur: 429 `report_limit`.

    ## Proof of work

    `POST /auth/register` vereist altijd een proof of work wanneer `POW_REGISTER_BITS` groter is dan 0 (standaard 18; `GET /info` geeft het als `pow.register`). `POST /auth/login` en `POST /auth/sso/google/link` (één soort challenge voor beide) vereisen er alleen een gedurende 5 minuten nadat de server een golf van mislukte inlogpogingen ziet (`POW_LOGIN_TRIGGER_PER_MIN`, daarna `POW_LOGIN_BITS`); een client merkt dat aan het antwoord.

    1. Het verzoek zonder bewijs (of met een geweigerd bewijs) krijgt als antwoord 428 `pow_required` met `reason` en een `pow`-challenge `{ challenge, bits, expiresAt }`.
    2. Zoek een nonce: een decimale string van hoogstens 20 cijfers zodat `SHA-256(challenge + ":" + nonce)` begint met `bits` nulbits (meest significante bit van de eerste byte eerst). 18 bits kosten gemiddeld ongeveer 260.000 hashes.
    3. Verstuur hetzelfde verzoek opnieuw met `"pow": { "challenge": "...", "nonce": "123456" }` in de body.

    Een challenge is 2 minuten en maar één keer geldig, voor één endpoint en één clientnetwerk (een IPv4-adres of IPv6-/64-netwerk). Hij is ondertekend, zodat de server er niets over bewaart tot hij terugkomt. `reason` zegt waarom een bewijs is geweigerd: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` of `replayed`.

    ## Herauthenticatie

    Accountwijzigingen vragen opnieuw om het wachtwoord en, wanneer tweestapsverificatie aanstaat, om een tweede factor:

    - alleen het wachtwoord: `POST /account/password` en `POST /account/mfa/totp/setup`;
    - het wachtwoord en een authenticatorcode, waarbij een herstelcode wordt geweigerd: `POST /account/mfa/recovery-codes`;
    - het wachtwoord en een authenticatorcode of een herstelcode: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` en `POST /account/delete`.

    In body's bevat `code` een 6-cijferige authenticatorcode en `recoveryCode` een herstelcode (`xxxx-xxxx-xx`; hoofd- en kleine letters, spaties en streepjes maken niet uit). Een herstelcode mag ook in `code` worden verstuurd waar herstelcodes worden geaccepteerd. Elke code werkt één keer: een gebruikte authenticatorcode wordt geweigerd tot de volgende stap van 30 seconden, en een herstelcode is weg zodra hij is gebruikt.

    | Status | `error` | Wanneer |
    |---|---|---|
    | 403 | `invalid_password` | Onjuist wachtwoord, of het wachtwoord is gereset of gewijzigd terwijl het verzoek liep (er is niets gewijzigd). |
    | 403 | `mfa_code_required` | Tweestapsverificatie staat aan en noch `code` noch `recoveryCode` is verstuurd. |
    | 403 | `invalid_code` | Onjuiste of al gebruikte code. |
    | 400 | `password_not_set` | Een account dat alleen Google gebruikt, heeft nog geen wachtwoord ("Wachtwoord vergeten?" stelt er een in). |
    | 429 | `too_many_attempts` | Te veel mislukkingen, of te veel geprobeerde codes, op dit account. |
    | 503 / 429 | `server_busy` / `rate_limited` | De wachtrij voor wachtwoordhashes is bezet. |

    Onjuiste wachtwoorden en codes tellen mee in de mislukkingenteller van het account en worden vastgelegd als beveiligingsgebeurtenissen.

    ## Metrics-poort

    Naast de API-poort beantwoordt de server gewone HTTP op de metrics-poort (`METRICS_PORT`, 9464, gebonden aan `METRICS_BIND`, standaard 127.0.0.1; houd die privé). Die hoort niet bij deze API: `GET /healthz` antwoordt `ok`; `GET /readyz` antwoordt `ready` zodra het opstarten voltooid is (elke shard heeft zijn journaal opnieuw afgespeeld en de listeners zijn gebonden) en tot het afsluiten begint, anders 503 `not ready`; `GET /metrics` levert de Prometheus-metrics en vereist, als `METRICS_TOKEN` is ingesteld, `Authorization: Bearer <token>` met precies dat token.
  contact:
    name: Scacelith
    url: https://github.com/DarkCenobyte/scacelith-chess-server
  license:
    name: GPL-3.0-or-later
    identifier: GPL-3.0-or-later
externalDocs:
  description: Broncode van de Scacelith-server en de referentiedocumentatie ervan (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: De officiële server.
  - url: https://{host}:{port}/api/v1
    description: Elke Scacelith-server, zoals een communityserver.
    variables:
      host:
        default: caissa.scacelith.com
        description: De openbare hostnaam van de server (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: De openbare API-poort (`PUBLIC_API_PORT`, anders `API_PORT`).
tags:
  - name: server-info
    x-displayName: Serverinformatie
    description: Wat een client moet weten voordat hij inlogt of verbinding maakt.
  - name: health
    x-displayName: Statuscontrole
    description: |-
      Liveness en readiness van de server, voor monitoring. Deze endpoints antwoorden op de API-poort vóór de authenticatie en elke endpointlimiet, maar zoals elk verzoek nemen ze een token van de laag per adres; een monitoringhost kan in `ABUSE_EXEMPT` worden opgenomen. Voor elk endpoint werken beide paden: in de root van de server en onder `/api/v1`.

      De metrics-poort heeft eigen endpoints voor statuscontrole, beschreven in de inleiding.
  - name: auth
    x-displayName: Registratie en inloggen
    description: |-
      Een account aanmaken, inloggen met een wachtwoord (en een tweede factor), e-mails voor bevestiging en wachtwoordreset.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` en `POST /auth/sso/complete` antwoorden met een sessie `{ token, expiresAt, user }` (of, bij de eerste daarvan, met een tweede stap).
  - name: google-sign-in
    x-displayName: Inloggen met Google
    description: |-
      Beschikbaar wanneer `GET /info` `sso.google: true` meldt; anders antwoordt elk endpoint hieronder met 404 `sso_disabled`. Een website logt op dezelfde manier in met een eigen client wanneer `sso.googleWeb` true is (hieronder, **Op een website**). Het spel logt in via de systeembrowser met de flow voor geïnstalleerde apps van RFC 8252 (een client van het type "Desktop-app"): Google stuurt de browser terug naar een listener van het spel op `127.0.0.1`, nooit naar deze server, en het spel ziet nooit Google-inloggegevens.

      1. De client luistert op `127.0.0.1:0` (het systeem kiest de poort) en maakt een PKCE-paar: een `codeVerifier` van 43 tot 128 tekens `[A-Za-z0-9._~-]` en `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, die 43 tekens telt en geen padding heeft.
      2. `POST /auth/sso/google/start` met de challenge en de poort geeft de Google-URL en de `state` van de poging terug. De client controleert de URL (zie hieronder) en opent hem in de browser.
      3. Google stuurt de browser naar `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. De client accepteert alleen de `state` uit het startantwoord, en verstuurt niets na een redirect met `error=`.
      4. `POST /auth/sso/google/finish` met de id van de poging, de `codeVerifier`, de `state` en de `code` (en `iss` als Google die heeft meegestuurd).
      5. Het antwoord is een sessie, een stap van tweestapsverificatie (ga verder met `POST /auth/login/mfa`), `needsUsername` voor een nieuwe speler (ga verder met `POST /auth/sso/complete`), of `needsPassword` wanneer een account met een wachtwoord het adres gebruikt (ga verder met `POST /auth/sso/google/link`).

      **De origin-tag.** De redirect-URI bevat een tag van de server die de speler heeft toegevoegd, zodat het spel een URL weigert die een andere server voor zijn eigen spelers heeft gekregen. De origin is de host in kleine letters (een IPv6-literal tussen vierkante haken), `:` en de decimale API-poort, altijd uitgeschreven, ook 443: de server neemt `SERVER_PUBLIC_HOST` en zijn openbare API-poort, het spel het adres waarmee het verbonden is. De tag bestaat uit de eerste 22 tekens van de base64url (zonder padding) van SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + origin). De redirect-URI is `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: altijd de IPv4-literal, en de poort van de listener van het spel (1024 tot 65535). De server neemt nooit een URI, host of pad van de client over.

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

      Inloggen met Google werkt dus alleen voor spelers die de server precies onder `SERVER_PUBLIC_HOST` en zijn openbare API-poort hebben toegevoegd.

      **Wat het spel controleert voordat het de browser opent.** De `authUrl` begint met precies `https://accounts.google.com/o/oauth2/v2/auth?` en is afdrukbare ASCII van minder dan 4096 tekens, en de query ervan bevat precies één `response_type=code`, precies één `redirect_uri` gelijk aan de URI die het spel berekent uit zijn poort en zijn origin-tag, precies één `state` gelijk aan de `state` van het antwoord, `code_challenge_method=S256` en een `code_challenge` van 43 tekens. Anders stopt het spel zijn listener en opent het niets. Het stuurt `finish` en `link` alleen naar de server die op `start` heeft geantwoord.

      **Op een website.** Een website (de officiële: <https://scacelith.com/>) logt in met een client van het type "Webapplicatie" van hetzelfde Google-project (`GOOGLE_WEB_CLIENT_ID`). Google stuurt de browser terug naar één pagina van de site die de beheerder heeft geconfigureerd (`GOOGLE_WEB_REDIRECT_URI`, vermeld bij "Geautoriseerde omleidings-URI's" van de client), en die pagina stuurt de code naar `finish`; ook hier neemt de server geen redirect-URI van de client over.

      1. De pagina maakt een PKCE-paar zoals hierboven, bewaart de verifier in het tabblad (`sessionStorage`), en stuurt `POST /auth/sso/google/web/start` met de challenge.
      2. Ze controleert de `authUrl` zoals het spel dat doet, behalve dat de `redirect_uri` ervan een pagina van haar eigen origin moet zijn, en navigeert er dan naartoe. Er wordt geen script van Google geladen.
      3. Google stuurt de browser naar `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. De pagina accepteert alleen de `state` uit het startantwoord en verstuurt niets na een redirect met `error=`.
      4. `finish`, `link` en `complete` volgen zoals bij het spel. De poging onthoudt dat een website haar is begonnen: de code wordt ingewisseld met de client en de pagina van de website, en het ID-token moet die client noemen (een code of ID-token van de client van het spel mislukt met 502 `sso_failed`, en omgekeerd).

      Een Google-account komt vanuit het spel en vanuit de website bij hetzelfde Scacelith-account uit: de koppeling bevat het subject van Google (`sub`), dat het Google-account identificeert, ongeacht de client. De origin van de website moet in `CORS_ORIGINS` staan (anders waarschuwt `check-config`).

      **Bij welk account inloggen met Google uitkomt:** het account dat al aan dat Google-account is gekoppeld; anders een actief account met het adres dat Google heeft bevestigd (met een wachtwoord antwoordt `finish` met `needsPassword` en wordt de koppeling pas opgeslagen zodra het wachtwoord, en daarna de tweede factor als die aanstaat, is geaccepteerd; zonder wachtwoord 409 `sso_account_exists`); anders een nieuw account (`needsUsername`). Een Google-account wordt nooit alleen op basis van het adres aan een bestaand account gekoppeld. Het adres van het account krijgt een mail wanneer inloggen met Google een account aanmaakt en wanneer het aan een bestaand account wordt toegevoegd.
  - name: sessions
    x-displayName: Sessies
    description: De ingelogde apparaten van het account, en uitloggen.
  - name: account
    x-displayName: Account
    description: Het overzicht van het account, de voorkeuren en het wachtwoord ervan.
  - name: two-step-verification
    x-displayName: Tweestapsverificatie
    description: Authenticator-apps (TOTP, RFC 6238), met SHA-1, 6 cijfers, stappen van 30 seconden en één stap tolerantie naar beide kanten, plus herstelcodes voor eenmalig gebruik.
  - name: email-change
    x-displayName: E-mailadres wijzigen
    description: Het adres van het account wijzigen, bevestigd met een link die naar het nieuwe adres wordt gestuurd.
  - name: data-export
    x-displayName: Gegevensexport
    description: Alles wat de server over het account bewaart, als één JSON-bestand.
  - name: account-deletion
    x-displayName: Account verwijderen
    description: Het account definitief verwijderen.
  - name: game-history
    x-displayName: Partijgeschiedenis
    description: De eigen partijen van de ingelogde speler, gefilterd en per pagina.
  - name: games
    x-displayName: Partijen en PGN
    description: |-
      Partijrecords met hun zetten en klokstanden, en hun PGN-bestanden.

      **Partijcodes.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (alleen afgelopen partijen worden opgeslagen). `result`: `1-0`, `0-1`, `1/2-1/2` of `*` (afgebroken). `reason`, met zijn naam (`termination`) en de woorden waarmee de zettentekst van het PGN-bestand eindigt (in het Engels in het bestand, hier met vertaling); codes 7, 14 en 21 zijn remises (de speler wiens tijd op was, die opgaf of die de partij verliet, stond tegenover een tegenstander die geen mat kon zetten: FIDE 6.9 en 5.1.2; `DESIGN.md` 6.3 geeft de regel), en de server beëindigt nooit een partij met codes 4 en 13 (die horen bij de gedeelde lijst met redenen):

      | Code | `termination` | Woorden in de PGN |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (schaakmat) |
      | 2 | `Resignation` | Resignation (opgave) |
      | 3 | `Timeout` | Loss on time (verlies op tijd) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (tweede illegale zet, reglementair verlies) |
      | 5 | `Stalemate` | Stalemate (pat) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (dode stelling, onvoldoende materiaal) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (vlag gevallen, maar de tegenstander kan niet mat zetten) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (vijfvoudige herhaling) |
      | 9 | `SeventyFiveMoves` | 75-move rule (75-zettenregel) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (drievoudige herhaling, geclaimd) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (50-zettenregel, geclaimd) |
      | 12 | `Agreement` | Draw by agreement (remise in onderling overleg) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (tweede illegale zet, maar de tegenstander kan niet mat zetten) |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate (opgave, maar de tegenstander kan niet mat zetten) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (partij verlaten, te lang geen verbinding) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (partij verlaten, maar de tegenstander kan niet mat zetten) |
      | 22 | `Aborted` | Game aborted (partij afgebroken) |
      | 23 | `NoShow` | Aborted: first move not played in time (afgebroken: eerste zet niet op tijd gespeeld) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (reglementair verlies wegens schending van de fairplay) |
      | 25 | `ServerAborted` | Aborted by the server (afgebroken door de server) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (afgebroken: verbinding met beide spelers verbroken) |

      Code 14 is nieuw in minorversie 1 van het protocol: een client die daarvoor is geschreven, hoort een onbekende code te tonen als een gewoon einde van de partij met zijn `result`.
  - name: gifs
    x-displayName: Geanimeerde GIF's
    description: |-
      Een partij als geanimeerde GIF, om te bewaren of te delen: het bord van bovenaf gezien, één frame per stelling van het begin tot de eindstelling, de namen en ratings van de spelers boven het bord, de laatste zet eronder, en op het laatste frame de uitslag en hoe de partij is geëindigd.

      **Kosten, cache en quota.** Een GIF wordt gemaakt op een eigen renderthread, nooit op de threads die de partijen afhandelen, en met de laagste CPU-prioriteit: `GIF_THREADS` threads (standaard `WORKERS`), gestart bij de eerste GIF en gestopt na een minuut zonder GIF. Hoogstens `GIF_QUEUE_MAX` (4 x `WORKERS`) GIF's kunnen op een thread wachten, elk hoogstens `GIF_QUEUE_TIMEOUT_MS` (10 s); een render mag `GIF_RENDER_TIMEOUT_MS` (30 s) duren. De server bewaart de GIF's die hij heeft gemaakt in een cache van `GIF_CACHE_MB` MB (32 x `WORKERS`), waarbij de minst recent gebruikte als eerste verdwijnt; een GIF uit de cache, of een GIF die al voor een ander verzoek wordt gemaakt, kost geen render. De cachesleutel omvat alles wat het beeld verandert, ook de namen.

      Elk verzoek telt mee in `gif` (30 per minuut per speler voor beide endpoints). Een GIF die moet worden gemaakt, telt ook mee in de renderlimieten: per speler `GIF_USER_RENDERS_PER_MIN` (4) per minuut en `GIF_USER_RENDERS_PER_HOUR` (30) per uur; per client, al zijn accounts samen, `GIF_IP_RENDERS_PER_MIN` (12) per minuut en `GIF_IP_RENDERS_PER_HOUR` (120) per uur, en 3 keer zoveel per IPv6-/48-netwerk. Een client hoort het gedownloade bestand te bewaren in plaats van het opnieuw op te vragen, en na een 429 of een 503 `retryAfter` te wachten.

      Formaten: `small` (velden van 32 px: 284 x 350 pixels), `medium` (48 px: 424 x 515), `large` (72 px: 628 x 762); zonder coördinaten 268 x 342, 400 x 503 en 600 x 748. De beginstelling blijft minstens 1 s staan (de vertraging als die langer is), de eindstelling 3 s, en de GIF wordt steeds herhaald; na het eerste frame wordt alleen het deel van het beeld opgeslagen dat verandert. Een partij van 40 zetten neemt ongeveer 135, 205 en 325 KiB in beslag (small, medium, large), een partij van 150 zetten 0,5, 0,8 en 1,2 MiB.
  - name: players
    x-displayName: Spelers
    description: Openbare profielen en recente partijen. Alleen openbare gegevens, nooit een e-mailadres, een sessie, een sanctie of een integriteitsniveau. Een verwijderd account heeft geen profiel.
  - name: leaderboard
    x-displayName: Ranglijst
    description: De beste spelers van elke officiële categorie.
  - name: reports
    x-displayName: Meldingen
    description: De tegenstander van een recente partij melden bij de moderators.
  - name: pages
    x-displayName: HTML-pagina's
    description: |-
      Pagina's voor een browser, geopend via de links in e-mails. Hun links verwijzen naar `https://<SERVER_PUBLIC_HOST>` (met `:<PUBLIC_API_PORT>` als die niet 443 is), buiten `/api/v1`.

      - De pagina's voeren geen JavaScript uit en laden geen externe bronnen. Ze worden geleverd met `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - Een `GET` toont alleen een knop of een formulier, zodat een mailscanner die de link opent hem niet verbruikt. De wijziging gebeurt bij `POST`: een formulier verstuurd als `application/x-www-form-urlencoded` (een JSON-body wordt ook geaccepteerd), met het token van de link in een verborgen veld. Een veld dat twee keer is opgegeven, wordt geweigerd.
      - Fouten (rate limits, ongeldige velden) zijn ook HTML-pagina's, met de titel "Request refused" (verzoek geweigerd) of, vanaf 500, "Server error" (serverfout). Alleen de fouten die worden vastgesteld voordat de pagina is bepaald (een verzoekdoel dat te lang of misvormd is, de laag per adres), krijgen een antwoord in JSON.
x-tagGroups:
  - name: server
    x-displayName: Server
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Accounts
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Partijen
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Spelers en meldingen
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Browserpagina's
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: De naam, versies, poorten en registratieregels van de server opvragen
      description: |-
        Wat een client nodig heeft voordat hij inlogt of verbinding maakt: de naam en id van de server, de versies van het WebSocket-protocol en waar de WebSocket staat, de registratieregels, de officiële speeltempo's en de limieten die een client kan controleren voordat hij een formulier verstuurt.

        **Limieten:** alleen de laag per adres.
      security: []
      responses:
        '200':
          description: De beschrijving van de server.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServerInfo'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de laag per adres.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Een account aanmaken
      description: |-
        Maakt een account aan: zodra het e-mailadres is bevestigd met de link die ernaartoe is gestuurd, of meteen zonder e-mailbevestiging.

        **Met e-mailbevestiging** (`REQUIRE_EMAIL_VERIFICATION`, de standaard): 202 `verification_sent`. Er bestaat nog geen account: de registratie wacht 24 uur en reserveert intussen de gebruikersnaam. Een link die die 24 uur geldig is, gaat naar het adres, hoogstens één per adres per 5 minuten (`POST /auth/verify-email/resend` stuurt een nieuwe); het account wordt aangemaakt, met bevestigd adres, wanneer de link wordt gebruikt (de knop van de pagina `/verify-email`), en daarna kan de speler inloggen. Tot dan levert inloggen met die gebruikersnaam `invalid_credentials` op, zoals bij een onbekend account, en bestaat het openbare profiel niet. Een nieuwe registratie met hetzelfde adres vervangt de wachtende. Het antwoord is hetzelfde wanneer een ander account het adres al gebruikt: er wordt dan geen link verstuurd, de eigenaar krijgt in plaats daarvan een bericht (hoogstens één per uur), en de gebruikersnaam wordt op dezelfde manier gereserveerd, zodat niets verraadt of het adres een account heeft. Een registratie waarvan de link niet is gebruikt, vervalt na 24 uur, en de gebruikersnaam is dan weer vrij.

        **Zonder e-mailbevestiging** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, het account wordt meteen aangemaakt en kan inloggen.

        **Regels.** `username`: `USERNAME_MIN` tot `USERNAME_MAX` tekens (3 tot 20), letters, cijfers, `_` en `-`, beginnend met een letter of een cijfer (`GET /info` geeft ze als `limits`); gereserveerde namen (`admin`, `moderator`, `deleted`...) en sommige voorvoegsels worden geweigerd; uniek ongeacht hoofd- of kleine letters. `email`: ontdaan van witruimte aan begin en eind en in kleine letters opgeslagen, gewone ASCII met een domein met een punt. `password`: minstens `PASSWORD_MIN_LENGTH` (10) tekens en hoogstens 256 bytes UTF-8; het mag de gebruikersnaam en het lokale deel van het e-mailadres niet bevatten en mag geen veelgebruikt wachtwoord zijn.

        **Fouten**, gecontroleerd in deze volgorde: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; de fouten van de wachtrij voor wachtwoordhashes; 409 `email_taken` (alleen zonder e-mailbevestiging).

        **Limieten:** `auth`, daarna `auth_register` (10 registraties per uur per client). **Proof of work:** altijd, wanneer `POW_REGISTER_BITS` groter is dan 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Eerste poging, zonder proof of work
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Opnieuw verstuurd met de proof of work
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: Het account is aangemaakt en kan inloggen (geen e-mailbevestiging op deze server).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: De registratie wacht op haar bevestigingslink (of het adres heeft al een account; het antwoord verraadt het niet).
          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` (met `field`), `invalid_json`, `invalid_username`, `invalid_email`, of `weak_password` met `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` of `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: de registratie is gesloten op deze server (`GET /info` meldt `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: een account heeft de gebruikersnaam, of een wachtende registratie van een ander adres heeft hem gereserveerd. `email_taken`: een ander account gebruikt het adres (alleen zonder e-mailbevestiging).'
        '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`: de limiet `auth` of `auth_register`, of de wachtrij voor wachtwoordhashes.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de wachtrij voor wachtwoordhashes, of de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Inloggen met een wachtwoord
      description: |-
        Logt in met een gebruikersnaam of e-mailadres en een wachtwoord. Er zijn twee mogelijke antwoorden, beide met status 200: een sessie, of, wanneer tweestapsverificatie aanstaat, een tweede stap die binnen 5 minuten moet worden voltooid met `POST /auth/login/mfa`.

        Een onbekend account, een onjuist wachtwoord en een account zonder wachtwoord krijgen hetzelfde antwoord, na dezelfde tijd: 401 `invalid_credentials`. De accountcontroles (`banned`, `email_unverified`) komen pas na een juist wachtwoord. Vanaf `AUTH_FAILURES_PER_ACCOUNT` (5) mislukkingen op één inlognaam moet elke poging wachten (429 `too_many_attempts`).

        **Limiet:** `auth`. **Proof of work:** alleen tijdens een golf van mislukte inlogpogingen (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: Een sessie, of de tweede stap van het inloggen met tweestapsverificatie.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Ingelogd
                  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: Tweestapsverificatie staat aan
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: onbekend account, onjuist wachtwoord, of een account zonder wachtwoord.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Alleen na een juist wachtwoord: `banned`, met `until` (epoch-ms, `null` bij een permanente schorsing), of `email_unverified` (een ouder account waarvan het adres nog niet is bevestigd).'
        '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` (de wachttijd na mislukte pogingen van deze inlognaam), of `rate_limited` (de limiet `auth`, de wachtrij voor wachtwoordhashes).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de wachtrij voor wachtwoordhashes, of de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Het inloggen voltooien met een tweede factor
      description: |-
        De tweede stap van het inloggen met tweestapsverificatie, nadat `POST /auth/login` of het inloggen met Google (`POST /auth/sso/google/finish` of `POST /auth/sso/google/link`) met `mfaRequired` heeft geantwoord. Stuur `code` (een 6-cijferige authenticatorcode, of een herstelcode) of `recoveryCode`. Een herstelcode die hier wordt gebruikt, is weg.

        Eén stap accepteert hoogstens 5 onjuiste codes; de stap eindigt ook wanneer het wachtwoord wordt gereset of gewijzigd. Na de wachtwoordstap van een Google-koppeling wordt de koppeling pas opgeslagen wanneer de code hier wordt geaccepteerd.

        **Limieten:** `auth`, en hoogstens `AUTH_MFA_PER_ACCOUNT` (10) codes per 15 minuten voor het account, vanaf elk adres; daarboven wordt de code niet gecontroleerd, zodat een herstelcode niet wordt verbruikt.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Een authenticatorcode
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Een herstelcode
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Ingelogd.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: noch `code` noch `recoveryCode` is verstuurd, of de body overtreedt het schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: de stap is verlopen, is al gebruikt of is beëindigd na 5 onjuiste codes, of het wachtwoord is sinds de eerste stap gereset of gewijzigd (log opnieuw in). `invalid_code`: een onjuiste of al gebruikte code.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (met `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: het Google-account is intussen aan een ander account gekoppeld (alleen na de wachtwoordstap van een Google-koppeling).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: het account is intussen gewijzigd (alleen na de wachtwoordstap van een Google-koppeling); begin opnieuw met inloggen.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: de wachttijd na mislukte pogingen van het account, of de `AUTH_MFA_PER_ACCOUNT` codes van de laatste 15 minuten zijn op. `rate_limited`: de limiet `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` met `retryAfter: 1`: de database bleef vergrendeld (na een stap van een Google-koppeling is er niets gekoppeld: begin opnieuw met inloggen). `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Deze sessie uitloggen
      description: |-
        Trekt de sessie van het gebruikte token in. De WebSocket die ermee is geopend, wordt gesloten. Geen body (of `{}`).

        **Limiet:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Uitgelogd.
          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`: een andere body dan `{}`; `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`: de limiet `sessions` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Alle sessies uitloggen
      description: |-
        Trekt alle sessies van het account in, ook deze. De WebSockets die ermee zijn geopend, worden gesloten. Geen body (of `{}`).

        **Limiet:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Alle sessies zijn uitgelogd.
          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`: een andere body dan `{}`; `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`: de limiet `sessions` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: De bevestigingslink opnieuw versturen
      description: |-
        Verstuurt de link voor de e-mailbevestiging opnieuw. Het antwoord is 202 `accepted`, ongeacht het adres, zodat het nooit verraadt of een account of een registratie het adres gebruikt.

        Een verzoek heeft hoogstens één keer per 5 minuten per adres effect. Een registratie die met dat adres wacht, krijgt opnieuw haar 24 uur, of een ander account het adres nu gebruikt of niet, zodat de gebruikersnaam in beide gevallen even lang gereserveerd blijft. Een link wordt alleen verstuurd voor die registratie wanneer het adres geen account heeft (een nieuwe link, 24 uur geldig, vervangt de vorige), of voor een actief, onbevestigd account met dat adres.

        **Limieten:** `auth`, daarna `auth_mail` (10 per uur per client).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Geaccepteerd (ongeacht het adres).
          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`: de limiet `auth` of `auth_mail` (ongeacht het adres).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` met `retryAfter: 1`: de database bleef vergrendeld terwijl het account werd opgezocht of de link van een onbevestigd account werd opgeslagen (het verlengen van een wachtende registratie gebeurt naar beste vermogen en laat het verzoek nooit mislukken); de 5 minuten van het adres gaan toch in, dus een nieuw verzoek binnen die tijd doet niets. `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Een resetlink voor het wachtwoord versturen
      description: |-
        Verstuurt een resetlink voor het wachtwoord, één uur geldig, die de pagina `/reset-password` opent. Het antwoord is 202 `accepted`, ongeacht het adres. Een link gaat alleen naar een actief account, hoogstens één keer per 5 minuten per adres. Een account dat alleen Google gebruikt, stelt zo zijn eerste wachtwoord in.

        Wachtwoordherstel heeft de strengste limieten van de API, allemaal geteld voor de hele server: 3 verzoeken per uur (`AUTH_FORGOT_PER_HOUR`) en 10 per 24 uur (`AUTH_FORGOT_PER_DAY`) per client (een IPv4-adres of een IPv6-/64-netwerk), 3 keer die aantallen per IPv6-/48-netwerk, bovenop de limiet `auth` (20 per 10 minuten) en de ene mail per adres per 5 minuten. Noch de 202 noch de 429 verraadt of een account het adres gebruikt. Het instellen van het nieuwe wachtwoord heeft een eigen limiet (`auth_reset`).

        **Limieten:** `auth`, `auth_forgot`, daarna `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Geaccepteerd (ongeacht het adres).
          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`: de limiet `auth`, `auth_forgot` of `auth_forgot_day` (ongeacht het adres).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` met `retryAfter: 1`: de database bleef vergrendeld terwijl het account werd opgezocht of de link werd opgeslagen; de 5 minuten van het adres gaan toch in, dus een nieuw verzoek binnen die tijd verstuurt geen link. `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Een nieuw wachtwoord instellen met het token van een resetlink
      description: |-
        Stelt een nieuw wachtwoord in met het token van een resetlink (de pagina `/reset-password` doet hetzelfde). Het nieuwe wachtwoord volgt de regels van de registratie.

        De reset heeft deze gevolgen, in één transactie (allemaal of geen enkele): hij trekt alle sessies in en annuleert een openstaande e-mailwijziging; de andere resetlinks van het account werken niet meer; het adres geldt als bevestigd (de link heeft dat bewezen); de eigenaar krijgt een mail. Tweestapsverificatie blijft ongemoeid. Na een fout van de wachtrij voor wachtwoordhashes, een 503 of een 500 is er niets gewijzigd: het oude wachtwoord en de sessies blijven, en de link blijft geldig.

        **Limieten:** `auth`, daarna `auth_reset` (10 per uur per client, 30 per IPv6-/48-netwerk, gedeeld met de pagina: elke poging hasht een wachtwoord).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Het wachtwoord is gewijzigd en alle sessies zijn uitgelogd.
          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`: de link is ongeldig, gebruikt of verlopen, of is gemaild naar een adres dat het account niet meer heeft. `weak_password` (met `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`: de limiet `auth` of `auth_reset`, of de wachtrij voor wachtwoordhashes.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: de wachtrij voor wachtwoordhashes, of de database bleef vergrendeld (`retryAfter: 1`, er is niets gewijzigd). `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Inloggen met Google starten
      description: |-
        Start een poging om in te loggen met Google voor de PKCE-challenge en de poort van de listener van het spel op `127.0.0.1`. Het antwoord geeft de Google-URL die in de systeembrowser moet worden geopend en de `state` van de poging; de poging is 10 minuten geldig.

        `authUrl` bevat `client_id`, `redirect_uri` (opgebouwd uit `redirectPort` en de origin-tag van de server), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 van de eigen verifier van de server voor Google) met `code_challenge_method=S256`, en `prompt=select_account`. Het spel controleert die URL voordat het hem opent (zie de beschrijving van de sectie).

        **Limiet:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: De poging.
          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`: inloggen met Google wordt op deze server niet aangeboden.'
        '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`: de limiet `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Inloggen met Google starten op een website
      description: |-
        Start een poging van een website om in te loggen met Google voor de PKCE-challenge van haar pagina. Het antwoord is hetzelfde als voor het spel: de Google-URL om naartoe te navigeren, en de `state` van de poging; de poging is 10 minuten geldig. Beschikbaar wanneer `GET /info` `sso.googleWeb: true` meldt.

        `authUrl` bevat de `client_id` van de website en `redirect_uri=GOOGLE_WEB_REDIRECT_URI`, met dezelfde overige parameters als voor het spel. De pagina controleert die URL voordat ze ernaartoe navigeert (zie **Op een website** in de beschrijving van de sectie).

        **Limiet:** `sso_start`, gedeeld met de start van het spel.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      responses:
        '200':
          description: De poging.
          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`: een ontbrekende of misvormde `codeChallenge`, of een ander veld (ook `redirectPort`).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: inloggen met Google wordt op deze server niet aangeboden, of niet aan websites.'
        '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`: de limiet `sso_start` (gedeeld met de start van het spel).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Het antwoord van Google aan de server doorgeven
      description: |-
        Geeft de `code` en `state` die Google naar de listener van het spel of naar de pagina van de website heeft gestuurd door aan de server, met de id van de poging en de PKCE-verifier. Een poging-id is nutteloos zonder de verifier, en de code is nutteloos zonder de eigen PKCE-verifier en het clientgeheim van de server.

        De server controleert de poging (onbekend, gebruikt of verlopen: 410), daarna de verifier (een onjuiste verifier laat de poging bruikbaar), gebruikt de poging dan één keer, controleert `state` en `iss`, wisselt de code in bij Google en verifieert het ID-token. Het antwoord (200) is een van:

        - `{ token, expiresAt, user }`: ingelogd op het gekoppelde account;
        - `{ mfaRequired, mfaToken, expiresIn }`: ga verder met `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: een nieuw account; ga binnen 10 minuten verder met `POST /auth/sso/complete`. `suggestedUsername` komt van de Google-naam of het adres, en is `""` als niets past;
        - `{ needsPassword, linkTicket, username, expiresIn }`: het account met dat adres heeft een wachtwoord; ga verder met `POST /auth/sso/google/link`. Er is nog niets gekoppeld.

        **Limiet:** `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: Een sessie, een tweede stap, of de volgende stap van een eerste keer inloggen met Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Ingelogd op het gekoppelde account
                  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: Tweestapsverificatie staat aan
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Een nieuwe speler
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Een account met een wachtwoord gebruikt het adres
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: de verifier komt niet overeen met de challenge van de poging (de poging blijft bruikbaar). `sso_email_unverified`: Google heeft het adres niet bevestigd. `registration_closed`. `account_disabled`. Alleen voor een gekoppeld account: `banned` (met `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: inloggen met Google wordt op deze server niet aangeboden.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: een actief account zonder wachtwoord gebruikt het adres (het account wordt niet genoemd).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: een onbekende, gebruikte of verlopen poging.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de limiet `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Google aan een bestaand account koppelen met het wachtwoord ervan
      description: |-
        Nadat `finish` met `needsPassword` heeft geantwoord: koppelt Google aan het bestaande account met het wachtwoord van dat account, ingetypt in het spel. Het antwoord (200) is een sessie (de koppeling is opgeslagen en de speler is ingelogd), of, wanneer tweestapsverificatie aanstaat, `{ mfaRequired, mfaToken, expiresIn }`: ga verder met `POST /auth/login/mfa`, en de koppeling wordt pas opgeslagen wanneer daar een code wordt geaccepteerd.

        Een onjuist wachtwoord laat het ticket bruikbaar, voor in totaal 5 pogingen. Een poging wordt afgeboekt vlak voordat het wachtwoord wordt gecontroleerd: een 429 `too_many_attempts` of een 428 `pow_required` boekt er geen af, terwijl een weigering van de wachtrij voor wachtwoordhashes (503 `server_busy`, 429 `rate_limited`) er al een heeft afgeboekt en als mislukking van het account telt. De wachttijd na mislukte pogingen gebruikt dezelfde teller als `POST /auth/login`.

        **Limiet:** `auth` (met de telling per IPv6-/48-netwerk). **Proof of work:** zoals bij `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: Een sessie, of de tweede stap van het inloggen.
          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`: een verkeerd wachtwoord; het ticket blijft bestaan, voor in totaal 5 pogingen.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (met `until`), alleen na een juist wachtwoord.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: inloggen met Google wordt op deze server niet aangeboden.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: het Google-account is intussen aan een ander account gekoppeld.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: een onbekend, gebruikt of verlopen ticket, het 5e verkeerde wachtwoord, een account waarvan de status of het adres sinds `finish` is gewijzigd, of een account waarvan de status, het adres, het wachtwoord of de tweestapsverificatie wijzigde terwijl de koppeling werd opgeslagen. Begin opnieuw met inloggen.'
        '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` (de wachttijd na mislukte pogingen van het account), of `rate_limited` (de limiet `auth`, de wachtrij voor wachtwoordhashes).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: de wachtrij voor wachtwoordhashes, of de database bleef vergrendeld (`retryAfter: 1`, er is niets gekoppeld). `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Het account aanmaken bij de eerste keer inloggen met Google
      description: |-
        Nadat `finish` met `needsUsername` heeft geantwoord: maakt het account aan met de gekozen gebruikersnaam (de regels van de registratie gelden) en logt het in. Het account heeft geen wachtwoord: `hasPassword` is `false`, en "Wachtwoord vergeten?" (`POST /auth/password/forgot`) geeft het er een.

        **Limiet:** `auth` (met de telling per IPv6-/48-netwerk).
      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: Het account is aangemaakt en ingelogd.
          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`: inloggen met Google wordt op deze server niet aangeboden.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (een account heeft de gebruikersnaam, of een wachtende registratie van een ander adres houdt hem bezet), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: een onbekend, gebruikt of verlopen ticket.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de limiet `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Ingelogde apparaten weergeven
      description: |-
        De actieve sessies van het account (ingelogde apparaten), de laatst gebruikte eerst. `current` markeert de sessie die het verzoek doet. `lastSeenAt` wordt hoogstens elke 5 minuten bijgewerkt (later zolang de server ver achterloopt); `expiresAt` is het absolute einde, en de inactiviteitslimiet kan de sessie eerder beëindigen.

        **Limiet:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: De actieve sessies.
          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`: de limiet `sessions` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Eén apparaat uitloggen
      description: |-
        Logt één sessie van het account uit; ook de huidige sessie kan worden uitgelogd. De WebSocket die ermee is geopend, wordt gesloten. Geen body (of `{}`).

        **Limiet:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: De sessie is uitgelogd.
          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`: een andere body dan `{}`, of een `id` die geen geldige URL-codering is; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: geen actieve sessie met deze id op dit account.'
        '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`: de limiet `sessions` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Het account, de ratings en de actieve sancties opvragen
      description: |-
        Het account zoals de speler het ziet: de accountweergave (ook de `user` van elk inlogantwoord), één ratingrecord per categorie waarin de speler partijen met rating heeft gespeeld, de actieve sancties en de schorsing. Het integriteitsniveau van het anti-cheatsysteem wordt nooit getoond.

        **Limiet:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Het account.
          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`, of `invalid_token` (ook wanneer het account is verwijderd).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de limiet `account` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Directe uitdagingen accepteren of weigeren
      description: |-
        Stelt in of andere spelers deze speler bij naam mogen uitdagen. Met `none` worden directe uitdagingen geweigerd: de uitdager krijgt te horen dat de speler niet beschikbaar is. Geen herauthenticatie.

        **Limiet:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: De voorkeuren die nu gelden.
          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`: de limiet `account` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Het wachtwoord wijzigen
      description: |-
        Wijzigt het wachtwoord. Het huidige wachtwoord is nodig, maar geen tweede factor, ook niet als tweestapsverificatie aanstaat. Het nieuwe wachtwoord volgt de regels van de registratie (gecontroleerd na het huidige wachtwoord).

        De wijziging heeft deze gevolgen, in één transactie (allemaal of geen enkele): ze trekt elke andere sessie in (deze blijft ingelogd), annuleert een openstaande e-mailwijziging, laat de wachtwoordresetlinks van het account niet meer werken en stuurt de eigenaar een mail.

        **Herauthenticatie:** alleen het wachtwoord. **Limieten:** `reauth`, daarna `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: Het wachtwoord is gewijzigd.
          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` (een account dat alleen via Google inlogt), `weak_password` (met `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: een verkeerd huidig wachtwoord, of een wachtwoordreset of -wijziging die plaatsvond terwijl het verzoek werd gecontroleerd.'
        '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` (mislukte herauthenticaties van het account), of `rate_limited` (de limiet `reauth` of `reauth_user`, het accountbudget, de wachtrij voor wachtwoordhashes).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: de wachtrij voor wachtwoordhashes, of de database bleef vergrendeld (`retryAfter: 1`, er is niets gewijzigd). `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Het inschakelen van tweestapsverificatie starten
      description: |-
        Slaat een nieuw openstaand authenticatorgeheim op, dat een eventueel eerder openstaand geheim vervangt, en geeft het terug voor de authenticator-app (als tekst en als `otpauth://`-URI om als QR-code te tonen). Tweestapsverificatie staat nog niet aan: `POST /account/mfa/totp/enable` schakelt die in met een code van dit geheim.

        **Herauthenticatie:** alleen het wachtwoord. **Limieten:** `reauth`, daarna `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Het openstaande geheim.
          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` (een account dat alleen via Google inlogt), `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` (gecontroleerd vóór het wachtwoord).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (mislukte herauthenticaties van het account), of `rate_limited` (de limiet `reauth` of `reauth_user`, het accountbudget, de wachtrij voor wachtwoordhashes).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de wachtrij voor wachtwoordhashes, of de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Het inschakelen van tweestapsverificatie voltooien en herstelcodes ontvangen
      description: |-
        Schakelt tweestapsverificatie in met een code van het openstaande geheim (precies 6 cijfers). Hier wordt geen wachtwoord gevraagd; dat is bij het instellen opgegeven. Het antwoord bevat 10 herstelcodes, die alleen deze ene keer worden getoond.

        **Limieten:** `reauth`, daarna `reauth_user`; een verkeerde code telt mee bij de mislukte herauthenticaties van het account.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Tweestapsverificatie staat aan.
          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` bestaat niet uit precies 6 cijfers, of de body voldoet niet aan het schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: een verkeerde code (controleer de tijd van het apparaat).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: geen openstaand geheim (roep eerst `POST /account/mfa/totp/setup` aan).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (mislukte herauthenticaties van het account), of `rate_limited` (de limiet `reauth` of `reauth_user`, het accountbudget).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Tweestapsverificatie uitschakelen
      description: |-
        Schakelt tweestapsverificatie uit. Het geheim en de herstelcodes worden verwijderd, en de eigenaar krijgt een mail.

        **Herauthenticatie:** het wachtwoord en een authenticatorcode of een herstelcode (een van `code` en `recoveryCode` is vereist). **Limieten:** `reauth`, daarna `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: Tweestapsverificatie staat uit.
          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` (een account dat alleen via Google inlogt), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (noch `code` noch `recoveryCode`, gecontroleerd vóór het wachtwoord), `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` (mislukte herauthenticaties, of te veel geprobeerde codes op dit account), of `rate_limited` (de limiet `reauth` of `reauth_user`, het accountbudget, de wachtrij voor wachtwoordhashes).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de wachtrij voor wachtwoordhashes, of de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: De herstelcodes vervangen
      description: |-
        Vervangt de herstelcodes door 10 nieuwe; de oude werken niet meer.

        **Herauthenticatie:** het wachtwoord en een authenticatorcode in `code` (een herstelcode wordt geweigerd met 403 `invalid_code`). **Limieten:** `reauth`, daarna `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: De nieuwe herstelcodes, die alleen deze ene keer worden getoond.
          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` (een account dat alleen via Google inlogt), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (ook voor een herstelcode).'
        '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` (mislukte herauthenticaties, of te veel geprobeerde codes op dit account), of `rate_limited` (de limiet `reauth` of `reauth_user`, het accountbudget, de wachtrij voor wachtwoordhashes).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de wachtrij voor wachtwoordhashes, of de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Het e-mailadres wijzigen
      description: |-
        Wijzigt het e-mailadres van het account. `newEmail` wordt ontdaan van witruimte aan begin en eind en in kleine letters omgezet, en daarna gecontroleerd zoals een adres bij de registratie.

        **Met e-mailbevestiging** (de standaard): 202 `verification_sent`. Een link die 24 uur geldig is, gaat naar het nieuwe adres; hij opent `/confirm-email-change`, en het adres verandert pas wanneer de speler op de knop van die pagina drukt. Tot dan toont `GET /account/me` het nieuwe adres als `pendingEmail`. Een nieuw verzoek vervangt het openstaande; een wijziging of reset van het wachtwoord annuleert het. Er gaat hoogstens één link per 5 minuten naar een bepaald nieuw adres, wie er ook om vraagt (een verzoek voor de wijziging die al openstaat, behoudt de eerder verstuurde link, die geldig blijft); het antwoord is hetzelfde. Het huidige adres krijgt een bericht dat een wijziging naar een gemaskeerd adres (`a***@example.org`) is aangevraagd. Het antwoord en `pendingEmail` zijn hetzelfde wanneer een ander account het nieuwe adres al gebruikt: er wordt dan geen link verstuurd, zodat die wijziging nooit wordt voltooid, en in plaats daarvan krijgt de eigenaar van dat adres een bericht (hoogstens één per uur).

        Wanneer de link wordt bevestigd, verandert het adres en geldt het als bevestigd, blijven de apparaten ingelogd, werken de eerder verstuurde links (bevestiging, wachtwoordreset, andere wijzigingen) niet meer, en wordt het vorige adres op de hoogte gebracht, met het nieuwe adres gemaskeerd.

        **Zonder e-mailbevestiging** (`REQUIRE_EMAIL_VERIFICATION=false`): het adres verandert meteen (200 `email_changed`), en het vorige adres wordt op de hoogte gebracht. Als een ander account het adres gebruikt, is het antwoord 409 `email_taken`, en krijgt de eigenaar van dat adres het bericht.

        **Herauthenticatie:** het wachtwoord en, bij tweestapsverificatie, een authenticatorcode of een herstelcode. `invalid_email` en `same_email` worden vóór het wachtwoord gecontroleerd en tellen dus niet als mislukte poging. **Limieten:** `reauth`, daarna `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: Het adres is meteen gewijzigd (geen e-mailbevestiging op deze server).
          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: Het nieuwe adres, zoals opgeslagen.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Er is een bevestigingslink naar het nieuwe adres verstuurd (of het adres hoort bij een ander account; het antwoord vertelt dat niet).
          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` (het huidige adres van het account), `password_not_set` (een account dat alleen via Google inlogt), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (ook wanneer een wijziging of reset van het wachtwoord het verzoek inhaalde: er wordt geen link verstuurd), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: alleen zonder e-mailbevestiging.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (mislukte herauthenticaties, of te veel geprobeerde codes op dit account), of `rate_limited` (de limiet `reauth` of `reauth_user`, het accountbudget, de wachtrij voor wachtwoordhashes).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: de wachtrij voor wachtwoordhashes, of de database bleef vergrendeld (`retryAfter: 1`: er is niets gewijzigd, en hetzelfde verzoek kan opnieuw worden verstuurd). `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: De gegevens van het account downloaden
      description: |-
        Alles wat de server over het account bewaart, als één JSON-bestand om op te slaan (`format` `scacelith-account-export`, `version` 1). De export legt een beveiligingsgebeurtenis vast (`account_exported`). De array `notes` van het document vertelt de speler in eenvoudig Engels wat er is weggelaten.

        **Nooit in de export:** de wachtwoordhash, het geheim van de tweestapsverificatie en de herstelcodes; enig sessie- of linktoken, of een hash daarvan; de gegevens van het anti-cheatsysteem (integriteitsniveau en -score, afwijkingen, de analyse van de partijen, het gewicht van een melding); de meldingen die andere spelers over de speler hebben gedaan; de identiteit van moderators; privégegevens van andere spelers (tegenstanders verschijnen met hun openbare naam en rating, niets verraadt of een andere speler een sanctie kreeg, en er staat geen IP-adres in dat van iemand anders kan zijn).

        **Herauthenticatie:** het wachtwoord en, bij tweestapsverificatie, een authenticatorcode of een herstelcode. **Limieten:** `account_export` (5 per uur per speler, voor de hele server; elke poging telt, mislukte inbegrepen; als eerste gecontroleerd), daarna `reauth` en `reauth_user`. **Tijdslimiet:** 60 seconden.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Het exportdocument, als bijlage.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Andere tekens dan letters, cijfers, `_`, `.` en `-` in de gebruikersnaam worden `_`.'
              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` (een account dat alleen via Google inlogt), `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` (mislukte herauthenticaties, of te veel geprobeerde codes op dit account), of `rate_limited` (de limiet `account_export`, `reauth` of `reauth_user`, het accountbudget, de wachtrij voor wachtwoordhashes).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (de database bleef vergrendeld, met een `Retry-After: 1`-header), `server_busy` (de wachtrij voor wachtwoordhashes), `timeout` (60 seconden). Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Het account verwijderen
      description: |-
        Verwijdert het account; dit kan niet ongedaan worden gemaakt.

        - Elke sessie wordt meteen ingetrokken: het token levert vanaf dan 401 `invalid_token` op.
        - De gebruikersnaam wordt `deleted#<id>`, in het account en in elk partijrecord.
        - Gewist worden: het e-mailadres, de wachtwoordhash, het geheim van de tweestapsverificatie en de herstelcodes, de sessies en linktokens, de Google-koppeling, het integriteitsrecord van het anti-cheatsysteem, en de IP-adressen die bij beveiligingsgebeurtenissen zijn opgeslagen.
        - Ratings en partijen blijven bewaard. Partijen blijven leesbaar onder de anonieme naam, en `GET /players/{username}` antwoordt met 404 voor de vorige naam.

        **Herauthenticatie:** het wachtwoord en, bij tweestapsverificatie, een authenticatorcode of een herstelcode. **Limieten:** `reauth`, daarna `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: Het account is verwijderd.
          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` (een account dat alleen via Google inlogt), `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` (mislukte herauthenticaties, of te veel geprobeerde codes op dit account), of `rate_limited` (de limiet `reauth` of `reauth_user`, het accountbudget, de wachtrij voor wachtwoordhashes).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (de wachtrij voor wachtwoordhashes, of de database bleef vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: De partijen van de speler weergeven, gefilterd en per pagina
      description: |-
        De partijen van de ingelogde speler, nieuwste eerst, gefilterd en per pagina, met het aantal partijen dat aan het filter voldoet. Elke queryparameter is optioneel, en een lege waarde telt als afwezig.

        Pagineren: geef de `next` van de vorige pagina mee als `before`; `next` is `null` op de laatste pagina. `total` telt de partijen die aan het filter voldoen, over alle pagina's.

        **Limiet:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: De id van een officiële categorie (`3+2`, of `3%2B2`), of `custom` voor elke partij met een ander speeltempo.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Alleen partijen met rating (`true`) of vrijblijvende partijen (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Alleen de gewonnen, verloren of in remise geëindigde partijen, vanuit de speler gezien. Afgebroken partijen verschijnen alleen zonder dit filter.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Eén pagina van de geschiedenis.
          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` is geen partij-id), `invalid_limit`, of `invalid_filter` (`category`, `rated` of `result`), elk met `field` dat de parameter noemt.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de limiet `account_games` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (de database bleef vergrendeld; `retryAfter: 1` in de body, geen `Retry-After`-header), `server_busy` (bij het opzoeken van de sessie was de database vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Een partijrecord met zetten en klokstanden opvragen
      description: |-
        Eén partijrecord, met zetten en klokstanden. Zonder token, of met het token van een speler die de partij niet speelde, is het antwoord het openbare. Wanneer de speler van het token de partij speelde, voegt het antwoord `you` en `reportable` toe.

        **Limiet:** `public_read` (per speler met een token, per client zonder).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Het partijrecord.
          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`: geen positief geheel getal van hoogstens 16 cijfers (onder 2^53); `invalid_request`: geen geldige URL-codering.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: er is een token meegestuurd en het is niet geldig.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: deze partij bestaat niet.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de limiet `public_read` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (de database bleef vergrendeld; `retryAfter: 1` in de body, geen `Retry-After`-header), `server_busy` (bij het opzoeken van de sessie was de database vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Een partij downloaden als PGN-bestand
      description: |-
        Dezelfde partij als PGN-bestand: één partij met regeleinden `\n`, en zettekst in regels van minder dan 80 kolommen. Het antwoord is hetzelfde met of zonder token.

        Tags, in deze volgorde: `Event` (`<SERVER_NAME> rated <category>` of `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (de startdatum in UTC), `Round`, `White`, `Black`, `Result` (`*` voor een afgebroken partij), `UTCDate` en `UTCTime` (de start), `WhiteElo` en `BlackElo` (de ratings bij de start, of `-`), `WhiteRatingDiff` en `BlackRatingDiff` (de wijzigingen, zoals `+10` en `-10`, in elke partij met rating, `+0` wanneer de ratingregels de rating laten waar hij was; een vrijblijvende partij, een partij met aangepast speeltempo of een afgebroken partij heeft geen van beide), `TimeControl` (in seconden), `Termination` (een standaardwaarde van PGN: `normal`; `time forfeit`, het vallen van de vlag, ook wanneer dat in remise eindigt; `abandoned`; `rules infraction`, een tweede onreglementaire zet of verlies wegens een fair-play-overtreding; `unterminated`, een afgebroken partij), `PlyCount`, `ScacelithGameId` (de decimale id).

        Elke zet krijgt `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: de klok van de speler die de zet deed, na de zet, en de tijd die ervoor is aangerekend, in tienden van een seconde (afgekapt). Een waarde wordt weggelaten wanneer het record haar niet heeft. Na de laatste zet volgt de reden van beëindiging in woorden, daarna de uitslag.

        **Limiet:** `public_read` (per speler met een token, per client zonder).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Het PGN-bestand.
          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: 'De PGN-tekst, in UTF-8 (`Content-Type: application/x-chess-pgn; charset=utf-8`).'
              example: |
                [Event "Scacelith rated 3+2"]
                [Site "caissa.scacelith.com"]
                [Date "2026.09.28"]
                [Round "-"]
                [White "alice"]
                [Black "bob"]
                [Result "1-0"]
                [UTCDate "2026.09.28"]
                [UTCTime "18:30:05"]
                [WhiteElo "1500"]
                [BlackElo "1520"]
                [WhiteRatingDiff "+10"]
                [BlackRatingDiff "-10"]
                [TimeControl "180+2"]
                [Termination "normal"]
                [PlyCount "2"]
                [ScacelithGameId "4100000000001"]

                1. e4 {[%clk 0:03:00.0] [%emt 0:00:00.0]} 1... e5 {[%clk 0:03:00.3]
                [%emt 0:00:01.7]} {Resignation} 1-0
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_game_id`: geen positief geheel getal van hoogstens 16 cijfers (onder 2^53); `invalid_request`: geen geldige URL-codering.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: er is een token meegestuurd en het is niet geldig.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: deze partij bestaat niet.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de limiet `public_read` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: onder meer wanneer de opgeslagen zetten niet tot het opgeslagen einde kunnen worden nagespeeld (de server logt het).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (de database bleef vergrendeld; `retryAfter: 1` in de body, geen `Retry-After`-header), `server_busy` (bij het opzoeken van de sessie was de database vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Een partij van deze server downloaden als geanimeerde GIF
      description: |-
        De partij als geanimeerde GIF. De namen en ratings zijn die van het partijrecord (de ratings bij de start, een verwijderd account als `deleted#<id>`), en dat geldt ook voor de uitslag en het einde (`Resignation`, `Loss on time`...).

        De controles gebeuren in deze volgorde: `gif_disabled`, de beeldopties (`size`, `orientation`, `delay`, `coords`), de partij-id, de partij, de lengte ervan.

        **Verplichte sessie:** de quota tellen per account. **Limieten:** `gif` bij elk verzoek; `gif_user_min`, `gif_user_hour`, `gif_ip_min` en `gif_ip_hour` alleen wanneer de GIF moet worden gemaakt (zie de beschrijving van de sectie). **Tijdslimiet:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 seconden (standaard 45 seconden).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: De beeldgrootte, `small` (velden van 32 px), `medium` (48 px) of `large` (72 px).
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: De kleur die onderaan het bord staat.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Milliseconden per zet (1 tot 6 decimale cijfers).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Of de letters van de lijnen en de cijfers van de rijen rond het bord worden getekend (`1`) of niet (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` met `field` (`size`, `orientation`, `delay` of `coords`): een waarde buiten de toegestane. `invalid_game_id`. `invalid_request`: geen geldige URL-codering.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: deze partij bestaat niet. `gif_disabled`: de server heeft GIF''s uitgeschakeld (`GIF_ENABLED=false`; het `gif`-token wordt teruggegeven).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de limiet `gif`, een renderlimiet, of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: de GIF kon niet worden gemaakt (de server logt waarom). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` met `retryAfter` (3 tot 10 seconden) en `Retry-After`: de renderwachtrij is vol, of de GIF wachtte `GIF_QUEUE_TIMEOUT_MS` (10 seconden) op een vrije thread; elk limiettoken dat het verzoek nam, wordt teruggegeven. `busy`: de database bleef vergrendeld (`retryAfter: 1` alleen in de body). `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Een geanimeerde GIF maken van een willekeurige partij die als PGN is verstuurd
      description: |-
        Hetzelfde beeld als `GET /games/{id}/gif` voor een willekeurige partij die als PGN-tekst wordt verstuurd: een partij die het spel heeft opgeslagen, een export van een andere site, een met de hand ingetypte partij. Alleen de eerste partij van de tekst wordt gebruikt.

        - De PGN-lezer accepteert wat de eigen lezer van het spel accepteert: elke PGN die deze server schrijft en de gebruikelijke exports van andere sites (commentaar, varianten, NAG's en klokannotaties worden overgeslagen; zetnummers en SAN worden soepel gelezen; een tag `FEN` geeft de beginstelling, tenzij `SetUp` gelijk is aan `"0"`).
        - De namen en ratings komen uit de tags `White`, `Black`, `WhiteElo` en `BlackElo`. Letters met accenten verliezen hun accenten, andere tekens buiten afdrukbare ASCII worden `?`, en lange namen worden ingekort (48 tekens). De uitslag komt uit de tag `Result`, anders van het einde van de zettekst. De tag `Termination` wordt getoond, tenzij die `normal` is: de eindstelling spreekt dan voor zich (mat, pat).
        - Dit endpoint controleert zijn body zelf: een onbekend veld, of een `pgn` die ontbreekt of geen string is, levert 400 `invalid_request` met `field` op; een verkeerde optie levert 400 `invalid_option` op (`delayMs` moet een JSON-getal zijn, `coords` een boolean; `null` wordt geweigerd).

        **Verplichte sessie:** de quota tellen per account. **Limieten:** zoals voor `GET /games/{id}/gif`. **Bodylimiet:** 135.168 bytes, ongeacht `HTTP_BODY_LIMIT` (de PGN als JSON-string, inclusief escapetekens). **Tijdslimiet:** standaard 45 seconden.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Een korte ingetypte partij, zonder coördinaten
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Een PGN-bestand met alle opties
                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` met `field` (een onbekend veld, of `pgn` ontbreekt of is geen string; zonder `field` wanneer de body geen object is); `invalid_json`; `invalid_option` met `field` (`size`, `orientation`, `delayMs` of `coords`); `invalid_pgn` met `line` en `column` (vanaf 1, kolommen in tekens) en de `message` van de lezer: een onreglementaire of dubbelzinnige zet, een kapotte tag, een onbekende schaakvariant, meer dan 65.536 bytes.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: de server heeft GIF''s uitgeschakeld (`GIF_ENABLED=false`; het `gif`-token wordt teruggegeven).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: een body van meer dan 135.168 bytes.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de limiet `gif`, een renderlimiet, of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: de GIF kon niet worden gemaakt (de server logt waarom). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` met `retryAfter` (3 tot 10 seconden) en `Retry-After`: de renderwachtrij is vol, of de GIF wachtte te lang op een vrije thread; elk limiettoken dat het verzoek nam, wordt teruggegeven. `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Het openbare profiel van een speler opvragen
      description: |-
        Het openbare profiel van een speler: ratings in de officiële categorieën (in de volgorde van de server) en aantallen partijen. `games.total` telt elke opgeslagen partij, vrijblijvende en afgebroken partijen inbegrepen; `games.rated`, `wins`, `draws` en `losses` worden opgeteld over de ratingrecords, dus alleen partijen met rating. Het antwoord is hetzelfde met of zonder token.

        **Limiet:** `public_read` (per speler met een token, per client zonder).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Het profiel.
          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`: niet 2 tot 24 tekens uit `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: er is een token meegestuurd en het is niet geldig.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: deze speler bestaat niet, of het account is verwijderd.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de limiet `public_read` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (de database bleef vergrendeld; `retryAfter: 1` in de body, geen `Retry-After`-header), `server_busy` (bij het opzoeken van de sessie was de database vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: De recente partijen van een speler weergeven
      description: |-
        De recente partijen van de speler, nieuwste eerst, per pagina zoals de geschiedenis, maar zonder filters of totaal. `color` is de kleur van deze speler. `next` is de id van de laatste partij telkens wanneer de pagina vol is, dus de volgende pagina kan leeg zijn. De speler wordt eerst opgezocht: een onbekende speler levert 404 op, wat de query ook zegt.

        **Limiet:** `public_read` (per speler met een token, per client zonder).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Eén pagina met partijen van de speler.
          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` is geen partij-id), `invalid_limit` (deze twee zonder `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: er is een token meegestuurd en het is niet geldig.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: deze speler bestaat niet, of het account is verwijderd.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de limiet `public_read` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (de database bleef vergrendeld; `retryAfter: 1` in de body, geen `Retry-After`-header), `server_busy` (bij het opzoeken van de sessie was de database vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: De beste spelers van een categorie opvragen
      description: |-
        De 100 hoogste ratingrecords van een officiële categorie met minstens `minGames` (`PROVISIONAL_GAMES`) meegetelde partijen, zonder verwijderde accounts en bevestigde valsspelers. De server berekent elke ranglijst hoogstens elke 10 seconden opnieuw; `updatedAt` zegt wanneer. Per categorie loopt er één berekening tegelijk: intussen krijgen de andere verzoeken de vorige lijst, of wachten ze op de berekening wanneer er nog geen lijst is.

        **Limieten:** alleen de laag per adres.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: De id van een officiële categorie (`3+2`, of `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Hoeveel spelers, 1 tot 100. Een groter getal van hoogstens 3 cijfers telt als 100; een lege waarde telt als afwezig.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: De ranglijst.
          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` (ontbreekt, of geen officiële categorie), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: de laag per adres.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (de database bleef vergrendeld; `retryAfter: 1` in de body, geen `Retry-After`-header), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: De tegenstander van een recente partij melden
      description: |-
        Meldt de tegenstander van een van de eigen partijen van de speler die in de laatste 7 dagen is geëindigd. Een melding verandert nooit uit zichzelf een rating, een sanctie of een integriteitsniveau. Ze verhoogt de beoordelingsprioriteit die moderators zien, en vraagt, behalve bij `abuse`, de engine-analyse van de partij aan.

        Een melding van dezelfde tegenstander voor dezelfde partij krijgt hetzelfde antwoord en verandert niets; het antwoord vertelt nooit iets over het gemelde account. `GET /games/{id}` vertelt de spelers van de partij vooraf of een melding zou worden aangenomen (`reportable`).

        Dit endpoint controleert zijn body zelf, in de volgorde `gameId`, `reported`, `category`, `comment`: een fout levert 400 `invalid_request` zonder `field` op. Velden die het niet kent, worden genegeerd.

        **Limieten:** `reports` (per speler), daarna `REPORTS_PER_DAY` (5) meldingen per speler in 24 uur (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: De melding is ontvangen (of was al ingediend).
          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` (zonder `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: niet de tegenstander van de melder in een partij die in de laatste 7 dagen is geëindigd.'
        '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`, met een `Retry-After`-header): `REPORTS_PER_DAY` meldingen in de laatste 24 uur. `rate_limited`: de limiet `reports` of het accountbudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (bij het opzoeken van de sessie was de database vergrendeld), `timeout`. Ook `server_busy` met `retryAfter` 2 tot 5 wanneer de server ver achterloopt (zie de inleiding): het verzoek is niet uitgevoerd.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: De officiële server (de pagina's staan in de root, buiten `/api/v1`).
      - url: https://{host}:{port}
        description: Elke Scacelith-server (de pagina's staan in de root, buiten `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: De openbare hostnaam van de server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: De openbare API-poort (`PUBLIC_API_PORT`, anders `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: De bevestigingspagina voor het e-mailadres tonen
      description: |-
        De pagina van een link om het e-mailadres te bevestigen (registratie, of een opnieuw verstuurde bevestigingsmail). Ze toont alleen een knop "Confirm my e-mail address" (mijn e-mailadres bevestigen), zodat een mailscanner die de link opent, hem niet verbruikt; de knop verstuurt het formulier naar `POST /verify-email`.

        **Limiet:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: De pagina met de bevestigingsknop.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: De link is ongeldig of verlopen (een HTML-pagina).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: De limiet `page` (een HTML-pagina), of de laag per adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Het e-mailadres bevestigen
      description: |-
        Het formulier van de bevestigingspagina. Het bevestigt het adres; bij een nieuwe registratie maakt het dan het account aan, en kan de speler inloggen.

        **Limiet:** `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: Het adres is bevestigd (en het account van een nieuwe registratie aangemaakt).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: De link is ongeldig, gebruikt of verlopen, of het formulier is ongeldig (een HTML-pagina).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: De link van een nieuwe registratie waarvan een ander account intussen de gebruikersnaam of het adres heeft ingenomen; er wordt geen account aangemaakt (een HTML-pagina).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: De limiet `auth` (een HTML-pagina), of de laag per adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'De database bleef vergrendeld (`Retry-After: 1`), of de server loopt ver achter (`Retry-After` 2 tot 5, zie de inleiding): er is niets gewijzigd en de link werkt nog. Ook de time-out van de handler.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: De officiële server (de pagina's staan in de root, buiten `/api/v1`).
      - url: https://{host}:{port}
        description: Elke Scacelith-server (de pagina's staan in de root, buiten `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: De openbare hostnaam van de server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: De openbare API-poort (`PUBLIC_API_PORT`, anders `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Het formulier voor de wachtwoordreset tonen
      description: |-
        De pagina van een wachtwoordresetlink: het formulier voor het nieuwe wachtwoord (het wachtwoord twee keer). Het formulier wordt verstuurd naar `POST /reset-password`.

        **Limiet:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Het formulier voor het nieuwe wachtwoord.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: De link is ongeldig (ook wanneer hij is gemaild naar een adres dat het account niet meer heeft).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: De limiet `page` (een HTML-pagina), of de laag per adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Een nieuw wachtwoord instellen via het resetformulier
      description: |-
        Het formulier van de resetpagina; het doet wat `POST /auth/password/reset` doet, in één transactie: elk apparaat wordt uitgelogd, een openstaande e-mailwijziging wordt geannuleerd, de andere resetlinks werken niet meer, het adres geldt als bevestigd en de eigenaar krijgt een mail.

        Eerst wordt de link gecontroleerd, dan of beide wachtwoorden gelijk zijn, dan de wachtwoordregels. Wanneer de server overbelast is, komt het formulier terug met `Retry-After` en blijft de link geldig.

        **Limieten:** `auth`, daarna `auth_reset` (gedeeld met `POST /auth/password/reset`).
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/ResetPasswordForm'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
              confirmPassword: a much better passphrase
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordForm'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
              confirmPassword: a much better passphrase
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Het wachtwoord is gewijzigd, en elk apparaat is uitgelogd.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Opnieuw het formulier met de fout (de wachtwoorden verschillen, een zwak wachtwoord), de link is ongeldig, of het formulier is ongeldig (een HTML-pagina).
        '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: De limiet `auth` of `auth_reset` (een HTML-pagina), opnieuw het formulier met `Retry-After` wanneer deze client te veel wachtende wachtwoordhashes heeft (de link blijft geldig), of de laag per adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Opnieuw het formulier met `Retry-After` wanneer de server overbelast is (de wachtrij voor wachtwoordhashes, of de database bleef vergrendeld); de link blijft geldig. Wanneer de server ver achterloopt (zie de inleiding), in plaats daarvan een pagina "Server error" (serverfout) met `Retry-After` (2 tot 5): er is niets uitgevoerd en de link blijft geldig. Ook de time-out van de handler.'
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: De officiële server (de pagina's staan in de root, buiten `/api/v1`).
      - url: https://{host}:{port}
        description: Elke Scacelith-server (de pagina's staan in de root, buiten `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: De openbare hostnaam van de server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: De openbare API-poort (`PUBLIC_API_PORT`, anders `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: De bevestigingspagina voor de e-mailwijziging tonen
      description: |-
        De pagina van een link voor een e-mailwijziging: ze toont het nieuwe adres en de naam van het account, met een knop "Use this e-mail address" (dit e-mailadres gebruiken) die het formulier verstuurt naar `POST /confirm-email-change`.

        **Limiet:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: De pagina met het nieuwe adres en de bijbehorende bevestigingsknop.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: De link is ongeldig of verlopen (ook wanneer het adres van het account sinds het verzoek is gewijzigd).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: De limiet `page` (een HTML-pagina), of de laag per adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Het nieuwe e-mailadres bevestigen
      description: |-
        Het formulier van de pagina voor de e-mailwijziging. Het adres verandert en geldt als bevestigd; de apparaten blijven ingelogd; de eerder verstuurde links (bevestiging, wachtwoordreset, andere wijzigingen) werken niet meer; het vorige adres wordt op de hoogte gebracht, met het nieuwe adres gemaskeerd.

        **Limiet:** `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: Het adres is gewijzigd.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: De link is ongeldig, gebruikt of verlopen, of het formulier is ongeldig (een HTML-pagina).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Een ander account heeft het adres intussen ingenomen (een HTML-pagina).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: De limiet `auth` (een HTML-pagina), of de laag per adres (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'De database bleef vergrendeld (`Retry-After: 1`), of de server loopt ver achter (`Retry-After` 2 tot 5, zie de inleiding): er is niets gewijzigd en de link werkt nog. Ook de time-out van de handler.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: De officiële server, in de root.
      - url: https://caissa.scacelith.com/api/v1
        description: De officiële server, onder `/api/v1`.
      - url: https://{host}:{port}
        description: Elke Scacelith-server, in de root.
        variables:
          host:
            default: caissa.scacelith.com
            description: De openbare hostnaam van de server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: De openbare API-poort (`PUBLIC_API_PORT`, anders `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Elke Scacelith-server, onder `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: De openbare hostnaam van de server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: De openbare API-poort (`PUBLIC_API_PORT`, anders `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Controleren of het proces draait
      description: |-
        Antwoordt met 200 `ok` zolang het proces draait. `HEAD` werkt ook. Elke andere methode levert 405 `method_not_allowed` op met `Allow: GET, HEAD` (`OPTIONS` inbegrepen).

        **Limieten:** alleen de laag per adres.
      security: []
      responses:
        '200':
          description: Het proces draait.
          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`: de laag per adres.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: De officiële server, in de root.
      - url: https://caissa.scacelith.com/api/v1
        description: De officiële server, onder `/api/v1`.
      - url: https://{host}:{port}
        description: Elke Scacelith-server, in de root.
        variables:
          host:
            default: caissa.scacelith.com
            description: De openbare hostnaam van de server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: De openbare API-poort (`PUBLIC_API_PORT`, anders `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Elke Scacelith-server, onder `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: De openbare hostnaam van de server (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: De openbare API-poort (`PUBLIC_API_PORT`, anders `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Controleren of de server spelers accepteert
      description: |-
        Antwoordt met 200 `ready` wanneer de server spelers accepteert, en 503 `not_ready` terwijl hij start of stopt. `HEAD` werkt ook. Elke andere methode levert 405 `method_not_allowed` op met `Allow: GET, HEAD` (`OPTIONS` inbegrepen).

        **Limieten:** alleen de laag per adres.
      security: []
      responses:
        '200':
          description: De server accepteert spelers.
          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`: de laag per adres.'
        '503':
          description: De server is aan het starten of stoppen.
          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: |-
        Een sessietoken (`sct_` gevolgd door 43 base64url-tekens) uit een inlogantwoord, in de `Authorization`-header: `Authorization: Bearer <token>`. Het prefix is precies `Bearer` (hoofdlettergevoelig) en één spatie; een waarde zonder dat prefix, of een token dat niet uit 1 tot 512 afdrukbare ASCII-tekens bestaat, levert 401 `invalid_token` op, net als elk ander ongeldig token. Een lege header telt als geen header.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: De partij-id, een positief decimaal geheel getal van hoogstens 16 cijfers zonder voorloopnullen, onder 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: De sessie-id, zoals `GET /auth/sessions` die geeft, geschreven zonder voorloopnullen (`03` is niet sessie 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: De gebruikersnaam van de speler, ongeacht hoofd- of kleine letters. De server accepteert elke naam van 2 tot 24 tekens uit `[A-Za-z0-9_.-]` (de ruimste regel die hij ooit toestond).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Een partij-id; alleen oudere partijen worden weergegeven. Geef de `next` van de vorige pagina mee. Een lege waarde telt als afwezig.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: De paginagrootte, 1 tot 50. Een groter getal van hoogstens 3 cijfers telt als 50; een lege waarde telt als afwezig.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Het token van de e-maillink (43 base64url-tekens). Een ontbrekend of verkeerd token toont de pagina "link invalid or expired" (link ongeldig of verlopen).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Elk antwoord van de server verbiedt caching.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Seconden om te wachten voor een nieuwe poging; dezelfde waarde als `retryAfter` in de body.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: De bearer-challenge, met `error="invalid_token"` voor een ongeldig token.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: De server sluit de verbinding na dit antwoord.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (met `field` wanneer een veld van de body de fout veroorzaakt) of `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Een veld voldoet niet aan het schema
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: De body is geen JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Een zwak wachtwoord
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: Een PGN die niet kan worden gelezen
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (geen `Authorization`-header) of `invalid_token` (het token is misvormd, verlopen, ingetrokken, of hoort bij een verwijderd account).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Geen sessie
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Een ongeldig token
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: Het verzoek wordt geweigerd.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Een verkeerd wachtwoord bij herauthenticatie
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Een geschorst account
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found`, of een functie die deze server heeft uitgeschakeld.'
      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`: de body is niet binnen 10 seconden aangekomen. De server sluit de verbinding.'
      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: Het verzoek is in strijd met de toestand van de server.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: username_taken
            message: This username is already taken.
    Gone:
      description: '`sso_expired`: de Google-inlogpoging of het ticket is onbekend, gebruikt of verlopen; begin opnieuw met inloggen.'
      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`: de body overschrijdt `HTTP_BODY_LIMIT` (standaard 16.384 bytes). De server sluit de verbinding.'
      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`: het verzoekdoel is langer dan 4096 tekens.'
      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`: de body is geen `application/json`, of de charset ervan is geen 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`: de partij heeft meer dan `GIF_MAX_PLIES` (600) halve zetten.'
      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`: los de `pow`-challenge op en verstuur hetzelfde verzoek opnieuw met `pow: { challenge, nonce }`. `reason` zegt waarom: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` of `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` (een rate limit, het accountbudget, of de wachtrij voor wachtwoordhashes) of `too_many_attempts` (een beperking van het account), met `retryAfter` en een `Retry-After`-header.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rateLimited:
              summary: Een rate limit
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Te veel mislukte pogingen op dit account
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: een onverwachte fout. De server logt die.'
      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`: een verkeerde `state` of `iss`, of Google weigerde de code of stuurde een ID-token dat de verificatie niet doorstaat (inclusief een code of ID-token van de andere client, die van het spel of die van de website). De fout bevat nooit de tekst van Google.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: sso_failed
            message: Google sign-in could not be completed.
    ServiceUnavailable:
      description: '`server_busy`, `busy` of `timeout`: probeer het opnieuw na `retryAfter` wanneer die is opgegeven.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: De server is overbelast
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: De database bleef vergrendeld (een leesbewerking)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: De server deed er te lang over
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: De geanimeerde GIF, als bijlage.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` voor een partij van deze server, `attachment; filename="scacelith-game.gif"` voor een PGN die met `POST /gif` is verstuurd.'
          schema:
            type: string
            pattern: '^attachment; filename="scacelith-([1-9][0-9]{0,15}|game)\.gif"$'
          example: attachment; filename="scacelith-4100000000001.gif"
        Content-Length:
          description: De grootte van het bestand in bytes.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: Een HTML-pagina.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: Een HTML-pagina die de weigering uitlegt.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Het formulier is niet binnen 10 seconden aangekomen (een HTML-pagina). De server sluit de verbinding.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: Een HTML-pagina die het conflict uitlegt.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Het formulier overschrijdt `HTTP_BODY_LIMIT` (een HTML-pagina). De server sluit de verbinding.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: De body is geen formulier (`application/x-www-form-urlencoded`) en geen JSON, of de charset ervan is geen UTF-8 (een HTML-pagina).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Een rate limit (een HTML-pagina), met `Retry-After`.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlInternalError:
      description: Een onverwachte fout, als HTML-pagina met de titel "Server error" (serverfout). De server logt die.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: De server is overbelast (de database bleef vergrendeld, of de server loopt ver achter; met `Retry-After`) of deed er te lang over, als HTML-pagina met de titel "Server error" (serverfout).
      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: De enige vorm van elk foutantwoord van de JSON-API.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: De foutcode in `snake_case`. Een client kiest op basis daarvan wat hij toont.
          examples:
            - rate_limited
        message:
          type: string
          description: Een Engelse zin, bedoeld voor logs en als terugvaloptie.
        retryAfter:
          type: integer
          minimum: 1
          description: Seconden om te wachten voor een nieuwe poging, alleen aanwezig bij weigeringen die na verloop van tijd aflopen. Het antwoord bevat dan ook een `Retry-After`-header met dezelfde waarde, behalve bij de 503 `busy` van de leesbewerkingen voor geschiedenis, partijen, spelers en ranglijst.
        field:
          type: string
          description: Het veld of de queryparameter die de fout veroorzaakt (`invalid_request`, `invalid_option`, en `invalid_cursor`, `invalid_limit` en `invalid_filter` van `GET /account/games`). Een genest veld wordt met punten geschreven (`pow.nonce`).
        reason:
          type: string
          description: 'Waarom: de regel die een `weak_password` overtreedt, of waarom een proof of work werd gevraagd of geweigerd (`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: 'Alleen `banned`: het einde van de schorsing (epoch-ms), `null` voor een permanente schorsing.'
        line:
          type: integer
          minimum: 1
          description: 'Alleen `invalid_pgn`: de regel van de fout, vanaf 1.'
        column:
          type: integer
          minimum: 1
          description: 'Alleen `invalid_pgn`: de kolom van de fout, vanaf 1, in tekens.'
    PowChallenge:
      type: object
      description: Een proof-of-work-challenge (de `pow` van een 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: De ondertekende challenge, om ongewijzigd terug te sturen.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Het aantal voorloopnulbits dat `SHA-256(challenge + ":" + nonce)` moet hebben.
        expiresAt:
          type: integer
          format: int64
          description: Wanneer de challenge verloopt (epoch-ms), 2 minuten na de uitgifte.
    PowAnswer:
      type: object
      description: Het antwoord op een proof-of-work-challenge.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: De `challenge` van het 428-antwoord, zoals die is gegeven.
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Een decimale string van hoogstens 20 cijfers waarvoor `SHA-256(challenge + ":" + nonce)` met `bits` nulbits begint.
    ServerInfo:
      type: object
      description: Wat een client nodig heeft voordat hij inlogt of verbinding maakt.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: De naam van de server (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: Een UUID die de database bij de eerste start krijgt. Hij blijft hetzelfde bij herstarts. Hij is `null` wanneer de database hem niet kan geven.
        motd:
          type: string
          description: Het bericht van de dag (`SERVER_MOTD`), mogelijk leeg.
        protocol:
          type: object
          description: Het WebSocket-protocol (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: De oudste protocolversie die de server spreekt.
            max:
              type: integer
              minimum: 1
              description: De nieuwste protocolversie die de server spreekt.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 'De vingerafdruk van het protocolschema (de eerste 4 bytes van de SHA-256 van de canonieke vorm ervan, als geheel getal zonder teken: `0xc649140f`, 3326678031, voor protocol-minorversie 2); ter informatie, wordt nooit vergeleken.'
            subprotocol:
              type: string
              description: Het WebSocket-subprotocoltoken (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: De WebSocket-poort die spelers gebruiken (`PUBLIC_WS_PORT`, anders `WS_PORT`, anders `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Het pad van de WebSocket.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Of er nieuwe accounts kunnen worden aangemaakt.
        emailVerification:
          type: boolean
          description: Of nieuwe accounts hun adres bevestigen (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Of inloggen met Google aan het spel wordt aangeboden.
            googleWeb:
              type: boolean
              description: Of inloggen met Google ook aan een website wordt aangeboden (`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: Tweestapsverificatie is altijd beschikbaar.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: De proof-of-work-bits die de registratie vereist (0 voor geen).
        categories:
          type: array
          description: De officiële speeltempo's, met rating (`RATED_CATEGORIES`), in de volgorde van de server. Elk ander speeltempo is `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: De regels die een client kan controleren voordat hij een formulier verstuurt.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: De kortste gebruikersnaam (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: De langste gebruikersnaam (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: De reguliere expressie waaraan een nieuwe gebruikersnaam moet voldoen.
            passwordMinLength:
              type: integer
              description: Het kortste wachtwoord, in tekens (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: Het langste wachtwoord, in bytes UTF-8.
            customTimeControls:
              type: boolean
              description: Of uitdagingen en privépartijen een aangepast speeltempo mogen gebruiken.
            reportsPerDay:
              type: integer
              description: Hoeveel meldingen een speler per 24 uur mag indienen (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: Het grootste WebSocket-bericht dat een client mag versturen, in bytes.
      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: Een officieel speeltempo.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: De categorie-id, minuten en incrementseconden (`3+2`).
        baseSec:
          type: number
          minimum: 0
          description: De basistijd, in seconden.
        incSec:
          type: number
          minimum: 0
          description: Het increment per zet, in seconden.
    AccountView:
      type: object
      description: Het account zoals de speler het ziet (`user` van de inlogantwoorden en van `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: De account-id.
        username:
          type: string
          description: De gebruikersnaam.
        email:
          type: string
          format: email
          description: Het e-mailadres.
        emailVerified:
          type: boolean
          description: Of het adres is bevestigd.
        mfaEnabled:
          type: boolean
          description: Of tweestapsverificatie aanstaat.
        googleLinked:
          type: boolean
          description: Of er een Google-account is gekoppeld.
        hasPassword:
          type: boolean
          description: '`false` voor een account dat via Google is aangemaakt en nog geen wachtwoord heeft ingesteld.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Of directe uitdagingen bij naam worden geaccepteerd (zie `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Wanneer het account is aangemaakt (epoch-ms).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: De laatste keer inloggen (epoch-ms), of `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Het nieuwe adres van een e-mailwijziging die nog op de link wacht, of `null`.
    SessionAnswer:
      type: object
      description: Een nieuwe sessie.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Het sessietoken, voor de `Authorization`-header en de WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: Het absolute einde van de sessie (epoch-ms); de inactiviteitslimiet kan de sessie eerder beëindigen.
        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: De tweede stap van het inloggen met tweestapsverificatie; ga verder met `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: Het token van de stap, voor `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Resterende seconden om de stap te voltooien.
    LoginAnswer:
      description: Een sessie, of de tweede stap van het inloggen met tweestapsverificatie.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Een eerste keer inloggen met Google; ga verder met `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: Het ticket voor `POST /auth/sso/complete`, 10 minuten geldig.
        suggestedUsername:
          type: string
          description: Een gebruikersnaam gemaakt van de Google-naam of het adres, of `""` wanneer niets past.
    SsoNeedsPassword:
      type: object
      description: Een account met een wachtwoord gebruikt het adres; ga verder met `POST /auth/sso/google/link`. Er is nog niets gekoppeld.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Het ticket voor `POST /auth/sso/google/link`.
        username:
          type: string
          description: De gebruikersnaam van dat account (alleen iemand die het adres aan Google heeft bewezen, ziet hem).
        expiresIn:
          type: integer
          const: 600
          description: Seconden dat het ticket geldig blijft.
    SsoFinishAnswer:
      description: Het antwoord van `POST /auth/sso/google/finish`.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
        - $ref: '#/components/schemas/SsoNeedsUsername'
        - $ref: '#/components/schemas/SsoNeedsPassword'
    SsoStartAnswer:
      type: object
      description: Een Google-inlogpoging.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: De poging, voor `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: De Google-URL om in de systeembrowser te openen, na controle.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: De `state` die Google moet terugsturen.
        expiresIn:
          type: integer
          const: 600
          description: Seconden dat de poging geldig blijft.
    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: De gebruikersnaam; daarna gelden de regels van de server (zie de beschrijving).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Het e-mailadres. Ontdaan van witruimte aan begin en eind en in kleine letters opgeslagen.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Het wachtwoord; daarna gelden de wachtwoordregels.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: De gebruikersnaam, of het e-mailadres (elke tekst met `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Optioneel. Getoond in de lijst met ingelogde apparaten (zoals `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: Het `mfaToken` van het inlogantwoord (of van het antwoord bij inloggen met Google).
        code:
          type: string
          maxLength: 32
          description: Optioneel. Een 6-cijferige authenticatorcode, of een herstelcode.
        recoveryCode:
          type: string
          maxLength: 32
          description: Optioneel. Een herstelcode. Een van `code` en `recoveryCode` is nodig.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Het e-mailadres.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: De parameter `token` van de resetlink.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Het nieuwe wachtwoord; de wachtwoordregels van de registratie gelden.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: De S256-PKCE-challenge van de `codeVerifier` van de client (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: De poort van de listener van de client op `127.0.0.1`.
    SsoWebStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: De S256-PKCE-challenge van de `codeVerifier` van de pagina (`BASE64URL(SHA-256(codeVerifier))`).
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: De `attemptId` van `POST /auth/sso/google/start`.
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: De PKCE-verifier; de SHA-256 ervan moet overeenkomen met de challenge die aan `start` is gegeven.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: De `state` zoals Google die heeft teruggestuurd.
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: De `code` zoals Google die heeft teruggestuurd (afdrukbare ASCII zonder spaties).
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Optioneel. De `iss` zoals Google die heeft teruggestuurd, als dat zo is.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: Het `linkTicket` van `finish`, 10 minuten geldig.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Het wachtwoord van het account.
        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: Het `ssoTicket` van `finish`, 10 minuten geldig.
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: De gebruikersnaam; de regels van de registratie gelden.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: De actieve sessies, de laatst gebruikte eerst.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Een actieve sessie (een ingelogd apparaat).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: De sessie-id, voor `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: Het moment van inloggen (epoch-ms).
        lastSeenAt:
          type: integer
          format: int64
          description: Het laatste gebruik (epoch-ms), hoogstens elke 5 minuten bijgewerkt (later zolang de server ver achterloopt).
        expiresAt:
          type: integer
          format: int64
          description: Het absolute einde (epoch-ms); de inactiviteitslimiet kan de sessie eerder beëindigen.
        clientLabel:
          type:
            - string
            - 'null'
          description: Het `clientLabel` van het inloggen, of `null` wanneer er geen is meegestuurd.
        current:
          type: boolean
          description: Of dit de sessie is die het verzoek doet.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Eén record per categorie waarin de speler partijen met rating heeft gespeeld.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: De actieve sancties.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` zolang het account geschorst is, anders `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Het ratingrecord van één categorie.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: De categorie-id.
        rating:
          type: integer
          description: De rating.
        games:
          type: integer
          minimum: 0
          description: De partijen die in de categorie zijn gespeeld (meegeteld of niet).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: De hoogste bereikte rating.
        provisional:
          type: boolean
          description: '`true` zolang de rating nog in de fase zonder rating is of minder dan `PROVISIONAL_GAMES` meegetelde partijen heeft (het spel toont hem als `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Het soort sanctie.
        reason:
          type:
            - string
            - 'null'
          description: De opgegeven reden, als die er is.
        startsAt:
          type: integer
          format: int64
          description: Het begin (epoch-ms).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Het einde (epoch-ms), `null` wanneer de sanctie permanent is.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Het einde van de schorsing (epoch-ms), `null` wanneer die permanent is.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` om directe uitdagingen bij naam te accepteren, `none` om ze te weigeren.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Het huidige wachtwoord.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Het nieuwe wachtwoord; de wachtwoordregels van de registratie gelden.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Het wachtwoord van het account.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Een code van het openstaande geheim, precies 6 cijfers.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Het wachtwoord van het account.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Optioneel; nodig bij tweestapsverificatie (of `recoveryCode`). Een authenticatorcode, of een herstelcode.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Optioneel. Een herstelcode (`xxxx-xxxx-xx`; hoofd- en kleine letters, spaties en streepjes maken niet uit).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Het wachtwoord van het account.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Een authenticatorcode (een herstelcode wordt geweigerd).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Het nieuwe adres; ontdaan van witruimte aan begin en eind en in kleine letters omgezet, daarna gecontroleerd zoals een adres bij de registratie.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Het wachtwoord van het account.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Optioneel; nodig bij tweestapsverificatie (of `recoveryCode`). Een authenticatorcode, of een herstelcode.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Optioneel. Een herstelcode.
    TotpSetup:
      type: object
      description: Het openstaande geheim voor de authenticator-app.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Het geheim in base32, om in de app in te typen.
        uri:
          type: string
          format: uri
          description: De URI `otpauth://totp/...`, om als QR-code te tonen.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Seconden per stap.
    RecoveryCodeList:
      type: array
      description: De 10 herstelcodes, die alleen deze ene keer worden getoond. Elke code werkt één keer.
      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: Eén kant van een partij.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: De naam in het partijrecord (`deleted#<id>` voor een verwijderd account).
        rating:
          type:
            - integer
            - 'null'
          description: De rating bij de start, `null` wanneer onbekend.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: De rating na de partij. Een partij met rating heeft die altijd; `null` alleen voor een partij die niet meetelt voor de ratings (vrijblijvend, aangepast speeltempo, afgebroken).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: De ratingwijziging, `0` wanneer de ratingregels de rating laten waar hij was (de FIDE-regel bij een nulscore, een tegenstander zonder rating, de eerste partijen van een speler zonder rating); `null` zoals bij `ratingAfter`.
    GameSummary:
      type: object
      description: De samenvatting van een opgeslagen partij.
      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: De partij-id.
        category:
          type: string
          description: De id van de officiële categorie (`3+2`), of `custom`.
        rated:
          type: boolean
          description: Of de partij meetelt voor de ratings.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Het speeltempo in seconden (`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` (zie de partijcodes).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: De code van de reden van beëindiging (zie de partijcodes).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: De uitslag, `*` voor een afgebroken partij.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Het aantal halve zetten.
        startedAt:
          type: integer
          format: int64
          description: Het begin (epoch-ms).
        endedAt:
          type: integer
          format: int64
          description: Het einde (epoch-ms).
    Termination:
      type: string
      description: De naam van de reden van beëindiging (zie de partijcodes); `Unknown` voor een code die het protocol niet kent.
      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: Een partijsamenvatting vanuit één speler gezien.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: De kleur van de speler.
    HistoryGameSummary:
      description: Een partij uit de geschiedenis van de ingelogde speler.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: De basistijd in milliseconden.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Het increment in milliseconden.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: De uitkomst vanuit de speler gezien.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: De partijen van de pagina, nieuwste eerst.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: De id om als `before` mee te geven voor de volgende pagina, of `null` op de laatste pagina.
        total:
          type: integer
          minimum: 0
          description: Het aantal partijen dat aan het filter voldoet, over alle pagina's.
    MoveRecord:
      type: object
      description: Eén halve zet van een partij.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: De zet in UCI-notatie, met `n`, `b`, `r` of `q` erachter voor een promotie.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: De tijd die voor de zet is aangerekend (ms), `null` wanneer het record die niet heeft.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: De klok van de speler die de zet deed, na de zet (ms), `null` wanneer het record die niet heeft.
    PgnTagsView:
      type: object
      description: De belangrijkste PGN-tags, om te tonen. `Termination` is hier de naam van de reden van beëindiging; het PGN-bestand gebruikt de standaardwaarden van PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` of `<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: De startdatum in UTC.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: De rating bij de start, of `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: De rating bij de start, of `"-"`.
          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: Een partijrecord met zetten en klokstanden. Het heeft de velden van een partijsamenvatting, zonder `color` en `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: De basistijd in milliseconden.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Het increment in milliseconden.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: De naam van `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: De id van de partij waarvan deze partij de revanche is, of `null`.
            moves:
              type: array
              description: Eén item per halve zet.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Alleen wanneer de speler van het token deze partij speelde; de kleur van die speler.
            reportable:
              type: boolean
              description: Alleen wanneer de speler van het token deze partij speelde; `true` wanneer `POST /reports` nu een melding van de tegenstander voor deze partij zou aannemen (de partij is binnen 7 dagen geëindigd, het dagquotum is niet opgebruikt, en de tegenstander is voor deze partij nog niet gemeld).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: De gebruikersnaam, zoals de speler hem schreef.
        createdAt:
          type: integer
          format: int64
          description: Wanneer het account is aangemaakt (epoch-ms).
        ratings:
          type: array
          description: Alleen de officiële categorieën, in de volgorde van de server.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Elke opgeslagen partij, vrijblijvende en afgebroken partijen inbegrepen.
            rated:
              type: integer
              minimum: 0
              description: Partijen met rating, opgeteld over de ratingrecords.
            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: De gebruikersnaam, zoals de speler hem schreef.
        games:
          type: array
          description: De partijen van de pagina, nieuwste eerst.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: De id van de laatste partij telkens wanneer de pagina vol is (de volgende pagina kan dan leeg zijn), anders `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: De categorie-id.
        minGames:
          type: integer
          minimum: 0
          description: Het aantal meegetelde partijen dat een record nodig heeft om in de lijst te staan (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Wanneer de ranglijst is berekend (epoch-ms).
        players:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LeaderboardEntry'
    LeaderboardEntry:
      type: object
      required:
        - rank
        - username
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
      properties:
        rank:
          type: integer
          minimum: 1
        username:
          type: string
        rating:
          type: integer
        games:
          type: integer
          minimum: 0
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
    GifRequest:
      type: object
      additionalProperties: false
      required:
        - pgn
      properties:
        pgn:
          type: string
          description: De PGN-tekst, hoogstens 65.536 bytes UTF-8. Alleen de eerste partij wordt gebruikt.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Optioneel. De beeldgrootte.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Optioneel. De kleur die onderaan het bord staat.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Optioneel. Milliseconden per zet, een JSON-getal met een gehele waarde.
        coords:
          type: boolean
          default: true
          description: Optioneel. Of de letters van de lijnen en de cijfers van de rijen rond het bord worden getekend.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: De partij, als geheel getal of als string van 1 tot 16 cijfers.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: De gebruikersnaam van de tegenstander, zoals in het partijrecord of zoals hij nu is, ongeacht hoofd- of kleine letters. Hij mag niet leeg zijn.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: Waar de melding over gaat.
        comment:
          type:
            - string
            - 'null'
          description: Optioneel. Hoogstens 500 tekens nadat stuurtekens (behalve tab en regelinvoer) zijn verwijderd en de tekst is ontdaan van witruimte aan begin en eind.
    AccountExport:
      type: object
      description: Alles wat de server over het account bewaart (tijden in epoch-ms).
      required:
        - format
        - version
        - exportedAt
        - server
        - notes
        - account
        - ratings
        - ratingRefunds
        - sessions
        - securityEvents
        - sanctions
        - conduct
        - reportsFiled
        - games
      properties:
        format:
          type: string
          const: scacelith-account-export
        version:
          type: integer
          const: 1
        exportedAt:
          type: integer
          format: int64
          description: Wanneer de export is gemaakt.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Wat het bestand bevat en weglaat, in eenvoudig Engels voor de speler.
          items:
            type: string
        account:
          description: De accountweergave van `GET /account/me`, met `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: Het adres van het gekoppelde Google-account, of `null`.
        ratings:
          type: array
          description: De volledige ratingrecords.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Ratingpunten die zijn teruggegeven nadat een tegenstander op valsspelen werd betrapt, opgeteld per UTC-dag en categorie, nieuwste eerst. Noch de partijen, noch de valsspelers worden genoemd.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Elke opgeslagen sessie, nieuwste eerst, zonder enig token. De retentie-opschoning verwijdert een verlopen sessie, en een uitgelogde sessie een dag na het uitloggen.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: De beveiligingsgebeurtenissen, nieuwste eerst, bewaard gedurende `RETENTION_SECURITY_DAYS` dagen.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Elke sanctie, opgeheven sancties inbegrepen. De naam van de moderator wordt nooit vermeld.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: De gedragsgebeurtenissen die zijn vastgelegd voor verlaten en afgebroken partijen van de speler en voor partijen zonder eerste zet, 30 dagen bewaard.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: De meldingen die de speler heeft gedaan.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Het aantal partijen.
            list:
              type: array
              description: Elke partij, nieuwste eerst, als samenvattingen van `GET /account/games`. De zetten staan bij `GET /games/{id}` en de PGN bij `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Een volledig ratingrecord.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Of de speler de fase zonder rating heeft verlaten.
            countedGames:
              type: integer
              minimum: 0
              description: De partijen die in de rating zijn meegeteld.
            updatedAt:
              type: integer
              format: int64
              description: Wanneer het record voor het laatst is gewijzigd.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: 00:00 UTC van de dag (epoch-ms).
        category:
          type: string
        points:
          type: integer
          description: De punten die die dag in die categorie zijn teruggegeven.
    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: Wanneer de sessie is uitgelogd, of `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: Het adres van waaruit is ingelogd, gewist na `RETENTION_IP_DAYS` dagen.
    SecurityEvent:
      type: object
      description: |-
        Een beveiligingsgebeurtenis. `ip` wordt alleen gegeven voor wat in ingelogde toestand is gedaan, met het wachtwoord van het account (en de tweede factor) of met een link die naar het adres ervan is gemaild: `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` en `account_exported`; elke andere soort heeft `ip: null`, en `ip` wordt gewist na `RETENTION_IP_DAYS` dagen.

        `detail` bewaart alleen deze velden: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` en `method` (`password`, of `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` en `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. Een gebeurtenis `moderator_action` bewaart alleen `{ action }`, voor de acties `ban`, `unban`, `reset_mfa`, `verify_email` en `revoke_sessions` (de andere moderatoracties worden weggelaten). Elke andere soort heeft `detail: null`. De gebeurtenissen `rating_refund` worden weggelaten: hun punten staan in `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: De soort gebeurtenis (`login`, `password_changed`...).
        at:
          type: integer
          format: int64
          description: Wanneer het gebeurde.
        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: De huidige openbare naam van de gemelde speler.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Of de melding nog openstaat (of de gemelde speler een sanctie kreeg, wordt niet gezegd).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Het token van de e-maillink (het verborgen veld van het formulier van de pagina).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Het token van de resetlink (het verborgen veld van het formulier).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Het nieuwe wachtwoord; de wachtwoordregels van de registratie gelden.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Opnieuw het nieuwe wachtwoord; het moet hetzelfde zijn.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: Een HTML-pagina in UTF-8 (`text/html; charset=utf-8`), zonder JavaScript of externe bronnen.
