openapi: 3.1.1
info:
  title: HTTPS-API des Scacelith-Servers
  version: "0.9.0"
  summary: Die HTTPS-API jedes Scacelith-Servers, des offiziellen wie der Community-Server.
  description: |-
    Jeder Scacelith-Server, der offizielle `caissa.scacelith.com` ebenso wie jeder Community-Server, beantwortet diese HTTPS-API. Das Spiel nutzt sie für alles außerhalb der eigentlichen Partie: Registrierung und Anmeldung (einschließlich Zwei-Faktor-Authentifizierung und Google), die Kontoseite, den Partieverlauf, PGN-Downloads und animierte GIFs von Partien, die angemeldeten Geräte, das Herunterladen der eigenen Daten, die Löschung des Kontos und Meldungen.

    Das Live-Spiel (Gegnersuche, Herausforderungen, Züge, Uhren) läuft über den WebSocket desselben Servers (`wss://<host>/ws`), der mit einem Sitzungstoken dieser API geöffnet wird; es ist in `PROTOCOL.md` beschrieben, nicht hier.

    Die unten genannten Standardwerte sind die eines Servers mit unveränderter Konfiguration; ein Community-Server kann sie ändern (`CONFIG.md` nennt jede Einstellung).

    ## Basis-URL, Port und Transport

    - Offizieller Server: `https://caissa.scacelith.com/api/v1`, TCP-Port 443.
    - Ein Community-Server: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` ist standardmäßig 443; hinter einem NAT oder einem Proxy ist der Port, den die Spieler verwenden, `PUBLIC_API_PORT`.
    - Der WebSocket des Spiels liegt standardmäßig auf demselben Port (`WS_PORT` verlegt ihn; `GET /info` teilt dem Client mit, wo er liegt).
    - HTTP/1.1 über TLS. Mit `TLS_MODE=proxy` terminiert ein Reverse Proxy vor dem Server das TLS. `TLS_MODE=off` (reines HTTP) ist nur für die lokale Entwicklung gedacht.
    - Die HTML-Seiten, die über die Links in E-Mails geöffnet werden (`/verify-email`, `/reset-password`, `/confirm-email-change`), liegen im Wurzelpfad des Servers, außerhalb von `/api/v1`. Die Endpunkte zur Zustandsprüfung antworten sowohl im Wurzelpfad als auch unter `/api/v1`. Die Anmeldung mit Google hat keine Seite auf dem Server: Google schickt den Browser direkt zum Spiel zurück, auf `127.0.0.1`.
    - Ein abschließender Schrägstrich wird ignoriert (`/api/v1/info/` ist `/api/v1/info`), und Pfadparameter werden URL-dekodiert (ein Parameter, der keine gültige URL-Kodierung ist, wird mit 400 `invalid_request` beantwortet).

    ## Anfragen

    - **JSON-Bodys.** `POST`-, `PUT`- und `DELETE`-Anfragen enthalten ein JSON-Objekt mit `Content-Type: application/json`. Ein anderer `charset` als UTF-8 wird abgelehnt, ebenso jeder andere Inhaltstyp (415 `unsupported_media_type`). Ein leerer Body gilt als `{}`, und genau das erwarten die Endpunkte ohne Parameter (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Größe und Zeit.** Ein Body ist auf `HTTP_BODY_LIMIT` Bytes begrenzt (standardmäßig 16.384; `POST /gif` hat ein eigenes Limit von 135.168 Bytes): 413 `payload_too_large`. Er muss innerhalb von 10 Sekunden eintreffen: 408 `request_timeout`. Nach jedem dieser beiden Fehler schließt der Server die Verbindung.
    - **Strikte Schemas.** Ein Feld, das der Endpunkt nicht kennt, wird abgelehnt, ein nicht als optional gekennzeichnetes Feld ist Pflicht, und Typen und Längen werden geprüft. Jeder dieser Fehler wird mit 400 `invalid_request` beantwortet, wobei `field` das Feld nennt (bei einem verschachtelten Feld mit Punkten, etwa `pow.nonce`). Zeichenketten dürfen keine Steuerzeichen enthalten. Längen werden in UTF-16-Codeeinheiten gezählt, sodass ein Zeichen außerhalb der mehrsprachigen Basisebene (BMP), etwa ein Emoji, doppelt zählt. Ein Body, der kein JSON ist, wird mit 400 `invalid_json` beantwortet. `POST /gif` und `POST /reports` prüfen ihre Bodys selbst (siehe den jeweiligen Endpunkt).
    - **Abfrageparameter.** Es zählt das erste Vorkommen eines Parameters, unbekannte Parameter werden ignoriert, und ein `+` wird als Leerzeichen dekodiert: Die Endpunkte, die eine Bedenkzeitkategorie annehmen, akzeptieren sowohl `3%2B2` als auch `3+2`.
    - **Methoden.** `HEAD` ist `GET` ohne Body. `OPTIONS` auf einem existierenden Pfad antwortet mit 204 und einem `Allow`-Header. Jede andere Methode, die der Pfad nicht hat, wird mit 405 `method_not_allowed` und `Allow` beantwortet.
    - **Anfrageziel.** Es darf 4096 Zeichen nicht überschreiten (414 `uri_too_long`). Eine Anfrage, die sich überhaupt nicht parsen lässt oder deren Kopf zu groß ist oder zu langsam eintrifft, erhält eine leere Antwort 400, 431 oder 408, und die Verbindung wird geschlossen.
    - **Kein CORS.** Die API bedient das Spiel, keine Webseiten. Es wird nie ein `Access-Control-*`-Header gesendet, sodass eine Webseite keine Antwort lesen kann; da nur JSON-Bodys angenommen werden, bräuchte ein seitenübergreifender Schreibzugriff eine Preflight-Anfrage, und diese schlägt fehl.

    ## Antworten und Fehler

    - Antworten sind JSON in UTF-8, außer dem PGN-Download (`application/x-chess-pgn`), den animierten GIFs (`image/gif`) und den HTML-Seiten. Zeitangaben sind Millisekunden seit dem 1970-01-01 UTC. IDs sind Ganzzahlen. Partie-IDs haben bis zu 16 Stellen, bleiben aber unter 2^53, sodass eine JSON-Zahl (ein Double) sie exakt darstellt.
    - Jede Antwort der API trägt `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` und `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (die HTML-Seiten haben eine eigene Richtlinie). Mit `TLS_MODE=native` trägt sie außerdem `Strict-Transport-Security: max-age=31536000`.
    - Eine Antwort muss innerhalb von 60 Sekunden ab dem Moment gelesen werden, in dem der Server sie bereithält, was nur bei großen Antworten eine Rolle spielt (ein GIF, der Datenexport, eine lange PGN). Die Zeit, die der Server zum Erstellen einer Antwort braucht, zählt weder hierfür noch für die 30 Sekunden ohne ein- oder ausgehendes Byte, nach denen eine Verbindung geschlossen wird.

    Fehler haben eine einheitliche Form (das Schema `Error`):

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

    `message` ist für Logs und als Rückfalltext gedacht; ein Client wählt anhand von `error`, was er anzeigt. `retryAfter` (Sekunden) ist nur bei Ablehnungen vorhanden, die mit der Zeit enden, und die Antwort trägt dann auch einen `Retry-After`-Header mit demselben Wert; die einzige Ausnahme ist das 503 `busy` der Lesezugriffe auf Partieverlauf, Partien, Spieler und Rangliste, das nur das Feld enthält. Manche Fehler fügen Felder hinzu: `field` (ungültige Eingabe), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` und `column` (`invalid_pgn`).

    Fehler, die jeder Endpunkt liefern kann:

    | Status | `error` | Wann |
    |---|---|---|
    | 400 | `invalid_request` | Der Body verletzt das Schema des Endpunkts (`field` sagt, wo), das Anfrageziel oder `Content-Length` ist fehlerhaft, ein Pfadparameter ist keine gültige URL-Kodierung, oder der Body wurde abgeschnitten. |
    | 400 | `invalid_json` | Der Body ist kein JSON. |
    | 401 | `unauthorized` | Kein `Authorization`-Header bei einem Endpunkt, der eine Sitzung benötigt. |
    | 401 | `invalid_token` | Das Token ist fehlerhaft, abgelaufen, widerrufen oder gehört zu einem gelöschten Konto; auch bei Endpunkten, an denen die Sitzung optional ist. |
    | 404 | `not_found` | Endpunkt nicht vorhanden. Manche Endpunkte verwenden ihn auch: Partie, Spieler oder Sitzung nicht vorhanden. |
    | 405 | `method_not_allowed` | Der Pfad existiert für andere Methoden (siehe `Allow`). |
    | 408 | `request_timeout` | Der Body ist nicht innerhalb von 10 Sekunden eingetroffen. |
    | 413 | `payload_too_large` | Der Body überschreitet `HTTP_BODY_LIMIT` (135.168 Bytes bei `POST /gif`). |
    | 414 | `uri_too_long` | Das Anfrageziel überschreitet 4096 Zeichen. |
    | 415 | `unsupported_media_type` | Der Body ist nicht `application/json`, oder sein Zeichensatz ist nicht UTF-8. |
    | 429 | `rate_limited` | Ein Ratenlimit (siehe unten): `retryAfter` plus ein `Retry-After`-Header. |
    | 500 | `internal_error` | Ein unerwarteter Fehler. Der Server protokolliert ihn. |
    | 503 | `timeout` | Der Server hat nicht innerhalb von 30 Sekunden geantwortet (60 Sekunden beim Export, 45 Sekunden bei den GIFs mit den Standardeinstellungen). |
    | 503 | `server_busy` | Eine Sitzungsabfrage oder eine Kontoänderung fand die Datenbank gesperrt vor (`retryAfter` 1), oder die Passwort-Hash-Warteschlange ist voll (siehe unten). |

    Die Lese-Endpunkte (Partieverlauf, Partien, Spieler, Rangliste) und der Export antworten mit 503 `busy` und `retryAfter: 1`, wenn die Datenbank gesperrt blieb.

    Endpunkte, die ein Passwort prüfen oder hashen, können außerdem einen der folgenden Fehler liefern, beide mit einem zufälligen `retryAfter` von 5 bis 15 Sekunden:

    - 503 `server_busy`: Die Passwort-Hash-Warteschlange des Servers (`PASSWORD_HASH_QUEUE_MAX`) ist voll, oder die Wartezeit ist abgelaufen.
    - 429 `rate_limited`: Sobald die Warteschlange halb voll ist, hat dieser Client (eine IPv4-Adresse oder ein IPv6-/48-Netz) bereits `PASSWORD_HASH_WAITERS_PER_SOURCE` wartende Hashes. Dieses 429 gibt die Ratenlimit-Tokens zurück, die die Anfrage verbraucht hat.

    In beiden Fällen wurde nichts geändert und kein Fehlversuch gezählt (außer bei `POST /auth/sso/google/link`, dessen Ticketversuch und Kontofehlversuch schon vor dem Hashen gezählt wurden), und ein Link zum Zurücksetzen bleibt gültig.

    Die HTML-Seiten liefern ihre Fehler als HTML-Seiten mit denselben Statuscodes.

    ## Authentifizierung

    Die Endpunkte, die eine Sitzung benötigen, erwarten ein Bearer-Token im `Authorization`-Header (das Schema `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Ein Token erhalten.** Ein Token (`sct_` gefolgt von 43 base64url-Zeichen) stammt von `POST /auth/login`, danach von `POST /auth/login/mfa`, wenn die Zwei-Faktor-Authentifizierung eingeschaltet ist, von `POST /auth/sso/google/finish` und `POST /auth/sso/google/link` (Anmeldung mit Google) sowie von `POST /auth/sso/complete`. Jeder dieser Endpunkte antwortet mit `{ token, expiresAt, user }`. Der Server speichert nur einen SHA-256-Hash des Tokens.
    - **Erforderliche Sitzung.** Ein fehlender Header wird mit 401 `unauthorized` und `WWW-Authenticate: Bearer realm="scacelith"` beantwortet. Ein ungültiges Token wird mit 401 `invalid_token` und `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"` beantwortet. Ein Client sollte ein Token, das `invalid_token` erhält, verwerfen und sich erneut anmelden.
    - **Optionale Sitzung.** Bei `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` und `GET /players/{username}/games` ist die Sitzung optional. Ohne den Header liefern sie die öffentliche Ansicht; wird ein Header gesendet, muss sein Token gültig sein.
    - **Lebensdauer.** Eine Sitzung endet beim früheren von zwei Zeitpunkten: `SESSION_MAX_DAYS` (90) Tage nach der Anmeldung (das ist `expiresAt`) oder nach `SESSION_IDLE_DAYS` (30) Tagen ohne Nutzung. Jede Nutzung schiebt die Inaktivitätsgrenze hinaus; der Server schreibt den neuen Wert höchstens alle 5 Minuten. Ein Konto behält höchstens `MAX_SESSIONS_PER_USER` (10) Sitzungen: Eine neue Anmeldung widerruft die ältesten über diese Anzahl hinaus.
    - **Widerruf.** Das Abmelden (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) widerruft Sitzungen; ebenso eine Passwortänderung (die anderen Sitzungen), das Zurücksetzen des Passworts und die Löschung des Kontos (alle Sitzungen) sowie ein Administrator. Ein Widerruf über die API wirkt sofort, und der mit einer widerrufenen Sitzung geöffnete WebSocket wird geschlossen. Der Server speichert Sitzungsabfragen 30 Sekunden lang zwischen, sodass ein Widerruf über den Admin-Befehl, einen separaten Prozess, innerhalb von 30 Sekunden wirksam wird.
    - **Geltungsbereich.** Ein Token gehört zu genau einem Server und öffnet auch dessen WebSocket. Senden Sie es nie an einen anderen Server.

    ## Ratenlimits und andere Drosselungen

    Limits gelten für einen von zwei Bereichen. **Client:** eine IPv4-Adresse oder ein IPv6-/64-Netz (im Proxy-Modus stammt die Adresse aus `X-Forwarded-For`, gesendet von einer `TRUSTED_PROXIES`-Adresse); manche Limits zählen zusätzlich jedes IPv6-/48-Netz als Ganzes, über jedes seiner /64-Netze hinaus. **Spieler:** das angemeldete Konto, unabhängig von seiner Adresse; ein pro Spieler gezähltes Limit an einem Endpunkt mit optionaler Sitzung zählt bei einer Anfrage ohne Token pro Client.

    Jedes Limit ist ein Token-Bucket, der `limit` Anfragen fasst und sich kontinuierlich mit `limit / window` auffüllt; `retryAfter` ist die Zeit bis zum nächsten Token. Als *geteilt* markierte Limits werden zusätzlich über ein gleitendes Fenster derselben Länge gezählt, sodass kein Fenster wesentlich mehr als `limit` Anfragen enthält. Jedes Limit wird für den gesamten Server gezählt. Wenn eines der Limits eines Endpunkts eine Anfrage ablehnt, werden die Tokens zurückgegeben, die seine anderen Limits für diese Anfrage verbraucht haben. Eine Ablehnung wird mit 429 `rate_limited` samt `retryAfter` und `Retry-After` beantwortet.

    - **Adressebene.** Jede Anfrage (jeder Pfad und jede Methode, einschließlich der Endpunkte zur Zustandsprüfung und der WebSocket-Upgrades) verbraucht zuerst ein Token aus dem Budget ihres Clients: `HTTP_RATE_PER_IP` (600) pro Minute mit einem Burst von einer halben Minute und `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) für ein IPv6-/48-Netz. Ein Client darf außerdem höchstens `IP_MAX_INFLIGHT` (32 x `WORKERS`) Anfragen gleichzeitig in Bearbeitung haben (darüber hinaus `retryAfter` 1). Ein Client, der nach seinen Ablehnungen weitermacht, wird gesperrt: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) Ablehnungen in einer Minute sperren ihn für 1 Minute, dann bei jeder neuen Sperre innerhalb von 6 Stunden für 4, 16 und 60 Minuten. Eine Ablehnung durch die Limits `auth`, `auth_*` und `reauth` zählt fünffach; pro Spieler gezählte Limits zählen nie. Adressen in `ABUSE_EXEMPT` überspringen diese Ebene, nicht aber das Kontobudget oder die Endpunktlimits.
    - **Kontobudget.** Jede Anfrage mit einem gültigen Sitzungstoken zählt außerdem gegen ihr Konto: `USER_RATE_PER_MIN` (120) pro Minute für alle Endpunkte zusammen, unabhängig von der Adresse, mit einem Burst von einer halben Minute.
    - **Endpunktlimits:**

    | Limit | Standardwert | Gezählt pro | Endpunkte |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, geteilt | Client und `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) pro IPv6-/48-Netz | `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) / Stunde, geteilt | Client, das Dreifache pro IPv6-/48-Netz | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / Stunde, geteilt | Client, das Dreifache pro IPv6-/48-Netz | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / Stunde, geteilt | Client, das Dreifache pro IPv6-/48-Netz | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 Stunden, geteilt | Client, das Dreifache pro IPv6-/48-Netz | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / Stunde, geteilt | Client, das Dreifache pro IPv6-/48-Netz | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | die Werte von `auth`, eigener Bucket, geteilt | Client und IPv6-/48-Netz | die Kontoänderungen, die das Passwort verlangen (siehe Erneute Authentifizierung), und `POST /account/export` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 min, geteilt | Spieler | dieselben Endpunkte wie `reauth` |
    | `account` | 60 / min | Spieler | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | Spieler | `GET /account/games` |
    | `account_export` | 5 / Stunde, geteilt | Spieler | `POST /account/export` (vor `reauth` geprüft; jeder Versuch zählt) |
    | `sessions` | 60 / min | Spieler | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | Spieler (Client ohne Token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (ein Bucket für alle vier) |
    | `gif` | 30 / min | Spieler | `GET /games/{id}/gif`, `POST /gif` (ein Bucket für beide) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min und `GIF_USER_RENDERS_PER_HOUR` (30) / Stunde, geteilt | Spieler | dieselben beiden, nur wenn das GIF erstellt werden muss (nicht aus dem Cache) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min und `GIF_IP_RENDERS_PER_HOUR` (120) / Stunde, geteilt | Client (alle seine Konten zusammen), das Dreifache pro IPv6-/48-Netz | dieselben, wie oben |
    | `reports` | 30 / Stunde | Spieler | `POST /reports` |
    | `sso_start` | 30 / 10 min, geteilt | Client und 90 pro IPv6-/48-Netz | `POST /auth/sso/google/start` |
    | `sso_finish` | 30 / min | Client | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | Client | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` und `GET /leaderboard` haben kein eigenes Limit, nur die Adressebene. Ein Endpunkt mit mehreren Limits prüft sie in der Reihenfolge, die in seiner Beschreibung angegeben ist.

    Der Server bearbeitet eine Anfrage in dieser Reihenfolge: die Adressebene, dann die Endpunkte zur Zustandsprüfung; Zuordnung des Endpunkts; Authentifizierung; das Kontobudget, wenn die Anfrage eine Sitzung trägt; die Limits des Endpunkts; der Body; der Endpunkt selbst (die GIF-Endpunkte verbrauchen ihre Render-Limits nur, wenn sie ein GIF erstellen müssen). Eine wegen ihres Tokens abgelehnte Anfrage verbraucht also keine Tokens des Endpunkts, eine Anfrage mit ungültigem Body dagegen schon.

    Weitere Drosselungen, die die Endpunkte selbst beantworten:

    - **Fehlgeschlagene Anmeldungen mit einem Anmeldenamen** (Benutzername oder E-Mail): Ab `AUTH_FAILURES_PER_ACCOUNT` (5) Fehlversuchen muss jeder Versuch doppelt so lange warten wie der vorherige (2 s, 4 s usw., bis zu 15 Minuten): 429 `too_many_attempts` mit `retryAfter`. Der Zähler vergisst nach einer Stunde ohne Fehlversuche.
    - **Fehlgeschlagene zweite Faktoren bei der Anmeldung:** dieselbe Regel ab dem 5. falschen Code des Kontos. Ein Anmeldeschritt akzeptiert höchstens 5 falsche Codes.
    - **Codes des zweiten Faktors eines Kontos:** höchstens `AUTH_MFA_PER_ACCOUNT` (10) Codes (Authenticator- oder Wiederherstellungscodes, richtig oder falsch) pro 15 Minuten, von jeder Adresse, bei der Anmeldung und bei erneuten Authentifizierungen; darüber hinaus 429 `too_many_attempts`, bevor der Code geprüft wird, sodass kein Wiederherstellungscode verbraucht wird.
    - **Fehlgeschlagene erneute Authentifizierungen eines Kontos** (falsches Passwort oder falscher Code): dieselbe Regel (429 `too_many_attempts`), gemeinsam für alle Endpunkte, die erneut authentifizieren.
    - **E-Mails:** eine Bestätigungs- oder Zurücksetzungs-E-Mail pro Adresse alle 5 Minuten (die Antwort bleibt dieselbe); ein Hinweis „Jemand hat versucht, Ihre Adresse zu verwenden“ pro Adresse und Stunde.
    - **Meldungen:** `REPORTS_PER_DAY` (5) pro Spieler in 24 Stunden: 429 `report_limit`.

    ## Arbeitsnachweis (Proof of Work)

    `POST /auth/register` verlangt immer einen Arbeitsnachweis, wenn `POW_REGISTER_BITS` größer als 0 ist (standardmäßig 18; `GET /info` liefert den Wert als `pow.register`). `POST /auth/login` und `POST /auth/sso/google/link` (eine gemeinsame Art von Challenge für beide) verlangen einen nur für 5 Minuten, nachdem der Server eine Welle fehlgeschlagener Anmeldungen bemerkt hat (`POW_LOGIN_TRIGGER_PER_MIN`, dann `POW_LOGIN_BITS`); ein Client erfährt es aus der Antwort.

    1. Die Anfrage ohne Nachweis (oder mit einem abgelehnten) wird mit 428 `pow_required` beantwortet, samt `reason` und einer `pow`-Challenge `{ challenge, bits, expiresAt }`.
    2. Finden Sie eine Nonce: eine Dezimalzeichenkette aus höchstens 20 Ziffern, sodass `SHA-256(challenge + ":" + nonce)` mit `bits` Nullbits beginnt (höchstwertiges Bit des ersten Bytes zuerst). 18 Bits erfordern im Mittel etwa 260.000 Hashes.
    3. Senden Sie dieselbe Anfrage erneut mit `"pow": { "challenge": "...", "nonce": "123456" }` im Body.

    Eine Challenge ist 2 Minuten lang und nur einmal gültig, für einen Endpunkt und ein Client-Netz (eine IPv4-Adresse oder ein IPv6-/64-Netz). Sie ist signiert, sodass der Server nichts über sie speichert, bis sie zurückkommt. `reason` gibt an, warum ein Nachweis abgelehnt wurde: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` oder `replayed`.

    ## Erneute Authentifizierung

    Kontoänderungen verlangen erneut das Passwort und, wenn die Zwei-Faktor-Authentifizierung eingeschaltet ist, einen zweiten Faktor:

    - nur das Passwort: `POST /account/password` und `POST /account/mfa/totp/setup`;
    - das Passwort und einen Authenticator-Code, wobei ein Wiederherstellungscode abgelehnt wird: `POST /account/mfa/recovery-codes`;
    - das Passwort und einen Authenticator-Code oder einen Wiederherstellungscode: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` und `POST /account/delete`.

    In Bodys enthält `code` einen 6-stelligen Authenticator-Code und `recoveryCode` einen Wiederherstellungscode (`xxxx-xxxx-xx`; Groß- und Kleinschreibung, Leerzeichen und Bindestriche spielen keine Rolle). Wo Wiederherstellungscodes akzeptiert werden, darf ein Wiederherstellungscode auch in `code` gesendet werden. Jeder Code funktioniert nur einmal: Ein verwendeter Authenticator-Code wird bis zum nächsten 30-Sekunden-Schritt abgelehnt, und ein Wiederherstellungscode ist nach seiner Verwendung verbraucht.

    | Status | `error` | Wann |
    |---|---|---|
    | 403 | `invalid_password` | Falsches Passwort. |
    | 403 | `mfa_code_required` | Die Zwei-Faktor-Authentifizierung ist eingeschaltet, und weder `code` noch `recoveryCode` wurde gesendet. |
    | 403 | `invalid_code` | Falscher oder bereits verwendeter Code. |
    | 400 | `password_not_set` | Ein Konto, das sich nur mit Google anmeldet, hat noch kein Passwort („Passwort vergessen?“ legt eines fest). |
    | 429 | `too_many_attempts` | Zu viele Fehlversuche oder zu viele ausprobierte Codes bei diesem Konto. |
    | 503 / 429 | `server_busy` / `rate_limited` | Die Passwort-Hash-Warteschlange ist ausgelastet. |

    Falsche Passwörter und Codes zählen im Fehlversuchszähler des Kontos und werden als Sicherheitsereignisse protokolliert.

    ## Metrik-Port

    Neben dem API-Port antwortet der Server mit reinem HTTP auf dem Metrik-Port (`METRICS_PORT`, 9464, gebunden an `METRICS_BIND`, standardmäßig 127.0.0.1; halten Sie ihn privat). Er ist nicht Teil dieser API: `GET /healthz` antwortet mit `ok`; `GET /readyz` antwortet mit `ready`, sobald der Start abgeschlossen ist (jeder Shard hat sein Journal erneut abgespielt und die Listener sind gebunden) und bis das Herunterfahren beginnt, sonst mit 503 `not ready`; `GET /metrics` liefert die Prometheus-Metriken und verlangt, wenn `METRICS_TOKEN` gesetzt ist, `Authorization: Bearer <token>` mit genau diesem 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: Quellcode des Scacelith-Servers und seine Referenzdokumentation (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Der offizielle Server.
  - url: https://{host}:{port}/api/v1
    description: Ein beliebiger Scacelith-Server, etwa ein Community-Server.
    variables:
      host:
        default: caissa.scacelith.com
        description: Der öffentliche Hostname des Servers (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: Der öffentliche API-Port (`PUBLIC_API_PORT`, sonst `API_PORT`).
tags:
  - name: server-info
    x-displayName: Serverinformationen
    description: Was ein Client wissen muss, bevor er sich anmeldet oder verbindet.
  - name: health
    x-displayName: Zustandsprüfung
    description: |-
      Erreichbarkeit und Betriebsbereitschaft des Servers, für die Überwachung. Diese Endpunkte antworten auf dem API-Port vor der Authentifizierung und vor allen Endpunktlimits, verbrauchen aber wie jede Anfrage ein Token der Adressebene; ein Überwachungshost kann in `ABUSE_EXEMPT` eingetragen werden. Für jeden Endpunkt funktionieren beide Pfade: im Wurzelpfad des Servers und unter `/api/v1`.

      Der Metrik-Port hat eigene Endpunkte zur Zustandsprüfung, die in der Einleitung beschrieben sind.
  - name: auth
    x-displayName: Registrierung und Anmeldung
    description: |-
      Konto erstellen, Anmeldung mit Passwort (und zweitem Faktor), E-Mails zur Bestätigung und zum Zurücksetzen des Passworts.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` und `POST /auth/sso/complete` antworten mit einer Sitzung `{ token, expiresAt, user }` (oder, bei den ersten von ihnen, mit einem zweiten Schritt).
  - name: google-sign-in
    x-displayName: Anmeldung mit Google
    description: |-
      Verfügbar, wenn `GET /info` `sso.google: true` meldet; andernfalls antwortet jeder der folgenden Endpunkte mit 404 `sso_disabled`. Das Spiel meldet sich über den Systembrowser mit dem Ablauf für installierte Apps nach RFC 8252 an (ein Client vom Typ „Desktop-App“): Google schickt den Browser zu einem Listener des Spiels auf `127.0.0.1` zurück, nie zu diesem Server, und das Spiel sieht nie Google-Anmeldedaten.

      1. Der Client lauscht auf `127.0.0.1:0` (das System wählt den Port) und erzeugt ein PKCE-Paar: einen `codeVerifier` aus 43 bis 128 Zeichen `[A-Za-z0-9._~-]` und `codeChallenge = BASE64URL(SHA-256(codeVerifier))` mit 43 Zeichen ohne Padding.
      2. `POST /auth/sso/google/start` mit der Code-Challenge und dem Port liefert die Google-URL und den `state` des Versuchs. Der Client prüft die URL (siehe unten) und öffnet sie im Browser.
      3. Google schickt den Browser zu `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Der Client akzeptiert nur den `state` aus der Antwort auf den Start und sendet nach einer Weiterleitung mit `error=` nichts.
      4. `POST /auth/sso/google/finish` mit der ID des Versuchs, dem `codeVerifier`, dem `state` und dem `code` (und `iss`, wenn Google ihn gesendet hat).
      5. Die Antwort ist eine Sitzung, ein Schritt der Zwei-Faktor-Authentifizierung (weiter mit `POST /auth/login/mfa`), `needsUsername` für einen neuen Spieler (weiter mit `POST /auth/sso/complete`) oder `needsPassword`, wenn ein Konto mit Passwort die Adresse verwendet (weiter mit `POST /auth/sso/google/link`).

      **Das Herkunfts-Tag.** Die Weiterleitungs-URI trägt ein Tag des Servers, den der Spieler hinzugefügt hat, sodass das Spiel eine URL ablehnt, die ein anderer Server für seine eigenen Spieler erhalten hat. Die Herkunft ist der Host in Kleinbuchstaben (ein IPv6-Literal in eckigen Klammern), `:` und der dezimale API-Port, immer ausgeschrieben, auch 443: Der Server nimmt `SERVER_PUBLIC_HOST` und seinen öffentlichen API-Port, das Spiel die Adresse, mit der es verbunden ist. Das Tag sind die ersten 22 Zeichen der base64url-Kodierung (ohne Padding) von SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + Herkunft). Die Weiterleitungs-URI ist `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: immer das IPv4-Literal und der Port des Listeners des Spiels (1024 bis 65535). Der Server übernimmt nie eine URI, einen Host oder einen Pfad vom Client.

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

      Die Anmeldung mit Google funktioniert daher nur für Spieler, die den Server unter genau `SERVER_PUBLIC_HOST` und seinem öffentlichen API-Port hinzugefügt haben.

      **Was das Spiel prüft, bevor es den Browser öffnet.** Die `authUrl` beginnt mit genau `https://accounts.google.com/o/oauth2/v2/auth?`, besteht aus druckbarem ASCII mit weniger als 4096 Zeichen, und ihr Query-Teil enthält genau ein `response_type=code`, genau eine `redirect_uri`, die der URI entspricht, die das Spiel aus seinem Port und seinem Herkunfts-Tag berechnet, genau einen `state`, der dem `state` der Antwort entspricht, `code_challenge_method=S256` und eine `code_challenge` aus 43 Zeichen. Andernfalls beendet das Spiel seinen Listener und öffnet nichts. Es sendet `finish` und `link` nur an den Server, der auf `start` geantwortet hat.

      **Welches Konto die Anmeldung mit Google erreicht:** das bereits mit diesem Google-Konto verknüpfte Konto; andernfalls ein aktives Konto mit der von Google bestätigten Adresse (hat es ein Passwort, antwortet `finish` mit `needsPassword`, und die Verknüpfung wird erst gespeichert, wenn sein Passwort und danach, falls eingeschaltet, sein zweiter Faktor bestanden sind; ohne Passwort 409 `sso_account_exists`); andernfalls ein neues Konto (`needsUsername`). Ein Google-Konto wird nie allein anhand seiner Adresse mit einem bestehenden Konto verknüpft. Die Adresse des Kontos erhält eine E-Mail, wenn die Anmeldung mit Google ein Konto erstellt und wenn sie einem bestehenden Konto hinzugefügt wird.
  - name: sessions
    x-displayName: Sitzungen
    description: Die angemeldeten Geräte des Kontos und das Abmelden.
  - name: account
    x-displayName: Konto
    description: Die Ansicht des Kontos, seine Einstellungen und sein Passwort.
  - name: two-step-verification
    x-displayName: Zwei-Faktor-Authentifizierung
    description: Authenticator-Apps (TOTP, RFC 6238) mit SHA-1, 6 Ziffern, 30-Sekunden-Schritten und einem Schritt Toleranz in beide Richtungen, dazu einmal verwendbare Wiederherstellungscodes.
  - name: email-change
    x-displayName: Änderung der E-Mail-Adresse
    description: Ändern der Adresse des Kontos, bestätigt über einen Link, der an die neue Adresse gesendet wird.
  - name: data-export
    x-displayName: Datenexport
    description: Alles, was der Server über das Konto speichert, als eine einzige JSON-Datei.
  - name: account-deletion
    x-displayName: Kontolöschung
    description: Endgültiges Löschen des Kontos.
  - name: game-history
    x-displayName: Partieverlauf
    description: Die eigenen Partien des angemeldeten Spielers, gefiltert und seitenweise.
  - name: games
    x-displayName: Partien und PGN
    description: |-
      Partieaufzeichnungen mit ihren Zügen und Uhrständen sowie ihre PGN-Dateien.

      **Partiecodes.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (nur beendete Partien werden gespeichert). `result`: `1-0`, `0-1`, `1/2-1/2` oder `*` (abgebrochen). `reason`, mit seinem Namen (`termination`) und den Wörtern, mit denen der Zugtext der PGN-Datei endet (auf Englisch, hier mit Übersetzung); die Codes 7 und 21 sind Remis (der Spieler, dessen Zeit ablief oder der die Partie verließ, stand einem Gegner gegenüber, der nicht mattsetzen konnte), und der Server beendet nie eine Partie mit den Codes 4 und 13 (sie gehören zur gemeinsamen Liste der Gründe):

      | Code | `termination` | Wörter im PGN |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (Schachmatt) |
      | 2 | `Resignation` | Resignation (Aufgabe) |
      | 3 | `Timeout` | Loss on time (Zeitüberschreitung) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (Zweiter regelwidriger Zug, verloren) |
      | 5 | `Stalemate` | Stalemate (Patt) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (Tote Stellung, ungenügendes Material) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (Zeit überschritten, aber der Gegner kann nicht mattsetzen) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (Fünffache Stellungswiederholung) |
      | 9 | `SeventyFiveMoves` | 75-move rule (75-Züge-Regel) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (Dreifache Stellungswiederholung, reklamiert) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (50-Züge-Regel, reklamiert) |
      | 12 | `Agreement` | Draw by agreement (Remis durch Vereinbarung) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (Zweiter regelwidriger Zug, aber der Gegner kann nicht mattsetzen) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (Partie verlassen, zu lange getrennt) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (Partie verlassen, aber der Gegner kann nicht mattsetzen) |
      | 22 | `Aborted` | Game aborted (Partie abgebrochen) |
      | 23 | `NoShow` | Aborted: first move not played in time (Abgebrochen: erster Zug nicht rechtzeitig gespielt) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (Kampflos verloren, Verstoß gegen das Fairplay) |
      | 25 | `ServerAborted` | Aborted by the server (Vom Server abgebrochen) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (Abgebrochen: beide Spieler getrennt) |
  - name: gifs
    x-displayName: Animierte GIFs
    description: |-
      Eine Partie als animiertes GIF, zum Aufbewahren oder Teilen: das Brett von oben gesehen, ein Bild pro Stellung von der Anfangs- bis zur Schlussstellung, die Namen und Wertungen der Spieler über dem Brett, der letzte Zug darunter und auf dem letzten Bild das Ergebnis und wie die Partie endete.

      **Kosten, Cache und Kontingente.** Ein GIF wird auf einem eigenen Render-Thread erstellt, nie auf den Threads, die die Partien ausführen, und mit der niedrigsten CPU-Priorität: `GIF_THREADS` Threads (standardmäßig `WORKERS`), die mit dem ersten GIF gestartet und nach einer Minute ohne GIF beendet werden. Bis zu `GIF_QUEUE_MAX` (4 x `WORKERS`) GIFs warten auf einen Thread, jedes höchstens `GIF_QUEUE_TIMEOUT_MS` (10 s); ein Rendervorgang darf `GIF_RENDER_TIMEOUT_MS` (30 s) dauern. Der Server bewahrt die erstellten GIFs in einem Cache von `GIF_CACHE_MB` MB (32 x `WORKERS`) auf, wobei die am längsten nicht verwendeten zuerst weichen; ein GIF aus dem Cache oder eines, das gerade für eine andere Anfrage erstellt wird, kostet keinen Rendervorgang. Der Cache-Schlüssel umfasst alles, was das Bild verändert, auch die Namen.

      Jede Anfrage zählt in `gif` (30 pro Minute und Spieler für beide Endpunkte). Ein GIF, das erstellt werden muss, zählt außerdem in den Render-Limits: pro Spieler `GIF_USER_RENDERS_PER_MIN` (4) pro Minute und `GIF_USER_RENDERS_PER_HOUR` (30) pro Stunde; pro Client, alle seine Konten zusammen, `GIF_IP_RENDERS_PER_MIN` (12) pro Minute und `GIF_IP_RENDERS_PER_HOUR` (120) pro Stunde, sowie das Dreifache pro IPv6-/48-Netz. Ein Client sollte die heruntergeladene Datei aufbewahren, statt sie erneut anzufordern, und nach einem 429 oder 503 `retryAfter` abwarten.

      Größen: `small` (Felder von 32 px: 284 x 350 Pixel), `medium` (48 px: 424 x 515), `large` (72 px: 628 x 762); ohne Koordinaten 268 x 342, 400 x 503 und 600 x 748. Die Anfangsstellung bleibt mindestens 1 s stehen (die Verzögerung, wenn diese länger ist), die Schlussstellung 3 s, und das GIF läuft in einer Schleife; nach dem ersten Bild wird nur der Teil des Bildes gespeichert, der sich ändert. Eine Partie mit 40 Zügen belegt etwa 135, 205 und 325 KiB (klein, mittel, groß), eine Partie mit 150 Zügen 0,5, 0,8 und 1,2 MiB.
  - name: players
    x-displayName: Spieler
    description: Öffentliche Profile und letzte Partien. Nur öffentliche Daten, nie eine E-Mail-Adresse, eine Sitzung, eine Sanktion oder eine Integritätsstufe. Ein gelöschtes Konto hat kein Profil.
  - name: leaderboard
    x-displayName: Rangliste
    description: Die besten Spieler jeder offiziellen Kategorie.
  - name: reports
    x-displayName: Meldungen
    description: Den Gegner einer kürzlich gespielten Partie den Moderatoren melden.
  - name: pages
    x-displayName: HTML-Seiten
    description: |-
      Seiten für einen Browser, die über die Links in E-Mails geöffnet werden. Ihre Links zeigen auf `https://<SERVER_PUBLIC_HOST>` (mit `:<PUBLIC_API_PORT>`, wenn dieser nicht 443 ist), außerhalb von `/api/v1`.

      - Die Seiten führen kein JavaScript aus und laden keine externen Ressourcen. Sie werden mit `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'` ausgeliefert.
      - Ein `GET` zeigt nur eine Schaltfläche oder ein Formular, damit ein Mail-Scanner, der den Link öffnet, ihn nicht verbraucht. Die Änderung erfolgt per `POST`: ein Formular, gesendet als `application/x-www-form-urlencoded` (ein JSON-Body wird ebenfalls akzeptiert), mit dem Token des Links in einem versteckten Feld. Ein doppelt angegebenes Feld wird abgelehnt.
      - Auch Fehler (Ratenlimits, ungültige Felder) sind HTML-Seiten, mit dem Titel „Request refused“ (Anfrage abgelehnt) oder, ab 500, „Server error“ (Serverfehler). Nur die Fehler, die festgestellt werden, bevor die Seite zugeordnet ist (ein zu langes oder fehlerhaftes Anfrageziel, die Adressebene), werden in JSON beantwortet.
x-tagGroups:
  - name: server
    x-displayName: Server
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Konten
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Partien
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Spieler und Meldungen
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Browserseiten
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Name, Versionen, Ports und Registrierungsregeln des Servers abrufen
      description: |-
        Was ein Client braucht, bevor er sich anmeldet oder verbindet: Name und ID des Servers, die Versionen des WebSocket-Protokolls und wo der WebSocket liegt, die Registrierungsregeln, die offiziellen Bedenkzeiten und die Grenzwerte, die ein Client prüfen kann, bevor er ein Formular sendet.

        **Limits:** nur die Adressebene.
      security: []
      responses:
        '200':
          description: Die Beschreibung des Servers.
          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`: die Adressebene.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Konto erstellen
      description: |-
        Erstellt ein Konto: sobald seine E-Mail-Adresse mit dem an sie gesendeten Link bestätigt ist, oder sofort, wenn keine E-Mail-Bestätigung verlangt wird.

        **Mit E-Mail-Bestätigung** (`REQUIRE_EMAIL_VERIFICATION`, der Standard): 202 `verification_sent`. Noch existiert kein Konto: Die Registrierung wartet 24 Stunden und reserviert so lange ihren Benutzernamen. Ein für diese 24 Stunden gültiger Link geht an die Adresse, höchstens einer pro Adresse alle 5 Minuten (`POST /auth/verify-email/resend` sendet einen neuen); das Konto wird mit bestätigter Adresse erstellt, wenn der Link verwendet wird (die Schaltfläche der Seite `/verify-email`), und der Spieler kann sich dann anmelden. Bis dahin wird eine Anmeldung mit diesem Benutzernamen wie bei einem unbekannten Konto mit `invalid_credentials` beantwortet, und das öffentliche Profil existiert nicht. Eine neue Registrierung mit derselben Adresse ersetzt die wartende. Die Antwort ist dieselbe, wenn bereits ein anderes Konto die Adresse verwendet: Dann wird kein Link gesendet, sondern dessen Inhaber erhält einen Hinweis (höchstens einen pro Stunde), und der Benutzername wird auf dieselbe Weise reserviert, sodass nichts verrät, ob zu der Adresse ein Konto existiert. Eine Registrierung, deren Link nicht verwendet wurde, wird nach 24 Stunden verworfen, und ihr Benutzername ist wieder frei.

        **Ohne E-Mail-Bestätigung** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, das Konto wird sofort erstellt und kann sich anmelden.

        **Regeln.** `username`: `USERNAME_MIN` bis `USERNAME_MAX` Zeichen (3 bis 20), Buchstaben, Ziffern, `_` und `-`, beginnend mit einem Buchstaben oder einer Ziffer (`GET /info` liefert die Werte als `limits`); reservierte Namen (`admin`, `moderator`, `deleted`...) und einige Präfixe werden abgelehnt; eindeutig ohne Berücksichtigung der Groß- und Kleinschreibung. `email`: ohne umgebende Leerzeichen und in Kleinbuchstaben gespeichert, reines ASCII mit einer Domain, die einen Punkt enthält. `password`: mindestens `PASSWORD_MIN_LENGTH` (10) Zeichen und höchstens 256 Bytes UTF-8; es darf weder den Benutzernamen noch den lokalen Teil der E-Mail-Adresse enthalten und darf kein gängiges Passwort sein.

        **Fehler**, in dieser Reihenfolge geprüft: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; die Fehler der Passwort-Hash-Warteschlange; 409 `email_taken` (nur ohne E-Mail-Bestätigung).

        **Limits:** `auth`, dann `auth_register` (10 Registrierungen pro Stunde und Client). **Arbeitsnachweis:** immer, wenn `POW_REGISTER_BITS` größer als 0 ist.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Erster Versuch, ohne Arbeitsnachweis
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Erneut gesendet, mit dem Arbeitsnachweis
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: Das Konto ist erstellt und kann sich anmelden (keine E-Mail-Bestätigung auf diesem 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: Die Registrierung wartet auf ihren Bestätigungslink (oder zu der Adresse gibt es bereits ein Konto; die Antwort verrät es nicht).
          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` (mit `field`), `invalid_json`, `invalid_username`, `invalid_email` oder `weak_password` mit `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` oder `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: Die Registrierung ist auf diesem Server geschlossen (`GET /info` meldet `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: Ein Konto hat diesen Benutzernamen, oder eine wartende Registrierung mit einer anderen Adresse reserviert ihn. `email_taken`: Ein anderes Konto verwendet die Adresse (nur ohne E-Mail-Bestätigung).'
        '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`: das Limit `auth` oder `auth_register` oder die Passwort-Hash-Warteschlange.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Passwort-Hash-Warteschlange, oder die Datenbank blieb gesperrt), `timeout`.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Mit Passwort anmelden
      description: |-
        Meldet mit einem Benutzernamen oder einer E-Mail-Adresse und einem Passwort an. Es gibt zwei mögliche Antworten, beide mit Status 200: eine Sitzung oder, wenn die Zwei-Faktor-Authentifizierung eingeschaltet ist, einen zweiten Schritt, der innerhalb von 5 Minuten mit `POST /auth/login/mfa` abzuschließen ist.

        Unbekanntes Konto, falsches Passwort und Konto ohne Passwort erhalten dieselbe Antwort nach derselben Zeit: 401 `invalid_credentials`. Die Kontoprüfungen (`banned`, `email_unverified`) folgen erst nach einem richtigen Passwort. Ab `AUTH_FAILURES_PER_ACCOUNT` (5) Fehlversuchen mit einem Anmeldenamen muss jeder Versuch warten (429 `too_many_attempts`).

        **Limit:** `auth`. **Arbeitsnachweis:** nur während einer Welle fehlgeschlagener Anmeldungen (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: Eine Sitzung oder der zweite Schritt einer Anmeldung mit Zwei-Faktor-Authentifizierung.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Angemeldet
                  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: Zwei-Faktor-Authentifizierung ist eingeschaltet
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: unbekanntes Konto, falsches Passwort oder ein Konto ohne Passwort.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Nur nach einem richtigen Passwort: `banned` mit `until` (Epoch-Millisekunden, `null` bei einer dauerhaften Sperre) oder `email_unverified` (ein älteres Konto, dessen Adresse noch nicht bestätigt ist).'
        '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` (die Wartezeit nach Fehlversuchen für diesen Anmeldenamen) oder `rate_limited` (das Limit `auth`, die Passwort-Hash-Warteschlange).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Passwort-Hash-Warteschlange, oder die Datenbank blieb gesperrt), `timeout`.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Anmeldung mit einem zweiten Faktor abschließen
      description: |-
        Der zweite Schritt einer Anmeldung mit Zwei-Faktor-Authentifizierung, nachdem `POST /auth/login` oder eine Anmeldung mit Google (`POST /auth/sso/google/finish` oder `POST /auth/sso/google/link`) mit `mfaRequired` geantwortet hat. Senden Sie `code` (einen 6-stelligen Authenticator-Code oder einen Wiederherstellungscode) oder `recoveryCode`. Ein hier verwendeter Wiederherstellungscode ist verbraucht.

        Ein Schritt akzeptiert höchstens 5 falsche Codes; der Schritt endet auch, wenn das Passwort zurückgesetzt oder geändert wird. Nach dem Passwortschritt einer Google-Verknüpfung wird die Verknüpfung erst gespeichert, wenn der Code hier bestanden ist.

        **Limits:** `auth` und höchstens `AUTH_MFA_PER_ACCOUNT` (10) Codes pro 15 Minuten für das Konto, von jeder Adresse; darüber hinaus wird der Code nicht geprüft, sodass kein Wiederherstellungscode verbraucht wird.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Ein Authenticator-Code
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Ein Wiederherstellungscode
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Angemeldet.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: Weder `code` noch `recoveryCode` wurde gesendet, oder der Body verletzt das Schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: Der Schritt ist abgelaufen, wurde bereits verwendet oder endete nach 5 falschen Codes, oder das Passwort wurde seit dem ersten Schritt zurückgesetzt oder geändert (erneut anmelden). `invalid_code`: ein falscher oder bereits verwendeter Code.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (mit `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: nur nach dem Passwortschritt einer Google-Verknüpfung; das Google-Konto wurde inzwischen mit einem anderen Konto verknüpft.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: nur nach dem Passwortschritt einer Google-Verknüpfung; das Konto hat sich inzwischen geändert. Beginnen Sie im Spiel von vorn.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: die Wartezeit des Kontos nach Fehlversuchen, oder seine `AUTH_MFA_PER_ACCOUNT` Codes der letzten 15 Minuten sind aufgebraucht. `rate_limited`: das Limit `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` mit `retryAfter: 1`: Die Datenbank blieb gesperrt (nach einem Schritt einer Google-Verknüpfung wurde nichts verknüpft: Beginnen Sie im Spiel von vorn). `timeout`.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Diese Sitzung abmelden
      description: |-
        Widerruft die Sitzung des verwendeten Tokens. Der damit geöffnete WebSocket wird geschlossen. Kein Body (oder `{}`).

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Abgemeldet.
          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`: ein anderer Body als `{}`; `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`: das Limit `sessions` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Datenbank blieb gesperrt), `timeout`.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Alle Sitzungen abmelden
      description: |-
        Widerruft alle Sitzungen des Kontos, auch diese. Die damit geöffneten WebSockets werden geschlossen. Kein Body (oder `{}`).

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Alle Sitzungen sind abgemeldet.
          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`: ein anderer Body als `{}`; `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`: das Limit `sessions` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Datenbank blieb gesperrt), `timeout`.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Bestätigungslink erneut senden
      description: |-
        Sendet den Link zur Bestätigung der E-Mail-Adresse erneut. Die Antwort ist unabhängig von der Adresse 202 `accepted`, sodass sie nie verrät, ob ein Konto oder eine Registrierung die Adresse verwendet.

        Eine Anfrage wirkt höchstens einmal alle 5 Minuten pro Adresse. Eine mit dieser Adresse wartende Registrierung erhält wieder volle 24 Stunden, unabhängig davon, ob ein anderes Konto die Adresse verwendet, sodass ihr Benutzername in beiden Fällen gleich lange reserviert bleibt. Ein Link wird nur für diese Registrierung gesendet, wenn zu der Adresse kein Konto existiert (ein neuer, 24 Stunden gültiger Link ersetzt den vorherigen), oder für ein aktives, unbestätigtes Konto mit dieser Adresse.

        **Limits:** `auth`, dann `auth_mail` (10 pro Stunde und Client).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Angenommen (unabhängig von der Adresse).
          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`: das Limit `auth` oder `auth_mail` (unabhängig von der Adresse).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` mit `retryAfter: 1`: Die Datenbank blieb beim Nachschlagen des Kontos gesperrt (die Verlängerung einer wartenden Registrierung erfolgt nach bestem Bemühen und lässt die Anfrage nie scheitern). `timeout`.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Link zum Zurücksetzen des Passworts senden
      description: |-
        Sendet einen eine Stunde lang gültigen Link zum Zurücksetzen des Passworts, der die Seite `/reset-password` öffnet. Die Antwort ist unabhängig von der Adresse 202 `accepted`. Ein Link geht nur an ein aktives Konto, höchstens einmal alle 5 Minuten pro Adresse. Ein Konto, das sich nur mit Google anmeldet, legt auf diesem Weg sein erstes Passwort fest.

        Die Passwortwiederherstellung hat die strengsten Limits der API, alle für den gesamten Server gezählt: 3 Anfragen pro Stunde (`AUTH_FORGOT_PER_HOUR`) und 10 pro 24 Stunden (`AUTH_FORGOT_PER_DAY`) pro Client (eine IPv4-Adresse oder ein IPv6-/64-Netz), das Dreifache pro IPv6-/48-Netz, zusätzlich zum Limit `auth` (20 pro 10 Minuten) und zu der einen E-Mail pro Adresse alle 5 Minuten. Weder das 202 noch das 429 verrät, ob ein Konto die Adresse verwendet. Das Festlegen des neuen Passworts hat ein eigenes Limit (`auth_reset`).

        **Limits:** `auth`, `auth_forgot`, dann `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Angenommen (unabhängig von der Adresse).
          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`: das Limit `auth`, `auth_forgot` oder `auth_forgot_day` (unabhängig von der Adresse).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` mit `retryAfter: 1`: Die Datenbank blieb gesperrt. `timeout`.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Neues Passwort mit dem Token eines Links zum Zurücksetzen festlegen
      description: |-
        Legt mit dem Token eines Links zum Zurücksetzen ein neues Passwort fest (die Seite `/reset-password` tut dasselbe). Für das neue Passwort gelten die Regeln der Registrierung.

        Das Zurücksetzen widerruft alle Sitzungen und bricht eine ausstehende Änderung der E-Mail-Adresse ab; die anderen Links zum Zurücksetzen des Kontos werden ungültig; die Adresse gilt als bestätigt (der Link hat sie nachgewiesen); der Inhaber erhält eine E-Mail. Die Zwei-Faktor-Authentifizierung bleibt unberührt. Nach einem Fehler der Passwort-Hash-Warteschlange oder einem 503 bleibt der Link gültig.

        **Limits:** `auth`, dann `auth_reset` (10 pro Stunde und Client, 30 pro IPv6-/48-Netz, gemeinsam mit der Seite: Jeder Versuch hasht ein Passwort).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Das Passwort ist geändert, und alle Sitzungen sind abgemeldet.
          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`: Der Link ist ungültig, verwendet oder abgelaufen, oder er wurde an eine Adresse gesendet, die das Konto nicht mehr hat. `weak_password` (mit `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`: das Limit `auth` oder `auth_reset` oder die Passwort-Hash-Warteschlange.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: die Passwort-Hash-Warteschlange, oder die Datenbank blieb gesperrt (`retryAfter: 1`, nichts geändert). `timeout`.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Anmeldung mit Google starten
      description: |-
        Startet einen Versuch der Anmeldung mit Google für die PKCE-Challenge und den Port des Listeners des Spiels auf `127.0.0.1`. Die Antwort liefert die im Systembrowser zu öffnende Google-URL und den `state` des Versuchs; der Versuch ist 10 Minuten gültig.

        `authUrl` enthält `client_id`, `redirect_uri` (gebildet aus `redirectPort` und dem Herkunfts-Tag des Servers), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 des eigenen Verifiers des Servers gegenüber Google) mit `code_challenge_method=S256` sowie `prompt=select_account`. Das Spiel prüft sie, bevor es sie öffnet (siehe die Einleitung dieses Abschnitts).

        **Limit:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: Der Versuch.
          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`: Die Anmeldung mit Google wird auf diesem Server nicht angeboten.'
        '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`: das Limit `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Datenbank blieb gesperrt), `timeout`.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Die Antwort von Google an den Server übergeben
      description: |-
        Übergibt dem Server den `code` und den `state`, die Google an den Listener des Spiels gesendet hat, zusammen mit der ID des Versuchs und dem PKCE-Verifier. Eine Versuchs-ID ist ohne den Verifier nutzlos, und der Code ist ohne den eigenen PKCE-Verifier und den Clientschlüssel des Servers nutzlos.

        Der Server prüft den Versuch (unbekannt, verwendet oder abgelaufen: 410), dann den Verifier (ein falscher lässt den Versuch verwendbar), verbraucht dann den Versuch, prüft `state` und `iss`, tauscht den Code bei Google ein und verifiziert das ID-Token. Die Antwort (200) ist eine der folgenden:

        - `{ token, expiresAt, user }`: beim verknüpften Konto angemeldet;
        - `{ mfaRequired, mfaToken, expiresIn }`: weiter mit `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: ein neues Konto; weiter mit `POST /auth/sso/complete` innerhalb von 10 Minuten. `suggestedUsername` stammt aus dem Google-Namen oder der Adresse und ist `""`, wenn nichts passt;
        - `{ needsPassword, linkTicket, username, expiresIn }`: Das Konto mit dieser Adresse hat ein Passwort; weiter mit `POST /auth/sso/google/link`. Noch ist nichts verknüpft.

        **Limit:** `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: Eine Sitzung, ein zweiter Schritt oder der nächste Schritt einer ersten Anmeldung mit Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Beim verknüpften Konto angemeldet
                  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: Zwei-Faktor-Authentifizierung ist eingeschaltet
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Ein neuer Spieler
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Ein Konto mit Passwort verwendet die Adresse
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: Der Verifier passt nicht zur Challenge des Versuchs (der Versuch bleibt verwendbar). `sso_email_unverified`: Google hat die Adresse nicht bestätigt. `registration_closed`. `account_disabled`. Nur bei einem verknüpften Konto: `banned` (mit `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: Die Anmeldung mit Google wird auf diesem Server nicht angeboten.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: Ein aktives Konto ohne Passwort verwendet die Adresse (es wird nicht genannt).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: ein unbekannter, bereits verwendeter oder abgelaufener Versuch.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: das Limit `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Datenbank blieb gesperrt), `timeout`.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Google mit dem Passwort eines bestehenden Kontos verknüpfen
      description: |-
        Nachdem `finish` mit `needsPassword` geantwortet hat: verknüpft Google mit dem bestehenden Konto, mithilfe des im Spiel eingegebenen Passworts dieses Kontos. Die Antwort (200) ist eine Sitzung (die Verknüpfung ist gespeichert und der Spieler angemeldet) oder, wenn die Zwei-Faktor-Authentifizierung eingeschaltet ist, `{ mfaRequired, mfaToken, expiresIn }`: weiter mit `POST /auth/login/mfa`; die Verknüpfung wird erst gespeichert, wenn dort ein Code bestanden ist.

        Ein falsches Passwort lässt das Ticket verwendbar, für insgesamt 5 Versuche. Ein Versuch wird unmittelbar vor der Prüfung des Passworts verbraucht: Ein 429 `too_many_attempts` oder ein 428 `pow_required` verbraucht keinen, während eine Ablehnung durch die Passwort-Hash-Warteschlange (503 `server_busy`, 429 `rate_limited`) einen verbraucht hat und als Fehlversuch des Kontos zählt. Die Wartezeit nach Fehlversuchen verwendet denselben Zähler wie `POST /auth/login`.

        **Limit:** `auth` (mit seiner Zählung pro IPv6-/48-Netz). **Arbeitsnachweis:** wie bei `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: Eine Sitzung oder der zweite Schritt der Anmeldung.
          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`: ein falsches Passwort; das Ticket bleibt gültig, für insgesamt 5 Versuche.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (mit `until`), nur nach einem richtigen Passwort.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: Die Anmeldung mit Google wird auf diesem Server nicht angeboten.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: Das Google-Konto wurde inzwischen mit einem anderen Konto verknüpft.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: ein unbekanntes, bereits verwendetes oder abgelaufenes Ticket, das 5. falsche Passwort, ein Konto, dessen Status oder Adresse sich seit `finish` geändert hat, oder eines, dessen Status, Adresse, Passwort oder Zwei-Faktor-Authentifizierung sich geändert hat, während die Verknüpfung gespeichert wurde. Beginnen Sie im Spiel von vorn.'
        '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` (die Wartezeit des Kontos nach Fehlversuchen) oder `rate_limited` (das Limit `auth`, die Passwort-Hash-Warteschlange).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: die Passwort-Hash-Warteschlange, oder die Datenbank blieb gesperrt (`retryAfter: 1`, nichts wurde verknüpft). `timeout`.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Konto bei der ersten Anmeldung mit Google erstellen
      description: |-
        Nachdem `finish` mit `needsUsername` geantwortet hat: erstellt das Konto mit dem gewählten Benutzernamen (es gelten die Regeln der Registrierung) und meldet es an. Das Konto hat kein Passwort: `hasPassword` ist `false`, und „Passwort vergessen?“ (`POST /auth/password/forgot`) gibt ihm eines.

        **Limit:** `auth` (mit seiner Zählung pro IPv6-/48-Netz).
      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: Das Konto ist erstellt und angemeldet.
          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` oder `invalid_json`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: Die Anmeldung mit Google wird auf diesem Server nicht angeboten.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (ein Konto hat diesen Benutzernamen, oder eine wartende Registrierung mit einer anderen Adresse reserviert ihn), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: ein unbekanntes, bereits verwendetes oder abgelaufenes Ticket.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: das Limit `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Datenbank blieb gesperrt), `timeout`.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Angemeldete Geräte auflisten
      description: |-
        Die aktiven Sitzungen des Kontos (angemeldete Geräte), die zuletzt verwendete zuerst. `current` kennzeichnet die Sitzung, die die Anfrage stellt. `lastSeenAt` wird höchstens alle 5 Minuten aktualisiert; `expiresAt` ist das absolute Ende, und die Inaktivitätsgrenze kann die Sitzung früher beenden.

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Die aktiven Sitzungen.
          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`: das Limit `sessions` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Datenbank blieb gesperrt), `timeout`.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Ein Gerät abmelden
      description: |-
        Meldet eine Sitzung des Kontos ab; auch die aktuelle kann abgemeldet werden. Der damit geöffnete WebSocket wird geschlossen. Kein Body (oder `{}`).

        **Limit:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Die Sitzung ist abgemeldet.
          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`: ein anderer Body als `{}` oder eine `id`, die keine gültige URL-Kodierung ist; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: keine aktive Sitzung mit dieser ID in diesem 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`: das Limit `sessions` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Datenbank blieb gesperrt), `timeout`.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Konto, Wertungen und aktive Sanktionen abrufen
      description: |-
        Das Konto, wie sein Spieler es sieht: die Kontoansicht (auch der `user` jeder Anmeldeantwort), ein Wertungseintrag pro Kategorie, in der der Spieler gewertete Partien gespielt hat, die aktiven Sanktionen und die Sperre. Die Integritätsstufe der Betrugserkennung wird nie angezeigt.

        **Limit:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Das Konto.
          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` oder `invalid_token` (auch wenn das Konto gelöscht wurde).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: das Limit `account` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Datenbank blieb gesperrt), `timeout`.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Direkte Herausforderungen annehmen oder ablehnen
      description: |-
        Legt fest, ob andere Spieler diesen Spieler über seinen Namen herausfordern dürfen. Mit `none` werden direkte Herausforderungen abgelehnt: Dem Herausforderer wird mitgeteilt, dass der Spieler nicht verfügbar ist. Keine erneute Authentifizierung.

        **Limit:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Die jetzt geltenden Einstellungen.
          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`: das Limit `account` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Datenbank blieb gesperrt), `timeout`.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Passwort ändern
      description: |-
        Ändert das Passwort. Das aktuelle Passwort ist erforderlich, aber kein zweiter Faktor, auch nicht bei eingeschalteter Zwei-Faktor-Authentifizierung. Für das neue Passwort gelten die Regeln der Registrierung (geprüft nach dem aktuellen Passwort).

        Die Änderung widerruft alle anderen Sitzungen (diese bleibt angemeldet), bricht eine ausstehende Änderung der E-Mail-Adresse ab, macht die Links zum Zurücksetzen des Passworts des Kontos ungültig und sendet dem Inhaber eine E-Mail.

        **Erneute Authentifizierung:** nur das Passwort. **Limits:** `reauth`, dann `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: Das Passwort ist geändert.
          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` (Konto nur mit Google-Anmeldung), `weak_password` (mit `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: ein falsches aktuelles Passwort oder ein Zurücksetzen oder Ändern des Passworts, das während der Prüfung der Anfrage eingetroffen ist.'
        '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` (fehlgeschlagene erneute Authentifizierungen des Kontos) oder `rate_limited` (das Limit `reauth` oder `reauth_user`, das Kontobudget, die Passwort-Hash-Warteschlange).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Passwort-Hash-Warteschlange, oder die Datenbank blieb gesperrt), `timeout`.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Einschalten der Zwei-Faktor-Authentifizierung beginnen
      description: |-
        Speichert ein neues ausstehendes Authenticator-Geheimnis, das jedes frühere ausstehende ersetzt, und gibt es für die Authenticator-App zurück (als Text und als `otpauth://`-URI zur Anzeige als QR-Code). Die Zwei-Faktor-Authentifizierung ist noch nicht eingeschaltet: `POST /account/mfa/totp/enable` schaltet sie mit einem Code dieses Geheimnisses ein.

        **Erneute Authentifizierung:** nur das Passwort. **Limits:** `reauth`, dann `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Das ausstehende Geheimnis.
          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` (Konto nur mit Google-Anmeldung), `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` (vor dem Passwort geprüft).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (fehlgeschlagene erneute Authentifizierungen des Kontos) oder `rate_limited` (das Limit `reauth` oder `reauth_user`, das Kontobudget, die Passwort-Hash-Warteschlange).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Passwort-Hash-Warteschlange, oder die Datenbank blieb gesperrt), `timeout`.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Einschalten der Zwei-Faktor-Authentifizierung abschließen und Wiederherstellungscodes erhalten
      description: |-
        Schaltet die Zwei-Faktor-Authentifizierung mit einem Code des ausstehenden Geheimnisses ein (genau 6 Ziffern). Hier wird kein Passwort verlangt; es wurde beim Einrichten angegeben. Die Antwort enthält 10 Wiederherstellungscodes, die nur dieses eine Mal angezeigt werden.

        **Limits:** `reauth`, dann `reauth_user`; ein falscher Code zählt zu den fehlgeschlagenen erneuten Authentifizierungen des Kontos.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Die Zwei-Faktor-Authentifizierung ist eingeschaltet.
          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` besteht nicht aus genau 6 Ziffern, oder der Body verletzt das Schema; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: ein falscher Code (prüfen Sie die Uhrzeit des Geräts).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: kein ausstehendes Geheimnis (rufen Sie zuerst `POST /account/mfa/totp/setup` auf).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (fehlgeschlagene erneute Authentifizierungen des Kontos) oder `rate_limited` (das Limit `reauth` oder `reauth_user`, das Kontobudget).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Datenbank blieb gesperrt), `timeout`.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Zwei-Faktor-Authentifizierung ausschalten
      description: |-
        Schaltet die Zwei-Faktor-Authentifizierung aus. Das Geheimnis und die Wiederherstellungscodes werden gelöscht, und der Inhaber erhält eine E-Mail.

        **Erneute Authentifizierung:** das Passwort und ein Authenticator-Code oder ein Wiederherstellungscode (`code` oder `recoveryCode` ist erforderlich). **Limits:** `reauth`, dann `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: Die Zwei-Faktor-Authentifizierung ist ausgeschaltet.
          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` (Konto nur mit Google-Anmeldung), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (weder `code` noch `recoveryCode`, vor dem Passwort geprüft), `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` (fehlgeschlagene erneute Authentifizierungen oder zu viele ausprobierte Codes bei diesem Konto) oder `rate_limited` (das Limit `reauth` oder `reauth_user`, das Kontobudget, die Passwort-Hash-Warteschlange).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Passwort-Hash-Warteschlange, oder die Datenbank blieb gesperrt), `timeout`.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Wiederherstellungscodes ersetzen
      description: |-
        Ersetzt die Wiederherstellungscodes durch 10 neue; die alten werden ungültig.

        **Erneute Authentifizierung:** das Passwort und ein Authenticator-Code in `code` (ein Wiederherstellungscode wird mit 403 `invalid_code` abgelehnt). **Limits:** `reauth`, dann `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: Die neuen Wiederherstellungscodes, nur dieses eine Mal angezeigt.
          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` (Konto nur mit Google-Anmeldung), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (auch bei einem Wiederherstellungscode).'
        '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` (fehlgeschlagene erneute Authentifizierungen oder zu viele ausprobierte Codes bei diesem Konto) oder `rate_limited` (das Limit `reauth` oder `reauth_user`, das Kontobudget, die Passwort-Hash-Warteschlange).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Passwort-Hash-Warteschlange, oder die Datenbank blieb gesperrt), `timeout`.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: E-Mail-Adresse ändern
      description: |-
        Ändert die E-Mail-Adresse des Kontos. `newEmail` wird von umgebenden Leerzeichen befreit, in Kleinbuchstaben umgewandelt und dann wie eine Adresse bei der Registrierung geprüft.

        **Mit E-Mail-Bestätigung** (der Standard): 202 `verification_sent`. Ein 24 Stunden gültiger Link geht an die neue Adresse; er öffnet `/confirm-email-change`, und die Adresse ändert sich erst, wenn der Spieler die Schaltfläche dieser Seite drückt. Bis dahin zeigt `GET /account/me` die neue Adresse als `pendingEmail`. Eine neue Anfrage ersetzt die ausstehende; eine Änderung oder ein Zurücksetzen des Passworts bricht sie ab. Höchstens ein Link geht alle 5 Minuten an eine bestimmte neue Adresse, gleich wer ihn anfordert (eine Anfrage für die bereits ausstehende Änderung behält den früher gesendeten Link, der gültig bleibt); die Antwort ist dieselbe. Die aktuelle Adresse erhält einen Hinweis, dass eine Änderung auf eine maskierte Adresse (`a***@example.org`) angefordert wurde. Antwort und `pendingEmail` sind dieselben, wenn bereits ein anderes Konto die neue Adresse verwendet: Dann wird kein Link gesendet, sodass diese Änderung nie abgeschlossen wird, und stattdessen erhält der Inhaber dieser Adresse einen Hinweis (höchstens einen pro Stunde).

        Wenn der Link bestätigt wird, ändert sich die Adresse und gilt als bestätigt, die Geräte bleiben angemeldet, die früher gesendeten Links (Bestätigung, Zurücksetzen des Passworts, andere Änderungen) werden ungültig, und die bisherige Adresse wird benachrichtigt, wobei die neue maskiert ist.

        **Ohne E-Mail-Bestätigung** (`REQUIRE_EMAIL_VERIFICATION=false`): Die Adresse ändert sich sofort (200 `email_changed`), und die bisherige Adresse wird benachrichtigt. Verwendet ein anderes Konto die Adresse, lautet die Antwort 409 `email_taken`, und der Inhaber dieser Adresse erhält den Hinweis.

        **Erneute Authentifizierung:** das Passwort und, bei Zwei-Faktor-Authentifizierung, ein Authenticator-Code oder ein Wiederherstellungscode. `invalid_email` und `same_email` werden vor dem Passwort geprüft und zählen daher nicht als Fehlversuch. **Limits:** `reauth`, dann `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: Die Adresse wurde sofort geändert (keine E-Mail-Bestätigung auf diesem 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: Die neue Adresse, wie sie gespeichert ist.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Ein Bestätigungslink wurde an die neue Adresse gesendet (oder die Adresse gehört zu einem anderen Konto; die Antwort verrät es nicht).
          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` (die aktuelle Adresse des Kontos), `password_not_set` (Konto nur mit Google-Anmeldung), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (auch wenn eine Änderung oder ein Zurücksetzen des Passworts der Anfrage zuvorkam: Es wird kein Link gesendet), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: nur ohne E-Mail-Bestätigung.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (fehlgeschlagene erneute Authentifizierungen oder zu viele ausprobierte Codes bei diesem Konto) oder `rate_limited` (das Limit `reauth` oder `reauth_user`, das Kontobudget, die Passwort-Hash-Warteschlange).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: die Passwort-Hash-Warteschlange, oder die Datenbank blieb gesperrt (`retryAfter: 1`: nichts geändert, und dieselbe Anfrage kann erneut gesendet werden). `timeout`.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Daten des Kontos herunterladen
      description: |-
        Alles, was der Server über das Konto speichert, als eine JSON-Datei zum Speichern (`format` `scacelith-account-export`, `version` 1). Der Export erzeugt ein Sicherheitsereignis (`account_exported`). Das Array `notes` des Dokuments erklärt dem Spieler in einfachem Englisch, was ausgelassen ist.

        **Nie im Export:** der Passwort-Hash, das Geheimnis der Zwei-Faktor-Authentifizierung und die Wiederherstellungscodes; jegliche Sitzungs- oder Link-Tokens oder deren Hashes; die Daten der Betrugserkennung (Integritätsstufe und -wert, Anomalien, die Analyse der Partien, das Gewicht einer Meldung); die Meldungen anderer Spieler über den Spieler; die Identitäten der Moderatoren; private Daten anderer Spieler (Gegner erscheinen mit ihrem öffentlichen Namen und ihrer Wertung, nichts verrät, ob ein anderer Spieler sanktioniert wurde, und keine IP-Adresse, die einer anderen Person gehören könnte, ist enthalten).

        **Erneute Authentifizierung:** das Passwort und, bei Zwei-Faktor-Authentifizierung, ein Authenticator-Code oder ein Wiederherstellungscode. **Limits:** `account_export` (5 pro Stunde und Spieler, für den gesamten Server; jeder Versuch zählt, auch fehlgeschlagene; wird zuerst geprüft), dann `reauth` und `reauth_user`. **Zeitlimit:** 60 Sekunden.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Das Exportdokument, als Anhang.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Andere Zeichen als Buchstaben, Ziffern, `_`, `.` und `-` im Benutzernamen werden zu `_`.'
              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` (Konto nur mit Google-Anmeldung), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required` oder `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` (fehlgeschlagene erneute Authentifizierungen oder zu viele ausprobierte Codes bei diesem Konto) oder `rate_limited` (das Limit `account_export`, `reauth` oder `reauth_user`, das Kontobudget, die Passwort-Hash-Warteschlange).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (die Datenbank blieb gesperrt, mit einem Header `Retry-After: 1`), `server_busy` (die Passwort-Hash-Warteschlange), `timeout` (60 Sekunden).'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Konto löschen
      description: |-
        Löscht das Konto; dies kann nicht rückgängig gemacht werden.

        - Alle Sitzungen werden sofort widerrufen: Das Token erhält von da an 401 `invalid_token`.
        - Der Benutzername wird zu `deleted#<id>`, im Konto und in jeder Partieaufzeichnung.
        - Gelöscht werden: die E-Mail-Adresse, der Passwort-Hash, das Geheimnis der Zwei-Faktor-Authentifizierung und die Wiederherstellungscodes, die Sitzungen und Link-Tokens, die Google-Verknüpfung, der Integritätseintrag der Betrugserkennung und die mit Sicherheitsereignissen gespeicherten IP-Adressen.
        - Wertungen und Partien bleiben erhalten. Partien bleiben unter dem anonymen Namen lesbar, und `GET /players/{username}` antwortet für den früheren Namen mit 404.

        **Erneute Authentifizierung:** das Passwort und, bei Zwei-Faktor-Authentifizierung, ein Authenticator-Code oder ein Wiederherstellungscode. **Limits:** `reauth`, dann `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: Das Konto ist gelöscht.
          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` (Konto nur mit Google-Anmeldung), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required` oder `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` (fehlgeschlagene erneute Authentifizierungen oder zu viele ausprobierte Codes bei diesem Konto) oder `rate_limited` (das Limit `reauth` oder `reauth_user`, das Kontobudget, die Passwort-Hash-Warteschlange).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Passwort-Hash-Warteschlange, oder die Datenbank blieb gesperrt), `timeout`.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Partien des Spielers auflisten, gefiltert und seitenweise
      description: |-
        Die Partien des angemeldeten Spielers, neueste zuerst, gefiltert und seitenweise, mit der Anzahl der zum Filter passenden Partien. Jeder Abfrageparameter ist optional, und ein leerer Wert gilt als nicht angegeben.

        Blättern: Übergeben Sie `next` der vorherigen Seite als `before`; auf der letzten Seite ist `next` gleich `null`. `total` zählt die zum Filter passenden Partien über alle Seiten hinweg.

        **Limit:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: Eine offizielle Kategorie-ID (`3+2` oder `3%2B2`) oder `custom` für jede Partie mit einer anderen Bedenkzeit.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Nur gewertete (`true`) oder ungewertete (`false`) Partien.
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Nur die gewonnenen, verlorenen oder remis gespielten Partien, aus Sicht des Spielers. Abgebrochene Partien erscheinen nur ohne diesen Filter.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Eine Seite des Verlaufs.
          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` ist keine Partie-ID), `invalid_limit` oder `invalid_filter` (`category`, `rated` oder `result`), jeweils mit `field`, das den Parameter nennt.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: das Limit `account_games` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (die Datenbank blieb gesperrt; `retryAfter: 1` im Body, kein `Retry-After`-Header), `server_busy` (die Sitzungsabfrage fand die Datenbank gesperrt vor), `timeout`.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Partieaufzeichnung mit Zügen und Uhrständen abrufen
      description: |-
        Eine Partieaufzeichnung mit ihren Zügen und Uhrständen. Ohne Token oder mit dem Token eines Spielers, der die Partie nicht gespielt hat, ist die Antwort die öffentliche. Hat der Spieler des Tokens die Partie gespielt, enthält die Antwort zusätzlich `you` und `reportable`.

        **Limit:** `public_read` (pro Spieler mit Token, pro Client ohne).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Die Partieaufzeichnung.
          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`: keine positive Ganzzahl mit höchstens 16 Stellen (unter 2^53); `invalid_request`: keine gültige URL-Kodierung.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: Ein Token wurde gesendet und ist ungültig.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: Partie nicht vorhanden.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: das Limit `public_read` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (die Datenbank blieb gesperrt; `retryAfter: 1` im Body, kein `Retry-After`-Header), `server_busy` (die Sitzungsabfrage fand die Datenbank gesperrt vor), `timeout`.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Partie als PGN-Datei herunterladen
      description: |-
        Dieselbe Partie als PGN-Datei: eine Partie mit `\n`-Zeilenenden und Zugtext in Zeilen unter 80 Spalten. Die Antwort ist mit und ohne Token dieselbe.

        Tags, in dieser Reihenfolge: `Event` (`<SERVER_NAME> rated <category>` oder `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (das Startdatum in UTC), `Round`, `White`, `Black`, `Result` (`*` bei einer abgebrochenen Partie), `UTCDate` und `UTCTime` (der Start), `WhiteElo` und `BlackElo` (die Wertungen zu Beginn oder `-`), `WhiteRatingDiff` und `BlackRatingDiff` (die Änderungen, etwa `+10` und `-10`, in jeder gewerteten Partie, `+0`, wenn die Wertungsregeln die Wertung unverändert lassen; eine ungewertete, eine abgebrochene oder eine Partie mit eigener Bedenkzeit hat keines von beiden), `TimeControl` (in Sekunden), `Termination` (ein Standardwert von PGN: `normal`; `time forfeit`, eine Zeitüberschreitung, auch wenn sie mit Remis endet; `abandoned`; `rules infraction`, ein zweiter regelwidriger Zug oder ein kampfloser Verlust wegen eines Fairplay-Verstoßes; `unterminated`, eine abgebrochene Partie), `PlyCount`, `ScacelithGameId` (die dezimale ID).

        Jeder Zug trägt `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: die Uhr des Ziehenden nach dem Zug und die dafür angerechnete Zeit, in Zehntelsekunden (abgeschnitten). Ein Wert fehlt, wenn die Aufzeichnung ihn nicht enthält. Nach dem letzten Zug folgt der Grund für das Ende in Worten (auf Englisch), dann das Ergebnis.

        **Limit:** `public_read` (pro Spieler mit Token, pro Client ohne).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Die PGN-Datei.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: 'Wert: `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: 'Der PGN-Text in UTF-8 (`Content-Type: application/x-chess-pgn; charset=utf-8`).'
              example: |
                [Event "Scacelith rated 3+2"]
                [Site "caissa.scacelith.com"]
                [Date "2026.09.28"]
                [Round "-"]
                [White "alice"]
                [Black "bob"]
                [Result "1-0"]
                [UTCDate "2026.09.28"]
                [UTCTime "18:30:05"]
                [WhiteElo "1500"]
                [BlackElo "1520"]
                [WhiteRatingDiff "+10"]
                [BlackRatingDiff "-10"]
                [TimeControl "180+2"]
                [Termination "normal"]
                [PlyCount "2"]
                [ScacelithGameId "4100000000001"]

                1. e4 {[%clk 0:03:00.0] [%emt 0:00:00.0]} 1... e5 {[%clk 0:03:00.3]
                [%emt 0:00:01.7]} {Resignation} 1-0
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_game_id`: keine positive Ganzzahl mit höchstens 16 Stellen (unter 2^53); `invalid_request`: keine gültige URL-Kodierung.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: Ein Token wurde gesendet und ist ungültig.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: Partie nicht vorhanden.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: das Limit `public_read` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: unter anderem, wenn sich die gespeicherten Züge nicht bis zum gespeicherten Ende nachspielen lassen (der Server protokolliert es).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (die Datenbank blieb gesperrt; `retryAfter: 1` im Body, kein `Retry-After`-Header), `server_busy` (die Sitzungsabfrage fand die Datenbank gesperrt vor), `timeout`.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Partie dieses Servers als animiertes GIF herunterladen
      description: |-
        Die Partie als animiertes GIF. Namen und Wertungen stammen aus der Partieaufzeichnung (die Wertungen zu Beginn, ein gelöschtes Konto als `deleted#<id>`), ebenso das Ergebnis und das Ende (`Resignation`, `Loss on time`...).

        Die Prüfungen erfolgen in dieser Reihenfolge: `gif_disabled`, die Bildoptionen (`size`, `orientation`, `delay`, `coords`), die Partie-ID, die Partie, ihre Länge.

        **Sitzung erforderlich:** Die Kontingente zählen pro Konto. **Limits:** `gif` bei jeder Anfrage; `gif_user_min`, `gif_user_hour`, `gif_ip_min` und `gif_ip_hour` nur, wenn das GIF erstellt werden muss (siehe die Einleitung dieses Abschnitts). **Zeitlimit:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 Sekunden (standardmäßig 45 Sekunden).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: 'Die Bildgröße: `small` (Felder von 32 px), `medium` (48 px) oder `large` (72 px).'
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Die Farbe, die unten auf dem Brett steht.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Millisekunden pro Zug (1 bis 6 Dezimalziffern).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Ob die Linienbuchstaben und Reihenzahlen um das Brett gezeichnet werden (`1`) oder nicht (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` mit `field` (`size`, `orientation`, `delay` oder `coords`): ein Wert außerhalb der erlaubten. `invalid_game_id`. `invalid_request`: keine gültige URL-Kodierung.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: Partie nicht vorhanden. `gif_disabled`: Der Server hat GIFs abgeschaltet (`GIF_ENABLED=false`; das Token von `gif` wird zurückgegeben).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: das Limit `gif`, ein Render-Limit oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: Das GIF konnte nicht erstellt werden (der Server protokolliert den Grund). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` mit `retryAfter` (3 bis 10 Sekunden) und `Retry-After`: Die Render-Warteschlange ist voll, oder das GIF hat `GIF_QUEUE_TIMEOUT_MS` (10 Sekunden) auf einen freien Thread gewartet; jedes Limit-Token, das die Anfrage verbraucht hat, wird zurückgegeben. `busy`: Die Datenbank blieb gesperrt (`retryAfter: 1` nur im Body). `timeout`.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Animiertes GIF einer beliebigen, als PGN gesendeten Partie erstellen
      description: |-
        Dasselbe Bild wie `GET /games/{id}/gif` für eine beliebige, als PGN-Text gesendete Partie: eine vom Spiel gespeicherte Partie, ein Export einer anderen Website, eine von Hand eingegebene Partie. Nur die erste Partie des Textes wird verwendet.

        - Der PGN-Leser akzeptiert, was auch der eigene Leser des Spiels akzeptiert: jede PGN, die dieser Server schreibt, und die üblichen Exporte anderer Websites (Kommentare, Varianten, NAGs und Uhr-Annotationen werden übersprungen; Zugnummern und SAN werden tolerant gelesen; ein `FEN`-Tag gibt die Anfangsstellung vor, sofern `SetUp` nicht `"0"` ist).
        - Namen und Wertungen stammen aus den Tags `White`, `Black`, `WhiteElo` und `BlackElo`. Buchstaben mit diakritischen Zeichen verlieren diese, andere Zeichen außerhalb des druckbaren ASCII werden zu `?`, und lange Namen werden gekürzt (48 Zeichen). Das Ergebnis stammt aus dem Tag `Result`, sonst vom Ende des Zugtexts. Das Tag `Termination` wird angezeigt, sofern es nicht `normal` ist: Dann spricht die Schlussstellung für sich (Matt, Patt).
        - Dieser Endpunkt prüft seinen Body selbst: Ein unbekanntes Feld oder ein fehlendes oder nicht als Zeichenkette angegebenes `pgn` wird mit 400 `invalid_request` und `field` beantwortet; eine falsche Option mit 400 `invalid_option` (`delayMs` muss eine JSON-Zahl sein, `coords` ein Boolean; `null` wird abgelehnt).

        **Sitzung erforderlich:** Die Kontingente zählen pro Konto. **Limits:** wie bei `GET /games/{id}/gif`. **Body-Limit:** 135.168 Bytes, unabhängig von `HTTP_BODY_LIMIT` (die PGN als JSON-Zeichenkette, einschließlich ihrer Escape-Sequenzen). **Zeitlimit:** standardmäßig 45 Sekunden.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Eine kurze, von Hand eingegebene Partie, ohne Koordinaten
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Eine PGN-Datei mit allen Optionen
                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` mit `field` (ein unbekanntes Feld, oder `pgn` fehlt oder ist keine Zeichenkette; ohne `field`, wenn der Body kein Objekt ist); `invalid_json`; `invalid_option` mit `field` (`size`, `orientation`, `delayMs` oder `coords`); `invalid_pgn` mit `line` und `column` (ab 1, Spalten in Zeichen) und der `message` des Lesers: ein regelwidriger oder mehrdeutiger Zug, ein fehlerhaftes Tag, eine unbekannte Schachvariante, mehr als 65.536 Bytes.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: Der Server hat GIFs abgeschaltet (`GIF_ENABLED=false`; das Token von `gif` wird zurückgegeben).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: ein Body über 135.168 Bytes.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: das Limit `gif`, ein Render-Limit oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: Das GIF konnte nicht erstellt werden (der Server protokolliert den Grund). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` mit `retryAfter` (3 bis 10 Sekunden) und `Retry-After`: Die Render-Warteschlange ist voll, oder das GIF hat zu lange auf einen freien Thread gewartet; jedes Limit-Token, das die Anfrage verbraucht hat, wird zurückgegeben. `timeout`.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Öffentliches Profil eines Spielers abrufen
      description: |-
        Das öffentliche Profil eines Spielers: Wertungen in den offiziellen Kategorien (in der Reihenfolge des Servers) und Partiezahlen. `games.total` zählt jede gespeicherte Partie, auch ungewertete und abgebrochene; `games.rated`, `wins`, `draws` und `losses` werden über die Wertungseinträge summiert, umfassen also nur gewertete Partien. Die Antwort ist mit und ohne Token dieselbe.

        **Limit:** `public_read` (pro Spieler mit Token, pro Client ohne).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Das Profil.
          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`: nicht 2 bis 24 Zeichen aus `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: Ein Token wurde gesendet und ist ungültig.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: Spieler nicht vorhanden oder Konto gelöscht.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: das Limit `public_read` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (die Datenbank blieb gesperrt; `retryAfter: 1` im Body, kein `Retry-After`-Header), `server_busy` (die Sitzungsabfrage fand die Datenbank gesperrt vor), `timeout`.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Letzte Partien eines Spielers auflisten
      description: |-
        Die letzten Partien des Spielers, neueste zuerst, seitenweise wie der Verlauf, aber ohne Filter und ohne Gesamtzahl. `color` ist die Farbe dieses Spielers. `next` ist die ID der letzten Partie, sobald die Seite voll ist, sodass die nächste Seite leer sein kann. Der Spieler wird zuerst nachgeschlagen: Ein unbekannter Spieler ergibt 404, gleich was die Abfrage enthält.

        **Limit:** `public_read` (pro Spieler mit Token, pro Client ohne).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Eine Seite der Partien des Spielers.
          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` ist keine Partie-ID), `invalid_limit` (diese beiden ohne `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: Ein Token wurde gesendet und ist ungültig.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: Spieler nicht vorhanden oder Konto gelöscht.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: das Limit `public_read` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (die Datenbank blieb gesperrt; `retryAfter: 1` im Body, kein `Retry-After`-Header), `server_busy` (die Sitzungsabfrage fand die Datenbank gesperrt vor), `timeout`.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Beste Spieler einer Kategorie abrufen
      description: |-
        Die 100 besten Wertungseinträge einer offiziellen Kategorie mit mindestens `minGames` (`PROVISIONAL_GAMES`) gezählten Partien, ohne gelöschte Konten und nachgewiesene Betrüger. Der Server berechnet jede Rangliste höchstens alle 10 Sekunden neu; `updatedAt` gibt an, wann.

        **Limits:** nur die Adressebene.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Eine offizielle Kategorie-ID (`3+2` oder `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Wie viele Spieler, 1 bis 100. Eine größere Zahl mit bis zu 3 Stellen gilt als 100; ein leerer Wert gilt als nicht angegeben.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: Die Rangliste.
          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` (fehlt oder ist keine offizielle Kategorie), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: die Adressebene.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (die Datenbank blieb gesperrt; `retryAfter: 1` im Body, kein `Retry-After`-Header), `timeout`.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Gegner einer kürzlich gespielten Partie melden
      description: |-
        Meldet den Gegner einer eigenen Partie des Spielers, die innerhalb der letzten 7 Tage endete. Eine Meldung ändert nie von sich aus eine Wertung, eine Sanktion oder eine Integritätsstufe. Sie erhöht die Prüfpriorität, die die Moderatoren sehen, und fordert, außer bei `abuse`, die Engine-Analyse der Partie an.

        Eine erneute Meldung desselben Gegners für dieselbe Partie erhält dieselbe Antwort und ändert nichts; die Antwort verrät nie etwas über das gemeldete Konto. `GET /games/{id}` teilt den Spielern der Partie vorab mit, ob eine Meldung angenommen würde (`reportable`).

        Dieser Endpunkt prüft seinen Body selbst, in der Reihenfolge `gameId`, `reported`, `category`, `comment`: Ein Fehler wird mit 400 `invalid_request` ohne `field` beantwortet. Felder, die er nicht kennt, werden ignoriert.

        **Limits:** `reports` (pro Spieler), dann `REPORTS_PER_DAY` (5) Meldungen pro Spieler in 24 Stunden (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: Die Meldung ist eingegangen (oder wurde bereits erstattet).
          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` (ohne `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: nicht der Gegner des Meldenden in einer Partie, die innerhalb der letzten 7 Tage endete.'
        '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`, mit einem `Retry-After`-Header): `REPORTS_PER_DAY` Meldungen in den letzten 24 Stunden. `rate_limited`: das Limit `reports` oder das Kontobudget.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (die Sitzungsabfrage fand die Datenbank gesperrt vor), `timeout`.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Der offizielle Server (die Seiten liegen im Wurzelpfad, außerhalb von `/api/v1`).
      - url: https://{host}:{port}
        description: Ein beliebiger Scacelith-Server (die Seiten liegen im Wurzelpfad, außerhalb von `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Der öffentliche Hostname des Servers (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Der öffentliche API-Port (`PUBLIC_API_PORT`, sonst `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Seite zur Bestätigung der E-Mail-Adresse anzeigen
      description: |-
        Die Seite eines Links zur Bestätigung der E-Mail-Adresse (Registrierung oder erneut gesendete Bestätigungs-E-Mail). Sie zeigt nur eine Schaltfläche „Confirm my e-mail address“ (Meine E-Mail-Adresse bestätigen), damit ein Mail-Scanner, der den Link öffnet, ihn nicht verbraucht; die Schaltfläche sendet das Formular an `POST /verify-email`.

        **Limit:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Die Seite mit ihrer Bestätigungsschaltfläche.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Der Link ist ungültig oder abgelaufen (eine HTML-Seite).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Das Limit `page` (eine HTML-Seite) oder die Adressebene (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: E-Mail-Adresse bestätigen
      description: |-
        Das Formular der Bestätigungsseite. Es bestätigt die Adresse; bei einer neuen Registrierung erstellt es dann das Konto, und der Spieler kann sich anmelden.

        **Limit:** `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: Die Adresse ist bestätigt (und das Konto einer neuen Registrierung erstellt).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Der Link ist ungültig, verwendet oder abgelaufen, oder das Formular ist ungültig (eine HTML-Seite).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Der Link einer neuen Registrierung, deren Benutzername oder Adresse inzwischen von einem anderen Konto belegt wurde; es wird kein Konto erstellt (eine HTML-Seite).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Das Limit `auth` (eine HTML-Seite) oder die Adressebene (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Die Datenbank blieb gesperrt (`Retry-After: 1`): Nichts wurde geändert, und der Link funktioniert weiterhin. Ebenso bei einer Zeitüberschreitung des Handlers.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Der offizielle Server (die Seiten liegen im Wurzelpfad, außerhalb von `/api/v1`).
      - url: https://{host}:{port}
        description: Ein beliebiger Scacelith-Server (die Seiten liegen im Wurzelpfad, außerhalb von `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Der öffentliche Hostname des Servers (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Der öffentliche API-Port (`PUBLIC_API_PORT`, sonst `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Formular zum Zurücksetzen des Passworts anzeigen
      description: |-
        Die Seite eines Links zum Zurücksetzen des Passworts: das Formular für das neue Passwort (das Passwort zweimal). Das Formular wird an `POST /reset-password` gesendet.

        **Limit:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Das Formular für das neue Passwort.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Der Link ist ungültig (auch wenn er an eine Adresse gesendet wurde, die das Konto nicht mehr hat).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Das Limit `page` (eine HTML-Seite) oder die Adressebene (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Neues Passwort über das Formular zum Zurücksetzen festlegen
      description: |-
        Das Formular der Seite zum Zurücksetzen; es tut dasselbe wie `POST /auth/password/reset`: Alle Geräte werden abgemeldet, eine ausstehende Änderung der E-Mail-Adresse wird abgebrochen, die anderen Links zum Zurücksetzen werden ungültig, die Adresse gilt als bestätigt, und der Inhaber erhält eine E-Mail.

        Zuerst wird der Link geprüft, dann, ob beide Passwörter übereinstimmen, dann die Passwortregeln. Ist der Server ausgelastet, kommt das Formular mit `Retry-After` zurück, und der Link bleibt gültig.

        **Limits:** `auth`, dann `auth_reset` (gemeinsam mit `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: Das Passwort ist geändert, und alle Geräte sind abgemeldet.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Erneut das Formular mit dem Fehler (die Passwörter stimmen nicht überein, ein zu schwaches Passwort), der Link ist ungültig, oder das Formular ist ungültig (eine HTML-Seite).
        '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: Das Limit `auth` oder `auth_reset` (eine HTML-Seite), erneut das Formular mit `Retry-After`, wenn dieser Client zu viele wartende Passwort-Hashes hat (der Link bleibt gültig), oder die Adressebene (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: Erneut das Formular mit `Retry-After`, wenn der Server ausgelastet ist (die Passwort-Hash-Warteschlange, oder die Datenbank blieb gesperrt); der Link bleibt gültig. Ebenso bei einer Zeitüberschreitung des Handlers.
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Der offizielle Server (die Seiten liegen im Wurzelpfad, außerhalb von `/api/v1`).
      - url: https://{host}:{port}
        description: Ein beliebiger Scacelith-Server (die Seiten liegen im Wurzelpfad, außerhalb von `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Der öffentliche Hostname des Servers (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Der öffentliche API-Port (`PUBLIC_API_PORT`, sonst `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Seite zur Bestätigung der Änderung der E-Mail-Adresse anzeigen
      description: |-
        Die Seite eines Links zur Änderung der E-Mail-Adresse: Sie zeigt die neue Adresse und den Namen des Kontos, mit einer Schaltfläche „Use this e-mail address“ (Diese E-Mail-Adresse verwenden), die das Formular an `POST /confirm-email-change` sendet.

        **Limit:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Die Seite mit der neuen Adresse und ihrer Bestätigungsschaltfläche.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Der Link ist ungültig oder abgelaufen (auch wenn sich die Adresse des Kontos seit der Anfrage geändert hat).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Das Limit `page` (eine HTML-Seite) oder die Adressebene (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Neue E-Mail-Adresse bestätigen
      description: |-
        Das Formular der Seite zur Änderung der E-Mail-Adresse. Die Adresse ändert sich und gilt als bestätigt; die Geräte bleiben angemeldet; die früher gesendeten Links (Bestätigung, Zurücksetzen des Passworts, andere Änderungen) werden ungültig; die bisherige Adresse wird benachrichtigt, wobei die neue maskiert ist.

        **Limit:** `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: Die Adresse ist geändert.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Der Link ist ungültig, verwendet oder abgelaufen, oder das Formular ist ungültig (eine HTML-Seite).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Ein anderes Konto hat die Adresse inzwischen belegt (eine HTML-Seite).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Das Limit `auth` (eine HTML-Seite) oder die Adressebene (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Die Datenbank blieb gesperrt (`Retry-After: 1`): Nichts wurde geändert, und der Link funktioniert weiterhin. Ebenso bei einer Zeitüberschreitung des Handlers.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Der offizielle Server, im Wurzelpfad.
      - url: https://caissa.scacelith.com/api/v1
        description: Der offizielle Server, unter `/api/v1`.
      - url: https://{host}:{port}
        description: Ein beliebiger Scacelith-Server, im Wurzelpfad.
        variables:
          host:
            default: caissa.scacelith.com
            description: Der öffentliche Hostname des Servers (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Der öffentliche API-Port (`PUBLIC_API_PORT`, sonst `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Ein beliebiger Scacelith-Server, unter `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Der öffentliche Hostname des Servers (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Der öffentliche API-Port (`PUBLIC_API_PORT`, sonst `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Prüfen, ob der Prozess läuft
      description: |-
        Antwortet mit 200 `ok`, solange der Prozess läuft. `HEAD` funktioniert ebenfalls. Jede andere Methode wird mit 405 `method_not_allowed` und `Allow: GET, HEAD` beantwortet (auch `OPTIONS`).

        **Limits:** nur die Adressebene.
      security: []
      responses:
        '200':
          description: Der Prozess läuft.
          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`: die Adressebene.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Der offizielle Server, im Wurzelpfad.
      - url: https://caissa.scacelith.com/api/v1
        description: Der offizielle Server, unter `/api/v1`.
      - url: https://{host}:{port}
        description: Ein beliebiger Scacelith-Server, im Wurzelpfad.
        variables:
          host:
            default: caissa.scacelith.com
            description: Der öffentliche Hostname des Servers (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Der öffentliche API-Port (`PUBLIC_API_PORT`, sonst `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Ein beliebiger Scacelith-Server, unter `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Der öffentliche Hostname des Servers (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Der öffentliche API-Port (`PUBLIC_API_PORT`, sonst `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Prüfen, ob der Server Spieler annimmt
      description: |-
        Antwortet mit 200 `ready`, wenn der Server Spieler annimmt, und mit 503 `not_ready`, während er startet oder herunterfährt. `HEAD` funktioniert ebenfalls. Jede andere Methode wird mit 405 `method_not_allowed` und `Allow: GET, HEAD` beantwortet (auch `OPTIONS`).

        **Limits:** nur die Adressebene.
      security: []
      responses:
        '200':
          description: Der Server nimmt Spieler an.
          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`: die Adressebene.'
        '503':
          description: Der Server startet oder fährt herunter.
          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: |-
        Ein Sitzungstoken (`sct_` gefolgt von 43 base64url-Zeichen) aus einer Anmeldeantwort, im `Authorization`-Header: `Authorization: Bearer <token>`. Das Präfix ist genau `Bearer` (Groß- und Kleinschreibung wird unterschieden) mit einem Leerzeichen; ein Wert ohne dieses Präfix oder ein Token, das nicht aus 1 bis 512 druckbaren ASCII-Zeichen besteht, wird wie jedes andere ungültige Token mit 401 `invalid_token` beantwortet. Ein leerer Header gilt als fehlender Header.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: Die Partie-ID, eine positive dezimale Ganzzahl mit höchstens 16 Stellen ohne führende Nullen, unter 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: Die Sitzungs-ID, wie `GET /auth/sessions` sie liefert, ohne führende Nullen geschrieben (`03` ist nicht Sitzung 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Der Benutzername des Spielers, ohne Berücksichtigung der Groß- und Kleinschreibung. Der Server akzeptiert jeden Namen aus 2 bis 24 Zeichen aus `[A-Za-z0-9_.-]` (die weiteste Regel, die er je erlaubt hat).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Eine Partie-ID; nur ältere Partien werden aufgelistet. Übergeben Sie `next` der vorherigen Seite. Ein leerer Wert gilt als nicht angegeben.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Die Seitengröße, 1 bis 50. Eine größere Zahl mit bis zu 3 Stellen gilt als 50; ein leerer Wert gilt als nicht angegeben.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Das Token des E-Mail-Links (43 base64url-Zeichen). Fehlt es oder ist es falsch, erscheint die Seite „link invalid or expired“ (Link ungültig oder abgelaufen).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Jede Antwort des Servers verbietet das Caching.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Sekunden bis zum nächsten Versuch; derselbe Wert wie `retryAfter` im Body.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Die Bearer-Challenge, mit `error="invalid_token"` bei einem ungültigen Token.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: Der Server schließt die Verbindung nach dieser Antwort.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (mit `field`, wenn ein Feld des Bodys fehlerhaft ist) oder `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Ein Feld verletzt das Schema
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Der Body ist kein JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Ein zu schwaches Passwort
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: Eine unlesbare PGN
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (kein `Authorization`-Header) oder `invalid_token` (das Token ist fehlerhaft, abgelaufen, widerrufen oder gehört zu einem gelöschten 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: Keine Sitzung
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Ein ungültiges Token
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: Die Anfrage wird abgelehnt.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Ein falsches Passwort bei der erneuten Authentifizierung
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Ein gesperrtes Konto
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found` oder eine Funktion, die dieser Server abgeschaltet hat.'
      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`: Der Body ist nicht innerhalb von 10 Sekunden eingetroffen. Der Server schließt die Verbindung.'
      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: Die Anfrage steht im Widerspruch zum Zustand des Servers.
      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`: Der Versuch oder das Ticket der Anmeldung mit Google ist unbekannt, verwendet oder abgelaufen; beginnen Sie im Spiel von vorn.'
      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`: Der Body überschreitet `HTTP_BODY_LIMIT` (standardmäßig 16.384 Bytes). Der Server schließt die Verbindung.'
      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`: Das Anfrageziel überschreitet 4096 Zeichen.'
      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`: Der Body ist nicht `application/json`, oder sein Zeichensatz ist nicht 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`: Die Partie hat mehr als `GIF_MAX_PLIES` (600) Halbzüge.'
      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ösen Sie die `pow`-Challenge und senden Sie dieselbe Anfrage erneut mit `pow: { challenge, nonce }`. `reason` gibt den Grund an: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` oder `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` (ein Ratenlimit, das Kontobudget oder die Passwort-Hash-Warteschlange) oder `too_many_attempts` (eine Drosselung des Kontos), mit `retryAfter` und einem `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: Ein Ratenlimit
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Zu viele Fehlversuche bei diesem Konto
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: ein unerwarteter Fehler. Der Server protokolliert ihn.'
      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`: ein falscher `state` oder `iss`, oder Google hat den Code abgelehnt oder ein ID-Token gesendet, das sich nicht verifizieren lässt. Die Antwort enthält nie den Text von Google.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: sso_failed
            message: Google sign-in could not be completed.
    ServiceUnavailable:
      description: '`server_busy`, `busy` oder `timeout`: Versuchen Sie es nach `retryAfter` erneut, sofern angegeben.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Der Server ist ausgelastet
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: Die Datenbank blieb gesperrt (ein Lesezugriff)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Der Server hat zu lange gebraucht
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: Das animierte GIF, als Anhang.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` für eine Partie dieses Servers, `attachment; filename="scacelith-game.gif"` für eine mit `POST /gif` gesendete PGN.'
          schema:
            type: string
            pattern: '^attachment; filename="scacelith-([1-9][0-9]{0,15}|game)\.gif"$'
          example: attachment; filename="scacelith-4100000000001.gif"
        Content-Length:
          description: Die Größe der Datei in Bytes.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: Eine HTML-Seite.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: Eine HTML-Seite, die die Ablehnung erklärt.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Das Formular ist nicht innerhalb von 10 Sekunden eingetroffen (eine HTML-Seite). Der Server schließt die Verbindung.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: Eine HTML-Seite, die den Konflikt erklärt.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Das Formular überschreitet `HTTP_BODY_LIMIT` (eine HTML-Seite). Der Server schließt die Verbindung.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Der Body ist weder ein Formular (`application/x-www-form-urlencoded`) noch JSON, oder sein Zeichensatz ist nicht UTF-8 (eine HTML-Seite).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Ein Ratenlimit (eine HTML-Seite), mit `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: Ein unerwarteter Fehler, als HTML-Seite mit dem Titel „Server error“ (Serverfehler). Der Server protokolliert ihn.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Der Server ist ausgelastet (die Datenbank blieb gesperrt, mit `Retry-After`) oder hat zu lange gebraucht, jeweils als HTML-Seite mit dem Titel „Server error“ (Serverfehler).
      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: Die einheitliche Form jeder Fehlerantwort der JSON-API.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: Der Fehlercode in `snake_case`. Ein Client wählt anhand dessen, was er anzeigt.
          examples:
            - rate_limited
        message:
          type: string
          description: Ein englischer Satz, gedacht für Logs und als Rückfalltext.
        retryAfter:
          type: integer
          minimum: 1
          description: Sekunden bis zum nächsten Versuch, nur bei Ablehnungen vorhanden, die mit der Zeit enden. Die Antwort trägt dann auch einen `Retry-After`-Header mit demselben Wert, außer beim 503 `busy` der Lesezugriffe auf Partieverlauf, Partien, Spieler und Rangliste.
        field:
          type: string
          description: Das fehlerhafte Feld oder der fehlerhafte Abfrageparameter (`invalid_request`, `invalid_option` sowie `invalid_cursor`, `invalid_limit` und `invalid_filter` von `GET /account/games`). Ein verschachteltes Feld wird mit Punkten geschrieben (`pow.nonce`).
        reason:
          type: string
          description: 'Der Grund: die Regel, gegen die ein `weak_password` verstößt, oder warum ein Arbeitsnachweis verlangt oder abgelehnt wurde (`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: 'Nur bei `banned`: das Ende der Sperre (Epoch-Millisekunden), `null` bei einer dauerhaften Sperre.'
        line:
          type: integer
          minimum: 1
          description: 'Nur bei `invalid_pgn`: die Zeile des Fehlers, ab 1.'
        column:
          type: integer
          minimum: 1
          description: 'Nur bei `invalid_pgn`: die Spalte des Fehlers, ab 1, in Zeichen.'
    PowChallenge:
      type: object
      description: Eine Challenge für den Arbeitsnachweis (das `pow` eines 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: Die signierte Challenge, unverändert zurückzusenden.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Die Anzahl führender Nullbits, die `SHA-256(challenge + ":" + nonce)` haben muss.
        expiresAt:
          type: integer
          format: int64
          description: Wann die Challenge abläuft (Epoch-Millisekunden), 2 Minuten nach ihrer Ausstellung.
    PowAnswer:
      type: object
      description: Die Antwort auf eine Challenge für den Arbeitsnachweis.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: Die `challenge` der 428-Antwort, so wie sie geliefert wurde.
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Eine Dezimalzeichenkette aus höchstens 20 Ziffern, sodass `SHA-256(challenge + ":" + nonce)` mit `bits` Nullbits beginnt.
    ServerInfo:
      type: object
      description: Was ein Client braucht, bevor er sich anmeldet oder verbindet.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Der Name des Servers (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: Eine UUID, die die Datenbank bei ihrem ersten Start erhält. Sie bleibt über Neustarts hinweg gleich. Sie ist `null`, wenn die Datenbank sie nicht liefern kann.
        motd:
          type: string
          description: Die Nachricht des Tages (`SERVER_MOTD`), möglicherweise leer.
        protocol:
          type: object
          description: Das WebSocket-Protokoll (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: Die älteste Protokollversion, die der Server spricht.
            max:
              type: integer
              minimum: 1
              description: Die neueste Protokollversion, die der Server spricht.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: Der Fingerabdruck des Protokollschemas (die ersten 4 Bytes des SHA-256 seiner kanonischen Form, als vorzeichenlose Ganzzahl); nur zur Information.
            subprotocol:
              type: string
              description: Das Token des WebSocket-Subprotokolls (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: Der WebSocket-Port, den die Spieler verwenden (`PUBLIC_WS_PORT`, sonst `WS_PORT`, sonst `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Der Pfad des WebSockets.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Ob neue Konten erstellt werden können.
        emailVerification:
          type: boolean
          description: Ob neue Konten ihre Adresse bestätigen müssen (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
          properties:
            google:
              type: boolean
              description: Ob die Anmeldung mit Google angeboten wird.
        mfa:
          type: boolean
          const: true
          description: Die Zwei-Faktor-Authentifizierung ist immer verfügbar.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Die Bits des Arbeitsnachweises, die die Registrierung verlangt (0 für keinen).
        categories:
          type: array
          description: Die offiziellen (gewerteten) Bedenkzeiten (`RATED_CATEGORIES`), in der Reihenfolge des Servers. Jede andere Bedenkzeit ist `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Die Regeln, die ein Client prüfen kann, bevor er ein Formular sendet.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: Die Mindestlänge eines Benutzernamens (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: Die Höchstlänge eines Benutzernamens (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: Der reguläre Ausdruck, dem ein neuer Benutzername entsprechen muss.
            passwordMinLength:
              type: integer
              description: Die Mindestlänge eines Passworts, in Zeichen (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: Die Höchstlänge eines Passworts, in Bytes UTF-8.
            customTimeControls:
              type: boolean
              description: Ob Herausforderungen und private Partien eigene Bedenkzeiten verwenden dürfen.
            reportsPerDay:
              type: integer
              description: Wie viele Meldungen ein Spieler pro 24 Stunden erstatten darf (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: Die größte WebSocket-Nachricht, die ein Client senden darf, in Bytes.
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 97842216
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: Eine offizielle Bedenkzeit.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 'Die Kategorie-ID: Minuten und Inkrement in Sekunden (`3+2`).'
        baseSec:
          type: number
          minimum: 0
          description: Die Grundzeit, in Sekunden.
        incSec:
          type: number
          minimum: 0
          description: Das Inkrement pro Zug, in Sekunden.
    AccountView:
      type: object
      description: Das Konto, wie sein Spieler es sieht (`user` der Anmeldeantworten und von `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: Die Konto-ID.
        username:
          type: string
          description: Der Benutzername.
        email:
          type: string
          format: email
          description: Die E-Mail-Adresse.
        emailVerified:
          type: boolean
          description: Ob die Adresse bestätigt ist.
        mfaEnabled:
          type: boolean
          description: Ob die Zwei-Faktor-Authentifizierung eingeschaltet ist.
        googleLinked:
          type: boolean
          description: Ob ein Google-Konto verknüpft ist.
        hasPassword:
          type: boolean
          description: '`false` bei einem über Google erstellten Konto, das noch kein Passwort festgelegt hat.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Ob direkte Herausforderungen über den Namen angenommen werden (siehe `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Wann das Konto erstellt wurde (Epoch-Millisekunden).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Die letzte Anmeldung (Epoch-Millisekunden) oder `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Die neue Adresse einer Änderung der E-Mail-Adresse, die auf ihren Link wartet, oder `null`.
    SessionAnswer:
      type: object
      description: Eine neue Sitzung.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Das Sitzungstoken, für den `Authorization`-Header und den WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: Das absolute Ende der Sitzung (Epoch-Millisekunden); die Inaktivitätsgrenze kann sie früher beenden.
        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: Der zweite Schritt einer Anmeldung mit Zwei-Faktor-Authentifizierung; weiter mit `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: Das Token des Schritts, für `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Verbleibende Sekunden, um den Schritt abzuschließen.
    LoginAnswer:
      description: Eine Sitzung oder der zweite Schritt einer Anmeldung mit Zwei-Faktor-Authentifizierung.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Eine erste Anmeldung mit Google; weiter mit `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: Das Ticket für `POST /auth/sso/complete`, 10 Minuten gültig.
        suggestedUsername:
          type: string
          description: Ein aus dem Google-Namen oder der Adresse gebildeter Benutzername oder `""`, wenn nichts passt.
    SsoNeedsPassword:
      type: object
      description: Ein Konto mit Passwort verwendet die Adresse; weiter mit `POST /auth/sso/google/link`. Noch ist nichts verknüpft.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Das Ticket für `POST /auth/sso/google/link`.
        username:
          type: string
          description: Der Benutzername dieses Kontos (nur wer die Adresse gegenüber Google nachgewiesen hat, sieht ihn).
        expiresIn:
          type: integer
          const: 600
          description: Sekunden, die das Ticket gültig bleibt.
    SsoFinishAnswer:
      description: Die Antwort von `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: Ein Versuch der Anmeldung mit Google.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Der Versuch, für `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: Die Google-URL, die nach ihrer Prüfung im Systembrowser zu öffnen ist.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Der `state`, den Google zurücksenden muss.
        expiresIn:
          type: integer
          const: 600
          description: Sekunden, die der Versuch gültig bleibt.
    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: Der Benutzername; es gelten die Regeln des Servers (siehe die Beschreibung).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Die E-Mail-Adresse. Ohne umgebende Leerzeichen und in Kleinbuchstaben gespeichert.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Das Passwort; es gelten die Passwortregeln.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Der Benutzername oder die E-Mail-Adresse (jeder Text mit `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Optional. Wird in der Liste der angemeldeten Geräte angezeigt (etwa `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: Das `mfaToken` der Anmeldeantwort (oder der Antwort einer Anmeldung mit Google).
        code:
          type: string
          maxLength: 32
          description: Optional. Ein 6-stelliger Authenticator-Code oder ein Wiederherstellungscode.
        recoveryCode:
          type: string
          maxLength: 32
          description: Optional. Ein Wiederherstellungscode. `code` oder `recoveryCode` ist erforderlich.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Die E-Mail-Adresse.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Der Parameter `token` des Links zum Zurücksetzen.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Das neue Passwort; es gelten die Passwortregeln der Registrierung.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Die S256-PKCE-Challenge des `codeVerifier` des Clients (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: Der Port des Listeners des Clients auf `127.0.0.1`.
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: Die `attemptId` aus `POST /auth/sso/google/start`.
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: Der PKCE-Verifier; sein SHA-256 muss zu der an `start` übergebenen Challenge passen.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Der `state`, wie Google ihn zurückgesendet hat.
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: Der `code`, wie Google ihn zurückgesendet hat (druckbares ASCII ohne Leerzeichen).
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Optional. Der `iss`, wie Google ihn zurückgesendet hat, falls Google ihn gesendet hat.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: Das `linkTicket` aus `finish`, 10 Minuten gültig.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Das Passwort des Kontos.
        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: Das `ssoTicket` aus `finish`, 10 Minuten gültig.
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Der Benutzername; es gelten die Regeln der Registrierung.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Die aktiven Sitzungen, die zuletzt verwendete zuerst.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Eine aktive Sitzung (ein angemeldetes Gerät).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: Die Sitzungs-ID, für `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: Die Anmeldung (Epoch-Millisekunden).
        lastSeenAt:
          type: integer
          format: int64
          description: Die letzte Nutzung (Epoch-Millisekunden), höchstens alle 5 Minuten aktualisiert.
        expiresAt:
          type: integer
          format: int64
          description: Das absolute Ende (Epoch-Millisekunden); die Inaktivitätsgrenze kann die Sitzung früher beenden.
        clientLabel:
          type:
            - string
            - 'null'
          description: Das `clientLabel` der Anmeldung oder `null`, wenn keines gesendet wurde.
        current:
          type: boolean
          description: Ob dies die Sitzung ist, die die Anfrage stellt.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Ein Eintrag pro Kategorie, in der der Spieler gewertete Partien gespielt hat.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Die aktiven Sanktionen.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` während einer Sperre, sonst `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Der Wertungseintrag einer Kategorie.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: Die Kategorie-ID.
        rating:
          type: integer
          description: Die Wertung.
        games:
          type: integer
          minimum: 0
          description: Die in der Kategorie gespielten Partien (gezählt oder nicht).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Die höchste erreichte Wertung.
        provisional:
          type: boolean
          description: '`true`, solange sich die Wertung noch in der ungewerteten Anfangsphase befindet oder weniger als `PROVISIONAL_GAMES` gezählte Partien hat (das Spiel zeigt sie als `1510?` an).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Die Art der Sanktion.
        reason:
          type:
            - string
            - 'null'
          description: Der angegebene Grund, falls vorhanden.
        startsAt:
          type: integer
          format: int64
          description: Der Beginn (Epoch-Millisekunden).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Das Ende (Epoch-Millisekunden), `null`, wenn die Sanktion dauerhaft ist.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Das Ende der Sperre (Epoch-Millisekunden), `null`, wenn sie dauerhaft ist.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all`, um direkte Herausforderungen über den Namen anzunehmen, `none`, um sie abzulehnen.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Das aktuelle Passwort.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Das neue Passwort; es gelten die Passwortregeln der Registrierung.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Das Passwort des Kontos.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Ein Code des ausstehenden Geheimnisses, genau 6 Ziffern.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Das Passwort des Kontos.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Optional; bei Zwei-Faktor-Authentifizierung erforderlich (oder `recoveryCode`). Ein Authenticator-Code oder ein Wiederherstellungscode.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Optional. Ein Wiederherstellungscode (`xxxx-xxxx-xx`; Groß- und Kleinschreibung, Leerzeichen und Bindestriche spielen keine Rolle).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Das Passwort des Kontos.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Ein Authenticator-Code (ein Wiederherstellungscode wird abgelehnt).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Die neue Adresse; von umgebenden Leerzeichen befreit, in Kleinbuchstaben umgewandelt und dann wie eine Adresse bei der Registrierung geprüft.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Das Passwort des Kontos.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Optional; bei Zwei-Faktor-Authentifizierung erforderlich (oder `recoveryCode`). Ein Authenticator-Code oder ein Wiederherstellungscode.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Optional. Ein Wiederherstellungscode.
    TotpSetup:
      type: object
      description: Das ausstehende Geheimnis für die Authenticator-App.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Das Geheimnis in Base32, zum Eintippen in die App.
        uri:
          type: string
          format: uri
          description: Die URI `otpauth://totp/...`, zur Anzeige als QR-Code.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Sekunden pro Schritt.
    RecoveryCodeList:
      type: array
      description: Die 10 Wiederherstellungscodes, nur dieses eine Mal angezeigt. Jeder funktioniert genau einmal.
      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: Eine Seite einer Partie.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Der Name in der Partieaufzeichnung (`deleted#<id>` bei einem gelöschten Konto).
        rating:
          type:
            - integer
            - 'null'
          description: Die Wertung zu Beginn, `null`, wenn unbekannt.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Die Wertung nach der Partie. Eine gewertete Partie hat sie immer; `null` nur bei einer Partie, die nicht für die Wertungen zählt (ungewertet, eigene Bedenkzeit, abgebrochen).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: Die Änderung der Wertung, `0`, wenn die Wertungsregeln die Wertung unverändert lassen (die Null-Punkte-Regel der FIDE, ein Gegner ohne Wertung, die ersten Partien eines Spielers ohne Wertung); `null` wie bei `ratingAfter`.
    GameSummary:
      type: object
      description: Die Zusammenfassung einer gespeicherten Partie.
      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: Die Partie-ID.
        category:
          type: string
          description: Die offizielle Kategorie-ID (`3+2`) oder `custom`.
        rated:
          type: boolean
          description: Ob die Partie für die Wertungen zählt.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Die Bedenkzeit in Sekunden (`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` (siehe die Partiecodes).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Der Code des Beendigungsgrunds (siehe die Partiecodes).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Das Ergebnis, `*` bei einer abgebrochenen Partie.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Die Anzahl der Halbzüge.
        startedAt:
          type: integer
          format: int64
          description: Der Beginn (Epoch-Millisekunden).
        endedAt:
          type: integer
          format: int64
          description: Das Ende (Epoch-Millisekunden).
    Termination:
      type: string
      description: Der Name des Beendigungsgrunds (siehe die Partiecodes); `Unknown` bei einem Code, den das Protokoll nicht kennt.
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: Eine Partiezusammenfassung aus Sicht eines Spielers.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Die Farbe des Spielers.
    HistoryGameSummary:
      description: Eine Partie aus dem Verlauf des angemeldeten Spielers.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Die Grundzeit in Millisekunden.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Das Inkrement in Millisekunden.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: Der Ausgang aus Sicht des Spielers.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Die Partien der Seite, neueste zuerst.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Die ID, die für die nächste Seite als `before` zu übergeben ist, oder `null` auf der letzten Seite.
        total:
          type: integer
          minimum: 0
          description: Die Anzahl der zum Filter passenden Partien über alle Seiten hinweg.
    MoveRecord:
      type: object
      description: Ein Halbzug einer Partie.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: Der Zug in UCI-Notation, bei einer Umwandlung ergänzt um `n`, `b`, `r` oder `q`.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Die für den Zug angerechnete Zeit (ms), `null`, wenn die Aufzeichnung sie nicht enthält.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Die Uhr des Ziehenden nach dem Zug (ms), `null`, wenn die Aufzeichnung sie nicht enthält.
    PgnTagsView:
      type: object
      description: Die wichtigsten PGN-Tags, zur Anzeige. `Termination` ist hier der Name des Beendigungsgrunds; die PGN-Datei verwendet die Standardwerte von PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` oder `<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: Das Startdatum in UTC.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: Die Wertung zu Beginn oder `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Die Wertung zu Beginn oder `"-"`.
          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: Eine Partieaufzeichnung mit ihren Zügen und Uhrständen. Sie hat die Felder einer Partiezusammenfassung, ohne `color` und `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Die Grundzeit in Millisekunden.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Das Inkrement in Millisekunden.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: Der Name von `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: Die ID der Partie, deren Revanche diese ist, oder `null`.
            moves:
              type: array
              description: Ein Eintrag pro Halbzug.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: 'Nur wenn der Spieler des Tokens diese Partie gespielt hat: seine Farbe.'
            reportable:
              type: boolean
              description: 'Nur wenn der Spieler des Tokens diese Partie gespielt hat: `true`, wenn `POST /reports` jetzt eine Meldung des Gegners für diese Partie annehmen würde (die Partie endete innerhalb von 7 Tagen, das Tageskontingent ist nicht aufgebraucht, und der Gegner wurde für diese Partie noch nicht gemeldet).'
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Der Benutzername in der Schreibweise des Spielers.
        createdAt:
          type: integer
          format: int64
          description: Wann das Konto erstellt wurde (Epoch-Millisekunden).
        ratings:
          type: array
          description: Nur die offiziellen Kategorien, in der Reihenfolge des Servers.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Jede gespeicherte Partie, auch ungewertete und abgebrochene.
            rated:
              type: integer
              minimum: 0
              description: Gewertete Partien, über die Wertungseinträge summiert.
            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: Der Benutzername in der Schreibweise des Spielers.
        games:
          type: array
          description: Die Partien der Seite, neueste zuerst.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Die ID der letzten Partie, sobald die Seite voll ist (die nächste Seite kann dann leer sein), sonst `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: Die Kategorie-ID.
        minGames:
          type: integer
          minimum: 0
          description: Die Zahl gezählter Partien, die ein Eintrag braucht, um in der Liste zu erscheinen (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Wann die Rangliste berechnet wurde (Epoch-Millisekunden).
        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: Der PGN-Text, höchstens 65.536 Bytes UTF-8. Nur die erste Partie wird verwendet.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Optional. Die Bildgröße.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Optional. Die Farbe, die unten auf dem Brett steht.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Optional. Millisekunden pro Zug, eine JSON-Zahl mit ganzzahligem Wert.
        coords:
          type: boolean
          default: true
          description: Optional. Ob die Linienbuchstaben und Reihenzahlen um das Brett gezeichnet werden.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: Die Partie, als Ganzzahl oder als Zeichenkette aus 1 bis 16 Ziffern.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: Der Benutzername des Gegners, wie in der Partieaufzeichnung oder wie er jetzt lautet, ohne Berücksichtigung der Groß- und Kleinschreibung. Er darf nicht leer sein.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: Worum es in der Meldung geht.
        comment:
          type:
            - string
            - 'null'
          description: Optional. Höchstens 500 Zeichen, nachdem Steuerzeichen (außer Tabulator und Zeilenvorschub) entfernt und umgebende Leerzeichen abgeschnitten wurden.
    AccountExport:
      type: object
      description: Alles, was der Server über das Konto speichert (Zeitangaben in Epoch-Millisekunden).
      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: Wann der Export erstellt wurde.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Was die Datei enthält und was sie auslässt, in einfachem Englisch für den Spieler.
          items:
            type: string
        account:
          description: Die Kontoansicht von `GET /account/me`, mit `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: Die Adresse des verknüpften Google-Kontos oder `null`.
        ratings:
          type: array
          description: Die vollständigen Wertungseinträge.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Wertungspunkte, die zurückgegeben wurden, nachdem ein Gegner des Betrugs überführt wurde, summiert pro UTC-Tag und Kategorie, neueste zuerst. Weder die Partien noch die Betrüger werden genannt.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Jede gespeicherte Sitzung, neueste zuerst, ohne jegliches Token. Die Bereinigung nach Ablauf der Aufbewahrungsfrist löscht eine abgelaufene Sitzung, und eine abgemeldete einen Tag nach der Abmeldung.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Die Sicherheitsereignisse, neueste zuerst, aufbewahrt für `RETENTION_SECURITY_DAYS` Tage.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Jede Sanktion, auch aufgehobene. Der Name des Moderators ist nie enthalten.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Die Verhaltensereignisse, die für verlassene, abgebrochene und nicht angetretene Partien des Spielers erfasst wurden, 30 Tage lang aufbewahrt.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Die Meldungen, die der Spieler erstattet hat.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Die Anzahl der Partien.
            list:
              type: array
              description: Jede Partie, neueste zuerst, als Zusammenfassungen wie bei `GET /account/games`. Die Züge liefert `GET /games/{id}`, die PGN `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Ein vollständiger Wertungseintrag.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Ob der Spieler die ungewertete Anfangsphase verlassen hat.
            countedGames:
              type: integer
              minimum: 0
              description: Die Partien, die in die Wertung eingegangen sind.
            updatedAt:
              type: integer
              format: int64
              description: Wann sich der Eintrag zuletzt geändert hat.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: 00:00 UTC des Tages (Epoch-Millisekunden).
        category:
          type: string
        points:
          type: integer
          description: Die an diesem Tag in dieser Kategorie zurückgegebenen Punkte.
    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: Wann die Sitzung abgemeldet wurde, oder `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: Die Adresse der Anmeldung, gelöscht nach `RETENTION_IP_DAYS` Tagen.
    SecurityEvent:
      type: object
      description: |-
        Ein Sicherheitsereignis. `ip` wird nur für Handlungen angegeben, die im angemeldeten Zustand, mit dem Passwort des Kontos (und dem zweiten Faktor) oder mit einem an seine Adresse gesendeten Link vorgenommen wurden: `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` und `account_exported`; jede andere Art hat `ip: null`, und `ip` wird nach `RETENTION_IP_DAYS` Tagen gelöscht.

        `detail` behält nur diese Felder: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` und `method` (`password` oder `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` und `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. Ein Ereignis `moderator_action` behält nur `{ action }`, für die Aktionen `ban`, `unban`, `reset_mfa`, `verify_email` und `revoke_sessions` (die anderen Moderatorenaktionen werden ausgelassen). Jede andere Art hat `detail: null`. Die Ereignisse `rating_refund` werden ausgelassen: Ihre Punkte stehen in `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Die Art des Ereignisses (`login`, `password_changed`...).
        at:
          type: integer
          format: int64
          description: Wann es geschah.
        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: Der aktuelle öffentliche Name des gemeldeten Spielers.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Ob die Meldung noch offen ist (ob der gemeldete Spieler sanktioniert wurde, wird nicht mitgeteilt).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Das Token des E-Mail-Links (das versteckte Feld des Formulars der Seite).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Das Token des Links zum Zurücksetzen (das versteckte Feld des Formulars).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Das neue Passwort; es gelten die Passwortregeln der Registrierung.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Das neue Passwort noch einmal; es muss übereinstimmen.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: Eine HTML-Seite in UTF-8 (`text/html; charset=utf-8`), ohne JavaScript oder externe Ressourcen.
