openapi: 3.1.1
info:
  title: Scacelith-serverens HTTPS-API
  version: "1.1.0"
  summary: HTTPS-API'en for alle Scacelith-servere, både den officielle server og fællesskabsservere.
  description: |-
    Alle Scacelith-servere, den officielle `caissa.scacelith.com` og enhver fællesskabsserver, udstiller denne HTTPS-API. Spillet bruger den til alt uden for selve partiet: registrering og login (inklusive totrinsgodkendelse og Google), kontosiden, partihistorikken, PGN-downloads og animerede GIF'er af partier, indloggede enheder, download af data, sletning af kontoen og anmeldelser.

    Spil i realtid (søgning efter modstander, udfordringer, træk, ure) foregår via samme servers WebSocket (`wss://<host>/ws`), som åbnes med et sessionstoken fra denne API; det er beskrevet i `PROTOCOL.md`, ikke her.

    Standardværdierne nedenfor er dem, en server med uændret konfiguration har; en fællesskabsserver kan ændre dem (`CONFIG.md` nævner hver indstilling).

    ## Basis-URL, port og transport

    - Officiel server: `https://caissa.scacelith.com/api/v1`, TCP-port 443.
    - En fællesskabsserver: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` er 443 som standard; bag en NAT eller en proxy er den port, spillerne bruger, `PUBLIC_API_PORT`.
    - Spillets WebSocket ligger som standard på samme port (`WS_PORT` flytter den; `GET /info` fortæller klienten, hvor den er).
    - HTTP/1.1 over TLS. Med `TLS_MODE=proxy` terminerer en reverse proxy foran serveren TLS. `TLS_MODE=off` (ukrypteret HTTP) er kun til lokal udvikling.
    - De HTML-sider, der åbnes fra links i e-mails (`/verify-email`, `/reset-password`, `/confirm-email-change`), ligger i serverens rod, uden for `/api/v1`. Endpointene til tilstandstjek svarer både i roden og under `/api/v1`. Google-login har ingen side på serveren: Google sender browseren tilbage til selve spillet, på `127.0.0.1`, eller til en side på det website, der startede det (`GOOGLE_WEB_REDIRECT_URI`).
    - En afsluttende skråstreg ignoreres (`/api/v1/info/` er `/api/v1/info`), og stiparametre URL-dekodes (en parameter, der ikke er gyldig URL-kodning, giver svaret 400 `invalid_request`).

    ## Anmodninger

    - **JSON-bodies.** `POST`-, `PUT`- og `DELETE`-anmodninger indeholder et JSON-objekt med `Content-Type: application/json`. Et andet `charset` end UTF-8 afvises, og det samme gør enhver anden indholdstype (415 `unsupported_media_type`). En tom body tæller som `{}`, og det er netop det, endpoints uden parametre tager imod (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Størrelse og tid.** En body er begrænset til `HTTP_BODY_LIMIT` byte (16.384 som standard; `POST /gif` har sin egen grænse på 135.168 byte): 413 `payload_too_large`. Den skal ankomme inden for 10 sekunder: 408 `request_timeout`. Efter hver af disse fejl lukker serveren forbindelsen.
    - **Strikse skemaer.** Et felt, som endpointet ikke kender, afvises, et felt, der ikke er markeret som valgfrit, er påkrævet, og typer og længder kontrolleres. Enhver af disse fejl giver svaret 400 `invalid_request`, hvor `field` angiver feltet (med punktummer for et indlejret felt, fx `pow.nonce`). Strenge må ikke indeholde kontroltegn. Længder tælles i UTF-16-kodeenheder, så et tegn uden for Basic Multilingual Plane (en emoji) tæller dobbelt. En body, der ikke er JSON, giver svaret 400 `invalid_json`. `POST /gif` og `POST /reports` kontrollerer selv deres bodies (se de enkelte endpoints).
    - **Query-strenge.** Den første forekomst af en parameter gælder, ukendte parametre ignoreres, og et `+` dekodes til et mellemrum: de endpoints, der tager en betænkningstidskategori, accepterer både `3%2B2` og `3+2`.
    - **Metoder.** `HEAD` er `GET` uden body. `OPTIONS` på en eksisterende sti giver svaret 204 med en `Allow`-header (og headerne fra en CORS-preflight, se nedenfor). Enhver anden metode, som stien ikke har, giver svaret 405 `method_not_allowed` med `Allow`.
    - **Anmodningsmål.** Det må ikke overstige 4096 tegn (414 `uri_too_long`). En anmodning, der slet ikke kan parses, eller hvis hoved er for stort eller ankommer for langsomt, får et tomt svar med 400, 431 eller 408, og forbindelsen lukkes.
    - **CORS: en tilladelsesliste, slået fra som standard.** API'en betjener spillet, som ikke sender nogen `Origin`. Når `CORS_ORIGINS` er tom (standard), sendes der aldrig nogen `Access-Control-*`-header, så ingen webside kan læse et svar. `CORS_ORIGINS` angiver de weboprindelser, hvis sider må kalde API'en fra en browser, hver nøjagtigt som browsere sender den i `Origin`: `https://host` eller `https://host:port`, med små bogstaver, uden sti, afsluttende skråstreg eller standardport; `http://` kun for `localhost`, `127.0.0.1` og `[::1]` (lokal udvikling); intet jokertegn. Den officielle server sætter `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com` for sit website. Så gælder:
      - Ethvert svar for en sti under `/api/v1`, bortset fra endpointene til tilstandstjek, ved succes såvel som ved fejl (inklusive 429 fra laget pr. adresse), indeholder `Vary: Origin`. Når anmodningens `Origin` byte for byte er en af de angivne oprindelser, indeholder det også `Access-Control-Allow-Origin: <that origin>` og, undtagen i svaret på en preflight-anmodning, `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, så siden kan læse fejlkoderne, `Retry-After` og filnavnene på PGN- og GIF-downloads.
      - En preflight-anmodning (`OPTIONS` med `Access-Control-Request-Method`) fra en angivet oprindelse på en eksisterende sti får svaret 204, uden body, med `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (metoderne fra `Allow`), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600` og `Vary: Origin`. Den kræver intet token og tæller hverken mod noget kontobudget eller nogen endpointgrænse; som enhver anden anmodning tager den et token fra laget pr. adresse. En browser genbruger en preflight-anmodning i 10 minutter, men kun for præcis den URL, inklusive query-strengen. En anmodning uden `Authorization` og uden JSON-body, fx `GET /info` eller `GET /leaderboard`, kræver ingen preflight-anmodning.
      - Ethvert andet svar på en preflight-anmodning er en mislykket preflight, også med CORS-headerne: en 404 for en sti, der ikke findes, eller 429 fra laget pr. adresse. Browseren sender så ikke anmodningen, og siden ser en netværksfejl uden svaret og dets `Retry-After`.
      - `Access-Control-Allow-Credentials` sendes aldrig, og det gør `Access-Control-Allow-Origin: *` heller ikke: en side sender sit sessionstoken i `Authorization: Bearer`, ligesom spillet gør, og API'en sætter ingen cookie. HTML-siderne, endpointene til tilstandstjek og de tomme svar på ulæselige anmodninger har ingen CORS-header. WebSocket bruger ikke CORS: `WS_ALLOWED_ORIGINS` angiver de sider, der må åbne den.
      - En side fra en oprindelse, der ikke er angivet, kan intet læse, og dens skrivende anmodninger på tværs af sites sendes aldrig: JSON-bodies og `Authorization` kræver en preflight-anmodning, hvis svar ikke har nogen `Access-Control-Allow-Origin` for den oprindelse. `Cross-Origin-Resource-Policy: same-origin` forbliver på alle svar: den gælder kun for `no-cors`-anmodninger (et andet site, der indlejrer et svar), aldrig for `cors`-anmodninger fra en angivet side.

    ## Svar og fejl

    - Svar er JSON i UTF-8, undtagen PGN-download (`application/x-chess-pgn`), de animerede GIF'er (`image/gif`) og HTML-siderne. Tidspunkter er millisekunder siden 1970-01-01 UTC. Id'er er heltal. Parti-id'er har op til 16 cifre, men holder sig under 2^53, så et JSON-tal (en double) kan rumme dem nøjagtigt.
    - Alle API'ens svar indeholder `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` og `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (HTML-siderne har deres egen politik). Med `TLS_MODE=native` indeholder de også `Strict-Transport-Security: max-age=31536000`. De svar, som listeneren selv skriver, før API'en (`GET` og `HEAD` på endpointene til tilstandstjek, 429 fra laget pr. adresse og en 500 `internal_error`, når API'en fejler fuldstændigt), har de samme headers undtagen `Cross-Origin-Resource-Policy`, og deres `Content-Type` er `application/json` uden charset.
    - Et svar skal læses inden for 60 sekunder fra det øjeblik, serveren har det klar, hvilket kun har betydning for de store (en GIF, dataeksporten, en lang PGN). Den tid, serveren bruger på at forberede et svar, tæller hverken med i dette eller i de 30 sekunder uden en byte ind eller ud, hvorefter en forbindelse lukkes.

    Fejl har én form (skemaet `Error`):

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

    `message` er beregnet til logs og som reserve; en klient vælger ud fra `error`, hvad der skal vises. `retryAfter` (sekunder) findes kun ved afvisninger, der ophører med tiden, og svaret indeholder så også en `Retry-After`-header med samme værdi; den eneste undtagelse er 503 `busy` ved læsning af historik, partier, spillere og rangliste, som kun har feltet. Nogle fejl tilføjer felter: `field` (ugyldigt input), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` og `column` (`invalid_pgn`).

    Fejl, som ethvert endpoint kan give:

    | Status | `error` | Hvornår |
    |---|---|---|
    | 400 | `invalid_request` | Bodyen bryder endpointets skema (`field` angiver hvor), anmodningsmålet eller `Content-Length` er fejlformateret, en stiparameter er ikke gyldig URL-kodning, eller bodyen blev afkortet. |
    | 400 | `invalid_json` | Bodyen er ikke JSON. |
    | 401 | `unauthorized` | Ingen `Authorization`-header på et endpoint, der kræver en session. |
    | 401 | `invalid_token` | Tokenet er fejlformateret, udløbet, tilbagekaldt eller tilhører en slettet konto; også på endpoints, hvor sessionen er valgfri. |
    | 404 | `not_found` | Endpointet findes ikke. Nogle endpoints bruger den også: partiet, spilleren eller sessionen findes ikke. |
    | 405 | `method_not_allowed` | Stien findes for andre metoder (se `Allow`). |
    | 408 | `request_timeout` | Bodyen ankom ikke inden for 10 sekunder. |
    | 413 | `payload_too_large` | Bodyen overstiger `HTTP_BODY_LIMIT` (135.168 byte for `POST /gif`). |
    | 414 | `uri_too_long` | Anmodningsmålet overstiger 4096 tegn. |
    | 415 | `unsupported_media_type` | Bodyen er ikke `application/json`, eller dens charset er ikke UTF-8. |
    | 429 | `rate_limited` | En hastighedsgrænse (se nedenfor): `retryAfter` plus en `Retry-After`-header. |
    | 500 | `internal_error` | En uventet fejl. Serveren logger den. |
    | 503 | `timeout` | Serveren svarede ikke inden for 30 sekunder (60 sekunder for eksporten, 45 sekunder for GIF'erne med standardindstillingerne). |
    | 503 | `server_busy` | Et sessionsopslag eller en kontoændring fandt databasen låst (`retryAfter` 1), eller hashkøen for adgangskoder er fuld (se nedenfor). Også, med en `retryAfter` på 2 til 5, når serveren er langt bagud: for en `POST`, `PUT`, `PATCH` eller `DELETE` har databasen for mange ventende skrivninger; for enhver anmodning kører for mange tidligere anmodninger stadig ud over deres timeout, eller sessionstokenet kunne ikke kontrolleres inden for 5 sekunder. Anmodningen blev ikke udført, og dens hastighedsgrænser tog intet. |

    Læse-endpointene (partihistorik, partier, spillere, rangliste) og eksporten svarer 503 `busy` med `retryAfter: 1`, når databasen forblev låst.

    Endpoints, der kontrollerer eller hasher en adgangskode, kan også svare med en af disse, begge med en tilfældig `retryAfter` på 5 til 15 sekunder:

    - 503 `server_busy`: serverens hashkø for adgangskoder (`PASSWORD_HASH_QUEUE_MAX`) er fuld, eller ventetiden udløb.
    - 429 `rate_limited`: køen er mindst halvt fuld, og denne klient (en IPv4-adresse eller et IPv6-/48-net) har allerede `PASSWORD_HASH_WAITERS_PER_SOURCE` hashes i kø. Denne 429 giver de tokens til hastighedsgrænserne tilbage, som anmodningen tog.

    Intet blev ændret, og intet mislykket forsøg blev talt med (undtagen på `POST /auth/sso/google/link`, hvor forsøget på billetten og kontoens mislykkede forsøg blev registreret før hashingen), og et nulstillingslink forbliver gyldigt.

    HTML-siderne returnerer deres fejl som HTML-sider med de samme statuskoder.

    ## Godkendelse

    De endpoints, der kræver en session, tager et bearer-token i `Authorization`-headeren (skemaet `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Sådan fås et token.** Et token (`sct_` efterfulgt af 43 base64url-tegn) kommer fra `POST /auth/login`, derefter `POST /auth/login/mfa`, når totrinsgodkendelse er slået til, fra `POST /auth/sso/google/finish` og `POST /auth/sso/google/link` (Google-login) og fra `POST /auth/sso/complete`. Hver af dem svarer `{ token, expiresAt, user }`. Serveren gemmer kun en SHA-256 af tokenet.
    - **Påkrævet session.** En manglende header giver svaret 401 `unauthorized` med `WWW-Authenticate: Bearer realm="scacelith"`. Et ugyldigt token giver svaret 401 `invalid_token` med `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. En klient bør glemme et token, der får `invalid_token`, og logge ind igen.
    - **Valgfri session.** På `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` og `GET /players/{username}/games` er sessionen valgfri. Uden headeren svarer de med den offentlige visning; hvis der sendes en header, skal dens token være gyldigt.
    - **Optaget lager.** Når databasen forbliver låst, mens et token kontrolleres, svarer et endpoint, der kræver eller accepterer en session, 503 `server_busy` med `retryAfter: 1` (og `Retry-After`); når kontrollen tager mere end 5 sekunder, svarer det 503 `server_busy` med en `retryAfter` på 2 til 5. I begge tilfælde blev tokenet ikke bedømt: behold det, og send anmodningen igen.
    - **Levetid.** En session slutter ved det første af `SESSION_MAX_DAYS` (90) dage efter login (det er `expiresAt`) og `SESSION_IDLE_DAYS` (30) dage uden brug. Hver brug udskyder inaktivitetsgrænsen; serveren skriver den nye værdi højst hvert 5. minut, i baggrunden. Mens dens database har mange ventende skrivninger, udskyder den denne skrivning til en senere anmodning, undtagen inden for den sidste time før inaktivitetsgrænsen, så en session i brug ikke udløber. En konto beholder højst `MAX_SESSIONS_PER_USER` (10) sessioner: et nyt login tilbagekalder de ældste ud over dette antal.
    - **Tilbagekaldelse.** At logge ud (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) tilbagekalder sessioner; det samme gør et skift af adgangskode (de andre sessioner), en nulstilling af adgangskoden og sletning af kontoen (alle sessioner) samt en administrator. En tilbagekaldelse via API'en træder i kraft med det samme, og den WebSocket, der blev åbnet med en tilbagekaldt session, lukkes. Et skift eller en nulstilling af adgangskoden og sletning af kontoen tilbagekalder i samme transaktion som selve ændringen: når en af dem mislykkes, er ingen session blevet tilbagekaldt, og intet andet er heller ændret. Serveren cacher sessionsopslag i 30 sekunder, så en tilbagekaldelse via admin-kommandoen, en separat proces, træder i kraft inden for 30 sekunder.
    - **Omfang.** Et token hører til én server og åbner også dens WebSocket. Send det aldrig til en anden server.

    ## Hastighedsgrænser og anden drosling

    Grænser gælder for et af to omfang. **Klient:** en IPv4-adresse eller et IPv6-/64-net (i proxytilstand kommer adressen fra `X-Forwarded-For`, sendt af en `TRUSTED_PROXIES`-adresse); nogle grænser tæller også hvert IPv6-/48-net som helhed, oven i hvert af dets /64-net. **Spiller:** den indloggede konto, uanset dens adresse; en grænse, der tælles pr. spiller på et endpoint, hvor sessionen er valgfri, tælles pr. klient for en anmodning uden token.

    Hver grænse er en token bucket, der rummer `limit` anmodninger og fyldes løbende op med `limit / window`; `retryAfter` er tiden indtil næste token. Grænser markeret som *delt* tælles desuden over et glidende vindue af samme længde, så intet vindue indeholder meget mere end `limit` anmodninger. Hver grænse tælles for hele serveren. Når en af et endpoints grænser afviser en anmodning, gives de tokens tilbage, som dets andre grænser tog for den anmodning. En afvisning giver svaret 429 `rate_limited` med `retryAfter` og `Retry-After`.

    - **Laget pr. adresse.** Hver anmodning (enhver sti og metode, inklusive endpointene til tilstandstjek og WebSocket-upgrades) tager først ét token fra sin klients budget: `HTTP_RATE_PER_IP` (600) pr. minut med et burst på et halvt minut og `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) for et IPv6-/48-net. En klient må desuden højst have `IP_MAX_INFLIGHT` (32 x `WORKERS`) anmodninger i gang (`retryAfter` 1 derudover). En klient, der fortsætter efter sine afvisninger, blokeres: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) afvisninger på ét minut blokerer den i 1 minut, derefter i 4, 16 og 60 minutter ved hver ny blokering inden for 6 timer. En afvisning fra grænserne `auth`, `auth_*` og `reauth` tæller 5; grænser, der tælles pr. spiller, tæller aldrig. Adresser i `ABUSE_EXEMPT` springer dette lag over, men ikke kontobudgettet eller endpointgrænserne.
    - **Kontobudget.** Hver anmodning med et gyldigt sessionstoken tæller også mod sin konto: `USER_RATE_PER_MIN` (120) pr. minut for alle endpoints tilsammen, uanset adressen, med et burst på et halvt minut.
    - **Endpointgrænser:**

    | Grænse | Standard | Tælles pr. | Endpoints |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, delt | klient og `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) pr. IPv6-/48-net | `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) / time, delt | klient, 3 gange så mange pr. IPv6-/48-net | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / time, delt | klient, 3 gange så mange pr. IPv6-/48-net | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / time, delt | klient, 3 gange så mange pr. IPv6-/48-net | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 timer, delt | klient, 3 gange så mange pr. IPv6-/48-net | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / time, delt | klient, 3 gange så mange pr. IPv6-/48-net | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | samme tal som `auth`, sin egen bucket, delt | klient og IPv6-/48-net | `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, delt | spiller | de samme endpoints som `reauth` |
    | `account` | 60 / min | spiller | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | spiller | `GET /account/games` |
    | `account_export` | 5 / time, delt | spiller | `POST /account/export` (kontrolleres før `reauth`; hvert forsøg tæller) |
    | `sessions` | 60 / min | spiller | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | spiller (klient uden token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (én bucket for alle fire) |
    | `gif` | 30 / min | spiller | `GET /games/{id}/gif`, `POST /gif` (én bucket for begge) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min og `GIF_USER_RENDERS_PER_HOUR` (30) / time, delt | spiller | de samme to, kun når GIF'en skal laves (ikke fra cachen) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min og `GIF_IP_RENDERS_PER_HOUR` (120) / time, delt | klient (alle dens konti tilsammen), 3 gange så mange pr. IPv6-/48-net | de samme, som ovenfor |
    | `reports` | 30 / time | spiller | `POST /reports` |
    | `sso_start` | 30 / 10 min, delt | klient og 90 pr. IPv6-/48-net | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (tilsammen) |
    | `sso_finish` | 30 / min | klient | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | klient | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` og `GET /leaderboard` har ingen egen grænse: kun laget pr. adresse. Et endpoint med flere grænser kontrollerer dem i den rækkefølge, der er angivet i dets beskrivelse.

    Serveren behandler en anmodning i denne rækkefølge: laget pr. adresse, derefter endpointene til tilstandstjek; match af endpoint; kontrollerne af, at serveren ikke er langt bagud (503 `server_busy`): for mange tidligere anmodninger, der stadig kører ud over deres timeout, og, for en `POST`, `PUT`, `PATCH` eller `DELETE`, databasens kø af skrivninger; godkendelse, inden for 5 sekunder (503 `server_busy` derudover); kontobudgettet, når anmodningen har en session; endpointets grænser; bodyen; selve endpointet (GIF-endpointene tager kun deres renderingsgrænser, når de har en GIF at lave). En anmodning, der afvises på grund af sit token, bruger altså ingen af endpointets tokens, mens en anmodning med en ugyldig body bruger dem.

    Anden drosling, som endpointene selv besvarer:

    - **Mislykkede loginforsøg på ét loginnavn** (brugernavn eller e-mail): fra og med `AUTH_FAILURES_PER_ACCOUNT` (5) mislykkede forsøg skal hvert forsøg vente dobbelt så længe som det foregående (2 s, 4 s osv., op til 15 minutter): 429 `too_many_attempts` med `retryAfter`. Tælleren nulstilles efter en time uden mislykkede forsøg.
    - **Forkerte koder til anden faktor ved login:** samme regel fra og med kontoens 5. forkerte kode. Ét logintrin accepterer højst 5 forkerte koder.
    - **Koder til anden faktor for én konto:** højst `AUTH_MFA_PER_ACCOUNT` (10) koder (koder fra godkendelsesappen eller gendannelseskoder, rigtige eller forkerte) pr. 15 minutter, fra enhver adresse, ved login og ved fornyet godkendelse; derudover 429 `too_many_attempts`, før koden kontrolleres, så en gendannelseskode ikke bruges op.
    - **Mislykkede fornyede godkendelser for en konto** (forkert adgangskode eller kode): samme regel (429 `too_many_attempts`), fælles for alle endpoints, der kræver fornyet godkendelse.
    - **E-mails:** én bekræftelses- eller nulstillingsmail pr. adresse hvert 5. minut (svaret forbliver det samme); én meddelelse af typen »nogen har forsøgt at bruge din adresse« pr. adresse pr. time.
    - **Anmeldelser:** `REPORTS_PER_DAY` (5) pr. spiller i løbet af 24 timer: 429 `report_limit`.

    ## Proof of work

    `POST /auth/register` kræver altid et proof of work, når `POW_REGISTER_BITS` er over 0 (18 som standard; `GET /info` angiver det som `pow.register`). `POST /auth/login` og `POST /auth/sso/google/link` (én slags challenge for begge) kræver kun et i 5 minutter, efter at serveren har set en bølge af mislykkede loginforsøg (`POW_LOGIN_TRIGGER_PER_MIN`, derefter `POW_LOGIN_BITS`); en klient finder ud af det via svaret.

    1. Anmodningen uden bevis (eller med et afvist bevis) giver svaret 428 `pow_required` med `reason` og en `pow`-challenge `{ challenge, bits, expiresAt }`.
    2. Find en nonce: en decimal streng på højst 20 cifre, så `SHA-256(challenge + ":" + nonce)` starter med `bits` nulbit (den mest betydende bit i den første byte først). 18 bit kræver i gennemsnit omkring 260.000 hashes.
    3. Send den samme anmodning igen med `"pow": { "challenge": "...", "nonce": "123456" }` i bodyen.

    En challenge er gyldig i 2 minutter og kun én gang, for ét endpoint og ét klientnetværk (en IPv4-adresse eller et IPv6-/64-net). Den er signeret, så serveren gemmer intet om den, før den kommer tilbage. `reason` angiver, hvorfor et bevis blev afvist: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` eller `replayed`.

    ## Fornyet godkendelse

    Kontoændringer beder om adgangskoden igen og, når totrinsgodkendelse er slået til, om en anden faktor:

    - kun adgangskoden: `POST /account/password` og `POST /account/mfa/totp/setup`;
    - adgangskoden og en kode fra godkendelsesappen, idet en gendannelseskode afvises: `POST /account/mfa/recovery-codes`;
    - adgangskoden og en kode fra godkendelsesappen eller en gendannelseskode: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` og `POST /account/delete`.

    I bodies indeholder `code` en 6-cifret kode fra godkendelsesappen og `recoveryCode` en gendannelseskode (`xxxx-xxxx-xx`; store og små bogstaver, mellemrum og bindestreger er ligegyldige). En gendannelseskode må også sendes i `code`, hvor gendannelseskoder accepteres. Hver kode virker én gang: en brugt kode fra godkendelsesappen afvises indtil næste 30-sekunders trin, og en gendannelseskode er væk, når den er brugt.

    | Status | `error` | Hvornår |
    |---|---|---|
    | 403 | `invalid_password` | Forkert adgangskode, eller adgangskoden blev nulstillet eller skiftet, mens anmodningen kørte (intet blev ændret). |
    | 403 | `mfa_code_required` | Totrinsgodkendelse er slået til, og hverken `code` eller `recoveryCode` blev sendt. |
    | 403 | `invalid_code` | Forkert eller allerede brugt kode. |
    | 400 | `password_not_set` | En konto, der kun bruger Google, har endnu ingen adgangskode (»Glemt adgangskode?« opretter en). |
    | 429 | `too_many_attempts` | For mange mislykkede forsøg eller for mange afprøvede koder på denne konto. |
    | 503 / 429 | `server_busy` / `rate_limited` | Hashkøen for adgangskoder er optaget. |

    Forkerte adgangskoder og koder tæller med i kontoens tæller over mislykkede forsøg og registreres som sikkerhedshændelser.

    ## Metrikport

    Ud over API-porten svarer serveren med ukrypteret HTTP på metrikporten (`METRICS_PORT`, 9464, bundet til `METRICS_BIND`, 127.0.0.1 som standard; hold den privat). Den er ikke en del af denne API: `GET /healthz` svarer `ok`; `GET /readyz` svarer `ready`, når opstarten er fuldført (hver shard har afspillet sin journal igen, og listenerne er bundet), og indtil nedlukningen begynder, ellers 503 `not ready`; `GET /metrics` leverer Prometheus-metrikkerne og kræver, når `METRICS_TOKEN` er sat, `Authorization: Bearer <token>` med netop det 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: Kildekoden til Scacelith-serveren og dens referencedokumentation (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Den officielle server.
  - url: https://{host}:{port}/api/v1
    description: Enhver Scacelith-server, fx en fællesskabsserver.
    variables:
      host:
        default: caissa.scacelith.com
        description: Serverens offentlige værtsnavn (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: Den offentlige API-port (`PUBLIC_API_PORT`, ellers `API_PORT`).
tags:
  - name: server-info
    x-displayName: Serverinfo
    description: Det, en klient skal vide, før den logger ind eller opretter forbindelse.
  - name: health
    x-displayName: Tilstandstjek
    description: |-
      Om serveren kører og er klar (liveness og readiness), til overvågning. De svarer på API-porten før godkendelse og alle endpointgrænser, men som enhver anden anmodning tager de et token fra laget pr. adresse; en overvågningsvært kan angives i `ABUSE_EXEMPT`. Begge stier virker for hvert endpoint: i serverens rod og under `/api/v1`.

      Metrikporten har sine egne endpoints til tilstandstjek, beskrevet i introduktionen.
  - name: auth
    x-displayName: Registrering og login
    description: |-
      Oprettelse af en konto, login med adgangskode (og anden faktor), e-mails til bekræftelse og til nulstilling af adgangskoden.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` og `POST /auth/sso/complete` svarer med en session `{ token, expiresAt, user }` (eller, for de første, et andet trin).
  - name: google-sign-in
    x-displayName: Google-login
    description: |-
      Tilbydes, når `GET /info` angiver `sso.google: true`; ellers svarer alle endpoints nedenfor 404 `sso_disabled`. Et website logger ind på samme måde med sin egen klient, når `sso.googleWeb` er true (se nedenfor, **På et website**). Spillet logger ind via systemets browser med flowet for installerede apps fra RFC 8252 (en klient af typen »Computerapp«): Google sender browseren tilbage til en listener i spillet på `127.0.0.1`, aldrig til denne server, og spillet ser aldrig Google-loginoplysninger.

      1. Klienten lytter på `127.0.0.1:0` (systemet vælger porten) og laver et PKCE-par: en `codeVerifier` på 43 til 128 tegn `[A-Za-z0-9._~-]` og `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, som har 43 tegn og ingen padding.
      2. `POST /auth/sso/google/start` med challengen og porten returnerer Google-URL'en og forsøgets `state`. Klienten kontrollerer URL'en (se nedenfor) og åbner den i browseren.
      3. Google sender browseren til `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Klienten accepterer kun `state` fra startsvaret og sender intet efter en `error=`-omdirigering.
      4. `POST /auth/sso/google/finish` med forsøgets id, `codeVerifier`, `state` og `code` (og `iss`, når Google har sendt den).
      5. Svaret er en session, et trin i totrinsgodkendelsen (fortsæt med `POST /auth/login/mfa`), `needsUsername` for en ny spiller (fortsæt med `POST /auth/sso/complete`) eller `needsPassword`, når en konto med adgangskode bruger adressen (fortsæt med `POST /auth/sso/google/link`).

      **Oprindelsestagget.** Omdirigerings-URI'en indeholder et tag for den server, spilleren har tilføjet, så spillet afviser en URL, som en anden server har fået til sine egne spillere. Oprindelsen er værten med små bogstaver (en IPv6-literal i kantede parenteser), `:` og API-porten i decimaltal, altid skrevet, også 443: serveren tager `SERVER_PUBLIC_HOST` og sin offentlige API-port, spillet den adresse, det er forbundet til. Tagget er de første 22 tegn af base64url (uden padding) af SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + oprindelse). Omdirigerings-URI'en er `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: altid IPv4-literalen og porten på spillets listener (1024 til 65535). Serveren tager aldrig en URI, vært eller sti fra klienten.

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

      Google-login virker altså kun for spillere, der har tilføjet serveren under præcis `SERVER_PUBLIC_HOST` og dens offentlige API-port.

      **Hvad spillet kontrollerer, før det åbner browseren.** `authUrl` starter med præcis `https://accounts.google.com/o/oauth2/v2/auth?` og er printbar ASCII under 4096 tegn, og dens query har præcis én `response_type=code`, præcis én `redirect_uri`, der er lig med den URI, spillet beregner ud fra sin port og sit oprindelsestag, præcis én `state`, der er lig med svarets `state`, `code_challenge_method=S256` og en `code_challenge` på 43 tegn. Ellers stopper spillet sin listener og åbner intet. Det sender kun `finish` og `link` til den server, der svarede på `start`.

      **På et website.** Et website (det officielle: <https://scacelith.com/>) logger ind med en klient af typen »Webapplikation« i det samme Google-projekt (`GOOGLE_WEB_CLIENT_ID`). Google sender browseren tilbage til én side på websitet, som administratoren har konfigureret (`GOOGLE_WEB_REDIRECT_URI`, angivet under klientens »Autoriserede omdirigerings-URI'er«), og den side sender koden til `finish`; heller ikke her tager serveren en omdirigerings-URI fra klienten.

      1. Siden laver et PKCE-par som ovenfor, gemmer verifieren i fanen (`sessionStorage`) og sender `POST /auth/sso/google/web/start` med challengen.
      2. Den kontrollerer `authUrl` som spillet, bortset fra at dens `redirect_uri` skal være en side fra dens egen oprindelse, og navigerer derefter til den. Der indlæses intet script fra Google.
      3. Google sender browseren til `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. Siden accepterer kun `state` fra startsvaret og sender intet efter en `error=`-omdirigering.
      4. `finish`, `link` og `complete` følger som for spillet. Forsøget husker, at et website startede det: koden udveksles med websitets klient og side, og ID-tokenet skal angive den klient (en kode eller et ID-token fra spillets klient fejler med 502 `sso_failed`, og omvendt).

      En Google-konto fører til den samme Scacelith-konto fra spillet og fra websitet: tilknytningen indeholder Googles subject (`sub`), som identificerer Google-kontoen uanset klienten. Websitets oprindelse skal være i `CORS_ORIGINS` (ellers advarer `check-config`).

      **Hvilken konto Google-login fører til:** den konto, der allerede er knyttet til den Google-konto; ellers en aktiv konto med den adresse, Google har bekræftet (har den en adgangskode, svarer `finish` `needsPassword`, og tilknytningen gemmes først, når kontoens adgangskode og derefter dens anden faktor, hvis den er slået til, er godkendt; uden adgangskode 409 `sso_account_exists`); ellers en ny konto (`needsUsername`). En Google-konto knyttes aldrig til en eksisterende konto alene ud fra dens adresse. Kontoens adresse får en mail, når Google-login opretter en konto, og når det føjes til en eksisterende konto.
  - name: sessions
    x-displayName: Sessioner
    description: Kontoens indloggede enheder og udlogning.
  - name: account
    x-displayName: Konto
    description: Kontovisningen, dens præferencer og dens adgangskode.
  - name: two-step-verification
    x-displayName: Totrinsgodkendelse
    description: Godkendelsesapps (TOTP, RFC 6238) med SHA-1, 6 cifre, trin på 30 sekunder og ét trins tolerance i begge retninger samt gendannelseskoder til engangsbrug.
  - name: email-change
    x-displayName: Ændring af e-mailadresse
    description: Ændring af kontoens adresse, bekræftet med et link, der sendes til den nye adresse.
  - name: data-export
    x-displayName: Dataeksport
    description: Alt, hvad serveren gemmer om kontoen, som én JSON-fil.
  - name: account-deletion
    x-displayName: Sletning af konto
    description: Permanent sletning af kontoen.
  - name: game-history
    x-displayName: Partihistorik
    description: Den indloggede spillers egne partier, filtreret og opdelt i sider.
  - name: games
    x-displayName: Partier og PGN
    description: |-
      Partiposter med deres træk og ure samt deres PGN-filer.

      **Partikoder.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (kun afsluttede partier gemmes). `result`: `1-0`, `0-1`, `1/2-1/2` eller `*` (afbrudt). `reason` med sit navn (`termination`) og de ord, der afslutter træklisten i PGN-filen (på engelsk i filen, her med oversættelse); koderne 7, 14 og 21 er remis (den spiller, hvis tid løb ud, som opgav, eller som forlod partiet, stod over for en modstander, der ikke kunne sætte mat: FIDE 6.9 og 5.1.2; `DESIGN.md` 6.3 angiver reglen), og serveren afslutter aldrig et parti med koderne 4 og 13 (de hører til den fælles liste over årsager):

      | Kode | `termination` | Ord i PGN-filen |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (skakmat) |
      | 2 | `Resignation` | Resignation (opgivelse) |
      | 3 | `Timeout` | Loss on time (tab på tid) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (andet ulovlige træk, partiet tabt) |
      | 5 | `Stalemate` | Stalemate (pat) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (død stilling, utilstrækkeligt materiale) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (flaget faldt, men modstanderen kan ikke sætte mat) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (femfoldig gentagelse) |
      | 9 | `SeventyFiveMoves` | 75-move rule (75-træksreglen) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (trefoldig gentagelse, krævet) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (50-træksreglen, krævet) |
      | 12 | `Agreement` | Draw by agreement (remis efter aftale) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (andet ulovlige træk, men modstanderen kan ikke sætte mat) |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate (opgivelse, men modstanderen kan ikke sætte mat) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (partiet forladt, forbindelsen afbrudt for længe) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (partiet forladt, men modstanderen kan ikke sætte mat) |
      | 22 | `Aborted` | Game aborted (partiet afbrudt) |
      | 23 | `NoShow` | Aborted: first move not played in time (afbrudt: første træk ikke spillet i tide) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (erklæret tabt, brud på fair play) |
      | 25 | `ServerAborted` | Aborted by the server (afbrudt af serveren) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (afbrudt: begge spillere mistede forbindelsen) |

      Kode 14 er ny i protokollens minor-version 1: en klient, der er skrevet før den, bør vise en ukendt kode som en almindelig afslutning af partiet med dens `result`.
  - name: gifs
    x-displayName: Animerede GIF'er
    description: |-
      Et parti som animeret GIF, til at gemme eller dele: brættet set ovenfra, ét billede pr. stilling fra udgangsstillingen til slutstillingen, spillernes navne og ratinger over brættet, det seneste træk under det og på det sidste billede resultatet og måden, partiet sluttede på.

      **Omkostning, cache og kvoter.** En GIF laves på sin egen renderingstråd, aldrig på de tråde, der afvikler partierne, og med den laveste CPU-prioritet: `GIF_THREADS` tråde (`WORKERS` som standard), der startes med den første GIF og stoppes efter et minut uden nogen. Op til `GIF_QUEUE_MAX` (4 x `WORKERS`) GIF'er venter på en tråd, højst `GIF_QUEUE_TIMEOUT_MS` (10 s) hver; en rendering må vare `GIF_RENDER_TIMEOUT_MS` (30 s). Serveren gemmer de GIF'er, den har lavet, i en cache på `GIF_CACHE_MB` MB (32 x `WORKERS`), hvor de mindst nyligt brugte fjernes først; en GIF fra cachen, eller en, der er ved at blive lavet til en anden anmodning, koster ingen rendering. Cachens nøgle omfatter alt, der ændrer billedet, også navnene.

      Hver anmodning tæller i `gif` (30 pr. minut pr. spiller for begge endpoints). En GIF, der skal laves, tæller også i renderingsgrænserne: pr. spiller `GIF_USER_RENDERS_PER_MIN` (4) pr. minut og `GIF_USER_RENDERS_PER_HOUR` (30) pr. time; pr. klient, alle dens konti tilsammen, `GIF_IP_RENDERS_PER_MIN` (12) pr. minut og `GIF_IP_RENDERS_PER_HOUR` (120) pr. time og 3 gange så mange pr. IPv6-/48-net. En klient bør beholde den fil, den har downloadet, i stedet for at bede om den igen, og vente `retryAfter` efter en 429 eller en 503.

      Størrelser: `small` (felter på 32 px: 284 x 350 pixel), `medium` (48 px: 424 x 515), `large` (72 px: 628 x 762); uden koordinater 268 x 342, 400 x 503 og 600 x 748. Udgangsstillingen vises i mindst 1 s (forsinkelsen, når den er længere), slutstillingen i 3 s, og GIF'en kører i loop; efter det første billede gemmes kun den del af billedet, der ændrer sig. Et parti på 40 træk fylder omkring 135, 205 og 325 KiB (lille, mellem, stor), et parti på 150 træk 0,5, 0,8 og 1,2 MiB.
  - name: players
    x-displayName: Spillere
    description: Offentlige profiler og seneste partier. Kun offentlige data, aldrig en e-mailadresse, en session, en sanktion eller et integritetsniveau. En slettet konto har ingen profil.
  - name: leaderboard
    x-displayName: Rangliste
    description: De bedste spillere i hver officiel kategori.
  - name: reports
    x-displayName: Anmeldelser
    description: Anmeldelse af modstanderen fra et nyligt parti til moderatorerne.
  - name: pages
    x-displayName: HTML-sider
    description: |-
      Sider til en browser, åbnet fra links i e-mails. Deres links peger på `https://<SERVER_PUBLIC_HOST>` (med `:<PUBLIC_API_PORT>`, når den ikke er 443), uden for `/api/v1`.

      - Siderne kører intet JavaScript og indlæser ingen eksterne ressourcer. De leveres med `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - En `GET` viser kun en knap eller en formular, så en mailscanner, der åbner linket, ikke bruger det op. Ændringen sker ved `POST`: en formular sendt som `application/x-www-form-urlencoded` (en JSON-body accepteres også), med linkets token i et skjult felt. Et felt, der er angivet to gange, afvises.
      - Fejl (hastighedsgrænser, ugyldige felter) er også HTML-sider, med titlen »Request refused« (anmodning afvist) eller, fra 500 og op, »Server error« (serverfejl). Kun de fejl, der opdages, før siden er fundet (et anmodningsmål, der er for langt eller fejlformateret, laget pr. adresse), besvares i JSON.
x-tagGroups:
  - name: server
    x-displayName: Server
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Konti
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Partier
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Spillere og anmeldelser
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Browsersider
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Hent serverens navn, versioner, porte og regler for registrering
      description: |-
        Det, en klient har brug for, før den logger ind eller opretter forbindelse: serverens navn og id, WebSocket-protokollens versioner og hvor WebSocket'en ligger, reglerne for registrering, de officielle betænkningstider og de grænser, en klient kan kontrollere, før den sender en formular.

        **Grænser:** kun laget pr. adresse.
      security: []
      responses:
        '200':
          description: Serverens beskrivelse.
          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`: laget pr. adresse.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Opret en konto
      description: |-
        Opretter en konto: når dens e-mailadresse er bekræftet med det link, der sendes til den, eller med det samme uden e-mailbekræftelse.

        **Med e-mailbekræftelse** (`REQUIRE_EMAIL_VERIFICATION`, standard): 202 `verification_sent`. Der findes endnu ingen konto: registreringen venter i 24 timer og reserverer sit brugernavn imens. Et link, der er gyldigt i de 24 timer, sendes til adressen, højst ét pr. adresse hvert 5. minut (`POST /auth/verify-email/resend` sender et nyt); kontoen oprettes, med adressen bekræftet, når linket bruges (knappen på siden `/verify-email`), og spilleren kan derefter logge ind. Indtil da giver et login med det brugernavn svaret `invalid_credentials`, som for en ukendt konto, og den offentlige profil findes ikke. En ny registrering med samme adresse erstatter den ventende. Svaret er det samme, når en anden konto allerede bruger adressen: så sendes der intet link, kontoens ejer får i stedet en meddelelse (højst én pr. time), og brugernavnet reserveres på samme måde, så intet afslører, om adressen har en konto. En registrering, hvis link ikke blev brugt, kasseres efter 24 timer, og dens brugernavn er ledigt igen.

        **Uden e-mailbekræftelse** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, kontoen oprettes med det samme og kan logge ind.

        **Regler.** `username`: `USERNAME_MIN` til `USERNAME_MAX` tegn (3 til 20), bogstaver, cifre, `_` og `-`, og det skal starte med et bogstav eller et ciffer (`GET /info` angiver dem som `limits`); reserverede navne (`admin`, `moderator`, `deleted`...) og visse præfikser afvises; unikt uden hensyn til store og små bogstaver. `email`: trimmes og gemmes med små bogstaver, ren ASCII med et domæne med punktum. `password`: mindst `PASSWORD_MIN_LENGTH` (10) tegn og højst 256 byte UTF-8; den må ikke indeholde brugernavnet eller e-mailens lokale del og må ikke være en almindeligt brugt adgangskode.

        **Fejl**, kontrolleret i denne rækkefølge: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; fejlene fra hashkøen for adgangskoder; 409 `email_taken` (kun uden e-mailbekræftelse).

        **Grænser:** `auth`, derefter `auth_register` (10 registreringer pr. time pr. klient). **Proof of work:** altid, når `POW_REGISTER_BITS` er over 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Første forsøg, uden proof of work
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Sendt igen med 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: Kontoen er oprettet og kan logge ind (ingen e-mailbekræftelse på denne 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: Registreringen venter på sit bekræftelseslink (eller adressen har allerede en konto; svaret afslører det ikke).
          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` (med `field`), `invalid_json`, `invalid_username`, `invalid_email` eller `weak_password` med `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` eller `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: registrering er lukket på denne server (`GET /info` angiver `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: en konto har brugernavnet, eller en ventende registrering med en anden adresse reserverer det. `email_taken`: en anden konto bruger adressen (kun uden e-mailbekræftelse).'
        '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`: grænsen `auth` eller `auth_register` eller hashkøen for adgangskoder.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hashkøen for adgangskoder, eller databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Log ind med adgangskode
      description: |-
        Logger ind med et brugernavn eller en e-mailadresse og en adgangskode. Der er to mulige svar, begge med status 200: en session eller, når totrinsgodkendelse er slået til, et andet trin, der skal fuldføres inden for 5 minutter med `POST /auth/login/mfa`.

        Ukendt konto, forkert adgangskode og konto uden adgangskode får samme svar efter samme tid: 401 `invalid_credentials`. Kontokontrollerne (`banned`, `email_unverified`) kommer først efter en korrekt adgangskode. Fra og med `AUTH_FAILURES_PER_ACCOUNT` (5) mislykkede forsøg på ét loginnavn skal hvert forsøg vente (429 `too_many_attempts`).

        **Grænse:** `auth`. **Proof of work:** kun under en bølge af mislykkede loginforsøg (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: En session eller andet trin i et login med totrinsgodkendelse.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Logget ind
                  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: Totrinsgodkendelse er slået til
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: ukendt konto, forkert adgangskode eller en konto uden adgangskode.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Kun efter en korrekt adgangskode: `banned` med `until` (ms siden Unix-epoken, `null` ved en permanent udelukkelse) eller `email_unverified` (en ældre konto, hvis adresse endnu ikke er bekræftet).'
        '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` (ventetiden efter mislykkede forsøg for dette loginnavn) eller `rate_limited` (grænsen `auth`, hashkøen for adgangskoder).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hashkøen for adgangskoder, eller databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Fuldfør et login med anden faktor
      description: |-
        Andet trin i et login med totrinsgodkendelse, efter at `POST /auth/login` eller et Google-login (`POST /auth/sso/google/finish` eller `POST /auth/sso/google/link`) har svaret `mfaRequired`. Send `code` (en 6-cifret kode fra godkendelsesappen eller en gendannelseskode) eller `recoveryCode`. En gendannelseskode, der bruges her, er væk.

        Ét trin accepterer højst 5 forkerte koder; trinnet slutter også, når adgangskoden nulstilles eller skiftes. Efter adgangskodetrinnet i en Google-tilknytning gemmes tilknytningen først, når koden godkendes her.

        **Grænser:** `auth` og højst `AUTH_MFA_PER_ACCOUNT` (10) koder pr. 15 minutter for kontoen, fra enhver adresse; derudover kontrolleres koden ikke, så en gendannelseskode ikke bliver brugt.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: En kode fra godkendelsesappen
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: En gendannelseskode
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Logget ind.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: hverken `code` eller `recoveryCode` blev sendt, eller bodyen bryder skemaet; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: trinnet er udløbet, er brugt eller sluttede efter 5 forkerte koder, eller adgangskoden er nulstillet eller skiftet siden første trin (log ind igen). `invalid_code`: en forkert eller allerede brugt kode.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (med `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: kun efter adgangskodetrinnet i en Google-tilknytning, hvis Google-kontoen i mellemtiden er blevet knyttet til en anden konto.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: kun efter adgangskodetrinnet i en Google-tilknytning, hvis kontoen er ændret i mellemtiden; start login forfra.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: kontoens ventetid efter mislykkede forsøg, eller dens `AUTH_MFA_PER_ACCOUNT` koder for de seneste 15 minutter er brugt op. `rate_limited`: grænsen `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` med `retryAfter: 1`: databasen forblev låst (efter et trin i en Google-tilknytning er intet blevet knyttet: start login forfra). `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Log denne session ud
      description: |-
        Tilbagekalder sessionen for det anvendte token. Den WebSocket, der er åbnet med den, lukkes. Ingen body (eller `{}`).

        **Grænse:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Logget ud.
          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`: en anden body end `{}`; `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`: grænsen `sessions` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Log alle sessioner ud
      description: |-
        Tilbagekalder alle kontoens sessioner, også denne. De WebSockets, der er åbnet med dem, lukkes. Ingen body (eller `{}`).

        **Grænse:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Alle sessioner er logget ud.
          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`: en anden body end `{}`; `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`: grænsen `sessions` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Send bekræftelseslinket igen
      description: |-
        Sender linket til e-mailbekræftelse igen. Svaret er 202 `accepted` uanset adressen, så det aldrig afslører, om en konto eller en registrering bruger den.

        En anmodning har højst virkning én gang hvert 5. minut pr. adresse. En ventende registrering med den adresse får sine 24 timer igen, uanset om en anden konto bruger adressen, så dens brugernavn forbliver reserveret lige længe i begge tilfælde. Et link sendes kun for den registrering, når adressen ikke har nogen konto (et nyt link, gyldigt i 24 timer, erstatter det forrige), eller for en aktiv, ubekræftet konto med den adresse.

        **Grænser:** `auth`, derefter `auth_mail` (10 pr. time pr. klient).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Accepteret (uanset adressen).
          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`: grænsen `auth` eller `auth_mail` (uanset adressen).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` med `retryAfter: 1`: databasen forblev låst, mens kontoen blev slået op, eller mens linket til en ubekræftet konto blev gemt (fornyelsen af en ventende registrering sker efter bedste evne og får aldrig anmodningen til at mislykkes); adressens 5 minutter starter alligevel, så en ny anmodning inden for dem gør intet. `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Send et link til nulstilling af adgangskoden
      description: |-
        Sender et link til nulstilling af adgangskoden, gyldigt i én time, som åbner siden `/reset-password`. Svaret er 202 `accepted` uanset adressen. Et link sendes kun til en aktiv konto, højst én gang hvert 5. minut pr. adresse. En konto, der kun bruger Google, angiver sin første adgangskode på denne måde.

        Gendannelse af adgangskoden har API'ens strengeste grænser, alle talt for hele serveren: 3 anmodninger pr. time (`AUTH_FORGOT_PER_HOUR`) og 10 pr. 24 timer (`AUTH_FORGOT_PER_DAY`) pr. klient (en IPv4-adresse eller et IPv6-/64-net), 3 gange så mange pr. IPv6-/48-net, oven i grænsen `auth` (20 pr. 10 minutter) og den ene mail pr. adresse hvert 5. minut. Hverken 202 eller 429 afslører, om en konto bruger adressen. Angivelsen af den nye adgangskode har sin egen grænse (`auth_reset`).

        **Grænser:** `auth`, `auth_forgot`, derefter `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Accepteret (uanset adressen).
          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`: grænsen `auth`, `auth_forgot` eller `auth_forgot_day` (uanset adressen).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` med `retryAfter: 1`: databasen forblev låst, mens kontoen blev slået op, eller mens linket blev gemt; adressens 5 minutter starter alligevel, så en ny anmodning inden for dem sender intet link. `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Angiv en ny adgangskode med tokenet fra et nulstillingslink
      description: |-
        Angiver en ny adgangskode med tokenet fra et nulstillingslink (siden `/reset-password` gør det samme). Den nye adgangskode følger reglerne for registrering.

        Nulstillingen har disse virkninger i én transaktion (alle eller ingen): den tilbagekalder alle sessioner og annullerer en afventende ændring af e-mailadressen; kontoens andre nulstillingslinks holder op med at virke; adressen tæller som bekræftet (linket beviste det); ejeren får en mail. Totrinsgodkendelse berøres ikke. Efter en fejl fra hashkøen for adgangskoder, en 503 eller en 500 er intet ændret: den gamle adgangskode og sessionerne bevares, og linket forbliver gyldigt.

        **Grænser:** `auth`, derefter `auth_reset` (10 pr. time pr. klient, 30 pr. IPv6-/48-net, delt med siden: hvert forsøg hasher en adgangskode).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Adgangskoden er skiftet, og alle sessioner er logget ud.
          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`: linket er ugyldigt, brugt eller udløbet eller blev mailet til en adresse, som kontoen ikke længere har. `weak_password` (med `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`: grænsen `auth` eller `auth_reset` eller hashkøen for adgangskoder.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: hashkøen for adgangskoder, eller databasen forblev låst (`retryAfter: 1`, intet ændret). `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Start et Google-login
      description: |-
        Starter et forsøg på Google-login for PKCE-challengen og porten på spillets listener på `127.0.0.1`. Svaret giver den Google-URL, der skal åbnes i systemets browser, og forsøgets `state`; forsøget er gyldigt i 10 minutter.

        `authUrl` indeholder `client_id`, `redirect_uri` (bygget ud fra `redirectPort` og serverens oprindelsestag), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 af serverens egen verifier over for Google) med `code_challenge_method=S256` og `prompt=select_account`. Spillet kontrollerer den, før det åbner den (se afsnittets beskrivelse).

        **Grænse:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: Forsøget.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoStartAnswer'
              example:
                attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
                authUrl: https://accounts.google.com/o/oauth2/v2/auth?client_id=1234567890-abc.apps.googleusercontent.com&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2Foauth2%2Fgoogle%2FIhcScoV7eDOzTEcSnqPUPt&response_type=code&scope=openid%20email%20profile&state=yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ&nonce=gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU&code_challenge=ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc&code_challenge_method=S256&prompt=select_account
                state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
                expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: Google-login tilbydes ikke på denne server.'
        '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`: grænsen `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Start et Google-login på et website
      description: |-
        Starter et websites forsøg på Google-login for PKCE-challengen fra dets side. Svaret er det samme som for spillet: den Google-URL, der skal navigeres til, og forsøgets `state`; forsøget er gyldigt i 10 minutter. Tilbydes, når `GET /info` angiver `sso.googleWeb: true`.

        `authUrl` indeholder websitets `client_id` og `redirect_uri=GOOGLE_WEB_REDIRECT_URI` med de samme øvrige parametre som for spillet. Siden kontrollerer den, før den navigerer til den (se **På et website** i afsnittets beskrivelse).

        **Grænse:** `sso_start`, delt med spillets start.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      responses:
        '200':
          description: Forsøget.
          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`: en manglende eller fejlformateret `codeChallenge` eller et hvilket som helst andet felt (inklusive `redirectPort`).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: Google-login tilbydes ikke på denne server eller ikke til 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`: grænsen `sso_start` (delt med spillets start).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Giv Googles svar videre til serveren
      description: |-
        Giver serveren den `code` og `state`, som Google sendte til spillets listener eller til websitets side, sammen med forsøgets id og PKCE-verifieren. Et forsøgs-id er ubrugeligt uden verifieren, og koden er ubrugelig uden serverens egen PKCE-verifier og klienthemmelighed.

        Serveren kontrollerer forsøget (ukendt, brugt eller udløbet: 410), derefter verifieren (en forkert verifier lader forsøget forblive brugbart), bruger derefter forsøget én gang, kontrollerer `state` og `iss`, udveksler koden med Google og verificerer ID-tokenet. Svaret (200) er et af følgende:

        - `{ token, expiresAt, user }`: logget ind på den tilknyttede konto;
        - `{ mfaRequired, mfaToken, expiresIn }`: fortsæt med `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: en ny konto; fortsæt med `POST /auth/sso/complete` inden for 10 minutter. `suggestedUsername` kommer fra Google-navnet eller adressen og er `""`, når intet passer;
        - `{ needsPassword, linkTicket, username, expiresIn }`: kontoen med den adresse har en adgangskode; fortsæt med `POST /auth/sso/google/link`. Intet er knyttet endnu.

        **Grænse:** `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: En session, et andet trin eller næste trin i et første Google-login.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Logget ind på den tilknyttede konto
                  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: Totrinsgodkendelse er slået til
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: En ny spiller
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: En konto med adgangskode bruger adressen
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: verifieren passer ikke til forsøgets challenge (forsøget forbliver brugbart). `sso_email_unverified`: Google har ikke bekræftet adressen. `registration_closed`. `account_disabled`. Kun for en tilknyttet konto: `banned` (med `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: Google-login tilbydes ikke på denne server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: en aktiv konto uden adgangskode bruger adressen (den navngives ikke).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: et ukendt, brugt eller udløbet forsøg.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: grænsen `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Knyt Google til en eksisterende konto med dens adgangskode
      description: |-
        Efter at `finish` har svaret `needsPassword`: knytter Google til den eksisterende konto med den kontos adgangskode, indtastet i spillet. Svaret (200) er en session (tilknytningen er gemt, og spilleren er logget ind) eller, når totrinsgodkendelse er slået til, `{ mfaRequired, mfaToken, expiresIn }`: fortsæt med `POST /auth/login/mfa`, og tilknytningen gemmes først, når en kode godkendes der.

        En forkert adgangskode lader billetten forblive brugbar, i alt 5 forsøg. Et forsøg bruges lige før adgangskoden kontrolleres: en 429 `too_many_attempts` eller en 428 `pow_required` bruger intet forsøg, mens en afvisning fra hashkøen for adgangskoder (503 `server_busy`, 429 `rate_limited`) har brugt et og tæller som et mislykket forsøg for kontoen. Ventetiden efter mislykkede forsøg bruger samme tæller som `POST /auth/login`.

        **Grænse:** `auth` (med optællingen pr. IPv6-/48-net). **Proof of work:** som for `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: En session eller andet trin i login.
          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`: en forkert adgangskode; billetten bevares, til i alt 5 forsøg.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (med `until`), kun efter en korrekt adgangskode.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: Google-login tilbydes ikke på denne server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: Google-kontoen er i mellemtiden blevet knyttet til en anden konto.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: en ukendt, brugt eller udløbet billet, den 5. forkerte adgangskode, en konto, hvis status eller adresse er ændret siden `finish`, eller en konto, hvis status, adresse, adgangskode eller totrinsgodkendelse blev ændret, mens tilknytningen blev gemt. Start login forfra.'
        '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` (kontoens ventetid efter mislykkede forsøg) eller `rate_limited` (grænsen `auth`, hashkøen for adgangskoder).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: hashkøen for adgangskoder, eller databasen forblev låst (`retryAfter: 1`, intet blev tilknyttet). `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Opret kontoen ved et første Google-login
      description: |-
        Efter at `finish` har svaret `needsUsername`: opretter kontoen med det valgte brugernavn (reglerne for registrering gælder) og logger den ind. Kontoen har ingen adgangskode: `hasPassword` er `false`, og »Glemt adgangskode?« (`POST /auth/password/forgot`) giver den en.

        **Grænse:** `auth` (med dens optælling pr. IPv6-/48-net).
      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: Kontoen er oprettet og logget ind.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`, `invalid_request`, `invalid_json`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: Google-login tilbydes ikke på denne server.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (en konto har brugernavnet, eller en ventende registrering med en anden adresse reserverer det), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: en ukendt, brugt eller udløbet billet.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: grænsen `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Vis de indloggede enheder
      description: |-
        Kontoens aktive sessioner (indloggede enheder), den senest brugte først. `current` markerer den session, der foretager anmodningen. `lastSeenAt` opdateres højst hvert 5. minut (senere, mens serveren er langt bagud); `expiresAt` er det absolutte udløbstidspunkt, og inaktivitetsgrænsen kan afslutte sessionen tidligere.

        **Grænse:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: De aktive sessioner.
          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`: grænsen `sessions` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Log én enhed ud
      description: |-
        Logger én af kontoens sessioner ud; også den aktuelle kan logges ud. Den WebSocket, der blev åbnet med den, lukkes. Ingen body (eller `{}`).

        **Grænse:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Sessionen er logget ud.
          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`: en anden body end `{}`, eller et `id`, der ikke er gyldig URL-kodning; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: ingen aktiv session med dette id på denne konto.'
        '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`: grænsen `sessions` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Hent kontoen, dens ratinger og aktive sanktioner
      description: |-
        Kontoen, som dens spiller ser den: kontovisningen (også `user` i hvert loginsvar), én ratingpost pr. kategori, som spilleren har spillet ratede partier i, de aktive sanktioner og udelukkelsen. Anti-snydesystemets integritetsniveau vises aldrig.

        **Grænse:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Kontoen.
          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` eller `invalid_token` (også når kontoen er slettet).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: grænsen `account` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Modtag eller afvis direkte udfordringer
      description: |-
        Angiver, om andre spillere må udfordre denne spiller via navn. Med `none` afvises direkte udfordringer: udfordreren får at vide, at spilleren ikke er tilgængelig. Ingen fornyet godkendelse.

        **Grænse:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: De præferencer, der nu gælder.
          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`: grænsen `account` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Skift adgangskoden
      description: |-
        Skifter adgangskoden. Den nuværende adgangskode kræves, men ingen anden faktor, heller ikke når totrinsgodkendelse er slået til. Den nye adgangskode følger reglerne for registrering (kontrolleres efter den nuværende adgangskode).

        Ændringen har disse virkninger i én transaktion (dem alle eller ingen): den tilbagekalder alle andre sessioner (denne forbliver logget ind), annullerer en afventende ændring af e-mailadressen, får kontoens links til nulstilling af adgangskoden til at holde op med at virke og sender ejeren en e-mail.

        **Fornyet godkendelse:** kun adgangskoden. **Grænser:** `reauth`, derefter `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: Adgangskoden er skiftet.
          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` (en konto, der kun bruger Google), `weak_password` (med `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: en forkert nuværende adgangskode, eller en nulstilling eller ændring af adgangskoden, der kom ind, mens anmodningen blev kontrolleret.'
        '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` (kontoens mislykkede fornyede godkendelser) eller `rate_limited` (grænsen `reauth` eller `reauth_user`, kontobudgettet, hashkøen for adgangskoder).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: hashkøen for adgangskoder, eller databasen forblev låst (`retryAfter: 1`, intet ændret). `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Begynd at slå totrinsgodkendelse til
      description: |-
        Gemmer en ny afventende hemmelighed til godkendelsesappen, som erstatter en eventuel tidligere afventende, og returnerer den til godkendelsesappen (som tekst og som en `otpauth://`-URI, der kan vises som QR-kode). Totrinsgodkendelse er endnu ikke slået til: `POST /account/mfa/totp/enable` slår den til med en kode fra denne hemmelighed.

        **Fornyet godkendelse:** kun adgangskoden. **Grænser:** `reauth`, derefter `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Den afventende hemmelighed.
          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` (en konto, der kun bruger Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled` (kontrolleres før adgangskoden).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (kontoens mislykkede fornyede godkendelser) eller `rate_limited` (grænsen `reauth` eller `reauth_user`, kontobudgettet, hashkøen for adgangskoder).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hashkøen for adgangskoder, eller databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Fuldfør aktiveringen af totrinsgodkendelse og få gendannelseskoder
      description: |-
        Slår totrinsgodkendelse til med en kode fra den afventende hemmelighed (præcis 6 cifre). Her spørges der ikke om adgangskoden; den blev angivet ved opsætningen. Svaret indeholder 10 gendannelseskoder, som kun vises denne ene gang.

        **Grænser:** `reauth`, derefter `reauth_user`; en forkert kode tæller med i kontoens mislykkede fornyede godkendelser.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Totrinsgodkendelse er slået til.
          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` er ikke præcis 6 cifre, eller bodyen bryder skemaet; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: en forkert kode (kontrollér klokkeslættet på enheden).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: ingen afventende hemmelighed (kald først `POST /account/mfa/totp/setup`).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (kontoens mislykkede fornyede godkendelser) eller `rate_limited` (grænsen `reauth` eller `reauth_user`, kontobudgettet).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Slå totrinsgodkendelse fra
      description: |-
        Slår totrinsgodkendelse fra. Hemmeligheden og gendannelseskoderne slettes, og ejeren får en e-mail.

        **Fornyet godkendelse:** adgangskoden og en kode fra godkendelsesappen eller en gendannelseskode (enten `code` eller `recoveryCode` er påkrævet). **Grænser:** `reauth`, derefter `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: Totrinsgodkendelse er slået fra.
          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` (en konto, der kun bruger Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (hverken `code` eller `recoveryCode`, kontrolleres før adgangskoden), `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` (mislykkede fornyede godkendelser, eller for mange koder forsøgt på denne konto) eller `rate_limited` (grænsen `reauth` eller `reauth_user`, kontobudgettet, hashkøen for adgangskoder).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hashkøen for adgangskoder, eller databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Erstat gendannelseskoderne
      description: |-
        Erstatter gendannelseskoderne med 10 nye; de gamle holder op med at virke.

        **Fornyet godkendelse:** adgangskoden og en kode fra godkendelsesappen i `code` (en gendannelseskode afvises med 403 `invalid_code`). **Grænser:** `reauth`, derefter `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 nye gendannelseskoder, som kun vises denne ene gang.
          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` (en konto, der kun bruger Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (også for en gendannelseskode).'
        '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` (mislykkede fornyede godkendelser, eller for mange koder forsøgt på denne konto) eller `rate_limited` (grænsen `reauth` eller `reauth_user`, kontobudgettet, hashkøen for adgangskoder).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hashkøen for adgangskoder, eller databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Skift e-mailadressen
      description: |-
        Skifter kontoens e-mailadresse. `newEmail` trimmes og konverteres til små bogstaver og kontrolleres derefter som en adresse ved registrering.

        **Med e-mailbekræftelse** (standard): 202 `verification_sent`. Et link, der er gyldigt i 24 timer, sendes til den nye adresse; det åbner `/confirm-email-change`, og adressen skifter først, når spilleren trykker på sidens knap. Indtil da viser `GET /account/me` den nye adresse som `pendingEmail`. En ny anmodning erstatter den afventende; et skift eller en nulstilling af adgangskoden annullerer den. Der sendes højst ét link til en given ny adresse hvert 5. minut, uanset hvem der spørger (en anmodning om den ændring, der allerede afventer, beholder det tidligere sendte link, som forbliver gyldigt); svaret er det samme. Den nuværende adresse får besked om, at der er anmodet om en ændring til en maskeret adresse (`a***@example.org`). Svaret og `pendingEmail` er de samme, når en anden konto allerede bruger den nye adresse: så sendes der intet link, og den ændring bliver derfor aldrig gennemført, og ejeren af den adresse får i stedet en besked (højst én i timen).

        Når linket bekræftes, skifter adressen og regnes som bekræftet, enhederne forbliver logget ind, de tidligere sendte links (bekræftelse, nulstilling af adgangskoden, andre ændringer) holder op med at virke, og den tidligere adresse får besked, med den nye maskeret.

        **Uden e-mailbekræftelse** (`REQUIRE_EMAIL_VERIFICATION=false`): adressen skifter med det samme (200 `email_changed`), og den tidligere adresse får besked. Hvis en anden konto bruger adressen, er svaret 409 `email_taken`, og ejeren af den adresse får beskeden.

        **Fornyet godkendelse:** adgangskoden og, med totrinsgodkendelse, en kode fra godkendelsesappen eller en gendannelseskode. `invalid_email` og `same_email` kontrolleres før adgangskoden, så de tæller ikke som mislykkede forsøg. **Grænser:** `reauth`, derefter `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: Adressen er skiftet med det samme (ingen e-mailbekræftelse på denne 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: Den nye adresse, som den er gemt.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Et bekræftelseslink er sendt til den nye adresse (eller adressen tilhører en anden konto; svaret afslører det ikke).
          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` (kontoens nuværende adresse), `password_not_set` (en konto, der kun bruger Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (også når et skift eller en nulstilling af adgangskoden kom anmodningen i forkøbet: intet link sendes), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: kun uden e-mailbekræftelse.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (mislykkede fornyede godkendelser, eller for mange koder forsøgt på denne konto) eller `rate_limited` (grænsen `reauth` eller `reauth_user`, kontobudgettet, hashkøen for adgangskoder).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: hashkøen for adgangskoder, eller databasen forblev låst (`retryAfter: 1`: intet blev ændret, og den samme anmodning kan sendes igen). `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Download kontoens data
      description: |-
        Alt, hvad serveren gemmer om kontoen, som én JSON-fil, der kan gemmes (`format` `scacelith-account-export`, `version` 1). Eksporten registrerer en sikkerhedshændelse (`account_exported`). Dokumentets array `notes` fortæller spilleren på letforståeligt engelsk, hvad der er udeladt.

        **Aldrig med i eksporten:** adgangskodens hash, hemmeligheden til totrinsgodkendelse og gendannelseskoderne; ethvert sessions- eller linktoken eller en hash af et sådant; anti-snydesystemets data (integritetsniveau og score, anomalier, analysen af partierne, vægten af en anmeldelse); de anmeldelser, andre spillere har indgivet om spilleren; moderatorernes identitet; andre spilleres private data (modstandere optræder med deres offentlige navn og rating, intet afslører, om en anden spiller er blevet sanktioneret, og ingen IP-adresse, der kan tilhøre en anden person, er med).

        **Fornyet godkendelse:** adgangskoden og, med totrinsgodkendelse, en kode fra godkendelsesappen eller en gendannelseskode. **Grænser:** `account_export` (5 i timen pr. spiller, for hele serveren; hvert forsøg tæller, også mislykkede; kontrolleres først), derefter `reauth` og `reauth_user`. **Tidsgrænse:** 60 sekunder.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Eksportdokumentet som vedhæftet fil.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Andre tegn i brugernavnet end bogstaver, cifre, `_`, `.` og `-` bliver til `_`.'
              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` (en konto, der kun bruger Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (mislykkede fornyede godkendelser, eller for mange koder forsøgt på denne konto) eller `rate_limited` (grænsen `account_export`, `reauth` eller `reauth_user`, kontobudgettet, hashkøen for adgangskoder).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (databasen forblev låst, med en `Retry-After: 1`-header), `server_busy` (hashkøen for adgangskoder), `timeout` (60 sekunder). Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Slet kontoen
      description: |-
        Sletter kontoen; det kan ikke fortrydes.

        - Alle sessioner tilbagekaldes med det samme: tokenet får derefter 401 `invalid_token`.
        - Brugernavnet bliver til `deleted#<id>`, både i kontoen og i alle partiposter.
        - Følgende slettes: e-mailadressen, adgangskodens hash, totrinshemmeligheden og gendannelseskoderne, sessionerne og linktokens, Google-tilknytningen, anti-snydesystemets integritetspost og de IP-adresser, der er gemt med sikkerhedshændelser.
        - Ratinger og partier bevares. Partierne kan fortsat læses under det anonyme navn, og `GET /players/{username}` svarer 404 for det tidligere navn.

        **Fornyet godkendelse:** adgangskoden og, med totrinsgodkendelse, en kode fra godkendelsesappen eller en gendannelseskode. **Grænser:** `reauth`, derefter `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: Kontoen er slettet.
          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` (en konto, der kun bruger Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (mislykkede fornyede godkendelser, eller for mange koder forsøgt på denne konto) eller `rate_limited` (grænsen `reauth` eller `reauth_user`, kontobudgettet, hashkøen for adgangskoder).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (hashkøen for adgangskoder, eller databasen forblev låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Vis spillerens partier, filtreret og opdelt i sider
      description: |-
        Den indloggede spillers partier, nyeste først, filtreret og opdelt i sider, med antallet af partier, der matcher filteret. Alle query-parametre er valgfrie, og en tom værdi regnes som fraværende.

        Sideinddeling: send den forrige sides `next` som `before`; `next` er `null` på den sidste side. `total` tæller de partier, der matcher filteret, på tværs af alle sider.

        **Grænse:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: Et officielt kategori-id (`3+2` eller `3%2B2`) eller `custom` for alle partier med en anden betænkningstid.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Kun ratede (`true`) eller uratede (`false`) partier.
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Kun de vundne, tabte eller remiserede partier, set fra spillerens side. Afbrudte partier vises kun uden dette filter.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Én side af historikken.
          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` er ikke et parti-id), `invalid_limit` eller `invalid_filter` (`category`, `rated` eller `result`), hver med `field`, der angiver parameteren.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: grænsen `account_games` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (databasen forblev låst; `retryAfter: 1` i bodyen, ingen `Retry-After`-header), `server_busy` (sessionsopslaget fandt databasen låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Hent en partipost med dens træk og ure
      description: |-
        Én partipost med dens træk og ure. Uden token, eller med tokenet for en spiller, der ikke spillede partiet, er svaret det offentlige. Når tokenets spiller spillede partiet, tilføjer svaret `you` og `reportable`.

        **Grænse:** `public_read` (pr. spiller med token, pr. klient uden).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Partiposten.
          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`: ikke et positivt heltal på højst 16 cifre (under 2^53); `invalid_request`: ikke gyldig URL-kodning.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: der blev sendt et token, og det er ikke gyldigt.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: partiet findes ikke.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: grænsen `public_read` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (databasen forblev låst; `retryAfter: 1` i bodyen, ingen `Retry-After`-header), `server_busy` (sessionsopslaget fandt databasen låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Download et parti som PGN-fil
      description: |-
        Det samme parti som PGN-fil: ét parti med linjeskift `\n` og træktekst i linjer under 80 kolonner. Svaret er det samme med eller uden token.

        Tags i denne rækkefølge: `Event` (`<SERVER_NAME> rated <category>` eller `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (startdatoen i UTC), `Round`, `White`, `Black`, `Result` (`*` for et afbrudt parti), `UTCDate` og `UTCTime` (starttidspunktet), `WhiteElo` og `BlackElo` (ratingerne ved start eller `-`), `WhiteRatingDiff` og `BlackRatingDiff` (ændringerne, fx `+10` og `-10`, i alle ratede partier, `+0`, når ratingreglerne lader ratingen være uændret; et uratet, tilpasset eller afbrudt parti har ingen af dem), `TimeControl` (i sekunder), `Termination` (en standardværdi i PGN: `normal`; `time forfeit`, et flagfald, også når det ender remis; `abandoned`; `rules infraction`, et andet ulovligt træk eller et tab på grund af fair play; `unterminated`, et afbrudt parti), `PlyCount`, `ScacelithGameId` (det decimale id).

        Hvert træk har `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: den trækkende spillers ur efter trækket og den tid, der blev fratrukket for trækket, i tiendedele sekunder (afkortet). En værdi udelades, når posten mangler den. Efter det sidste træk følger slutårsagen i ord og derefter resultatet.

        **Grænse:** `public_read` (pr. spiller med token, pr. klient uden).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: PGN-filen.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-<id>.pgn"`.'
              schema:
                type: string
                pattern: '^attachment; filename="scacelith-[1-9][0-9]{0,15}\.pgn"$'
              example: attachment; filename="scacelith-4100000000001.pgn"
          content:
            application/x-chess-pgn:
              schema:
                type: string
                description: 'PGN-teksten i 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`: ikke et positivt heltal på højst 16 cifre (under 2^53); `invalid_request`: ikke gyldig URL-kodning.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: der blev sendt et token, og det er ikke gyldigt.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: partiet findes ikke.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: grænsen `public_read` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: blandt andet når de gemte træk ikke kan afspilles frem til den gemte afslutning (serveren logger det).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (databasen forblev låst; `retryAfter: 1` i bodyen, ingen `Retry-After`-header), `server_busy` (sessionsopslaget fandt databasen låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Download et parti fra denne server som animeret GIF
      description: |-
        Partiet som animeret GIF. Navnene og ratingerne er partipostens (ratingerne ved start, en slettet konto som `deleted#<id>`), og det samme gælder resultatet og afslutningen (`Resignation`, `Loss on time`...).

        Kontrollerne sker i denne rækkefølge: `gif_disabled`, billedindstillingerne (`size`, `orientation`, `delay`, `coords`), parti-id'et, partiet, dets længde.

        **Påkrævet session:** kvoterne tælles pr. konto. **Grænser:** `gif` ved hver anmodning; `gif_user_min`, `gif_user_hour`, `gif_ip_min` og `gif_ip_hour` kun når GIF'en skal laves (se afsnittets beskrivelse). **Tidsgrænse:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 sekunder (45 sekunder som standard).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: Billedstørrelsen, `small` (felter på 32 px), `medium` (48 px) eller `large` (72 px).
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Den side, der er nederst på brættet.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Millisekunder pr. træk (et tal på 1 til 6 cifre).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Om linjebogstaverne og rækkenumrene tegnes rundt om brættet (`1`) eller ej (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` med `field` (`size`, `orientation`, `delay` eller `coords`): en værdi uden for de tilladte. `invalid_game_id`. `invalid_request`: ikke gyldig URL-kodning.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: partiet findes ikke. `gif_disabled`: serveren har slået GIF''er fra (`GIF_ENABLED=false`; `gif`-tokenet gives tilbage).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: grænsen `gif`, en renderingsgrænse eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: GIF''en kunne ikke laves (serveren logger hvorfor). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` med `retryAfter` (3 til 10 sekunder) og `Retry-After`: renderingskøen er fuld, eller GIF''en ventede `GIF_QUEUE_TIMEOUT_MS` (10 sekunder) på en ledig tråd; alle de tokens fra grænserne, som anmodningen tog, gives tilbage. `busy`: databasen forblev låst (`retryAfter: 1` kun i bodyen). `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Lav en animeret GIF af et vilkårligt parti sendt som PGN
      description: |-
        Det samme billede som `GET /games/{id}/gif` for et vilkårligt parti sendt som PGN-tekst: et parti gemt af spillet, en eksport fra et andet website, et parti skrevet ind i hånden. Kun tekstens første parti bruges.

        - PGN-læseren accepterer det samme som spillets egen læser: al PGN, som denne server skriver, og de almindelige eksporter fra andre websites (kommentarer, varianter, NAG'er og urannotationer springes over; træknumre og SAN læses tolerant; et `FEN`-tag giver startstillingen, medmindre `SetUp` er `"0"`).
        - Navnene og ratingerne kommer fra tagsene `White`, `Black`, `WhiteElo` og `BlackElo`. Bogstaver med accent mister deres accent, andre tegn uden for printbar ASCII bliver til `?`, og lange navne afkortes (48 tegn). Resultatet kommer fra tagget `Result`, ellers fra slutningen af trækteksten. Tagget `Termination` vises, medmindre det er `normal`: så fortæller slutstillingen historien (skakmat, pat).
        - Dette endpoint kontrollerer selv sin body: et ukendt felt eller et `pgn`, der mangler eller ikke er en streng, giver 400 `invalid_request` med `field`; en forkert indstilling giver 400 `invalid_option` (`delayMs` skal være et JSON-tal, `coords` en boolesk værdi; `null` afvises).

        **Påkrævet session:** kvoterne tælles pr. konto. **Grænser:** som for `GET /games/{id}/gif`. **Grænse for bodyens størrelse:** 135.168 byte, uanset `HTTP_BODY_LIMIT` (PGN'en som JSON-streng, inklusive dens escape-sekvenser). **Tidsgrænse:** 45 sekunder som standard.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Et kort parti skrevet ind, uden koordinater
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: En PGN-fil med alle indstillinger
                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` med `field` (et ukendt felt, eller `pgn` mangler eller er ikke en streng; uden `field`, når bodyen ikke er et objekt); `invalid_json`; `invalid_option` med `field` (`size`, `orientation`, `delayMs` eller `coords`); `invalid_pgn` med `line` og `column` (fra 1, kolonner i tegn) og læserens `message`: et ulovligt eller tvetydigt træk, et defekt tag, en ukendt variant, mere end 65.536 byte.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: serveren har slået GIF''er fra (`GIF_ENABLED=false`; `gif`-tokenet gives tilbage).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: en body på over 135.168 byte.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: grænsen `gif`, en renderingsgrænse eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: GIF''en kunne ikke laves (serveren logger hvorfor). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` med `retryAfter` (3 til 10 sekunder) og `Retry-After`: renderingskøen er fuld, eller GIF''en ventede for længe på en ledig tråd; alle de tokens fra grænserne, som anmodningen tog, gives tilbage. `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Hent en spillers offentlige profil
      description: |-
        En spillers offentlige profil: ratinger i de officielle kategorier (i serverens rækkefølge) og antal partier. `games.total` tæller alle gemte partier, også uratede og afbrudte; `games.rated`, `wins`, `draws` og `losses` summeres over ratingposterne, altså kun ratede partier. Svaret er det samme med eller uden token.

        **Grænse:** `public_read` (pr. spiller med token, pr. klient uden).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Profilen.
          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`: ikke 2 til 24 tegn blandt `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: der blev sendt et token, og det er ikke gyldigt.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: spilleren findes ikke, eller kontoen er slettet.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: grænsen `public_read` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (databasen forblev låst; `retryAfter: 1` i bodyen, ingen `Retry-After`-header), `server_busy` (sessionsopslaget fandt databasen låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Vis en spillers seneste partier
      description: |-
        Spillerens seneste partier, nyeste først, opdelt i sider som historikken, men uden filtre eller total. `color` er denne spillers side. `next` er det sidste partis id, hver gang siden er fuld, så den næste side kan være tom. Spilleren slås op først: en ukendt spiller giver 404, uanset hvad forespørgslen siger.

        **Grænse:** `public_read` (pr. spiller med token, pr. klient uden).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Én side af spillerens partier.
          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` er ikke et parti-id), `invalid_limit` (disse to uden `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: der blev sendt et token, og det er ikke gyldigt.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: spilleren findes ikke, eller kontoen er slettet.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: grænsen `public_read` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (databasen forblev låst; `retryAfter: 1` i bodyen, ingen `Retry-After`-header), `server_busy` (sessionsopslaget fandt databasen låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Hent de bedste spillere i en kategori
      description: |-
        De 100 bedste ratingposter i en officiel kategori med mindst `minGames` (`PROVISIONAL_GAMES`) talte partier, uden slettede konti og bekræftede snydere. Serveren beregner hver rangliste på ny højst hvert 10. sekund; `updatedAt` angiver, hvornår. Der kører én beregning ad gangen pr. kategori: imens får de andre anmodninger den forrige liste, eller de venter på beregningen, hvis der endnu ikke er nogen.

        **Grænser:** kun laget pr. adresse.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Et officielt kategori-id (`3+2` eller `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Antal spillere, 1 til 100. Et større tal på op til 3 cifre regnes som 100; en tom værdi regnes som fraværende.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: Ranglisten.
          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` (mangler eller er ikke en officiel kategori), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: laget pr. adresse.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (databasen forblev låst; `retryAfter: 1` i bodyen, ingen `Retry-After`-header), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Anmeld modstanderen i et nyligt parti
      description: |-
        Anmelder modstanderen i et af spillerens egne partier, der er afsluttet inden for de seneste 7 dage. En anmeldelse ændrer aldrig i sig selv en rating, en sanktion eller et integritetsniveau. Den hæver den prioritet for gennemgang, som moderatorerne ser, og undtagen ved `abuse` beder den om motoranalyse af partiet.

        En anmeldelse af den samme modstander for det samme parti får det samme svar og ændrer intet; svaret fortæller aldrig noget om den anmeldte konto. `GET /games/{id}` fortæller partiets spillere på forhånd, om en anmeldelse ville blive modtaget (`reportable`).

        Dette endpoint kontrollerer selv sin body i rækkefølgen `gameId`, `reported`, `category`, `comment`: en fejl giver 400 `invalid_request` uden `field`. Felter, det ikke kender, ignoreres.

        **Grænser:** `reports` (pr. spiller), derefter `REPORTS_PER_DAY` (5) anmeldelser pr. spiller på 24 timer (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: Anmeldelsen er modtaget (eller var allerede indgivet).
          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` (uden `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: ikke anmelderens modstander i et parti, der er afsluttet inden for de seneste 7 dage.'
        '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`, med en `Retry-After`-header): `REPORTS_PER_DAY` anmeldelser inden for de seneste 24 timer. `rate_limited`: grænsen `reports` eller kontobudgettet.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (sessionsopslaget fandt databasen låst), `timeout`. Også `server_busy` med `retryAfter` 2 til 5, når serveren er langt bagud (se introduktionen): anmodningen blev ikke udført.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Den officielle server (siderne ligger i roden, uden for `/api/v1`).
      - url: https://{host}:{port}
        description: En vilkårlig Scacelith-server (siderne ligger i roden, uden for `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Serverens offentlige værtsnavn (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Den offentlige API-port (`PUBLIC_API_PORT`, ellers `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Vis siden til bekræftelse af e-mailadressen
      description: |-
        Siden for et link til bekræftelse af e-mailadressen (registrering eller en bekræftelsesmail, der er sendt igen). Den viser kun en knap »Confirm my e-mail address« (Bekræft min e-mailadresse), så en mailscanner, der åbner linket, ikke bruger det op; knappen sender formularen til `POST /verify-email`.

        **Grænse:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Siden med dens bekræftelsesknap.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linket er ugyldigt eller udløbet (en HTML-side).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Grænsen `page` (en HTML-side) eller laget pr. adresse (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Bekræft e-mailadressen
      description: |-
        Bekræftelsessidens formular. Den bekræfter adressen; ved en ny registrering opretter den derefter kontoen, og spilleren kan logge ind.

        **Grænse:** `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: Adressen er bekræftet (og kontoen for en ny registrering oprettet).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linket er ugyldigt, brugt eller udløbet, eller formularen er ugyldig (en HTML-side).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Linket for en ny registrering, hvis brugernavn eller adresse en anden konto har taget i mellemtiden; der oprettes ingen konto (en HTML-side).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Grænsen `auth` (en HTML-side) eller laget pr. adresse (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Databasen forblev låst (`Retry-After: 1`), eller serveren er langt bagud (`Retry-After` 2 til 5, se introduktionen): intet blev ændret, og linket virker stadig. Også ved timeout i handleren.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Den officielle server (siderne ligger i roden, uden for `/api/v1`).
      - url: https://{host}:{port}
        description: En vilkårlig Scacelith-server (siderne ligger i roden, uden for `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Serverens offentlige værtsnavn (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Den offentlige API-port (`PUBLIC_API_PORT`, ellers `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Vis formularen til nulstilling af adgangskoden
      description: |-
        Siden for et link til nulstilling af adgangskoden: formularen til den nye adgangskode (adgangskoden to gange). Formularen sendes til `POST /reset-password`.

        **Grænse:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Formularen til den nye adgangskode.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linket er ugyldigt (også når det blev sendt til en adresse, som kontoen ikke længere har).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Grænsen `page` (en HTML-side) eller laget pr. adresse (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Angiv en ny adgangskode fra nulstillingsformularen
      description: |-
        Nulstillingssidens formular; den gør det samme som `POST /auth/password/reset`, i én transaktion: alle enheder logges ud, en afventende ændring af e-mailadressen annulleres, de andre nulstillingslinks holder op med at virke, adressen regnes som bekræftet, og ejeren får en e-mail.

        Først kontrolleres linket, derefter at de to adgangskoder er ens, og derefter reglerne for adgangskoder. Når serveren er optaget, kommer formularen tilbage med `Retry-After`, og linket forbliver gyldigt.

        **Grænser:** `auth`, derefter `auth_reset` (delt med `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: Adgangskoden er skiftet, og alle enheder er logget ud.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Formularen igen med fejlen (adgangskoderne er forskellige, en svag adgangskode), linket er ugyldigt, eller formularen er ugyldig (en HTML-side).
        '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: Grænsen `auth` eller `auth_reset` (en HTML-side), formularen igen med `Retry-After`, når denne klient har for mange adgangskode-hashes i kø (linket forbliver gyldigt), eller laget pr. adresse (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Formularen igen med `Retry-After`, når serveren er optaget (hashkøen for adgangskoder, eller databasen forblev låst); linket forbliver gyldigt. Når serveren er langt bagud (se introduktionen), i stedet en side »Server error« (Serverfejl) med `Retry-After` (2 til 5): intet blev udført, og linket forbliver gyldigt. Også ved timeout i handleren.'
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Den officielle server (siderne ligger i roden, uden for `/api/v1`).
      - url: https://{host}:{port}
        description: En vilkårlig Scacelith-server (siderne ligger i roden, uden for `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Serverens offentlige værtsnavn (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Den offentlige API-port (`PUBLIC_API_PORT`, ellers `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Vis siden til bekræftelse af ændringen af e-mailadressen
      description: |-
        Siden for et link til ændring af e-mailadressen: den viser den nye adresse og kontoens navn med en knap »Use this e-mail address« (Brug denne e-mailadresse), der sender formularen til `POST /confirm-email-change`.

        **Grænse:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Siden med den nye adresse og dens bekræftelsesknap.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linket er ugyldigt eller udløbet (også når kontoens adresse er ændret siden anmodningen).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Grænsen `page` (en HTML-side) eller laget pr. adresse (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Bekræft den nye e-mailadresse
      description: |-
        Formularen på siden til ændring af e-mailadressen. Adressen skifter og regnes som bekræftet; enhederne forbliver logget ind; de tidligere sendte links (bekræftelse, nulstilling af adgangskoden, andre ændringer) holder op med at virke; den tidligere adresse får besked, med den nye maskeret.

        **Grænse:** `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: Adressen er skiftet.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Linket er ugyldigt, brugt eller udløbet, eller formularen er ugyldig (en HTML-side).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: En anden konto har taget adressen i mellemtiden (en HTML-side).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Grænsen `auth` (en HTML-side) eller laget pr. adresse (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Databasen forblev låst (`Retry-After: 1`), eller serveren er langt bagud (`Retry-After` 2 til 5, se introduktionen): intet blev ændret, og linket virker stadig. Også ved timeout i handleren.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Den officielle server, i roden.
      - url: https://caissa.scacelith.com/api/v1
        description: Den officielle server, under `/api/v1`.
      - url: https://{host}:{port}
        description: En vilkårlig Scacelith-server, i roden.
        variables:
          host:
            default: caissa.scacelith.com
            description: Serverens offentlige værtsnavn (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Den offentlige API-port (`PUBLIC_API_PORT`, ellers `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: En vilkårlig Scacelith-server, under `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Serverens offentlige værtsnavn (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Den offentlige API-port (`PUBLIC_API_PORT`, ellers `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Kontrollér, at processen kører
      description: |-
        Svarer 200 `ok`, så længe processen kører. `HEAD` virker også. Enhver anden metode giver 405 `method_not_allowed` med `Allow: GET, HEAD` (også `OPTIONS`).

        **Grænser:** kun laget pr. adresse.
      security: []
      responses:
        '200':
          description: Processen kører.
          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`: laget pr. adresse.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Den officielle server, i roden.
      - url: https://caissa.scacelith.com/api/v1
        description: Den officielle server, under `/api/v1`.
      - url: https://{host}:{port}
        description: En vilkårlig Scacelith-server, i roden.
        variables:
          host:
            default: caissa.scacelith.com
            description: Serverens offentlige værtsnavn (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Den offentlige API-port (`PUBLIC_API_PORT`, ellers `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: En vilkårlig Scacelith-server, under `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Serverens offentlige værtsnavn (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Den offentlige API-port (`PUBLIC_API_PORT`, ellers `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Kontrollér, at serveren tager imod spillere
      description: |-
        Svarer 200 `ready`, når serveren tager imod spillere, og 503 `not_ready`, mens den starter eller stopper. `HEAD` virker også. Enhver anden metode giver 405 `method_not_allowed` med `Allow: GET, HEAD` (også `OPTIONS`).

        **Grænser:** kun laget pr. adresse.
      security: []
      responses:
        '200':
          description: Serveren tager imod spillere.
          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`: laget pr. adresse.'
        '503':
          description: Serveren er ved at starte eller stoppe.
          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: |-
        Et sessionstoken (`sct_` efterfulgt af 43 base64url-tegn) fra et loginsvar, i headeren `Authorization`: `Authorization: Bearer <token>`. Præfikset er præcis `Bearer` (der skelnes mellem store og små bogstaver) og ét mellemrum; en værdi uden det, eller et token, der ikke består af 1 til 512 printbare ASCII-tegn, giver 401 `invalid_token` som ethvert andet ugyldigt token. En tom header regnes som ingen header.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: Parti-id'et, et positivt decimalt heltal på højst 16 cifre uden foranstillede nuller, under 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: Sessionens id, som `GET /auth/sessions` angiver det, skrevet uden foranstillede nuller (`03` er ikke session 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Spillerens brugernavn, uden hensyn til store og små bogstaver. Serveren accepterer ethvert navn på 2 til 24 tegn blandt `[A-Za-z0-9_.-]` (den bredeste regel, den nogensinde har tilladt).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Et parti-id; kun ældre partier vises. Send den forrige sides `next`. En tom værdi regnes som fraværende.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Sidestørrelsen, 1 til 50. Et større tal på op til 3 cifre regnes som 50; en tom værdi regnes som fraværende.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: E-mail-linkets token (43 base64url-tegn). Et manglende eller forkert token viser siden »link invalid or expired« (linket er ugyldigt eller udløbet).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Alle serverens svar forbyder caching.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Antal sekunder, der skal ventes før et nyt forsøg; samme værdi som `retryAfter` i bodyen.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Bearer-challengen, med `error="invalid_token"` for et ugyldigt token.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: Serveren lukker forbindelsen efter dette svar.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (med `field`, når et felt i bodyen er skyld i fejlen) eller `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Et felt bryder skemaet
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Bodyen er ikke JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: En svag adgangskode
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: En PGN, der ikke kan læses
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (ingen `Authorization`-header) eller `invalid_token` (tokenet er fejlformateret, udløbet, tilbagekaldt eller tilhører en slettet konto).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Ingen session
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Et ugyldigt token
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: Anmodningen afvises.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: En forkert adgangskode ved fornyet godkendelse
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: En suspenderet konto
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found` eller en funktion, som denne server har slået fra.'
      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`: bodyen nåede ikke frem inden for 10 sekunder. Serveren lukker forbindelsen.'
      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: Anmodningen er i konflikt med serverens tilstand.
      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`: forsøget på Google-login eller billetten er ukendt, brugt eller udløbet; start login forfra.'
      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`: bodyen overstiger `HTTP_BODY_LIMIT` (16.384 byte som standard). Serveren lukker forbindelsen.'
      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`: anmodningsmålet overstiger 4096 tegn.'
      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`: bodyen er ikke `application/json`, eller dens charset er ikke 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`: partiet har mere end `GIF_MAX_PLIES` (600) halvtræk.'
      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`: løs `pow`-challengen, og send den samme anmodning igen med `pow: { challenge, nonce }`. `reason` angiver, hvorfor: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` eller `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` (en hastighedsgrænse, kontobudgettet eller hashkøen for adgangskoder) eller `too_many_attempts` (en drosling af kontoen), med `retryAfter` og en `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: En hastighedsgrænse
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: For mange mislykkede forsøg på denne konto
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: en uventet fejl. Serveren logger den.'
      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`: en forkert `state` eller `iss`, eller Google afviste koden eller sendte et ID-token, der ikke kan verificeres (også en kode eller et ID-token fra den anden klient, spillets eller websitets). Den indeholder aldrig Googles tekst.'
      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` eller `timeout`: prøv igen efter `retryAfter`, når den er angivet.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Serveren er optaget
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: Databasen forblev låst (en læsning)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Serveren var for længe om det
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: Den animerede GIF som vedhæftet fil.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` for et parti fra denne server, `attachment; filename="scacelith-game.gif"` for en PGN sendt med `POST /gif`.'
          schema:
            type: string
            pattern: '^attachment; filename="scacelith-([1-9][0-9]{0,15}|game)\.gif"$'
          example: attachment; filename="scacelith-4100000000001.gif"
        Content-Length:
          description: Filens størrelse i byte.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: En HTML-side.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: En HTML-side, der forklarer afvisningen.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Formularen nåede ikke frem inden for 10 sekunder (en HTML-side). Serveren lukker forbindelsen.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: En HTML-side, der forklarer konflikten.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Formularen overstiger `HTTP_BODY_LIMIT` (en HTML-side). Serveren lukker forbindelsen.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Bodyen er hverken en formular (`application/x-www-form-urlencoded`) eller JSON, eller dens charset er ikke UTF-8 (en HTML-side).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: En hastighedsgrænse (en HTML-side), med `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: En uventet fejl (en HTML-side med titlen »Server error«). Serveren logger den.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Serveren er optaget (databasen forblev låst, eller serveren er langt bagud; med `Retry-After`) eller var for længe om det (en HTML-side med titlen »Server error«).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
  schemas:
    Error:
      type: object
      description: Den ene form, som alle fejlsvar fra JSON-API'en har.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: Fejlkoden i `snake_case`. En klient vælger ud fra den, hvad der skal vises.
          examples:
            - rate_limited
        message:
          type: string
          description: En engelsk sætning, beregnet til logs og som reserve.
        retryAfter:
          type: integer
          minimum: 1
          description: Antal sekunder, der skal ventes før et nyt forsøg; kun til stede ved afvisninger, der ophører med tiden. Svaret har så også en `Retry-After`-header med samme værdi, undtagen ved 503 `busy` ved læsning af historik, partier, spillere og rangliste.
        field:
          type: string
          description: Det felt eller den query-parameter, der er skyld i fejlen (`invalid_request`, `invalid_option` samt `invalid_cursor`, `invalid_limit` og `invalid_filter` fra `GET /account/games`). Et indlejret felt skrives med punktummer (`pow.nonce`).
        reason:
          type: string
          description: 'Hvorfor: den regel, som et `weak_password` bryder, eller hvorfor et proof of work blev krævet eller afvist (`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: 'Kun `banned`: udelukkelsens afslutning (ms siden Unix-epoken), `null` ved en permanent udelukkelse.'
        line:
          type: integer
          minimum: 1
          description: 'Kun `invalid_pgn`: fejlens linje, fra 1.'
        column:
          type: integer
          minimum: 1
          description: 'Kun `invalid_pgn`: fejlens kolonne, fra 1, i tegn.'
    PowChallenge:
      type: object
      description: En proof of work-challenge (`pow` i et 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: Den signerede challenge, som skal sendes uændret tilbage.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Det antal foranstillede nulbit, som `SHA-256(challenge + ":" + nonce)` skal have.
        expiresAt:
          type: integer
          format: int64
          description: Hvornår challengen udløber (ms siden Unix-epoken), 2 minutter efter at den blev udstedt.
    PowAnswer:
      type: object
      description: Svaret på en proof of work-challenge.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: '`challenge` fra 428-svaret, som den blev givet.'
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: En decimal streng på højst 20 cifre, så `SHA-256(challenge + ":" + nonce)` starter med `bits` nulbit.
    ServerInfo:
      type: object
      description: Hvad en klient har brug for, før den logger ind eller opretter forbindelse.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Serverens navn (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: Et UUID, som databasen får ved sin første start. Det forbliver det samme på tværs af genstarter. Det er `null`, når databasen ikke kan levere det.
        motd:
          type: string
          description: Dagens besked (`SERVER_MOTD`), muligvis tom.
        protocol:
          type: object
          description: WebSocket-protokollen (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: Den ældste protokolversion, som serveren taler.
            max:
              type: integer
              minimum: 1
              description: Den nyeste protokolversion, som serveren taler.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 'Protokolskemaets fingeraftryk (de første 4 byte af SHA-256 af dets kanoniske form, som et heltal uden fortegn: `0xc649140f`, 3326678031, for protokollens minor-version 2); kun til information, sammenlignes aldrig.'
            subprotocol:
              type: string
              description: WebSocket-underprotokollens token (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: Den WebSocket-port, som spillerne bruger (`PUBLIC_WS_PORT`, ellers `WS_PORT`, ellers `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Stien til WebSocket'en.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Om der kan oprettes nye konti.
        emailVerification:
          type: boolean
          description: Om nye konti skal bekræfte deres adresse (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Om Google-login tilbydes til spillet.
            googleWeb:
              type: boolean
              description: Om Google-login også tilbydes til et website (`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: Totrinsgodkendelse er altid tilgængelig.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: De proof of work-bit, som registrering kræver (0 for ingen).
        categories:
          type: array
          description: De officielle (ratede) betænkningstider (`RATED_CATEGORIES`) i serverens rækkefølge. Enhver anden betænkningstid er `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: De regler, som en klient kan kontrollere, før den sender en formular.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: Det korteste brugernavn (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: Det længste brugernavn (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: Det regulære udtryk, som et nyt brugernavn skal matche.
            passwordMinLength:
              type: integer
              description: Den korteste adgangskode, i tegn (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: Den længste adgangskode, i byte UTF-8.
            customTimeControls:
              type: boolean
              description: Om udfordringer og private partier må bruge tilpassede betænkningstider.
            reportsPerDay:
              type: integer
              description: Hvor mange anmeldelser en spiller må indgive pr. 24 timer (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: Den største WebSocket-besked, som en klient må sende, i byte.
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 3326678031
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
          googleWeb: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: En officiel betænkningstid.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Kategoriens id, minutter og sekunders tillæg (`3+2`).
        baseSec:
          type: number
          minimum: 0
          description: Grundtiden, i sekunder.
        incSec:
          type: number
          minimum: 0
          description: Tillægget pr. træk, i sekunder.
    AccountView:
      type: object
      description: Kontoen, som dens spiller ser den (`user` i loginsvarene og i `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: Kontoens id.
        username:
          type: string
          description: Brugernavnet.
        email:
          type: string
          format: email
          description: E-mailadressen.
        emailVerified:
          type: boolean
          description: Om adressen er bekræftet.
        mfaEnabled:
          type: boolean
          description: Om totrinsgodkendelse er slået til.
        googleLinked:
          type: boolean
          description: Om en Google-konto er tilknyttet.
        hasPassword:
          type: boolean
          description: '`false` for en konto oprettet via Google, som endnu ikke har angivet en adgangskode.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Om direkte udfordringer via navn modtages (se `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Hvornår kontoen blev oprettet (ms siden Unix-epoken).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Det seneste login (ms siden Unix-epoken) eller `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Den nye adresse for en ændring af e-mailadressen, der venter på sit link, eller `null`.
    SessionAnswer:
      type: object
      description: En ny session.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Sessionstokenet, til headeren `Authorization` og WebSocket'en.
        expiresAt:
          type: integer
          format: int64
          description: Sessionens absolutte udløbstidspunkt (ms siden Unix-epoken); inaktivitetsgrænsen kan afslutte den tidligere.
        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: Andet trin i et login med totrinsgodkendelse; fortsæt med `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: Trinnets token, til `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Antal sekunder, der er tilbage til at fuldføre trinnet.
    LoginAnswer:
      description: En session eller andet trin i et login med totrinsgodkendelse.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Et første Google-login; fortsæt med `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: Billetten til `POST /auth/sso/complete`, gyldig i 10 minutter.
        suggestedUsername:
          type: string
          description: Et brugernavn dannet ud fra Google-navnet eller adressen, eller `""`, når intet passer.
    SsoNeedsPassword:
      type: object
      description: En konto med adgangskode bruger adressen; fortsæt med `POST /auth/sso/google/link`. Intet er tilknyttet endnu.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Billetten til `POST /auth/sso/google/link`.
        username:
          type: string
          description: Brugernavnet på den konto (kun en, der har bevist adressen over for Google, ser det).
        expiresIn:
          type: integer
          const: 600
          description: Antal sekunder, billetten forbliver gyldig.
    SsoFinishAnswer:
      description: Svaret fra `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: Et forsøg på Google-login.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Forsøget, til `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: Den Google-URL, der skal åbnes i systemets browser, når den er kontrolleret.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Den `state`, som Google skal sende tilbage.
        expiresIn:
          type: integer
          const: 600
          description: Antal sekunder, forsøget forbliver gyldigt.
    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: Brugernavnet; dernæst serverens regler (se beskrivelsen).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: E-mailadressen. Trimmes og gemmes med små bogstaver.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Adgangskoden; dernæst reglerne for adgangskoder.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Brugernavnet eller e-mailadressen (enhver tekst med `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Valgfri. Vises i listen over indloggede enheder (fx `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: '`mfaToken` fra loginsvaret (eller fra svaret på et Google-login).'
        code:
          type: string
          maxLength: 32
          description: Valgfri. En 6-cifret kode fra godkendelsesappen eller en gendannelseskode.
        recoveryCode:
          type: string
          maxLength: 32
          description: Valgfri. En gendannelseskode. Enten `code` eller `recoveryCode` er nødvendig.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: E-mailadressen.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Parameteren `token` i nulstillingslinket.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Den nye adgangskode; reglerne for adgangskoder ved registrering gælder.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: S256-PKCE-challengen for klientens `codeVerifier` (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: Porten for klientens listener på `127.0.0.1`.
    SsoWebStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: S256-PKCE-challengen for sidens `codeVerifier` (`BASE64URL(SHA-256(codeVerifier))`).
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: '`attemptId` fra `POST /auth/sso/google/start`.'
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: PKCE-verifieren; dens SHA-256 skal matche den challenge, der blev givet til `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state`, som Google sendte den tilbage.'
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: '`code`, som Google sendte den tilbage (printbar ASCII uden mellemrum).'
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Valgfri. `iss`, som Google sendte den tilbage, når Google har gjort det.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`linkTicket` fra `finish`, gyldig i 10 minutter.'
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kontoens adgangskode.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    SsoCompleteRequest:
      type: object
      additionalProperties: false
      required:
        - ssoTicket
        - username
      properties:
        ssoTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`ssoTicket` fra `finish`, gyldig i 10 minutter.'
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Brugernavnet; reglerne for registrering gælder.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: De aktive sessioner, den senest brugte først.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: En aktiv session (en indlogget enhed).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: Sessionens id, til `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: Tidspunktet for login (ms siden Unix-epoken).
        lastSeenAt:
          type: integer
          format: int64
          description: Seneste brug (ms siden Unix-epoken), opdateres højst hvert 5. minut (senere, mens serveren er langt bagud).
        expiresAt:
          type: integer
          format: int64
          description: Det absolutte udløbstidspunkt (ms siden Unix-epoken); inaktivitetsgrænsen kan afslutte sessionen tidligere.
        clientLabel:
          type:
            - string
            - 'null'
          description: '`clientLabel` fra loginanmodningen, eller `null`, når den ikke sendte nogen.'
        current:
          type: boolean
          description: Om dette er den session, der foretager anmodningen.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Én post pr. kategori, som spilleren har spillet ratede partier i.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: De aktive sanktioner.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }`, mens kontoen er suspenderet, ellers `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Ratingposten for én kategori.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: Kategoriens id.
        rating:
          type: integer
          description: Ratingen.
        games:
          type: integer
          minimum: 0
          description: De spillede partier i kategorien (talt med eller ej).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Den højeste opnåede rating.
        provisional:
          type: boolean
          description: '`true`, så længe ratingen endnu ikke er etableret eller har færre end `PROVISIONAL_GAMES` talte partier (spillet viser den som `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Sanktionens type.
        reason:
          type:
            - string
            - 'null'
          description: Den angivne begrundelse, hvis der er en.
        startsAt:
          type: integer
          format: int64
          description: Starten (ms siden Unix-epoken).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Afslutningen (ms siden Unix-epoken), `null`, når sanktionen er permanent.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Udelukkelsens afslutning (ms siden Unix-epoken), `null`, når den er permanent.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` for at modtage direkte udfordringer via navn, `none` for at afvise dem.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Den nuværende adgangskode.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Den nye adgangskode; reglerne for adgangskoder ved registrering gælder.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kontoens adgangskode.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: En kode fra den afventende hemmelighed, præcis 6 cifre.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kontoens adgangskode.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Valgfri; nødvendig med totrinsgodkendelse (eller `recoveryCode`). En kode fra godkendelsesappen eller en gendannelseskode.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Valgfri. En gendannelseskode (`xxxx-xxxx-xx`; store og små bogstaver, mellemrum og bindestreger er ligegyldige).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kontoens adgangskode.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: En kode fra godkendelsesappen (en gendannelseskode afvises).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Den nye adresse; trimmes og konverteres til små bogstaver og kontrolleres derefter som en adresse ved registrering.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Kontoens adgangskode.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Valgfri; nødvendig med totrinsgodkendelse (eller `recoveryCode`). En kode fra godkendelsesappen eller en gendannelseskode.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Valgfri. En gendannelseskode.
    TotpSetup:
      type: object
      description: Den afventende hemmelighed til godkendelsesappen.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Hemmeligheden i base32, til at indtaste i appen.
        uri:
          type: string
          format: uri
          description: "`otpauth://totp/...`-URI'en, til at vise som QR-kode."
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Sekunder pr. trin.
    RecoveryCodeList:
      type: array
      description: De 10 gendannelseskoder, som kun vises denne ene gang. Hver af dem virker én gang.
      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: Den ene side i et parti.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Navnet i partiposten (`deleted#<id>` for en slettet konto).
        rating:
          type:
            - integer
            - 'null'
          description: Ratingen ved start, `null`, når den er ukendt.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Ratingen efter partiet. Et ratet parti har den altid; `null` kun for et parti, der ikke tæller med i ratingerne (uratet, tilpasset, afbrudt).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: Ratingændringen, `0`, når ratingreglerne lader ratingen være uændret (FIDE-reglen om nul point, en modstander uden rating, de første partier for en spiller uden rating); `null` som `ratingAfter`.
    GameSummary:
      type: object
      description: Sammendraget af et gemt parti.
      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: Partiets id.
        category:
          type: string
          description: Det officielle kategori-id (`3+2`) eller `custom`.
        rated:
          type: boolean
          description: Om partiet tæller med i ratingerne.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Betænkningstiden i sekunder (`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` (se partikoderne).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Koden for slutårsagen (se partikoderne).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Resultatet, `*` for et afbrudt parti.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Antallet af halvtræk.
        startedAt:
          type: integer
          format: int64
          description: Starten (ms siden Unix-epoken).
        endedAt:
          type: integer
          format: int64
          description: Afslutningen (ms siden Unix-epoken).
    Termination:
      type: string
      description: Navnet på slutårsagen (se partikoderne); `Unknown` for en kode, som protokollen ikke kender.
      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: Et partisammendrag set fra én spillers side.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Spillerens side.
    HistoryGameSummary:
      description: Et parti i den indloggede spillers historik.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Grundtiden i millisekunder.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Tillægget i millisekunder.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: Udfaldet set fra spillerens side.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Sidens partier, nyeste først.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Det id, der skal sendes som `before` for at få den næste side, eller `null` på den sidste side.
        total:
          type: integer
          minimum: 0
          description: Antallet af partier, der matcher filteret, på tværs af alle sider.
    MoveRecord:
      type: object
      description: Ét halvtræk i et parti.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: Trækket i UCI-notation, med `n`, `b`, `r` eller `q` tilføjet ved en forvandling.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Den tid, der blev fratrukket for trækket (ms), `null`, når posten ikke har den.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Den trækkende spillers ur efter trækket (ms), `null`, når posten ikke har det.
    PgnTagsView:
      type: object
      description: De vigtigste PGN-tags, til visning. `Termination` er her navnet på slutårsagen; PGN-filen bruger PGN-standardens værdier.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` eller `<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: Startdatoen i 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: Ratingen ved start eller `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Ratingen ved start eller `"-"`.
          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: En partipost med dens træk og ure. Den har felterne fra et partisammendrag, uden `color` og `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Grundtiden i millisekunder.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Tillægget i millisekunder.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: Navnet på `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: Id'et for det parti, som dette er en revanche for, eller `null`.
            moves:
              type: array
              description: Ét element pr. halvtræk.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Kun når tokenets spiller spillede dette parti; vedkommendes side.
            reportable:
              type: boolean
              description: Kun når tokenets spiller spillede dette parti; `true`, når `POST /reports` lige nu ville modtage en anmeldelse af modstanderen for dette parti (partiet sluttede inden for 7 dage, den daglige kvote er ikke brugt op, og modstanderen er ikke allerede anmeldt for dette parti).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Brugernavnet, som spilleren skrev det.
        createdAt:
          type: integer
          format: int64
          description: Hvornår kontoen blev oprettet (ms siden Unix-epoken).
        ratings:
          type: array
          description: Kun de officielle kategorier, i serverens rækkefølge.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Alle gemte partier, også uratede og afbrudte.
            rated:
              type: integer
              minimum: 0
              description: Ratede partier, summeret over ratingposterne.
            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: Brugernavnet, som spilleren skrev det.
        games:
          type: array
          description: Sidens partier, nyeste først.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Det sidste partis id, hver gang siden er fuld (den næste side kan så være tom), ellers `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: Kategoriens id.
        minGames:
          type: integer
          minimum: 0
          description: Det antal talte partier, en post skal have for at komme på listen (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Hvornår ranglisten blev beregnet (ms siden Unix-epoken).
        players:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LeaderboardEntry'
    LeaderboardEntry:
      type: object
      required:
        - rank
        - username
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
      properties:
        rank:
          type: integer
          minimum: 1
        username:
          type: string
        rating:
          type: integer
        games:
          type: integer
          minimum: 0
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
    GifRequest:
      type: object
      additionalProperties: false
      required:
        - pgn
      properties:
        pgn:
          type: string
          description: PGN-teksten, højst 65.536 byte UTF-8. Kun det første parti bruges.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Valgfri. Billedstørrelsen.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Valgfri. Den side, der er nederst på brættet.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Valgfri. Millisekunder pr. træk, et JSON-tal med en heltalsværdi.
        coords:
          type: boolean
          default: true
          description: Valgfri. Om linjebogstaverne og rækkenumrene tegnes rundt om brættet.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: Partiet, som et heltal eller en streng på 1 til 16 cifre.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: Modstanderens brugernavn, som i partiposten eller som det er nu, uden hensyn til store og små bogstaver. Det må ikke være tomt.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: Hvad anmeldelsen drejer sig om.
        comment:
          type:
            - string
            - 'null'
          description: Valgfri. Højst 500 tegn, efter at kontroltegn (bortset fra tabulator og linjeskift) er fjernet og teksten er trimmet.
    AccountExport:
      type: object
      description: Alt, hvad serveren gemmer om kontoen (tidspunkter i ms siden Unix-epoken).
      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: Hvornår eksporten blev lavet.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Hvad filen indeholder og udelader, på letforståeligt engelsk til spilleren.
          items:
            type: string
        account:
          description: Kontovisningen fra `GET /account/me`, med `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: Adressen på den tilknyttede Google-konto eller `null`.
        ratings:
          type: array
          description: De komplette ratingposter.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Ratingpoint, der er givet tilbage, efter at en modstander blev afsløret i snyd, lagt sammen pr. UTC-døgn og kategori, nyeste først. Hverken partierne eller snyderne nævnes.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Alle gemte sessioner, nyeste først, uden noget token. Oprydningen af opbevarede data sletter en udløbet session, og en session, der er logget ud, en dag efter udlogningen.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Sikkerhedshændelserne, nyeste først, opbevaret i `RETENTION_SECURITY_DAYS` dage.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Alle sanktioner, også ophævede. Moderatorens navn er aldrig med.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: De adfærdshændelser, der er registreret for spillerens forladte og afbrudte partier og dem, hvor spilleren udeblev, opbevaret i 30 dage.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: De anmeldelser, spilleren har indgivet.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Antallet af partier.
            list:
              type: array
              description: Alle partier, nyeste først, som sammendrag fra `GET /account/games`. Trækkene findes på `GET /games/{id}` og PGN'en på `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: En komplet ratingpost.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Om spilleren har forladt fasen uden etableret rating.
            countedGames:
              type: integer
              minimum: 0
              description: De partier, der er indgået i ratingen.
            updatedAt:
              type: integer
              format: int64
              description: Hvornår posten sidst blev ændret.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: Døgnets 00:00 UTC (ms siden Unix-epoken).
        category:
          type: string
        points:
          type: integer
          description: De point, der blev givet tilbage den dag i den kategori.
    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: Hvornår sessionen blev logget ud, eller `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: Adressen ved login, slettet efter `RETENTION_IP_DAYS` dage.
    SecurityEvent:
      type: object
      description: |-
        En sikkerhedshændelse. `ip` angives kun for det, der blev gjort som indlogget, med kontoens adgangskode (og anden faktor) eller med et link sendt til dens adresse: `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` og `account_exported`; alle andre typer har `ip: null`, og `ip` slettes efter `RETENTION_IP_DAYS` dage.

        `detail` beholder kun disse felter: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` og `method` (`password` eller `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` og `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. En `moderator_action`-hændelse beholder kun `{ action }`, for handlingerne `ban`, `unban`, `reset_mfa`, `verify_email` og `revoke_sessions` (moderatorernes andre handlinger er udeladt). Alle andre typer har `detail: null`. Hændelserne `rating_refund` er udeladt: deres point står i `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Hændelsens type (`login`, `password_changed`...).
        at:
          type: integer
          format: int64
          description: Hvornår den skete.
        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: Den anmeldte spillers nuværende offentlige navn.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Om anmeldelsen stadig er åben (om den anmeldte spiller blev sanktioneret, oplyses ikke).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: E-mail-linkets token (det skjulte felt i sidens formular).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Nulstillingslinkets token (formularens skjulte felt).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Den nye adgangskode; reglerne for adgangskoder ved registrering gælder.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Den nye adgangskode igen; den skal være den samme.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: En HTML-side i UTF-8 (`text/html; charset=utf-8`) uden JavaScript eller eksterne ressourcer.
