openapi: 3.1.1
info:
  title: HTTPS API poslužitelja Scacelith
  version: "1.1.0"
  summary: HTTPS API svakog poslužitelja Scacelith, službenog i poslužitelja zajednice.
  description: |-
    Svaki poslužitelj Scacelith, službeni `caissa.scacelith.com` i svaki poslužitelj zajednice, pruža ovaj HTTPS API. Igra ga koristi za sve što nije sama partija: registraciju i prijavu (uključujući dvofaktorsku provjeru i Google), stranicu računa, povijest partija, preuzimanje PGN datoteka i animirane GIF-ove partija, prijavljene uređaje, preuzimanje podataka, brisanje računa i prijave protivnika.

    Igra uživo (traženje protivnika, izazovi, potezi, satovi) odvija se preko WebSocketa istog poslužitelja (`wss://<host>/ws`), koji se otvara tokenom sesije ovog API-ja; opisana je u `PROTOCOL.md`, a ne ovdje.

    Zadane vrijednosti u nastavku su one poslužitelja s neizmijenjenom konfiguracijom; poslužitelj zajednice može ih promijeniti (`CONFIG.md` navodi svaku postavku).

    ## Osnovni URL, port i prijenos

    - Službeni poslužitelj: `https://caissa.scacelith.com/api/v1`, TCP port 443.
    - Poslužitelj zajednice: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. `API_PORT` je prema zadanim postavkama 443; iza NAT-a ili proxyja port kojim se igrači služe je `PUBLIC_API_PORT`.
    - WebSocket igre prema zadanim je postavkama na istom portu (`WS_PORT` ga premješta; `GET /info` govori klijentu gdje se nalazi).
    - HTTP/1.1 preko TLS-a. Uz `TLS_MODE=proxy` TLS završava na obrnutom proxyju (reverse proxy) ispred poslužitelja. `TLS_MODE=off` (običan HTTP) samo je za lokalni razvoj.
    - HTML stranice koje se otvaraju iz poveznica u e-porukama (`/verify-email`, `/reset-password`, `/confirm-email-change`) nalaze se u korijenu poslužitelja, izvan `/api/v1`. Krajnje točke za provjeru stanja odgovaraju i u korijenu i pod `/api/v1`. Prijava putem Googlea nema stranicu na poslužitelju: Google vraća preglednik izravno igri, na `127.0.0.1`, ili na stranicu web-mjesta koje ju je pokrenulo (`GOOGLE_WEB_REDIRECT_URI`).
    - Kosa crta na kraju zanemaruje se (`/api/v1/info/` je `/api/v1/info`), a parametri putanje dekodiraju se iz URL kodiranja (na parametar koji nije valjano URL kodiran odgovor je 400 `invalid_request`).

    ## Zahtjevi

    - **JSON tijela.** Zahtjevi `POST`, `PUT` i `DELETE` nose JSON objekt s `Content-Type: application/json`. `charset` različit od UTF-8 odbija se, kao i svaka druga vrsta sadržaja (415 `unsupported_media_type`). Prazno tijelo vrijedi kao `{}`, a upravo to primaju krajnje točke bez parametara (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Veličina i vrijeme.** Tijelo je ograničeno na `HTTP_BODY_LIMIT` bajtova (prema zadanim postavkama 16.384; `POST /gif` ima vlastito ograničenje od 135.168 bajtova): 413 `payload_too_large`. Mora stići u roku od 10 sekundi: 408 `request_timeout`. Nakon bilo koje od tih dviju pogrešaka poslužitelj zatvara vezu.
    - **Stroge sheme.** Polje koje krajnja točka ne poznaje odbija se, polje koje nije označeno kao neobavezno obavezno je, a tipovi i duljine se provjeravaju. Na svaku od tih pogrešaka odgovor je 400 `invalid_request`, a `field` navodi polje (s točkama za ugniježđeno polje, npr. `pow.nonce`). Nizovi znakova ne smiju sadržavati kontrolne znakove. Duljine se broje u UTF-16 kodnim jedinicama, pa se znak izvan osnovne višejezične ravnine (BMP), npr. emoji, broji dvaput. Na tijelo koje nije JSON odgovor je 400 `invalid_json`. `POST /gif` i `POST /reports` sami provjeravaju svoja tijela (vidi pojedinu krajnju točku).
    - **Upitni nizovi (query string).** Vrijedi prvo pojavljivanje parametra, nepoznati parametri zanemaruju se, a `+` se dekodira u razmak: krajnje točke koje primaju kategoriju tempa igre prihvaćaju i `3%2B2` i `3+2`.
    - **Metode.** `HEAD` je `GET` bez tijela. `OPTIONS` na postojećoj putanji odgovara s 204 i zaglavljem `Allow` (te zaglavljima CORS preflight zahtjeva, u nastavku). Na svaku drugu metodu koju putanja nema odgovor je 405 `method_not_allowed` s `Allow`.
    - **Cilj zahtjeva (request target).** Ne smije biti dulji od 4096 znakova (414 `uri_too_long`). Zahtjev koji se uopće ne može parsirati ili čija je glava (početni redak i zaglavlja) prevelika ili stiže presporo dobiva prazan odgovor 400, 431 ili 408, a veza se zatvara.
    - **CORS: popis dopuštenih, prema zadanim postavkama isključen.** API služi igri, koja ne šalje `Origin`. Kad je `CORS_ORIGINS` prazan (zadano), nikad se ne šalje nijedno zaglavlje `Access-Control-*`, pa nijedna web-stranica ne može pročitati odgovor. `CORS_ORIGINS` navodi web-podrijetla (origins) čije stranice smiju pozivati API iz preglednika, svako točno onako kako ga preglednici šalju u `Origin`: `https://host` ili `https://host:port`, malim slovima, bez putanje, kose crte na kraju i zadanog porta; `http://` samo za `localhost`, `127.0.0.1` i `[::1]` (lokalni razvoj); bez zamjenskih znakova (wildcard). Službeni poslužitelj postavlja `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com` za svoje web-mjesto. Tada:
      - Svaki odgovor za putanju pod `/api/v1` osim krajnjih točaka za provjeru stanja, i uspješan i pogreška (uključujući 429 sloja po adresi), nosi `Vary: Origin`. Kad je `Origin` zahtjeva bajt po bajt jednak jednom od navedenih podrijetla, odgovor nosi i `Access-Control-Allow-Origin: <that origin>` te, osim u odgovoru na preflight zahtjev, `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, kako bi stranica mogla pročitati kodove pogrešaka, `Retry-After` i nazive datoteka preuzetih PGN-ova i GIF-ova.
      - Na preflight zahtjev (`OPTIONS` s `Access-Control-Request-Method`) s navedenog podrijetla na postojeću putanju odgovor je 204, bez tijela, sa `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (metode iz `Allow`), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600` i `Vary: Origin`. Ne treba mu token i ne računa se ni u kvotu računa ni u ograničenje krajnje točke; kao svaki zahtjev, uzima jedan žeton sloja po adresi. Preglednik ponovno koristi preflight 10 minuta, ali samo za točno taj URL, uključujući upitni niz. Zahtjevu bez `Authorization` i bez JSON tijela, kao što su `GET /info` ili `GET /leaderboard`, preflight nije potreban.
      - Svaki drugi odgovor na preflight zahtjev neuspjeli je preflight, čak i sa zaglavljima CORS: 404 za putanju koja ne postoji ili 429 sloja po adresi. Preglednik tada ne šalje zahtjev, a stranica vidi mrežnu pogrešku, bez odgovora i njegova `Retry-After`.
      - Nikad se ne šalje `Access-Control-Allow-Credentials`, kao ni `Access-Control-Allow-Origin: *`: stranica šalje svoj token sesije u `Authorization: Bearer`, kao i igra, a API ne postavlja nijedan kolačić. HTML stranice, krajnje točke za provjeru stanja i prazni odgovori na nečitljive zahtjeve ne nose nijedno zaglavlje CORS. WebSocket ne koristi CORS: `WS_ALLOWED_ORIGINS` navodi stranice koje ga smiju otvoriti.
      - Stranica podrijetla koje nije navedeno ne čita ništa, a njezini zahtjevi za pisanje prema drugom web-mjestu (cross-site) nikad se ne šalju: JSON tijela i `Authorization` zahtijevaju preflight zahtjev, čiji odgovor nema `Access-Control-Allow-Origin` za to podrijetlo. `Cross-Origin-Resource-Policy: same-origin` ostaje na svakom odgovoru: primjenjuje se samo na zahtjeve `no-cors` (drugo web-mjesto ugrađuje odgovor), nikad na zahtjeve `cors` navedene stranice.

    ## Odgovori i pogreške

    - Odgovori su JSON u UTF-8, osim preuzimanja PGN-a (`application/x-chess-pgn`), animiranih GIF-ova (`image/gif`) i HTML stranica. Vremena su u milisekundama od 1970-01-01 UTC. Identifikatori (id) su cijeli brojevi. Identifikatori partija imaju do 16 znamenki, ali ostaju ispod 2^53, pa ih JSON broj (double) prikazuje točno.
    - Svaki odgovor API-ja nosi `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` i `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (HTML stranice imaju vlastitu politiku). Uz `TLS_MODE=native` nosi i `Strict-Transport-Security: max-age=31536000`. Odgovori koje slušatelj piše sam, prije API-ja (odgovori na `GET` i `HEAD` krajnjih točaka za provjeru stanja, 429 sloja po adresi i 500 `internal_error` kad API potpuno zakaže), nose ista zaglavlja osim `Cross-Origin-Resource-Policy`, a njihov je `Content-Type` `application/json` bez skupa znakova (charset).
    - Odgovor se mora pročitati u roku od 60 sekundi od trenutka kad ga poslužitelj ima spremnog, što je važno samo za velike odgovore (GIF, izvoz podataka, dugačak PGN). Vrijeme koje poslužitelju treba za pripremu odgovora ne računa se ni u to ni u 30 sekundi bez ijednog primljenog ili poslanog bajta nakon kojih se veza zatvara.

    Pogreške imaju jedan oblik (shema `Error`):

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

    `message` je namijenjen zapisima u dnevnik (logovima) i kao rezervni tekst; klijent odabire što će prikazati prema `error`. `retryAfter` (u sekundama) prisutan je samo kod odbijanja koja s vremenom prestaju, a odgovor tada nosi i zaglavlje `Retry-After` s istom vrijednošću; jedina je iznimka 503 `busy` kod čitanja povijesti, partija, igrača i ljestvice, koji ima samo polje. Neke pogreške dodaju polja: `field` (neispravan unos), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` i `column` (`invalid_pgn`).

    Pogreške koje može vratiti svaka krajnja točka:

    | Status | `error` | Kada |
    |---|---|---|
    | 400 | `invalid_request` | Tijelo krši shemu krajnje točke (`field` kaže gdje), cilj zahtjeva ili `Content-Length` neispravnog su oblika, parametar putanje nije valjano URL kodiran ili je tijelo prekinuto. |
    | 400 | `invalid_json` | Tijelo nije JSON. |
    | 401 | `unauthorized` | Nema zaglavlja `Authorization` na krajnjoj točki koja zahtijeva sesiju. |
    | 401 | `invalid_token` | Token je neispravnog oblika, istekao, opozvan ili pripada izbrisanom računu; vraća se i na krajnjim točkama na kojima je sesija neobavezna. |
    | 404 | `not_found` | Takva krajnja točka ne postoji. Koriste ga i neke krajnje točke: takva partija, igrač ili sesija ne postoji. |
    | 405 | `method_not_allowed` | Putanja postoji za druge metode (vidi `Allow`). |
    | 408 | `request_timeout` | Tijelo nije stiglo u roku od 10 sekundi. |
    | 413 | `payload_too_large` | Tijelo premašuje `HTTP_BODY_LIMIT` (135.168 bajtova za `POST /gif`). |
    | 414 | `uri_too_long` | Cilj zahtjeva premašuje 4096 znakova. |
    | 415 | `unsupported_media_type` | Tijelo nije `application/json` ili njegov skup znakova nije UTF-8. |
    | 429 | `rate_limited` | Ograničenje učestalosti (vidi u nastavku): `retryAfter` i zaglavlje `Retry-After`. |
    | 500 | `internal_error` | Neočekivana pogreška. Poslužitelj je bilježi u dnevnik. |
    | 503 | `timeout` | Poslužitelj nije odgovorio u roku od 30 sekundi (60 sekundi za izvoz, 45 sekundi za GIF-ove uz zadane postavke). |
    | 503 | `server_busy` | Dohvat sesije ili promjena računa naišli su na zaključanu bazu podataka (`retryAfter` 1) ili je red za hashiranje lozinki pun (vidi u nastavku). Također, s `retryAfter` od 2 do 5, kad poslužitelj jako kasni: za `POST`, `PUT`, `PATCH` ili `DELETE` baza podataka ima previše zapisivanja na čekanju; za bilo koji zahtjev previše se ranijih zahtjeva još izvršava nakon isteka svog vremenskog ograničenja ili se token sesije nije mogao provjeriti u roku od 5 sekundi. Zahtjev nije izvršen, a njegova ograničenja učestalosti nisu uzela ništa. |

    Krajnje točke za čitanje (povijest partija, partije, igrači, ljestvica) i izvoz odgovaraju s 503 `busy` i `retryAfter: 1` kad je baza podataka ostala zaključana.

    Krajnje točke koje provjeravaju ili hashiraju lozinku mogu odgovoriti i jednim od ovih odgovora, oba sa slučajnim `retryAfter` od 5 do 15 sekundi:

    - 503 `server_busy`: red za hashiranje lozinki poslužitelja (`PASSWORD_HASH_QUEUE_MAX`) je pun ili je čekanje isteklo.
    - 429 `rate_limited`: red je barem do pola pun, a ovaj klijent (IPv4 adresa ili IPv6 /48) već ima `PASSWORD_HASH_WAITERS_PER_SOURCE` hashiranja na čekanju. Ovaj 429 vraća žetone ograničenja učestalosti koje je zahtjev uzeo.

    Ništa nije promijenjeno i nijedan neuspjeli pokušaj nije ubrojen (osim kod `POST /auth/sso/google/link`, kod kojeg su pokušaj s tiketom i neuspjeh računa ubrojeni već prije hashiranja), a poveznica za ponovno postavljanje lozinke ostaje valjana.

    HTML stranice svoje pogreške vraćaju kao HTML stranice s istim statusnim kodovima.

    ## Autentifikacija

    Krajnje točke koje zahtijevaju sesiju primaju token nositelja (bearer token) u zaglavlju `Authorization` (shema `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Dobivanje tokena.** Token (`sct_` nakon kojeg slijede 43 znaka base64url) dolazi od `POST /auth/login`, zatim od `POST /auth/login/mfa` kad je dvofaktorska provjera uključena, od `POST /auth/sso/google/finish` i `POST /auth/sso/google/link` (prijava putem Googlea) te od `POST /auth/sso/complete`. Svaka od njih odgovara s `{ token, expiresAt, user }`. Poslužitelj sprema samo SHA-256 sažetak tokena.
    - **Obavezna sesija.** Na nedostajuće zaglavlje odgovor je 401 `unauthorized` s `WWW-Authenticate: Bearer realm="scacelith"`. Na nevaljani token odgovor je 401 `invalid_token` s `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Klijent treba zaboraviti token koji dobije `invalid_token` i ponovno se prijaviti.
    - **Neobavezna sesija.** Na `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` i `GET /players/{username}/games` sesija je neobavezna. Bez zaglavlja one odgovaraju javnim prikazom; ako se zaglavlje pošalje, njegov token mora biti valjan.
    - **Zauzeta pohrana.** Kad baza podataka ostane zaključana dok se token provjerava, krajnja točka koja zahtijeva ili prihvaća sesiju odgovara s 503 `server_busy` i `retryAfter: 1` (te `Retry-After`); kad provjera traje dulje od 5 sekundi, odgovara s 503 `server_busy` i `retryAfter` od 2 do 5. U oba slučaja o tokenu nije odlučeno: zadržite ga i ponovno pošaljite zahtjev.
    - **Trajanje.** Sesija završava kad nastupi prvo od dvoga: `SESSION_MAX_DAYS` (90) dana nakon prijave (to je `expiresAt`) ili `SESSION_IDLE_DAYS` (30) dana bez upotrebe. Svaka upotreba pomiče granicu neaktivnosti; poslužitelj novu vrijednost zapisuje najviše svakih 5 minuta, u pozadini. Dok njegova baza podataka ima mnogo zapisivanja na čekanju, poslužitelj to zapisivanje odgađa na neki kasniji zahtjev, osim u posljednjem satu prije granice neaktivnosti, pa sesija koja se koristi ne istječe. Račun zadržava najviše `MAX_SESSIONS_PER_USER` (10) sesija: nova prijava opoziva najstarije iznad tog broja.
    - **Opoziv.** Odjava (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) opoziva sesije; isto čine promjena lozinke (ostale sesije), ponovno postavljanje lozinke i brisanje računa (sve sesije) te administrator. Opoziv putem API-ja djeluje odmah, a WebSocket otvoren opozvanom sesijom zatvara se. Promjena ili ponovno postavljanje lozinke i brisanje računa opozivaju sesije u istoj transakciji kao i sama promjena: kad neka od njih ne uspije, nijedna sesija nije opozvana i ništa drugo nije promijenjeno. Poslužitelj rezultate dohvata sesija drži u predmemoriji 30 sekundi, pa opoziv administratorskom naredbom, koja je zaseban proces, djeluje u roku od 30 sekundi.
    - **Opseg.** Token pripada jednom poslužitelju i otvara i njegov WebSocket. Nikad ga ne šaljite drugom poslužitelju.

    ## Ograničenja učestalosti i druga ograničenja

    Ograničenja se primjenjuju na jedan od dvaju opsega. **Klijent:** IPv4 adresa ili IPv6 /64 (u načinu rada s proxyjem adresa dolazi iz `X-Forwarded-For` koji šalje adresa iz `TRUSTED_PROXIES`); neka ograničenja broje i svaku IPv6 /48 kao cjelinu, uz svaku od njezinih /64 mreža. **Igrač:** prijavljeni račun, bez obzira na adresu; ograničenje koje se broji po igraču na krajnjoj točki na kojoj je sesija neobavezna broji se po klijentu za zahtjev bez tokena.

    Svako je ograničenje spremnik žetona (token bucket) koji prima `limit` zahtjeva i neprekidno se puni brzinom `limit / window`; `retryAfter` je vrijeme do sljedećeg žetona. Ograničenja označena kao *dijeljena* broje se i u kliznom prozoru iste duljine, tako da nijedan prozor ne sadrži mnogo više od `limit` zahtjeva. Svako se ograničenje broji za cijeli poslužitelj. Kad jedno od ograničenja krajnje točke odbije zahtjev, žetoni koje su njezina druga ograničenja uzela za taj zahtjev vraćaju se. Na odbijanje se odgovara s 429 `rate_limited`, uz `retryAfter` i `Retry-After`.

    - **Sloj po adresi.** Svaki zahtjev (bilo koja putanja i metoda, uključujući krajnje točke za provjeru stanja i nadogradnje na WebSocket) najprije uzima jedan žeton iz kvote svog klijenta: `HTTP_RATE_PER_IP` (600) u minuti s naletom (burst) od pola minute te `HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) za IPv6 /48. Klijent smije imati i najviše `IP_MAX_INFLIGHT` (32 x `WORKERS`) zahtjeva u obradi (iznad toga `retryAfter` 1). Klijent koji nastavlja i nakon odbijanja blokira se: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) odbijanja u jednoj minuti blokiraju ga na 1 minutu, a zatim na 4, 16 i 60 minuta pri svakom novom blokiranju unutar 6 sati. Odbijanje ograničenja `auth`, `auth_*` i `reauth` broji se kao 5; odbijanja ograničenja koja se broje po igraču ne broje se nikad. Adrese u `ABUSE_EXEMPT` preskaču ovaj sloj, ali ne i kvotu računa ni ograničenja krajnjih točaka.
    - **Kvota računa.** Svaki zahtjev koji nosi valjani token sesije računa se i u kvotu svog računa: `USER_RATE_PER_MIN` (120) u minuti za sve krajnje točke zajedno, bez obzira na adresu, s naletom od pola minute.
    - **Ograničenja krajnjih točaka:**

    | Ograničenje | Zadano | Broji se po | Krajnje točke |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 min, dijeljeno | klijentu te `AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) po IPv6 /48 | `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) / sat, dijeljeno | klijentu, triput toliko po IPv6 /48 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / sat, dijeljeno | klijentu, triput toliko po IPv6 /48 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / sat, dijeljeno | klijentu, triput toliko po IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 sata, dijeljeno | klijentu, triput toliko po IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / sat, dijeljeno | klijentu, triput toliko po IPv6 /48 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | brojevi iz `auth`, vlastiti spremnik, dijeljeno | klijentu i IPv6 /48 | `POST /account/password`, `/account/mfa/totp/setup`, `/account/mfa/totp/enable`, `/account/mfa/totp/disable`, `/account/mfa/recovery-codes`, `/account/email`, `/account/export`, `/account/delete` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 min, dijeljeno | igraču | iste krajnje točke kao `reauth` |
    | `account` | 60 / min | igraču | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / min | igraču | `GET /account/games` |
    | `account_export` | 5 / sat, dijeljeno | igraču | `POST /account/export` (provjerava se prije `reauth`; broji se svaki pokušaj) |
    | `sessions` | 60 / min | igraču | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / min | igraču (klijentu bez tokena) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (jedan spremnik za sve četiri) |
    | `gif` | 30 / min | igraču | `GET /games/{id}/gif`, `POST /gif` (jedan spremnik za obje) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / min i `GIF_USER_RENDERS_PER_HOUR` (30) / sat, dijeljeno | igraču | iste dvije, samo kad se GIF mora izraditi (ne iz predmemorije) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / min i `GIF_IP_RENDERS_PER_HOUR` (120) / sat, dijeljeno | klijentu (svi njegovi računi zajedno), triput toliko po IPv6 /48 | iste, kao gore |
    | `reports` | 30 / sat | igraču | `POST /reports` |
    | `sso_start` | 30 / 10 min, dijeljeno | klijentu te 90 po IPv6 /48 | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (zajedno) |
    | `sso_finish` | 30 / min | klijentu | `POST /auth/sso/google/finish` |
    | `page` | 60 / min | klijentu | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    `GET /info` i `GET /leaderboard` nemaju vlastito ograničenje: samo sloj po adresi. Krajnja točka s više ograničenja provjerava ih redoslijedom navedenim u svom opisu.

    Poslužitelj obrađuje zahtjev ovim redoslijedom: sloj po adresi, zatim krajnje točke za provjeru stanja; pronalaženje krajnje točke; provjere da poslužitelj ne kasni jako (503 `server_busy`): previše ranijih zahtjeva koji se još izvršavaju nakon isteka svog vremenskog ograničenja te, za `POST`, `PUT`, `PATCH` ili `DELETE`, zaostatak zapisivanja u bazu podataka; autentifikacija, u roku od 5 sekundi (iznad toga 503 `server_busy`); kvota računa, kad zahtjev nosi sesiju; ograničenja krajnje točke; tijelo; sama krajnja točka (krajnje točke za GIF uzimaju svoja ograničenja iscrtavanja tek kad moraju izraditi GIF). Tako zahtjev odbijen zbog svog tokena ne troši nijedan žeton krajnje točke, a zahtjev s nevaljanim tijelom troši ih.

    Druga ograničenja, na koja odgovaraju same krajnje točke:

    - **Neuspjele prijave s jednim imenom za prijavu** (korisničko ime ili e-pošta): od `AUTH_FAILURES_PER_ACCOUNT` (5) neuspjeha nadalje svaki pokušaj mora čekati dvostruko dulje od prethodnog (2 s, 4 s i tako dalje, do 15 minuta): 429 `too_many_attempts` s `retryAfter`. Brojač se briše nakon sat vremena bez neuspjeha.
    - **Neuspjeli drugi faktori pri prijavi:** isto pravilo od 5. pogrešnog koda računa. Jedan korak prijave prihvaća najviše 5 pogrešnih kodova.
    - **Kodovi drugog faktora jednog računa:** najviše `AUTH_MFA_PER_ACCOUNT` (10) kodova (iz aplikacije za autentifikaciju ili za oporavak, točnih ili pogrešnih) u 15 minuta, s bilo koje adrese, pri prijavi i pri ponovnim autentifikacijama; iznad toga 429 `too_many_attempts` prije provjere koda, pa se kod za oporavak ne troši.
    - **Neuspjele ponovne autentifikacije računa** (pogrešna lozinka ili kod): isto pravilo (429 `too_many_attempts`), zajedničko svim krajnjim točkama koje traže ponovnu autentifikaciju.
    - **E-poruke:** jedna e-poruka za potvrdu ili za ponovno postavljanje lozinke po adresi svakih 5 minuta (odgovor ostaje isti); jedna obavijest „netko je pokušao upotrijebiti vašu adresu“ po adresi na sat.
    - **Prijave protivnika:** `REPORTS_PER_DAY` (5) po igraču u 24 sata: 429 `report_limit`.

    ## Dokaz rada

    `POST /auth/register` uvijek zahtijeva dokaz rada kad je `POW_REGISTER_BITS` veći od 0 (prema zadanim postavkama 18; `GET /info` daje ga kao `pow.register`). `POST /auth/login` i `POST /auth/sso/google/link` (jedna vrsta izazova za obje) zahtijevaju ga samo 5 minuta nakon što poslužitelj uoči val neuspjelih prijava (`POW_LOGIN_TRIGGER_PER_MIN`, zatim `POW_LOGIN_BITS`); klijent to saznaje iz odgovora.

    1. Na zahtjev bez dokaza (ili s odbijenim dokazom) odgovor je 428 `pow_required` s `reason` i izazovom `pow` `{ challenge, bits, expiresAt }`.
    2. Pronađite nonce: decimalni niz od najviše 20 znamenki takav da `SHA-256(challenge + ":" + nonce)` počinje s `bits` nultih bitova (najprije najznačajniji bit prvog bajta). Za 18 bitova potrebno je u prosjeku oko 260.000 hashiranja.
    3. Ponovno pošaljite isti zahtjev s `"pow": { "challenge": "...", "nonce": "123456" }` u tijelu.

    Izazov vrijedi 2 minute i samo jednom, za jednu krajnju točku i jednu mrežu klijenta (IPv4 adresa ili IPv6 /64). Potpisan je, pa poslužitelj o njemu ne čuva ništa dok se ne vrati. `reason` kaže zašto je dokaz odbijen: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` ili `replayed`.

    ## Ponovna autentifikacija

    Promjene računa ponovno traže lozinku i, kad je dvofaktorska provjera uključena, drugi faktor:

    - samo lozinku: `POST /account/password` i `POST /account/mfa/totp/setup`;
    - lozinku i kod iz aplikacije, pri čemu se kod za oporavak odbija: `POST /account/mfa/recovery-codes`;
    - lozinku i kod iz aplikacije ili kod za oporavak: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` i `POST /account/delete`.

    U tijelima `code` sadrži šesteroznamenkasti kod iz aplikacije za autentifikaciju, a `recoveryCode` kod za oporavak (`xxxx-xxxx-xx`; velika i mala slova, razmaci i crtice nisu važni). Kod za oporavak može se poslati i u `code` ondje gdje se kodovi za oporavak prihvaćaju. Svaki kod vrijedi jednom: upotrijebljeni kod iz aplikacije odbija se do sljedećeg 30-sekundnog koraka, a kod za oporavak nestaje čim se upotrijebi.

    | Status | `error` | Kada |
    |---|---|---|
    | 403 | `invalid_password` | Pogrešna lozinka ili je lozinka ponovno postavljena ili promijenjena dok se zahtjev izvršavao (ništa nije promijenjeno). |
    | 403 | `mfa_code_required` | Dvofaktorska provjera je uključena, a nije poslan ni `code` ni `recoveryCode`. |
    | 403 | `invalid_code` | Pogrešan ili već upotrijebljen kod. |
    | 400 | `password_not_set` | Račun samo s prijavom putem Googlea još nema lozinku („Zaboravljena lozinka?“ je postavlja). |
    | 429 | `too_many_attempts` | Previše neuspjeha ili previše isprobanih kodova na ovom računu. |
    | 503 / 429 | `server_busy` / `rate_limited` | Red za hashiranje lozinki je zauzet. |

    Pogrešne lozinke i kodovi broje se u brojaču neuspjeha računa i bilježe se kao sigurnosni događaji.

    ## Port za metrike

    Osim na portu API-ja, poslužitelj odgovara običnim HTTP-om na portu za metrike (`METRICS_PORT`, 9464, vezan na `METRICS_BIND`, prema zadanim postavkama 127.0.0.1; neka ostane privatan). On nije dio ovog API-ja: `GET /healthz` odgovara s `ok`; `GET /readyz` odgovara s `ready` kad je pokretanje dovršeno (svaki je shard ponovno izveo svoj dnevnik (journal), a slušatelji su vezani) i sve dok ne počne gašenje, inače 503 `not ready`; `GET /metrics` poslužuje metrike za Prometheus i, kad je postavljen `METRICS_TOKEN`, zahtijeva `Authorization: Bearer <token>` s točno tim tokenom.
  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: Izvorni kod poslužitelja Scacelith i njegova referentna dokumentacija (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Službeni poslužitelj.
  - url: https://{host}:{port}/api/v1
    description: Bilo koji poslužitelj Scacelith, npr. poslužitelj zajednice.
    variables:
      host:
        default: caissa.scacelith.com
        description: Javni naziv hosta poslužitelja (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: Javni port API-ja (`PUBLIC_API_PORT`, inače `API_PORT`).
tags:
  - name: server-info
    x-displayName: Podaci o poslužitelju
    description: Ono što klijent treba znati prije nego što se prijavi ili poveže.
  - name: health
    x-displayName: Provjera stanja
    description: |-
      Radi li poslužitelj i je li spreman, za nadzor. Ove krajnje točke odgovaraju na portu API-ja prije autentifikacije i svih ograničenja krajnjih točaka, ali kao svaki zahtjev uzimaju jedan žeton sloja po adresi; host za nadzor može se navesti u `ABUSE_EXEMPT`. Za svaku krajnju točku rade obje putanje: u korijenu poslužitelja i pod `/api/v1`.

      Port za metrike ima vlastite krajnje točke za provjeru stanja, opisane u uvodu.
  - name: auth
    x-displayName: Registracija i prijava
    description: |-
      Izrada računa, prijava lozinkom (i drugim faktorom), e-poruke za potvrdu i za ponovno postavljanje lozinke.

      `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` i `POST /auth/sso/complete` odgovaraju sesijom `{ token, expiresAt, user }` (ili, za prve od njih, drugim korakom).
  - name: google-sign-in
    x-displayName: Prijava putem Googlea
    description: |-
      Dostupna je kad `GET /info` kaže `sso.google: true`; inače svaka krajnja točka u nastavku odgovara s 404 `sso_disabled`. Web-mjesto se prijavljuje na isti način vlastitim klijentom kad je `sso.googleWeb` true (u nastavku, **Na web-mjestu**). Igra se prijavljuje preko preglednika sustava tokom za instalirane aplikacije iz RFC 8252 (klijent tipa „Desktop app“): Google vraća preglednik slušatelju igre na `127.0.0.1`, nikad ovom poslužitelju, a igra nikad ne vidi nijednu Googleovu vjerodajnicu.

      1. Klijent sluša na `127.0.0.1:0` (sustav bira port) i izrađuje PKCE par: `codeVerifier` od 43 do 128 znakova `[A-Za-z0-9._~-]` i `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, koji ima 43 znaka i nema dopune (padding).
      2. `POST /auth/sso/google/start` s izazovom i portom vraća Googleov URL i `state` pokušaja. Klijent provjerava URL (u nastavku) i otvara ga u pregledniku.
      3. Google šalje preglednik na `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Klijent prihvaća samo `state` iz odgovora na start i ne šalje ništa nakon preusmjeravanja s `error=`.
      4. `POST /auth/sso/google/finish` s ID-om pokušaja, `codeVerifier`, `state` i `code` (te `iss` kad ga je Google poslao).
      5. Odgovor je sesija, korak dvofaktorske provjere (nastavite s `POST /auth/login/mfa`), `needsUsername` za novog igrača (nastavite s `POST /auth/sso/complete`) ili `needsPassword` kad adresu koristi račun s lozinkom (nastavite s `POST /auth/sso/google/link`).

      **Oznaka podrijetla.** URI za preusmjeravanje nosi oznaku poslužitelja koji je igrač dodao, tako da igra odbija URL koji je neki drugi poslužitelj dobio za svoje igrače. Podrijetlo je host malim slovima (IPv6 literal u uglatim zagradama), `:` i decimalni port API-ja, uvijek napisan, uključujući 443: poslužitelj uzima `SERVER_PUBLIC_HOST` i svoj javni port API-ja, a igra adresu na koju je povezana. Oznaka je prva 22 znaka base64url kodiranja (bez dopune) vrijednosti SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + podrijetlo). URI za preusmjeravanje je `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: uvijek IPv4 literal i port slušatelja igre (od 1024 do 65535). Poslužitelj nikad ne uzima URI, host ni putanju od klijenta.

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

      Stoga prijava putem Googlea radi samo za igrače koji su poslužitelj dodali točno pod `SERVER_PUBLIC_HOST` i s njegovim javnim portom API-ja.

      **Što igra provjerava prije otvaranja preglednika.** `authUrl` počinje točno s `https://accounts.google.com/o/oauth2/v2/auth?` i ispisivi je ASCII kraći od 4096 znakova, a njegov upit sadrži točno jedan `response_type=code`, točno jedan `redirect_uri` jednak URI-ju koji igra izračunava iz svog porta i svoje oznake podrijetla, točno jedan `state` jednak `state` iz odgovora, `code_challenge_method=S256` i `code_challenge` od 43 znaka. U suprotnom igra zaustavlja svog slušatelja i ništa ne otvara. `finish` i `link` šalje samo poslužitelju koji je odgovorio na `start`.

      **Na web-mjestu.** Web-mjesto (službeno: <https://scacelith.com/>) prijavljuje se klijentom tipa „Web application“ istog Google projekta (`GOOGLE_WEB_CLIENT_ID`). Google vraća preglednik na jednu stranicu web-mjesta koju je operater konfigurirao (`GOOGLE_WEB_REDIRECT_URI`, navedena među „Authorized redirect URIs“ klijenta), a ta stranica šalje kod na `finish`; ni ovdje poslužitelj ne uzima URI za preusmjeravanje od klijenta.

      1. Stranica izrađuje PKCE par kao gore, čuva verifikator u kartici (`sessionStorage`) i šalje `POST /auth/sso/google/web/start` s izazovom.
      2. Provjerava `authUrl` kao i igra, osim što njegov `redirect_uri` mora biti stranica njezina vlastitog podrijetla, a zatim prelazi na njega. Ne učitava se nijedna Googleova skripta.
      3. Google šalje preglednik na `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. Stranica prihvaća samo `state` iz odgovora na start i ne šalje ništa nakon preusmjeravanja s `error=`.
      4. `finish`, `link` i `complete` slijede kao za igru. Pokušaj pamti da ga je pokrenulo web-mjesto: kod se razmjenjuje s klijentom i stranicom web-mjesta, a ID token mora navoditi taj klijent (kod ili ID token klijenta igre ne uspijeva s 502 `sso_failed`, i obrnuto).

      Google račun iz igre i s web-mjesta dolazi do istog računa Scacelith: povezivanje sadrži Googleov subjekt (`sub`), koji identificira Google račun bez obzira na klijent. Podrijetlo web-mjesta mora biti u `CORS_ORIGINS` (inače `check-config` upozorava).

      **Do kojeg računa vodi prijava putem Googlea:** do računa koji je već povezan s tim Google računom; inače do aktivnog računa s adresom koju je Google potvrdio (ako ima lozinku, `finish` odgovara s `needsPassword`, a povezivanje se sprema tek kad prođu njegova lozinka, a zatim i drugi faktor ako je uključen; bez lozinke 409 `sso_account_exists`); inače do novog računa (`needsUsername`). Google račun nikad se ne povezuje s postojećim računom samo na temelju adrese. Na adresu računa stiže e-poruka kad prijava putem Googlea izradi račun i kad se doda postojećem računu.
  - name: sessions
    x-displayName: Sesije
    description: Prijavljeni uređaji računa i odjava.
  - name: account
    x-displayName: Račun
    description: Prikaz računa, njegove postavke i lozinka.
  - name: two-step-verification
    x-displayName: Dvofaktorska provjera
    description: Aplikacije za autentifikaciju (TOTP, RFC 6238), uz SHA-1, 6 znamenki, korake od 30 sekundi i toleranciju od jednog koraka u oba smjera, te jednokratni kodovi za oporavak.
  - name: email-change
    x-displayName: Promjena adrese e-pošte
    description: Promjena adrese računa, potvrđena poveznicom poslanom na novu adresu.
  - name: data-export
    x-displayName: Izvoz podataka
    description: Sve što poslužitelj čuva o računu, kao jedna JSON datoteka.
  - name: account-deletion
    x-displayName: Brisanje računa
    description: Trajno brisanje računa.
  - name: game-history
    x-displayName: Povijest partija
    description: Vlastite partije prijavljenog igrača, filtrirane i podijeljene na stranice.
  - name: games
    x-displayName: Partije i PGN
    description: |-
      Zapisi partija s potezima i satovima te njihove PGN datoteke.

      **Kodovi partija.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (spremaju se samo završene partije). `result`: `1-0`, `0-1`, `1/2-1/2` ili `*` (poništena). `reason`, s nazivom (`termination`) i riječima kojima završava tekst poteza u PGN datoteci; kodovi 7, 14 i 21 su remiji (igrač kojem je isteklo vrijeme, koji je predao ili koji je napustio partiju igrao je protiv protivnika koji nije mogao dati mat: FIDE 6.9 i 5.1.2; `DESIGN.md` 6.3 navodi pravilo), a poslužitelj nikad ne završava partiju kodovima 4 i 13 (pripadaju zajedničkom popisu razloga):

      | Kod | `termination` | Riječi u PGN-u |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (mat) |
      | 2 | `Resignation` | Resignation (predaja) |
      | 3 | `Timeout` | Loss on time (poraz zbog isteka vremena) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (drugi nepravilan potez, poraz) |
      | 5 | `Stalemate` | Stalemate (pat) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (mrtva pozicija, nedovoljno materijala) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (isteklo vrijeme, ali protivnik ne može dati mat) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (peterostruko ponavljanje pozicije) |
      | 9 | `SeventyFiveMoves` | 75-move rule (pravilo 75 poteza) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (trostruko ponavljanje pozicije, na zahtjev) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (pravilo 50 poteza, na zahtjev) |
      | 12 | `Agreement` | Draw by agreement (remi dogovorom) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (drugi nepravilan potez, ali protivnik ne može dati mat) |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate (predaja, ali protivnik ne može dati mat) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (napuštena partija: predugo bez veze) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (napuštena partija, ali protivnik ne može dati mat) |
      | 22 | `Aborted` | Game aborted (partija poništena) |
      | 23 | `NoShow` | Aborted: first move not played in time (poništeno: prvi potez nije odigran na vrijeme) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (kontumacija zbog kršenja pravila poštene igre) |
      | 25 | `ServerAborted` | Aborted by the server (poslužitelj je poništio partiju) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (poništeno: oba su igrača izgubila vezu) |

      Kod 14 nov je u manjoj verziji (minor) 1 protokola: klijent napisan prije nje treba nepoznati kod prikazati kao običan kraj partije s njegovim `result`.
  - name: gifs
    x-displayName: Animirani GIF-ovi
    description: |-
      Partija kao animirani GIF, za čuvanje ili dijeljenje: ploča gledana odozgo, jedan okvir po poziciji od početne do završne pozicije, imena i rejtinzi igrača iznad ploče, posljednji potez ispod nje, a na posljednjem okviru rezultat i način na koji je partija završila.

      **Trošak, predmemorija i kvote.** GIF se izrađuje u vlastitoj dretvi za iscrtavanje, nikad u dretvama koje vode partije, i uz najniži prioritet CPU-a: `GIF_THREADS` dretvi (prema zadanim postavkama `WORKERS`), koje se pokreću s prvim GIF-om i zaustavljaju nakon minute bez ijednog GIF-a. Do `GIF_QUEUE_MAX` (4 x `WORKERS`) GIF-ova čeka na dretvu, svaki najviše `GIF_QUEUE_TIMEOUT_MS` (10 s); iscrtavanje smije trajati `GIF_RENDER_TIMEOUT_MS` (30 s). Poslužitelj GIF-ove koje je izradio čuva u predmemoriji od `GIF_CACHE_MB` MB (32 x `WORKERS`), iz koje najprije izlaze najdulje nekorišteni; GIF iz predmemorije ili onaj koji se već izrađuje za drugi zahtjev ne troši iscrtavanje. Ključ predmemorije obuhvaća sve što mijenja sliku, uključujući imena.

      Svaki zahtjev broji se u `gif` (30 u minuti po igraču za obje krajnje točke). GIF koji se mora izraditi broji se i u ograničenja iscrtavanja: po igraču `GIF_USER_RENDERS_PER_MIN` (4) u minuti i `GIF_USER_RENDERS_PER_HOUR` (30) na sat; po klijentu, za sve njegove račune zajedno, `GIF_IP_RENDERS_PER_MIN` (12) u minuti i `GIF_IP_RENDERS_PER_HOUR` (120) na sat, te triput toliko po IPv6 /48. Klijent treba zadržati preuzetu datoteku umjesto da je ponovno traži te nakon 429 ili 503 pričekati `retryAfter`.

      Veličine: `small` (polja od 32 px: 284 x 350 piksela), `medium` (48 px: 424 x 515), `large` (72 px: 628 x 762); bez koordinata 268 x 342, 400 x 503 i 600 x 748. Početna pozicija ostaje najmanje 1 s (ili koliko iznosi odgoda, ako je dulja), završna pozicija 3 s, a GIF se vrti u petlji; nakon prvog okvira sprema se samo dio slike koji se mijenja. Partija od 40 poteza zauzima oko 135, 205 i 325 KiB (small, medium, large), a partija od 150 poteza 0,5; 0,8 i 1,2 MiB.
  - name: players
    x-displayName: Igrači
    description: Javni profili i nedavne partije. Samo javni podaci, nikad adresa e-pošte, sesija, sankcija ili razina integriteta. Izbrisani račun nema profil.
  - name: leaderboard
    x-displayName: Ljestvica
    description: Najbolji igrači svake službene kategorije.
  - name: reports
    x-displayName: Prijave protivnika
    description: Prijava protivnika iz nedavne partije moderatorima.
  - name: pages
    x-displayName: HTML stranice
    description: |-
      Stranice za preglednik, koje se otvaraju iz poveznica u e-porukama. Njihove poveznice vode na `https://<SERVER_PUBLIC_HOST>` (s `:<PUBLIC_API_PORT>` kad port nije 443), izvan `/api/v1`.

      - Stranice ne izvršavaju JavaScript i ne učitavaju nikakve vanjske resurse. Poslužuju se sa `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - `GET` samo prikazuje gumb ili obrazac, kako poveznicu ne bi potrošio skener e-pošte koji je otvori. Promjena se događa na `POST`: obrazac poslan kao `application/x-www-form-urlencoded` (prihvaća se i JSON tijelo), s tokenom poveznice u skrivenom polju. Polje navedeno dvaput odbija se.
      - I pogreške (ograničenja učestalosti, nevaljana polja) su HTML stranice, s naslovom „Request refused“ (zahtjev odbijen) ili, od 500 naviše, „Server error“ (pogreška poslužitelja). Samo pogreške otkrivene prije nego što se pronađe stranica (predugačak ili neispravan cilj zahtjeva, sloj po adresi) vraćaju se u JSON-u.
x-tagGroups:
  - name: server
    x-displayName: Poslužitelj
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Računi
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Partije
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Igrači i prijave protivnika
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Stranice za preglednik
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Dohvaćanje naziva, verzija, portova i pravila registracije poslužitelja
      description: |-
        Ono što klijentu treba prije nego što se prijavi ili poveže: naziv i id poslužitelja, verzije protokola WebSocketa i gdje se WebSocket nalazi, pravila registracije, službeni tempovi igre i ograničenja koja klijent može provjeriti prije slanja obrasca.

        **Ograničenja:** samo sloj po adresi.
      security: []
      responses:
        '200':
          description: Opis poslužitelja.
          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`: sloj po adresi.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Izrada računa
      description: |-
        Izrađuje račun: kad se njegova adresa e-pošte potvrdi poveznicom poslanom na nju ili odmah, bez potvrde e-pošte.

        **S potvrdom e-pošte** (`REQUIRE_EMAIL_VERIFICATION`, zadano): 202 `verification_sent`. Račun još ne postoji: registracija čeka 24 sata i za to vrijeme drži svoje korisničko ime. Na adresu stiže poveznica valjana tih 24 sata, najviše jedna po adresi svakih 5 minuta (`POST /auth/verify-email/resend` šalje novu); račun se izrađuje, s potvrđenom adresom, kad se poveznica upotrijebi (gumb na stranici `/verify-email`), i igrač se tada može prijaviti. Do tada se na prijavu s tim korisničkim imenom odgovara s `invalid_credentials`, kao za nepoznati račun, a javni profil ne postoji. Nova registracija s istom adresom zamjenjuje onu na čekanju. Odgovor je isti kad adresu već koristi drugi račun: tada se poveznica ne šalje, nego njegov vlasnik dobiva obavijest (najviše jednu na sat), a korisničko ime drži se na isti način, tako da ništa ne otkriva ima li adresa račun. Registracija čija poveznica nije upotrijebljena odbacuje se nakon 24 sata, a njezino korisničko ime ponovno je slobodno.

        **Bez potvrde e-pošte** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, račun se izrađuje odmah i može se prijaviti.

        **Pravila.** `username`: od `USERNAME_MIN` do `USERNAME_MAX` znakova (od 3 do 20), slova, znamenke, `_` i `-`, s početnim slovom ili znamenkom (`GET /info` daje ih kao `limits`); rezervirana imena (`admin`, `moderator`, `deleted`...) i neki prefiksi odbijaju se; jedinstveno bez obzira na velika i mala slova. `email`: bez razmaka na početku i kraju, sprema se malim slovima, običan ASCII s domenom koja sadrži točku. `password`: najmanje `PASSWORD_MIN_LENGTH` (10) znakova i najviše 256 bajtova UTF-8; ne smije sadržavati korisničko ime ni lokalni dio adrese e-pošte i ne smije biti uobičajena lozinka.

        **Pogreške**, provjeravane ovim redoslijedom: 403 `registration_closed`; 400 `invalid_username`; 400 `invalid_email`; 400 `weak_password`; 409 `username_taken`; 428 `pow_required`; pogreške reda za hashiranje lozinki; 409 `email_taken` (samo bez potvrde e-pošte).

        **Ograničenja:** `auth`, zatim `auth_register` (10 registracija na sat po klijentu). **Dokaz rada:** uvijek, kad je `POW_REGISTER_BITS` veći od 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Prvi pokušaj, bez dokaza rada
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Ponovno poslano s dokazom rada
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: Račun je izrađen i može se prijaviti (na ovom poslužitelju nema potvrde e-pošte).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: Registracija čeka svoju poveznicu za potvrdu (ili adresa već ima račun; odgovor to ne otkriva).
          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` (s `field`), `invalid_json`, `invalid_username`, `invalid_email` ili `weak_password` s `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` ili `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: registracija je na ovom poslužitelju zatvorena (`GET /info` kaže `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: neki račun ima to korisničko ime ili ga drži registracija na čekanju s drugom adresom. `email_taken`: adresu koristi drugi račun (samo bez potvrde e-pošte).'
        '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`: ograničenje `auth` ili `auth_register` ili red za hashiranje lozinki.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (red za hashiranje lozinki ili je baza podataka ostala zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Prijava lozinkom
      description: |-
        Prijava korisničkim imenom ili adresom e-pošte i lozinkom. Moguća su dva odgovora, oba sa statusom 200: sesija ili, kad je dvofaktorska provjera uključena, drugi korak koji treba dovršiti u roku od 5 minuta s `POST /auth/login/mfa`.

        Nepoznati račun, pogrešna lozinka i račun bez lozinke dobivaju isti odgovor, nakon jednakog vremena: 401 `invalid_credentials`. Provjere računa (`banned`, `email_unverified`) dolaze tek nakon točne lozinke. Od `AUTH_FAILURES_PER_ACCOUNT` (5) neuspjeha s jednim imenom za prijavu svaki pokušaj mora čekati (429 `too_many_attempts`).

        **Ograničenje:** `auth`. **Dokaz rada:** samo tijekom vala neuspjelih prijava (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: Sesija ili drugi korak prijave s dvofaktorskom provjerom.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Prijava je uspjela
                  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: Dvofaktorska provjera je uključena
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: nepoznati račun, pogrešna lozinka ili račun bez lozinke.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Samo nakon točne lozinke: `banned`, s `until` (ms od Unix epohe, `null` za trajnu suspenziju), ili `email_unverified` (stariji račun čija adresa još nije potvrđena).'
        '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` (odgoda nakon neuspjeha za ovo ime za prijavu) ili `rate_limited` (ograničenje `auth`, red za hashiranje lozinki).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (red za hashiranje lozinki ili je baza podataka ostala zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Dovršetak prijave drugim faktorom
      description: |-
        Drugi korak prijave s dvofaktorskom provjerom, nakon odgovora `mfaRequired` na `POST /auth/login` ili na prijavu putem Googlea (`POST /auth/sso/google/finish` ili `POST /auth/sso/google/link`). Pošaljite `code` (šesteroznamenkasti kod iz aplikacije za autentifikaciju ili kod za oporavak) ili `recoveryCode`. Kod za oporavak upotrijebljen ovdje nestaje.

        Jedan korak prihvaća najviše 5 pogrešnih kodova; korak završava i kad se lozinka ponovno postavi ili promijeni. Nakon koraka lozinke pri povezivanju s Googleom povezivanje se sprema tek kad kod ovdje prođe.

        **Ograničenja:** `auth` te najviše `AUTH_MFA_PER_ACCOUNT` (10) kodova u 15 minuta za račun, s bilo koje adrese; iznad toga kod se ne provjerava, pa se kod za oporavak ne troši.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Kod iz aplikacije za autentifikaciju
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Kod za oporavak
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Prijava je uspjela.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: nije poslan ni `code` ni `recoveryCode` ili tijelo krši shemu; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: korak je istekao, upotrijebljen je ili je završio nakon 5 pogrešnih kodova, ili je lozinka ponovno postavljena ili promijenjena nakon prvog koraka (ponovno se prijavite). `invalid_code`: pogrešan ili već upotrijebljen kod.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (s `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: samo nakon koraka lozinke pri povezivanju s Googleom; Google račun u međuvremenu je povezan s drugim računom.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: samo nakon koraka lozinke pri povezivanju s Googleom; račun se u međuvremenu promijenio; ponovno pokrenite prijavu.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: odgoda nakon neuspjeha računa ili je račun u posljednjih 15 minuta potrošio svojih `AUTH_MFA_PER_ACCOUNT` kodova. `rate_limited`: ograničenje `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` s `retryAfter: 1`: baza podataka ostala je zaključana (nakon koraka povezivanja s Googleom ništa nije povezano: ponovno pokrenite prijavu). `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Odjava ove sesije
      description: |-
        Opoziva sesiju upotrijebljenog tokena. WebSocket otvoren njome zatvara se. Bez tijela (ili `{}`).

        **Ograničenje:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Odjava je uspjela.
          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`: tijelo različito od `{}`; `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`: ograničenje `sessions` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza podataka ostala je zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Odjava svih sesija
      description: |-
        Opoziva sve sesije računa, uključujući ovu. WebSocketi otvoreni njima zatvaraju se. Bez tijela (ili `{}`).

        **Ograničenje:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Sve su sesije odjavljene.
          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`: tijelo različito od `{}`; `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`: ograničenje `sessions` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza podataka ostala je zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Ponovno slanje poveznice za potvrdu
      description: |-
        Ponovno šalje poveznicu za potvrdu adrese e-pošte. Odgovor je 202 `accepted` bez obzira na adresu, tako da nikad ne otkriva koristi li je neki račun ili registracija.

        Zahtjev djeluje najviše jednom svakih 5 minuta po adresi. Registracija na čekanju s tom adresom ponovno dobiva svojih 24 sata, bez obzira na to koristi li adresu drugi račun, tako da njezino korisničko ime u oba slučaja ostaje zadržano jednako dugo. Poveznica se šalje samo za tu registraciju kad adresa nema račun (nova poveznica, valjana 24 sata, zamjenjuje prethodnu) ili za aktivni, nepotvrđeni račun s tom adresom.

        **Ograničenja:** `auth`, zatim `auth_mail` (10 na sat po klijentu).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Prihvaćeno (bez obzira na adresu).
          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`: ograničenje `auth` ili `auth_mail` (bez obzira na adresu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` s `retryAfter: 1`: baza podataka ostala je zaključana dok se tražio račun ili spremala poveznica nepotvrđenog računa (obnova registracije na čekanju izvodi se prema mogućnostima i nikad ne uzrokuje neuspjeh zahtjeva); 5 minuta za adresu ipak počinje teći, pa novi zahtjev unutar njih ne čini ništa. `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Slanje poveznice za ponovno postavljanje lozinke
      description: |-
        Šalje poveznicu za ponovno postavljanje lozinke, valjanu jedan sat, koja otvara stranicu `/reset-password`. Odgovor je 202 `accepted` bez obzira na adresu. Poveznica se šalje samo aktivnom računu, najviše jednom svakih 5 minuta po adresi. Račun samo s prijavom putem Googlea na ovaj način postavlja svoju prvu lozinku.

        Oporavak lozinke ima najstroža ograničenja API-ja, sva brojena za cijeli poslužitelj: 3 zahtjeva na sat (`AUTH_FORGOT_PER_HOUR`) i 10 u 24 sata (`AUTH_FORGOT_PER_DAY`) po klijentu (IPv4 adresa ili IPv6 /64), triput toliko po IPv6 /48, uz ograničenje `auth` (20 u 10 minuta) i jednu e-poruku po adresi svakih 5 minuta. Ni 202 ni 429 ne otkrivaju koristi li adresu neki račun. Postavljanje nove lozinke ima vlastito ograničenje (`auth_reset`).

        **Ograničenja:** `auth`, `auth_forgot`, zatim `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Prihvaćeno (bez obzira na adresu).
          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`: ograničenje `auth`, `auth_forgot` ili `auth_forgot_day` (bez obzira na adresu).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` s `retryAfter: 1`: baza podataka ostala je zaključana dok se tražio račun ili spremala poveznica; 5 minuta za adresu ipak počinje teći, pa novi zahtjev unutar njih ne šalje poveznicu. `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Postavljanje nove lozinke tokenom iz poveznice za ponovno postavljanje
      description: |-
        Postavlja novu lozinku tokenom iz poveznice za ponovno postavljanje lozinke (stranica `/reset-password` čini isto). Nova lozinka slijedi pravila registracije.

        Ponovno postavljanje ima ove učinke, u jednoj transakciji (svi ili nijedan): opoziva sve sesije i otkazuje promjenu e-pošte na čekanju; ostale poveznice za ponovno postavljanje lozinke tog računa prestaju vrijediti; adresa se smatra potvrđenom (poveznica je to dokazala); vlasnik dobiva e-poruku. Dvofaktorska provjera ostaje netaknuta. Nakon pogreške reda za hashiranje lozinki, 503 ili 500, ništa se nije promijenilo: stara lozinka i sesije ostaju, a poveznica ostaje valjana.

        **Ograničenja:** `auth`, zatim `auth_reset` (10 na sat po klijentu, 30 po IPv6 /48, zajedničko sa stranicom: svaki pokušaj hashira lozinku).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Lozinka je promijenjena i sve su sesije odjavljene.
          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`: poveznica je nevaljana, upotrijebljena ili istekla, ili je poslana na adresu koju račun više nema. `weak_password` (s `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`: ograničenje `auth` ili `auth_reset` ili red za hashiranje lozinki.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: red za hashiranje lozinki ili je baza podataka ostala zaključana (`retryAfter: 1`, ništa nije promijenjeno). `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Pokretanje prijave putem Googlea
      description: |-
        Pokreće pokušaj prijave putem Googlea za PKCE izazov i port slušatelja igre na `127.0.0.1`. Odgovor daje Googleov URL koji treba otvoriti u pregledniku sustava i `state` pokušaja; pokušaj vrijedi 10 minuta.

        `authUrl` sadrži `client_id`, `redirect_uri` (sastavljen od `redirectPort` i oznake podrijetla poslužitelja), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (S256 vlastitog verifikatora poslužitelja za Google) s `code_challenge_method=S256` te `prompt=select_account`. Igra ga provjerava prije nego što ga otvori (vidi opis odjeljka).

        **Ograničenje:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: Pokušaj.
          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`: prijava putem Googlea nije ponuđena na ovom poslužitelju.'
        '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`: ograničenje `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza podataka ostala je zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Pokretanje prijave putem Googlea na web-mjestu
      description: |-
        Pokreće pokušaj prijave putem Googlea s web-mjesta za PKCE izazov njegove stranice. Odgovor je isti kao za igru: Googleov URL na koji treba prijeći i `state` pokušaja; pokušaj vrijedi 10 minuta. Ponuđeno kad `GET /info` kaže `sso.googleWeb: true`.

        `authUrl` sadrži `client_id` web-mjesta i `redirect_uri=GOOGLE_WEB_REDIRECT_URI`, s istim ostalim parametrima kao za igru. Stranica ga provjerava prije nego što prijeđe na njega (vidi **Na web-mjestu** u opisu odjeljka).

        **Ograničenje:** `sso_start`, zajedničko s pokretanjem iz igre.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      responses:
        '200':
          description: Pokušaj.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoStartAnswer'
              example:
                attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
                authUrl: https://accounts.google.com/o/oauth2/v2/auth?client_id=1234567890-web.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fscacelith.com%2Faccount%2Fgoogle%2F&response_type=code&scope=openid%20email%20profile&state=yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ&nonce=gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU&code_challenge=ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc&code_challenge_method=S256&prompt=select_account
                state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
                expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: `codeChallenge` nedostaje ili je neispravnog oblika, ili je poslano bilo koje drugo polje (uključujući `redirectPort`).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: prijava putem Googlea nije ponuđena na ovom poslužitelju ili nije ponuđena web-mjestima.'
        '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`: ograničenje `sso_start` (zajedničko s pokretanjem iz igre).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza podataka ostala je zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Prosljeđivanje Googleova odgovora poslužitelju
      description: |-
        Prosljeđuje poslužitelju `code` i `state` koje je Google poslao slušatelju igre ili stranici web-mjesta, zajedno s ID-om pokušaja i PKCE verifikatorom. Id pokušaja beskoristan je bez verifikatora, a kod je beskoristan bez vlastitog PKCE verifikatora poslužitelja i tajne klijenta.

        Poslužitelj provjerava pokušaj (nepoznat, upotrijebljen ili istekao: 410), zatim verifikator (pogrešan verifikator ostavlja pokušaj upotrebljivim), zatim troši pokušaj (jednokratno), provjerava `state` i `iss`, razmjenjuje kod s Googleom i provjerava ID token. Odgovor (200) je jedno od sljedećeg:

        - `{ token, expiresAt, user }`: prijava u povezani račun je uspjela;
        - `{ mfaRequired, mfaToken, expiresIn }`: nastavite s `POST /auth/login/mfa`;
        - `{ needsUsername, ssoTicket, suggestedUsername }`: novi račun; nastavite s `POST /auth/sso/complete` u roku od 10 minuta. `suggestedUsername` dolazi iz imena na Googleu ili iz adrese, a `""` je kad ništa ne odgovara;
        - `{ needsPassword, linkTicket, username, expiresIn }`: račun s tom adresom ima lozinku; nastavite s `POST /auth/sso/google/link`. Još ništa nije povezano.

        **Ograničenje:** `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: Sesija, drugi korak ili sljedeći korak prve prijave putem Googlea.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Prijava u povezani račun je uspjela
                  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: Dvofaktorska provjera je uključena
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Novi igrač
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Adresu koristi račun s lozinkom
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: verifikator ne odgovara izazovu pokušaja (pokušaj ostaje upotrebljiv). `sso_email_unverified`: Google nije potvrdio adresu. `registration_closed`. `account_disabled`. Samo za povezani račun: `banned` (s `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: prijava putem Googlea nije ponuđena na ovom poslužitelju.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: adresu koristi aktivni račun bez lozinke (ne navodi se koji).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: nepoznat, upotrijebljen ili istekao pokušaj.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: ograničenje `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza podataka ostala je zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Povezivanje Googlea s postojećim računom pomoću njegove lozinke
      description: |-
        Nakon što je `finish` odgovorio s `needsPassword`: povezuje Google s postojećim računom pomoću lozinke tog računa, upisane u igri. Odgovor (200) je sesija (povezivanje je spremljeno i igrač je prijavljen) ili, kad je dvofaktorska provjera uključena, `{ mfaRequired, mfaToken, expiresIn }`: nastavite s `POST /auth/login/mfa`, a povezivanje se sprema tek kad ondje prođe kod.

        Pogrešna lozinka ostavlja tiket upotrebljivim, ukupno za 5 pokušaja. Pokušaj se troši neposredno prije provjere lozinke: 429 `too_many_attempts` ili 428 `pow_required` ne troše nijedan, dok je odbijanje reda za hashiranje lozinki (503 `server_busy`, 429 `rate_limited`) već potrošilo jedan i broji se kao neuspjeh računa. Odgoda nakon neuspjeha isti je brojač kao kod `POST /auth/login`.

        **Ograničenje:** `auth` (s brojanjem po IPv6 /48). **Dokaz rada:** kao za `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: Sesija ili drugi korak prijave.
          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`: pogrešna lozinka; tiket ostaje upotrebljiv, ukupno za 5 pokušaja.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (s `until`), tek nakon točne lozinke.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: prijava putem Googlea nije ponuđena na ovom poslužitelju.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: Google račun u međuvremenu je povezan s drugim računom.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: nepoznat, upotrijebljen ili istekao tiket, peta pogrešna lozinka, račun čiji su se status ili adresa promijenili nakon `finish` ili račun čiji su se status, adresa, lozinka ili dvofaktorska provjera promijenili dok se povezivanje spremalo. Ponovno pokrenite prijavu.'
        '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` (odgoda nakon neuspjeha računa) ili `rate_limited` (ograničenje `auth`, red za hashiranje lozinki).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: red za hashiranje lozinki ili je baza podataka ostala zaključana (`retryAfter: 1`, ništa nije povezano). `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Izrada računa pri prvoj prijavi putem Googlea
      description: |-
        Nakon što je `finish` odgovorio s `needsUsername`: izrađuje račun s odabranim korisničkim imenom (vrijede pravila registracije) i prijavljuje ga. Račun nema lozinku: `hasPassword` je `false`, a „Zaboravljena lozinka?“ (`POST /auth/password/forgot`) postavlja mu lozinku.

        **Ograničenje:** `auth` (uključujući njegovo brojanje po IPv6 /48).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoCompleteRequest'
            example:
              ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
              username: alice
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: Račun je izrađen i prijavljen.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`, `invalid_request`, `invalid_json`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: prijava putem Googlea nije ponuđena na ovom poslužitelju.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (neki račun ima to korisničko ime ili ga drži registracija na čekanju s drugom adresom), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: nepoznat, upotrijebljen ili istekao tiket.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: ograničenje `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza podataka ostala je zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Popis prijavljenih uređaja
      description: |-
        Aktivne sesije računa (prijavljeni uređaji), počevši od posljednje korištene. `current` označava sesiju koja šalje zahtjev. `lastSeenAt` se ažurira najviše svakih 5 minuta (i rjeđe dok poslužitelj jako kasni); `expiresAt` je apsolutni kraj, a ograničenje neaktivnosti može sesiju završiti i ranije.

        **Ograničenje:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Aktivne sesije.
          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`: ograničenje `sessions` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza podataka ostala je zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Odjava jednog uređaja
      description: |-
        Odjavljuje jednu sesiju računa; može se odjaviti i trenutna. WebSocket otvoren s tom sesijom zatvara se. Bez tijela (ili `{}`).

        **Ograničenje:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Sesija je odjavljena.
          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`: tijelo različito od `{}` ili `id` koji nije valjano URL kodiran; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: na ovom računu nema aktivne sesije s tim ID-om.'
        '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`: ograničenje `sessions` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza podataka ostala je zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Dohvaćanje računa, njegovih rejtinga i aktivnih sankcija
      description: |-
        Račun onako kako ga vidi njegov igrač: prikaz računa (ujedno `user` svakog odgovora na prijavu), po jedan zapis rejtinga za svaku kategoriju u kojoj je igrač igrao partije za rejting, aktivne sankcije i suspenzija. Razina integriteta iz sustava protiv varanja nikad se ne prikazuje.

        **Ograničenje:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Račun.
          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` ili `invalid_token` (i kad je račun izbrisan).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: ograničenje `account` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza podataka ostala je zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Prihvaćanje ili odbijanje izravnih izazova
      description: |-
        Određuje smiju li drugi igrači izazvati ovog igrača po imenu. Uz `none` izravni se izazovi odbijaju: izazivaču se javlja da igrač nije dostupan. Bez ponovne autentifikacije.

        **Ograničenje:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Postavke koje sada vrijede.
          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`: ograničenje `account` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza podataka ostala je zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Promjena lozinke
      description: |-
        Mijenja lozinku. Potrebna je trenutna lozinka, ali ne i drugi faktor, čak ni kad je dvofaktorska provjera uključena. Nova lozinka podliježe pravilima registracije (provjerava se nakon trenutne lozinke).

        Promjena ima ove učinke, u jednoj transakciji (svi ili nijedan): opoziva sve ostale sesije (ova ostaje prijavljena), otkazuje promjenu e-pošte na čekanju, poveznice za ponovno postavljanje lozinke tog računa prestaju raditi, a vlasniku se šalje e-poruka.

        **Ponovna autentifikacija:** samo lozinka. **Ograničenja:** `reauth`, zatim `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: Lozinka je promijenjena.
          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` (račun samo s prijavom putem Googlea), `weak_password` (s `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: pogrešna trenutna lozinka ili ponovno postavljanje odnosno promjena lozinke koja je stigla dok se zahtjev provjeravao.'
        '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` (neuspjele ponovne autentifikacije računa) ili `rate_limited` (ograničenje `reauth` ili `reauth_user`, kvota računa, red za hashiranje lozinki).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: red za hashiranje lozinki ili je baza podataka ostala zaključana (`retryAfter: 1`, ništa nije promijenjeno). `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Početak uključivanja dvofaktorske provjere
      description: |-
        Sprema novi tajni ključ za autentifikaciju na čekanju, koji zamjenjuje svaki raniji ključ na čekanju, i vraća ga za aplikaciju za autentifikaciju (kao tekst i kao URI `otpauth://` za prikaz u obliku QR koda). Dvofaktorska provjera još nije uključena: `POST /account/mfa/totp/enable` uključuje je kodom iz ovog tajnog ključa.

        **Ponovna autentifikacija:** samo lozinka. **Ograničenja:** `reauth`, zatim `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Tajni ključ na čekanju.
          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` (račun samo s prijavom putem Googlea), `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` (provjerava se prije lozinke).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (neuspjele ponovne autentifikacije računa) ili `rate_limited` (ograničenje `reauth` ili `reauth_user`, kvota računa, red za hashiranje lozinki).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (red za hashiranje lozinki ili je baza podataka ostala zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Dovršetak uključivanja dvofaktorske provjere i dohvaćanje kodova za oporavak
      description: |-
        Uključuje dvofaktorsku provjeru kodom iz tajnog ključa na čekanju (točno 6 znamenki). Ovdje se lozinka ne traži; unesena je pri postavljanju. Odgovor sadrži 10 kodova za oporavak, koji se prikazuju samo ovaj put.

        **Ograničenja:** `reauth`, zatim `reauth_user`; pogrešan kod ubraja se u neuspjele ponovne autentifikacije računa.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Dvofaktorska provjera je uključena.
          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` nema točno 6 znamenki ili tijelo krši shemu; `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: pogrešan kod (provjerite vrijeme na uređaju).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`; `mfa_setup_required`: nema tajnog ključa na čekanju (najprije pozovite `POST /account/mfa/totp/setup`).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (neuspjele ponovne autentifikacije računa) ili `rate_limited` (ograničenje `reauth` ili `reauth_user`, kvota računa).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (baza podataka ostala je zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Isključivanje dvofaktorske provjere
      description: |-
        Isključuje dvofaktorsku provjeru. Tajni ključ i kodovi za oporavak brišu se, a vlasnik dobiva e-poruku.

        **Ponovna autentifikacija:** lozinka i kod iz aplikacije ili kod za oporavak (potrebno je jedno od `code` ili `recoveryCode`). **Ograničenja:** `reauth`, zatim `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: Dvofaktorska provjera je isključena.
          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` (račun samo s prijavom putem Googlea), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (ni `code` ni `recoveryCode`, provjerava se prije lozinke), `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` (neuspjele ponovne autentifikacije ili previše isprobanih kodova na ovom računu) ili `rate_limited` (ograničenje `reauth` ili `reauth_user`, kvota računa, red za hashiranje lozinki).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (red za hashiranje lozinki ili je baza podataka ostala zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Zamjena kodova za oporavak
      description: |-
        Zamjenjuje kodove za oporavak s 10 novih; stari prestaju raditi.

        **Ponovna autentifikacija:** lozinka i kod iz aplikacije u `code` (kod za oporavak odbija se s 403 `invalid_code`). **Ograničenja:** `reauth`, zatim `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: Novi kodovi za oporavak, prikazani samo ovaj put.
          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` (račun samo s prijavom putem Googlea), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (i za kod za oporavak).'
        '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` (neuspjele ponovne autentifikacije ili previše isprobanih kodova na ovom računu) ili `rate_limited` (ograničenje `reauth` ili `reauth_user`, kvota računa, red za hashiranje lozinki).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (red za hashiranje lozinki ili je baza podataka ostala zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Promjena adrese e-pošte
      description: |-
        Mijenja adresu e-pošte računa. Iz `newEmail` uklanjaju se razmaci na početku i kraju, adresa se pretvara u mala slova, a zatim se provjerava kao adresa pri registraciji.

        **S potvrdom e-pošte** (zadano): 202 `verification_sent`. Poveznica valjana 24 sata šalje se na novu adresu; otvara `/confirm-email-change`, a adresa se mijenja tek kad igrač pritisne gumb na toj stranici. Do tada `GET /account/me` prikazuje novu adresu kao `pendingEmail`. Novi zahtjev zamjenjuje onaj na čekanju; promjena ili ponovno postavljanje lozinke otkazuje ga. Na određenu novu adresu šalje se najviše jedna poveznica svakih 5 minuta, bez obzira na to tko je traži (zahtjev za promjenu koja je već na čekanju zadržava ranije poslanu poveznicu, koja ostaje valjana); odgovor je isti. Trenutna adresa dobiva obavijest da je zatražena promjena na maskiranu adresu (`a***@example.org`). Odgovor i `pendingEmail` isti su i kad neki drugi račun već koristi novu adresu: tada se ne šalje nikakva poveznica, pa se ta promjena nikad ne dovršava, a vlasnik te adrese umjesto toga dobiva obavijest (najviše jednu na sat).

        Kad se poveznica potvrdi, adresa se mijenja i smatra se potvrđenom, uređaji ostaju prijavljeni, ranije poslane poveznice (potvrda, ponovno postavljanje lozinke, druge promjene) prestaju raditi, a prethodna adresa dobiva obavijest u kojoj je nova adresa maskirana.

        **Bez potvrde e-pošte** (`REQUIRE_EMAIL_VERIFICATION=false`): adresa se odmah mijenja (200 `email_changed`), a prethodna adresa dobiva obavijest. Ako adresu koristi neki drugi račun, odgovor je 409 `email_taken`, a vlasnik te adrese dobiva obavijest.

        **Ponovna autentifikacija:** lozinka i, uz dvofaktorsku provjeru, kod iz aplikacije ili kod za oporavak. `invalid_email` i `same_email` provjeravaju se prije lozinke, pa se ne broje kao neuspjeh. **Ograničenja:** `reauth`, zatim `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: Adresa je odmah promijenjena (na ovom poslužitelju nema potvrde e-pošte).
          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: Nova adresa, onako kako je spremljena.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Poveznica za potvrdu poslana je na novu adresu (ili adresa pripada drugom računu; odgovor to ne otkriva).
          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` (trenutna adresa računa), `password_not_set` (račun samo s prijavom putem Googlea), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (i kad je promjena ili ponovno postavljanje lozinke preteklo zahtjev: poveznica se ne šalje), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: samo bez potvrde e-pošte.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (neuspjele ponovne autentifikacije ili previše isprobanih kodova na ovom računu) ili `rate_limited` (ograničenje `reauth` ili `reauth_user`, kvota računa, red za hashiranje lozinki).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: red za hashiranje lozinki ili je baza podataka ostala zaključana (`retryAfter: 1`: ništa nije promijenjeno i isti se zahtjev može ponovno poslati). `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Preuzimanje podataka računa
      description: |-
        Sve što poslužitelj čuva o računu, kao jedna JSON datoteka za spremanje (`format` `scacelith-account-export`, `version` 1). Izvoz bilježi sigurnosni događaj (`account_exported`). Niz `notes` u dokumentu igraču na jednostavnom engleskom objašnjava što je izostavljeno.

        **Nikad u izvozu:** hash lozinke, tajni ključ dvofaktorske provjere i kodovi za oporavak; bilo koji token sesije ili poveznice, kao ni njegov hash; podaci sustava protiv varanja (razina i ocjena integriteta, anomalije, analiza partija, težina prijave); prijave koje su drugi igrači podnijeli protiv igrača; identiteti moderatora; privatni podaci drugih igrača (protivnici se pojavljuju pod svojim javnim imenom i rejtingom, ništa ne otkriva je li neki drugi igrač sankcioniran i nije uključena nijedna IP adresa koja bi mogla pripadati drugoj osobi).

        **Ponovna autentifikacija:** lozinka i, uz dvofaktorsku provjeru, kod iz aplikacije ili kod za oporavak. **Ograničenja:** `account_export` (5 na sat po igraču, za cijeli poslužitelj; broji se svaki pokušaj, uključujući neuspjele; provjerava se prvo), zatim `reauth` i `reauth_user`. **Vremensko ograničenje:** 60 sekundi.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Dokument izvoza, kao privitak.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Znakovi korisničkog imena osim slova, znamenki, `_`, `.` i `-` postaju `_`.'
              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` (račun samo s prijavom putem Googlea), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (neuspjele ponovne autentifikacije ili previše isprobanih kodova na ovom računu) ili `rate_limited` (ograničenje `account_export`, `reauth` ili `reauth_user`, kvota računa, red za hashiranje lozinki).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza podataka ostala je zaključana, sa zaglavljem `Retry-After: 1`), `server_busy` (red za hashiranje lozinki), `timeout` (60 sekundi). Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Brisanje računa
      description: |-
        Briše račun; to se ne može poništiti.

        - Sve se sesije odmah opozivaju: token od tada dobiva 401 `invalid_token`.
        - Korisničko ime postaje `deleted#<id>`, u računu i u svakom zapisu partije.
        - Brišu se: adresa e-pošte, hash lozinke, tajni ključ dvofaktorske provjere i kodovi za oporavak, sesije i tokeni poveznica, povezivanje s Googleom, zapis integriteta iz sustava protiv varanja te IP adrese spremljene uz sigurnosne događaje.
        - Rejtinzi i partije zadržavaju se. Partije ostaju čitljive pod anonimnim imenom, a `GET /players/{username}` za prijašnje ime odgovara s 404.

        **Ponovna autentifikacija:** lozinka i, uz dvofaktorsku provjeru, kod iz aplikacije ili kod za oporavak. **Ograničenja:** `reauth`, zatim `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: Račun je izbrisan.
          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` (račun samo s prijavom putem Googlea), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (neuspjele ponovne autentifikacije ili previše isprobanih kodova na ovom računu) ili `rate_limited` (ograničenje `reauth` ili `reauth_user`, kvota računa, red za hashiranje lozinki).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (red za hashiranje lozinki ili je baza podataka ostala zaključana), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Popis igračevih partija, filtriran i podijeljen na stranice
      description: |-
        Partije prijavljenog igrača, od najnovije, filtrirane i podijeljene na stranice, uz broj partija koje odgovaraju filtru. Svi su parametri upita neobavezni, a prazna vrijednost smatra se izostavljenom.

        Straničenje: pošaljite `next` prethodne stranice kao `before`; `next` je `null` na posljednjoj stranici. `total` broji partije koje odgovaraju filtru, na svim stranicama.

        **Ograničenje:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: ID službene kategorije (`3+2` ili `3%2B2`) ili `custom` za svaku partiju s drugim tempom igre.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Samo partije za rejting (`true`) ili bez rejtinga (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Samo dobivene, izgubljene ili remizirane partije, gledano s igračeve strane. Poništene partije pojavljuju se samo bez ovog filtra.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Jedna stranica povijesti.
          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` nije ID partije), `invalid_limit` ili `invalid_filter` (`category`, `rated` ili `result`), svaki s `field` koji navodi parametar.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: ograničenje `account_games` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza podataka ostala je zaključana; `retryAfter: 1` u tijelu, bez zaglavlja `Retry-After`), `server_busy` (dohvat sesije naišao je na zaključanu bazu podataka), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Dohvaćanje zapisa partije s potezima i satovima
      description: |-
        Jedan zapis partije, s potezima i satovima. Bez tokena ili s tokenom igrača koji nije igrao tu partiju odgovor je javni. Kad je igrač tokena igrao partiju, odgovor dodaje `you` i `reportable`.

        **Ograničenje:** `public_read` (po igraču s tokenom, po klijentu bez tokena).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Zapis partije.
          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`: nije pozitivan cijeli broj od najviše 16 znamenki (manji od 2^53); `invalid_request`: nije valjano URL kodiran.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: poslan je token koji nije valjan.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: takva partija ne postoji.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: ograničenje `public_read` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza podataka ostala je zaključana; `retryAfter: 1` u tijelu, bez zaglavlja `Retry-After`), `server_busy` (dohvat sesije naišao je na zaključanu bazu podataka), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Preuzimanje partije kao PGN datoteke
      description: |-
        Ista partija kao PGN datoteka: jedna partija sa završecima redaka `\n` i tekstom poteza u recima kraćima od 80 stupaca. Odgovor je isti s tokenom i bez njega.

        Oznake, ovim redoslijedom: `Event` (`<SERVER_NAME> rated <category>` ili `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (datum početka u UTC-u), `Round`, `White`, `Black`, `Result` (`*` za poništenu partiju), `UTCDate` i `UTCTime` (početak), `WhiteElo` i `BlackElo` (rejtinzi na početku ili `-`), `WhiteRatingDiff` i `BlackRatingDiff` (promjene, primjerice `+10` i `-10`, u svakoj partiji za rejting, `+0` kad pravila rejtinga ostave rejting nepromijenjenim; partija bez rejtinga, s prilagođenim tempom igre ili poništena partija nema ni jednu ni drugu), `TimeControl` (u sekundama), `Termination` (standardna vrijednost PGN-a: `normal`; `time forfeit`, istek vremena, i kad završi remijem; `abandoned`; `rules infraction`, drugi nepravilan potez ili kontumacija zbog kršenja pravila poštene igre; `unterminated`, poništena partija), `PlyCount`, `ScacelithGameId` (decimalni ID).

        Svaki potez nosi `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: sat igrača koji je odigrao potez, nakon tog poteza, i vrijeme obračunato za potez, u desetinkama sekunde (odsječeno). Vrijednost se izostavlja kad je zapis nema. Nakon posljednjeg poteza slijedi razlog završetka riječima, zatim rezultat.

        **Ograničenje:** `public_read` (po igraču s tokenom, po klijentu bez tokena).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: PGN datoteka.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-<id>.pgn"`.'
              schema:
                type: string
                pattern: '^attachment; filename="scacelith-[1-9][0-9]{0,15}\.pgn"$'
              example: attachment; filename="scacelith-4100000000001.pgn"
          content:
            application/x-chess-pgn:
              schema:
                type: string
                description: 'PGN tekst, u 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`: nije pozitivan cijeli broj od najviše 16 znamenki (manji od 2^53); `invalid_request`: nije valjano URL kodiran.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: poslan je token koji nije valjan.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: takva partija ne postoji.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: ograničenje `public_read` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: među ostalim, spremljeni potezi ne mogu se ponovno odigrati do spremljenog završetka (poslužitelj to bilježi u dnevnik).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza podataka ostala je zaključana; `retryAfter: 1` u tijelu, bez zaglavlja `Retry-After`), `server_busy` (dohvat sesije naišao je na zaključanu bazu podataka), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Preuzimanje partije s ovog poslužitelja kao animiranog GIF-a
      description: |-
        Partija kao animirani GIF. Imena i rejtinzi preuzimaju se iz zapisa partije (rejtinzi na početku, izbrisani račun kao `deleted#<id>`), kao i rezultat i način završetka (`Resignation`, `Loss on time`...).

        Provjere se obavljaju ovim redoslijedom: `gif_disabled`, mogućnosti slike (`size`, `orientation`, `delay`, `coords`), ID partije, partija, njezina duljina.

        **Potrebna sesija:** kvote se broje po računu. **Ograničenja:** `gif` na svakom zahtjevu; `gif_user_min`, `gif_user_hour`, `gif_ip_min` i `gif_ip_hour` samo kad GIF treba izraditi (vidi opis odjeljka). **Vremensko ograničenje:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 sekundi (prema zadanim postavkama 45 sekundi).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: 'Veličina slike: `small` (polja od 32 px), `medium` (48 px) ili `large` (72 px).'
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Strana koja je na dnu ploče.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Milisekunde po potezu (od 1 do 6 decimalnih znamenki).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Crtaju li se oko ploče slova linija i brojevi redova (`1`) ili ne (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` s `field` (`size`, `orientation`, `delay` ili `coords`): vrijednost izvan dopuštenih. `invalid_game_id`. `invalid_request`: nije valjano URL kodiran.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: takva partija ne postoji. `gif_disabled`: poslužitelj je isključio GIF-ove (`GIF_ENABLED=false`; žeton `gif` se vraća).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: ograničenje `gif`, ograničenje iscrtavanja ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: GIF nije bilo moguće izraditi (poslužitelj razlog bilježi u dnevnik). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` s `retryAfter` (od 3 do 10 sekundi) i `Retry-After`: red za iscrtavanje je pun ili je GIF `GIF_QUEUE_TIMEOUT_MS` (10 sekundi) čekao slobodnu dretvu; vraćaju se svi žetoni ograničenja koje je zahtjev uzeo. `busy`: baza podataka ostala je zaključana (`retryAfter: 1` samo u tijelu). `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Izrada animiranog GIF-a bilo koje partije poslane kao PGN
      description: |-
        Ista slika kao kod `GET /games/{id}/gif` za bilo koju partiju poslanu kao PGN tekst: partiju koju je spremila igra, izvoz s nekog drugog web-mjesta, partiju upisanu ručno. Koristi se samo prva partija iz teksta.

        - Čitač PGN-a prihvaća ono što prihvaća i vlastiti čitač igre: svaki PGN koji ovaj poslužitelj zapisuje i uobičajene izvoze drugih web-mjesta (komentari, varijante, NAG-ovi i bilješke sata preskaču se; brojevi poteza i SAN čitaju se tolerantno; oznaka `FEN` daje početnu poziciju osim ako je `SetUp` `"0"`).
        - Imena i rejtinzi uzimaju se iz oznaka `White`, `Black`, `WhiteElo` i `BlackElo`. Slova s dijakritičkim znakovima gube ih, ostali znakovi izvan ispisivog ASCII-ja postaju `?`, a duga se imena skraćuju (48 znakova). Rezultat se uzima iz oznake `Result`, inače s kraja teksta poteza. Oznaka `Termination` prikazuje se osim ako je `normal`: završna pozicija tada sama govori kako je partija završila (mat, pat).
        - Ova krajnja točka sama provjerava svoje tijelo: na nepoznato polje ili na `pgn` koji nedostaje ili nije niz znakova odgovara s 400 `invalid_request` s `field`; na pogrešnu mogućnost odgovara s 400 `invalid_option` (`delayMs` mora biti JSON broj, `coords` logička vrijednost; `null` se odbija).

        **Potrebna sesija:** kvote se broje po računu. **Ograničenja:** kao za `GET /games/{id}/gif`. **Ograničenje veličine tijela:** 135.168 bajtova, bez obzira na `HTTP_BODY_LIMIT` (PGN kao JSON niz znakova, uključujući njegove escape sekvence). **Vremensko ograničenje:** prema zadanim postavkama 45 sekundi.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Kratka ručno upisana partija, bez koordinata
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: PGN datoteka sa svim mogućnostima
                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` s `field` (nepoznato polje ili `pgn` nedostaje ili nije niz znakova; bez `field` kad tijelo nije objekt); `invalid_json`; `invalid_option` s `field` (`size`, `orientation`, `delayMs` ili `coords`); `invalid_pgn` s `line` i `column` (od 1, stupci u znakovima) i porukom čitača u `message`: nepravilan ili dvosmislen potez, neispravna oznaka, nepoznata varijanta šaha, više od 65.536 bajtova.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: poslužitelj je isključio GIF-ove (`GIF_ENABLED=false`; žeton `gif` se vraća).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: tijelo veće od 135.168 bajtova.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: ograničenje `gif`, ograničenje iscrtavanja ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: GIF nije bilo moguće izraditi (poslužitelj razlog bilježi u dnevnik). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` s `retryAfter` (od 3 do 10 sekundi) i `Retry-After`: red za iscrtavanje je pun ili je GIF predugo čekao slobodnu dretvu; vraćaju se svi žetoni ograničenja koje je zahtjev uzeo. `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Dohvaćanje javnog profila igrača
      description: |-
        Javni profil igrača: rejtinzi u službenim kategorijama (redoslijedom poslužitelja) i broj partija. `games.total` broji svaku spremljenu partiju, uključujući one bez rejtinga i poništene; `games.rated`, `wins`, `draws` i `losses` zbrajaju se po zapisima rejtinga, dakle samo partije za rejting. Odgovor je isti s tokenom i bez njega.

        **Ograničenje:** `public_read` (po igraču s tokenom, po klijentu bez tokena).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: 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`: nije od 2 do 24 znaka iz `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: poslan je token koji nije valjan.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: takav igrač ne postoji ili je račun izbrisan.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: ograničenje `public_read` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza podataka ostala je zaključana; `retryAfter: 1` u tijelu, bez zaglavlja `Retry-After`), `server_busy` (dohvat sesije naišao je na zaključanu bazu podataka), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Popis nedavnih partija igrača
      description: |-
        Nedavne partije igrača, od najnovije, podijeljene na stranice kao povijest, ali bez filtara i ukupnog broja. `color` je strana ovog igrača. `next` je ID posljednje partije kad god je stranica puna, pa sljedeća stranica može biti prazna. Najprije se traži igrač: nepoznat igrač daje 404 bez obzira na upit.

        **Ograničenje:** `public_read` (po igraču s tokenom, po klijentu bez tokena).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Jedna stranica igračevih partija.
          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` nije ID partije), `invalid_limit` (ova dva bez `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: poslan je token koji nije valjan.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: takav igrač ne postoji ili je račun izbrisan.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: ograničenje `public_read` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza podataka ostala je zaključana; `retryAfter: 1` u tijelu, bez zaglavlja `Retry-After`), `server_busy` (dohvat sesije naišao je na zaključanu bazu podataka), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Dohvaćanje najboljih igrača kategorije
      description: |-
        100 najboljih zapisa rejtinga službene kategorije s najmanje `minGames` (`PROVISIONAL_GAMES`) uračunatih partija, bez izbrisanih računa i potvrđenih varalica. Poslužitelj svaku ljestvicu ponovno izračunava najviše svakih 10 sekundi; `updatedAt` kaže kada. Za jednu kategoriju istodobno se izvodi samo jedan izračun: u međuvremenu ostali zahtjevi dobivaju prethodni popis ili čekaju izračun ako popisa još nema.

        **Ograničenja:** samo sloj po adresi.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: ID službene kategorije (`3+2` ili `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Broj igrača, od 1 do 100. Veći broj od najviše 3 znamenke smatra se brojem 100; prazna vrijednost smatra se izostavljenom.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: Ljestvica.
          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` (nedostaje ili nije službena kategorija), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: sloj po adresi.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (baza podataka ostala je zaključana; `retryAfter: 1` u tijelu, bez zaglavlja `Retry-After`), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Prijava protivnika iz nedavne partije
      description: |-
        Prijavljuje protivnika iz jedne od igračevih vlastitih partija koja je završila u posljednjih 7 dana. Prijava sama po sebi nikad ne mijenja rejting, sankciju ni razinu integriteta. Podiže prioritet pregleda koji vide moderatori i, osim za `abuse`, traži analizu partije šahovskim programom.

        Prijava istog protivnika za istu partiju dobiva isti odgovor i ništa ne mijenja; odgovor nikad ništa ne otkriva o računu protiv kojeg je podnesena prijava. `GET /games/{id}` igračima partije unaprijed govori bi li prijava bila prihvaćena (`reportable`).

        Ova krajnja točka sama provjerava svoje tijelo, redoslijedom `gameId`, `reported`, `category`, `comment`: na neuspjeh odgovara s 400 `invalid_request` bez `field`. Polja koja ne poznaje zanemaruju se.

        **Ograničenja:** `reports` (po igraču), zatim `REPORTS_PER_DAY` (5) prijava po igraču u 24 sata (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: Prijava je primljena (ili je već bila podnesena).
          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` (bez `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: nije protivnik prijavitelja u partiji koja je završila u posljednjih 7 dana.'
        '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`, sa zaglavljem `Retry-After`): `REPORTS_PER_DAY` prijava u posljednja 24 sata. `rate_limited`: ograničenje `reports` ili kvota računa.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (dohvat sesije naišao je na zaključanu bazu podataka), `timeout`. Također `server_busy` s `retryAfter` od 2 do 5 kad poslužitelj jako kasni (vidi uvod): zahtjev nije izvršen.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Službeni poslužitelj (stranice se nalaze u korijenu, izvan `/api/v1`).
      - url: https://{host}:{port}
        description: Bilo koji poslužitelj Scacelith (stranice se nalaze u korijenu, izvan `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Javni naziv hosta poslužitelja (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Javni port API-ja (`PUBLIC_API_PORT`, inače `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Prikaz stranice za potvrdu e-pošte
      description: |-
        Stranica poveznice za potvrdu e-pošte (registracija ili ponovno poslana e-poruka za potvrdu). Prikazuje samo gumb „Confirm my e-mail address“ (Potvrdi moju adresu e-pošte), kako je ne bi potrošio skener e-pošte koji otvori poveznicu; gumb šalje obrazac na `POST /verify-email`.

        **Ograničenje:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Stranica s gumbom za potvrdu.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Poveznica nije valjana ili je istekla (HTML stranica).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Ograničenje `page` (HTML stranica) ili sloj po adresi (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Potvrda adrese e-pošte
      description: |-
        Obrazac stranice za potvrdu. Potvrđuje adresu; kod nove registracije tada izrađuje račun i igrač se može prijaviti.

        **Ograničenje:** `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: Adresa je potvrđena (a račun nove registracije izrađen).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Poveznica nije valjana, već je upotrijebljena ili je istekla, ili obrazac nije valjan (HTML stranica).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Poveznica nove registracije čije je korisničko ime ili adresu u međuvremenu zauzeo drugi račun; račun se ne izrađuje (HTML stranica).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Ograničenje `auth` (HTML stranica) ili sloj po adresi (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Baza podataka ostala je zaključana (`Retry-After: 1`) ili poslužitelj jako kasni (`Retry-After` od 2 do 5, vidi uvod): ništa nije promijenjeno i poveznica i dalje radi. Također istek vremena za obradu zahtjeva.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Službeni poslužitelj (stranice se nalaze u korijenu, izvan `/api/v1`).
      - url: https://{host}:{port}
        description: Bilo koji poslužitelj Scacelith (stranice se nalaze u korijenu, izvan `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Javni naziv hosta poslužitelja (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Javni port API-ja (`PUBLIC_API_PORT`, inače `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Prikaz obrasca za ponovno postavljanje lozinke
      description: |-
        Stranica poveznice za ponovno postavljanje lozinke: obrazac za novu lozinku (lozinka dvaput). Obrazac se šalje na `POST /reset-password`.

        **Ograničenje:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Obrazac za novu lozinku.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Poveznica nije valjana (i kad je poslana na adresu koju račun više nema).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Ograničenje `page` (HTML stranica) ili sloj po adresi (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Postavljanje nove lozinke iz obrasca za ponovno postavljanje
      description: |-
        Obrazac stranice za ponovno postavljanje; radi isto što i `POST /auth/password/reset`, u jednoj transakciji: svi se uređaji odjavljuju, promjena e-pošte na čekanju otkazuje se, ostale poveznice za ponovno postavljanje prestaju raditi, adresa se smatra potvrđenom, a vlasnik dobiva e-poruku.

        Najprije se provjerava poveznica, zatim jesu li obje lozinke iste, a zatim pravila za lozinku. Kad je poslužitelj zauzet, obrazac se vraća s `Retry-After`, a poveznica ostaje valjana.

        **Ograničenja:** `auth`, zatim `auth_reset` (zajedničko s `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: Lozinka je promijenjena, a svi su uređaji odjavljeni.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ponovno obrazac s pogreškom (lozinke se razlikuju, slaba lozinka), poveznica nije valjana ili obrazac nije valjan (HTML stranica).
        '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: Ograničenje `auth` ili `auth_reset` (HTML stranica), ponovno obrazac s `Retry-After` kad ovaj klijent ima previše hashiranja lozinki na čekanju (poveznica ostaje valjana) ili sloj po adresi (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Ponovno obrazac s `Retry-After` kad je poslužitelj zauzet (red za hashiranje lozinki ili je baza podataka ostala zaključana); poveznica ostaje valjana. Kad poslužitelj jako kasni (vidi uvod), umjesto toga stranica „Server error“ (pogreška poslužitelja) s `Retry-After` (od 2 do 5): ništa nije izvršeno i poveznica ostaje valjana. Također istek vremena za obradu zahtjeva.'
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Službeni poslužitelj (stranice se nalaze u korijenu, izvan `/api/v1`).
      - url: https://{host}:{port}
        description: Bilo koji poslužitelj Scacelith (stranice se nalaze u korijenu, izvan `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Javni naziv hosta poslužitelja (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Javni port API-ja (`PUBLIC_API_PORT`, inače `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Prikaz stranice za potvrdu promjene e-pošte
      description: |-
        Stranica poveznice za promjenu e-pošte: prikazuje novu adresu i ime računa, s gumbom „Use this e-mail address“ (Koristi ovu adresu e-pošte) koji šalje obrazac na `POST /confirm-email-change`.

        **Ograničenje:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Stranica s novom adresom i gumbom za njezinu potvrdu.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Poveznica nije valjana ili je istekla (i kad se adresa računa promijenila nakon zahtjeva).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Ograničenje `page` (HTML stranica) ili sloj po adresi (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Potvrda nove adrese e-pošte
      description: |-
        Obrazac stranice za promjenu e-pošte. Adresa se mijenja i smatra se potvrđenom; uređaji ostaju prijavljeni; ranije poslane poveznice (potvrda, ponovno postavljanje lozinke, druge promjene) prestaju raditi; prethodna adresa dobiva obavijest u kojoj je nova adresa maskirana.

        **Ograničenje:** `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: Adresa je promijenjena.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Poveznica nije valjana, već je upotrijebljena ili je istekla, ili obrazac nije valjan (HTML stranica).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Drugi je račun u međuvremenu zauzeo adresu (HTML stranica).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Ograničenje `auth` (HTML stranica) ili sloj po adresi (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Baza podataka ostala je zaključana (`Retry-After: 1`) ili poslužitelj jako kasni (`Retry-After` od 2 do 5, vidi uvod): ništa nije promijenjeno i poveznica i dalje radi. Također istek vremena za obradu zahtjeva.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Službeni poslužitelj, u korijenu.
      - url: https://caissa.scacelith.com/api/v1
        description: Službeni poslužitelj, pod `/api/v1`.
      - url: https://{host}:{port}
        description: Bilo koji poslužitelj Scacelith, u korijenu.
        variables:
          host:
            default: caissa.scacelith.com
            description: Javni naziv hosta poslužitelja (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Javni port API-ja (`PUBLIC_API_PORT`, inače `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Bilo koji poslužitelj Scacelith, pod `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Javni naziv hosta poslužitelja (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Javni port API-ja (`PUBLIC_API_PORT`, inače `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Provjera radi li proces
      description: |-
        Odgovara s 200 `ok` dok proces radi. Radi i `HEAD`. Svaka druga metoda dobiva odgovor 405 `method_not_allowed` s `Allow: GET, HEAD` (uključujući `OPTIONS`).

        **Ograničenja:** samo sloj po adresi.
      security: []
      responses:
        '200':
          description: Proces radi.
          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`: sloj po adresi.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Službeni poslužitelj, u korijenu.
      - url: https://caissa.scacelith.com/api/v1
        description: Službeni poslužitelj, pod `/api/v1`.
      - url: https://{host}:{port}
        description: Bilo koji poslužitelj Scacelith, u korijenu.
        variables:
          host:
            default: caissa.scacelith.com
            description: Javni naziv hosta poslužitelja (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Javni port API-ja (`PUBLIC_API_PORT`, inače `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Bilo koji poslužitelj Scacelith, pod `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Javni naziv hosta poslužitelja (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Javni port API-ja (`PUBLIC_API_PORT`, inače `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Provjera prihvaća li poslužitelj igrače
      description: |-
        Odgovara s 200 `ready` kad poslužitelj prihvaća igrače i s 503 `not_ready` dok se pokreće ili zaustavlja. Radi i `HEAD`. Svaka druga metoda dobiva odgovor 405 `method_not_allowed` s `Allow: GET, HEAD` (uključujući `OPTIONS`).

        **Ograničenja:** samo sloj po adresi.
      security: []
      responses:
        '200':
          description: Poslužitelj prihvaća igrače.
          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`: sloj po adresi.'
        '503':
          description: Poslužitelj se pokreće ili zaustavlja.
          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: |-
        Token sesije (`sct_` iza kojeg slijede 43 base64url znaka) iz odgovora na prijavu, u zaglavlju `Authorization`: `Authorization: Bearer <token>`. Prefiks je točno `Bearer` (razlikuju se velika i mala slova) i jedan razmak; vrijednost koja ga nema ili token koji nema od 1 do 512 ispisivih ASCII znakova dobiva odgovor 401 `invalid_token` kao i svaki drugi nevaljani token. Prazno zaglavlje smatra se nepostojećim zaglavljem.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: ID partije, pozitivan decimalni cijeli broj od najviše 16 znamenki bez vodećih nula, manji od 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: ID sesije, onako kako ga daje `GET /auth/sessions`, napisan bez vodećih nula (`03` nije sesija 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Korisničko ime igrača, bez obzira na velika i mala slova. Poslužitelj prihvaća svako ime od 2 do 24 znaka iz `[A-Za-z0-9_.-]` (najšire pravilo koje je ikad dopuštao).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: ID partije; navode se samo starije partije. Pošaljite `next` prethodne stranice. Prazna vrijednost smatra se izostavljenom.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Veličina stranice, od 1 do 50. Veći broj od najviše 3 znamenke smatra se brojem 50; prazna vrijednost smatra se izostavljenom.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Token poveznice iz e-poruke (43 base64url znaka). Ako nedostaje ili je pogrešan, prikazuje se stranica „link invalid or expired“ (poveznica nije valjana ili je istekla).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Svaki odgovor poslužitelja zabranjuje spremanje u predmemoriju.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Broj sekundi čekanja prije ponovnog pokušaja; ista vrijednost kao `retryAfter` u tijelu.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Izazov sheme bearer, s `error="invalid_token"` za nevaljani token.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: Poslužitelj zatvara vezu nakon ovog odgovora.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (s `field` kad je problem u nekom polju tijela) ili `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Polje krši shemu
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Tijelo nije JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Slaba lozinka
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: PGN koji se ne može pročitati
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (nema zaglavlja `Authorization`) ili `invalid_token` (token je neispravnog oblika, istekao, opozvan ili pripada izbrisanom računu).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Nema sesije
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Nevaljani token
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: Zahtjev je odbijen.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Pogrešna lozinka pri ponovnoj autentifikaciji
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Suspendirani račun
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found` ili značajka koju je ovaj poslužitelj isključio.'
      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`: tijelo nije stiglo u roku od 10 sekundi. Poslužitelj zatvara vezu.'
      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: Zahtjev je u sukobu sa stanjem poslužitelja.
      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`: pokušaj prijave putem Googlea ili tiket nepoznat je, upotrijebljen ili istekao; ponovno pokrenite prijavu.'
      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`: tijelo premašuje `HTTP_BODY_LIMIT` (prema zadanim postavkama 16.384 bajta). Poslužitelj zatvara vezu.'
      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`: cilj zahtjeva premašuje 4096 znakova.'
      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`: tijelo nije `application/json` ili njegov skup znakova nije 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`: partija ima više od `GIF_MAX_PLIES` (600) polupoteza.'
      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`: riješite izazov `pow` i ponovno pošaljite isti zahtjev s `pow: { challenge, nonce }`. `reason` kaže zašto: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` ili `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` (ograničenje učestalosti, kvota računa ili red za hashiranje lozinki) ili `too_many_attempts` (ograničenje na razini računa), s `retryAfter` i zaglavljem `Retry-After`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rateLimited:
              summary: Ograničenje učestalosti
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Previše neuspjeha na ovom računu
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: neočekivana pogreška. Poslužitelj je bilježi u dnevnik.'
      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`: pogrešan `state` ili `iss`, ili je Google odbio kod ili poslao ID token koji ne prolazi provjeru (uključujući kod ili ID token drugog klijenta, igre ili web-mjesta). Nikad ne sadrži Googleov tekst.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: sso_failed
            message: Google sign-in could not be completed.
    ServiceUnavailable:
      description: '`server_busy`, `busy` ili `timeout`: pokušajte ponovno nakon `retryAfter` kad je naveden.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Poslužitelj je zauzet
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: Baza podataka ostala je zaključana (čitanje)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Poslužitelj je predugo odgovarao
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: Animirani GIF, kao privitak.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` za partiju s ovog poslužitelja, `attachment; filename="scacelith-game.gif"` za PGN poslan s `POST /gif`.'
          schema:
            type: string
            pattern: '^attachment; filename="scacelith-([1-9][0-9]{0,15}|game)\.gif"$'
          example: attachment; filename="scacelith-4100000000001.gif"
        Content-Length:
          description: Veličina datoteke u bajtovima.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: HTML stranica.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: HTML stranica koja objašnjava odbijanje.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Obrazac nije stigao u roku od 10 sekundi (HTML stranica). Poslužitelj zatvara vezu.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: HTML stranica koja objašnjava sukob.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Obrazac premašuje `HTTP_BODY_LIMIT` (HTML stranica). Poslužitelj zatvara vezu.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Tijelo nije ni obrazac (`application/x-www-form-urlencoded`) ni JSON ili njegov skup znakova nije UTF-8 (HTML stranica).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Ograničenje učestalosti (HTML stranica), s `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: Neočekivana pogreška (HTML stranica s naslovom „Server error“, tj. pogreška poslužitelja). Poslužitelj je bilježi u dnevnik.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Poslužitelj je zauzet (baza podataka ostala je zaključana ili poslužitelj jako kasni; s `Retry-After`) ili je predugo odgovarao (HTML stranica s naslovom „Server error“, tj. pogreška poslužitelja).
      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: Jedinstveni oblik svakog odgovora s pogreškom u JSON API-ju.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: Kod pogreške u obliku `snake_case`. Na temelju njega klijent odabire što će prikazati.
          examples:
            - rate_limited
        message:
          type: string
          description: Rečenica na engleskom, namijenjena dnevnicima i kao zamjenski tekst.
        retryAfter:
          type: integer
          minimum: 1
          description: Broj sekundi čekanja prije ponovnog pokušaja, prisutan samo kod odbijanja koja s vremenom prestaju. Odgovor tada nosi i zaglavlje `Retry-After` s istom vrijednošću, osim kod 503 `busy` pri čitanju povijesti, partija, igrača i ljestvice.
        field:
          type: string
          description: Polje ili parametar upita u kojem je problem (`invalid_request`, `invalid_option` te `invalid_cursor`, `invalid_limit` i `invalid_filter` kod `GET /account/games`). Ugniježđeno polje piše se s točkom (`pow.nonce`).
        reason:
          type: string
          description: 'Razlog: pravilo koje `weak_password` krši ili zašto je dokaz rada zatražen ili odbijen (`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: 'Samo `banned`: kraj suspenzije (ms od Unix epohe), `null` za trajnu suspenziju.'
        line:
          type: integer
          minimum: 1
          description: 'Samo `invalid_pgn`: redak pogreške, od 1.'
        column:
          type: integer
          minimum: 1
          description: 'Samo `invalid_pgn`: stupac pogreške, od 1, u znakovima.'
    PowChallenge:
      type: object
      description: Izazov dokaza rada (`pow` iz odgovora 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: Potpisani izazov, koji treba vratiti nepromijenjen.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Broj vodećih nultih bitova koje `SHA-256(challenge + ":" + nonce)` mora imati.
        expiresAt:
          type: integer
          format: int64
          description: Kad izazov istječe (ms od Unix epohe), 2 minute nakon izdavanja.
    PowAnswer:
      type: object
      description: Odgovor na izazov dokaza rada.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: '`challenge` iz odgovora 428, onako kako je dan.'
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Decimalni niz od najviše 20 znamenki takav da `SHA-256(challenge + ":" + nonce)` počinje s `bits` nultih bitova.
    ServerInfo:
      type: object
      description: Ono što klijentu treba prije nego što se prijavi ili poveže.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Naziv poslužitelja (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: UUID koji baza podataka dobiva pri prvom pokretanju. Ostaje isti nakon ponovnih pokretanja. Ima vrijednost `null` kad ga baza podataka ne može dati.
        motd:
          type: string
          description: Poruka dana (`SERVER_MOTD`), moguće prazna.
        protocol:
          type: object
          description: Protokol WebSocketa (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: Najstarija verzija protokola koju poslužitelj podržava.
            max:
              type: integer
              minimum: 1
              description: Najnovija verzija protokola koju poslužitelj podržava.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 'Otisak sheme protokola (prva 4 bajta SHA-256 njezina kanonskog oblika, kao cijeli broj bez predznaka: `0xc649140f`, 3326678031, za sporednu verziju protokola 2); informativno, nikad se ne uspoređuje.'
            subprotocol:
              type: string
              description: Token podprotokola WebSocketa (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: Port WebSocketa koji igrači koriste (`PUBLIC_WS_PORT`, inače `WS_PORT`, inače `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Putanja WebSocketa.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Mogu li se izrađivati novi računi.
        emailVerification:
          type: boolean
          description: Potvrđuju li novi računi svoju adresu (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Nudi li se igri prijava putem Googlea.
            googleWeb:
              type: boolean
              description: Nudi li se prijava putem Googlea i web-mjestu (`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: Dvofaktorska provjera uvijek je dostupna.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Broj bitova dokaza rada koji registracija zahtijeva (0 ako ga ne zahtijeva).
        categories:
          type: array
          description: Službeni tempovi igre, tj. oni za rejting (`RATED_CATEGORIES`), redoslijedom poslužitelja. Svaki drugi tempo igre je `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Pravila koja klijent može provjeriti prije slanja obrasca.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: Najmanja duljina korisničkog imena (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: Najveća duljina korisničkog imena (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: Regularni izraz kojem novo korisničko ime mora odgovarati.
            passwordMinLength:
              type: integer
              description: Najmanja duljina lozinke, u znakovima (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: Najveća duljina lozinke, u bajtovima UTF-8.
            customTimeControls:
              type: boolean
              description: Smiju li izazovi i privatne partije koristiti prilagođene tempove igre.
            reportsPerDay:
              type: integer
              description: Koliko prijava igrač smije podnijeti u 24 sata (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: Najveća poruka WebSocketa koju klijent smije poslati, u bajtovima.
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 3326678031
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
          googleWeb: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: Službeni tempo igre.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: 'ID kategorije: minute i sekunde dodatka (`3+2`).'
        baseSec:
          type: number
          minimum: 0
          description: Osnovno vrijeme, u sekundama.
        incSec:
          type: number
          minimum: 0
          description: Dodatak po potezu, u sekundama.
    AccountView:
      type: object
      description: Račun onako kako ga vidi njegov igrač (`user` u odgovorima na prijavu i u `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: ID računa.
        username:
          type: string
          description: Korisničko ime.
        email:
          type: string
          format: email
          description: Adresa e-pošte.
        emailVerified:
          type: boolean
          description: Je li adresa potvrđena.
        mfaEnabled:
          type: boolean
          description: Je li dvofaktorska provjera uključena.
        googleLinked:
          type: boolean
          description: Je li povezan Google račun.
        hasPassword:
          type: boolean
          description: '`false` za račun izrađen putem Googlea koji još nije postavio lozinku.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Prihvaćaju li se izravni izazovi po imenu (vidi `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Kad je račun izrađen (ms od Unix epohe).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Posljednja prijava (ms od Unix epohe) ili `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Nova adresa promjene e-pošte koja čeka svoju poveznicu ili `null`.
    SessionAnswer:
      type: object
      description: Nova sesija.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Token sesije, za zaglavlje `Authorization` i WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: Apsolutni kraj sesije (ms od Unix epohe); ograničenje neaktivnosti može je završiti i ranije.
        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: Drugi korak prijave s dvofaktorskom provjerom; nastavite s `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: Token koraka, za `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Broj preostalih sekundi za dovršetak koraka.
    LoginAnswer:
      description: Sesija ili drugi korak prijave s dvofaktorskom provjerom.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Prva prijava putem Googlea; nastavite s `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: Tiket za `POST /auth/sso/complete`, valjan 10 minuta.
        suggestedUsername:
          type: string
          description: Korisničko ime izvedeno iz imena na Googleu ili iz adrese, ili `""` kad ništa ne odgovara.
    SsoNeedsPassword:
      type: object
      description: Adresu koristi račun s lozinkom; nastavite s `POST /auth/sso/google/link`. Još ništa nije povezano.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Tiket za `POST /auth/sso/google/link`.
        username:
          type: string
          description: Korisničko ime tog računa (vidi ga samo onaj tko je Googleu dokazao vlasništvo nad adresom).
        expiresIn:
          type: integer
          const: 600
          description: Broj sekundi koliko tiket ostaje valjan.
    SsoFinishAnswer:
      description: Odgovor krajnje točke `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: Pokušaj prijave putem Googlea.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Pokušaj, za `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: Googleov URL koji treba otvoriti u pregledniku sustava, nakon provjere.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state` koji Google mora vratiti.'
        expiresIn:
          type: integer
          const: 600
          description: Broj sekundi koliko pokušaj ostaje valjan.
    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: Korisničko ime; zatim pravila poslužitelja (vidi opis).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Adresa e-pošte. Uklanjaju se razmaci na početku i kraju, a sprema se malim slovima.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Lozinka; zatim pravila za lozinku.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Korisničko ime ili adresa e-pošte (svaki tekst s `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Neobavezno. Prikazuje se na popisu prijavljenih uređaja (primjerice `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: '`mfaToken` iz odgovora na prijavu (ili iz odgovora na prijavu putem Googlea).'
        code:
          type: string
          maxLength: 32
          description: Neobavezno. Šesteroznamenkasti kod iz aplikacije ili kod za oporavak.
        recoveryCode:
          type: string
          maxLength: 32
          description: Neobavezno. Kod za oporavak. Potrebno je jedno od `code` i `recoveryCode`.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Adresa e-pošte.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Parametar `token` poveznice za ponovno postavljanje lozinke.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Nova lozinka; vrijede pravila za lozinku pri registraciji.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: PKCE izazov S256 za klijentov `codeVerifier` (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: Port klijentova slušatelja na `127.0.0.1`.
    SsoWebStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: PKCE izazov S256 za `codeVerifier` stranice (`BASE64URL(SHA-256(codeVerifier))`).
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: '`attemptId` iz `POST /auth/sso/google/start`.'
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: PKCE verifikator; njegov SHA-256 mora odgovarati izazovu predanom u `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: '`state` onako kako ga je Google vratio.'
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: '`code` onako kako ga je Google vratio (ispisivi ASCII bez razmaka).'
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Neobavezno. `iss` onako kako ga je Google vratio, ako ga je vratio.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`linkTicket` iz `finish`, valjan 10 minuta.'
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Lozinka računa.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    SsoCompleteRequest:
      type: object
      additionalProperties: false
      required:
        - ssoTicket
        - username
      properties:
        ssoTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: '`ssoTicket` iz `finish`, valjan 10 minuta.'
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Korisničko ime; vrijede pravila registracije.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Aktivne sesije, počevši od posljednje korištene.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Aktivna sesija (prijavljeni uređaj).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: ID sesije, za `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: Vrijeme prijave (ms od Unix epohe).
        lastSeenAt:
          type: integer
          format: int64
          description: Posljednja upotreba (ms od Unix epohe), ažurira se najviše svakih 5 minuta (i rjeđe dok poslužitelj jako kasni).
        expiresAt:
          type: integer
          format: int64
          description: Apsolutni kraj (ms od Unix epohe); ograničenje neaktivnosti može sesiju završiti i ranije.
        clientLabel:
          type:
            - string
            - 'null'
          description: '`clientLabel` prijave ili `null` ako ga prijava nije poslala.'
        current:
          type: boolean
          description: Je li to sesija koja šalje zahtjev.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Po jedan zapis za svaku kategoriju u kojoj je igrač igrao partije za rejting.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Aktivne sankcije.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` dok traje suspenzija, inače `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Zapis rejtinga jedne kategorije.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: ID kategorije.
        rating:
          type: integer
          description: Rejting.
        games:
          type: integer
          minimum: 0
          description: Partije odigrane u kategoriji (uračunate ili ne).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Najviši postignuti rejting.
        provisional:
          type: boolean
          description: '`true` dok rejting još nije utvrđen ili ima manje od `PROVISIONAL_GAMES` uračunatih partija (igra ga prikazuje kao `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Vrsta sankcije.
        reason:
          type:
            - string
            - 'null'
          description: Navedeni razlog, ako postoji.
        startsAt:
          type: integer
          format: int64
          description: Početak (ms od Unix epohe).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Kraj (ms od Unix epohe), `null` kad je sankcija trajna.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Kraj suspenzije (ms od Unix epohe), `null` kad je trajna.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` za prihvaćanje izravnih izazova po imenu, `none` za njihovo odbijanje.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Trenutna lozinka.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Nova lozinka; vrijede pravila za lozinku pri registraciji.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Lozinka računa.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Kod iz tajnog ključa na čekanju, točno 6 znamenki.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Lozinka računa.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Neobavezno; potrebno uz dvofaktorsku provjeru (ili `recoveryCode`). Kod iz aplikacije ili kod za oporavak.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Neobavezno. Kod za oporavak (`xxxx-xxxx-xx`; velika i mala slova, razmaci i crtice nisu važni).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Lozinka računa.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Kod iz aplikacije (kod za oporavak se odbija).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Nova adresa; uklanjaju se razmaci na početku i kraju, pretvara se u mala slova, a zatim se provjerava kao adresa pri registraciji.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Lozinka računa.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Neobavezno; potrebno uz dvofaktorsku provjeru (ili `recoveryCode`). Kod iz aplikacije ili kod za oporavak.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Neobavezno. Kod za oporavak.
    TotpSetup:
      type: object
      description: Tajni ključ na čekanju za aplikaciju za autentifikaciju.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Tajni ključ u base32, za upisivanje u aplikaciju.
        uri:
          type: string
          format: uri
          description: URI `otpauth://totp/...`, za prikaz u obliku QR koda.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Broj sekundi po koraku.
    RecoveryCodeList:
      type: array
      description: 10 kodova za oporavak, prikazanih samo ovaj put. Svaki radi jednom.
      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: Jedna strana partije.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Ime u zapisu partije (`deleted#<id>` za izbrisani račun).
        rating:
          type:
            - integer
            - 'null'
          description: Rejting na početku, `null` kad nije poznat.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Rejting nakon partije. Partija za rejting uvijek ga ima; `null` samo za partiju koja se ne računa za rejting (bez rejtinga, s prilagođenim tempom igre, poništena).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: Promjena rejtinga, `0` kad pravila rejtinga ostave rejting nepromijenjenim (nulti rezultat prema pravilima FIDE-a, protivnik bez utvrđenog rejtinga, prve partije igrača bez utvrđenog rejtinga); `null` kao kod `ratingAfter`.
    GameSummary:
      type: object
      description: Sažetak spremljene partije.
      required:
        - id
        - category
        - rated
        - timeControl
        - white
        - black
        - status
        - reason
        - result
        - termination
        - plies
        - startedAt
        - endedAt
      properties:
        id:
          type: integer
          format: int64
          minimum: 1
          maximum: 9007199254740991
          description: ID partije.
        category:
          type: string
          description: ID službene kategorije (`3+2`) ili `custom`.
        rated:
          type: boolean
          description: Računa li se partija za rejting.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Tempo igre u sekundama (`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` (vidi kodove partija).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Kod razloga završetka (vidi kodove partija).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Rezultat, `*` za poništenu partiju.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Broj polupoteza.
        startedAt:
          type: integer
          format: int64
          description: Početak (ms od Unix epohe).
        endedAt:
          type: integer
          format: int64
          description: Kraj (ms od Unix epohe).
    Termination:
      type: string
      description: Naziv razloga završetka (vidi kodove partija); `Unknown` za kod koji protokol ne poznaje.
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - ResignationVsInsufficient
        - Abandonment
        - AbandonmentVsInsufficient
        - Aborted
        - NoShow
        - Forfeit
        - ServerAborted
        - BothDisconnected
        - Unknown
    PlayerGameSummary:
      description: Sažetak partije sa strane jednog igrača.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Strana igrača.
    HistoryGameSummary:
      description: Partija iz povijesti prijavljenog igrača.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Osnovno vrijeme u milisekundama.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Dodatak u milisekundama.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: Ishod sa strane igrača.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Partije na stranici, od najnovije.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: ID koji treba poslati kao `before` za sljedeću stranicu ili `null` na posljednjoj stranici.
        total:
          type: integer
          minimum: 0
          description: Broj partija koje odgovaraju filtru, na svim stranicama.
    MoveRecord:
      type: object
      description: Jedan polupotez partije.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: Potez u notaciji UCI, s dodanim `n`, `b`, `r` ili `q` za promociju.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Vrijeme obračunato za potez (ms), `null` kad ga zapis nema.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Sat igrača koji je odigrao potez, nakon poteza (ms), `null` kad ga zapis nema.
    PgnTagsView:
      type: object
      description: Glavne oznake PGN-a, za prikaz. `Termination` je ovdje naziv razloga završetka; PGN datoteka koristi standardne vrijednosti PGN-a.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` ili `<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: Datum početka u UTC-u.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: Rejting na početku ili `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Rejting na početku ili `"-"`.
          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: Zapis partije s potezima i satovima. Ima polja sažetka partije, bez `color` i `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Osnovno vrijeme u milisekundama.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Dodatak u milisekundama.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: Naziv za `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: ID partije čiji je ova partija revanš ili `null`.
            moves:
              type: array
              description: Jedan unos po polupotezu.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Samo kad je igrač tokena igrao ovu partiju; njegova strana.
            reportable:
              type: boolean
              description: Samo kad je igrač tokena igrao ovu partiju; `true` kad bi `POST /reports` sada prihvatio prijavu protivnika za ovu partiju (partija je završila u posljednjih 7 dana, dnevna kvota nije potrošena, a za ovu partiju još nije podnesena prijava protiv protivnika).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Korisničko ime, onako kako ga je igrač napisao.
        createdAt:
          type: integer
          format: int64
          description: Kad je račun izrađen (ms od Unix epohe).
        ratings:
          type: array
          description: Samo službene kategorije, redoslijedom poslužitelja.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Svaka spremljena partija, uključujući one bez rejtinga i poništene.
            rated:
              type: integer
              minimum: 0
              description: Partije za rejting, zbrojene po zapisima rejtinga.
            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: Korisničko ime, onako kako ga je igrač napisao.
        games:
          type: array
          description: Partije na stranici, od najnovije.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: ID posljednje partije kad god je stranica puna (sljedeća stranica tada može biti prazna), inače `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: ID kategorije.
        minGames:
          type: integer
          minimum: 0
          description: Broj uračunatih partija koji zapis treba da bi bio naveden (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Kad je ljestvica izračunana (ms od Unix epohe).
        players:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LeaderboardEntry'
    LeaderboardEntry:
      type: object
      required:
        - rank
        - username
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
      properties:
        rank:
          type: integer
          minimum: 1
        username:
          type: string
        rating:
          type: integer
        games:
          type: integer
          minimum: 0
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
    GifRequest:
      type: object
      additionalProperties: false
      required:
        - pgn
      properties:
        pgn:
          type: string
          description: PGN tekst, najviše 65.536 bajtova UTF-8. Koristi se samo prva partija.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Neobavezno. Veličina slike.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Neobavezno. Strana koja je na dnu ploče.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Neobavezno. Milisekunde po potezu, JSON broj s cjelobrojnom vrijednošću.
        coords:
          type: boolean
          default: true
          description: Neobavezno. Crtaju li se oko ploče slova linija i brojevi redova.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: Partija, kao cijeli broj ili niz od 1 do 16 znamenki.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: Korisničko ime protivnika, kao u zapisu partije ili kakvo je sada, bez obzira na velika i mala slova. Ne smije biti prazno.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: Na što se prijava odnosi.
        comment:
          type:
            - string
            - 'null'
          description: Neobavezno. Najviše 500 znakova nakon uklanjanja kontrolnih znakova (osim tabulatora i prijelaza u novi red) i razmaka na početku i kraju teksta.
    AccountExport:
      type: object
      description: Sve što poslužitelj čuva o računu (vremena u ms od Unix epohe).
      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: Kad je izvoz napravljen.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Što datoteka sadrži i što izostavlja, na jednostavnom engleskom za igrača.
          items:
            type: string
        account:
          description: Prikaz računa iz `GET /account/me`, s `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: Adresa povezanog Google računa ili `null`.
        ratings:
          type: array
          description: Potpuni zapisi rejtinga.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Bodovi rejtinga vraćeni nakon što je utvrđeno da je protivnik varao, zbrojeni po danu (UTC) i kategoriji, od najnovijih. Ne navode se ni partije ni varalice.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Svaka spremljena sesija, od najnovije, bez ikakvog tokena. Čišćenje zastarjelih podataka briše isteklu sesiju, a odjavljenu dan nakon odjave.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Sigurnosni događaji, od najnovijeg, koji se čuvaju `RETENTION_SECURITY_DAYS` dana.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Svaka sankcija, uključujući ukinute. Ime moderatora nikad nije uključeno.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Događaji ponašanja zabilježeni za igračeve partije koje su napuštene, poništene ili u kojima prvi potez nije odigran na vrijeme; čuvaju se 30 dana.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Prijave koje je igrač podnio.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Broj partija.
            list:
              type: array
              description: Svaka partija, od najnovije, kao sažeci iz `GET /account/games`. Potezi su na `GET /games/{id}`, a PGN na `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Potpuni zapis rejtinga.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Je li igrač izašao iz faze u kojoj rejting još nije utvrđen.
            countedGames:
              type: integer
              minimum: 0
              description: Partije koje su ušle u rejting.
            updatedAt:
              type: integer
              format: int64
              description: Kad se zapis posljednji put promijenio.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: 00:00 UTC tog dana (ms od Unix epohe).
        category:
          type: string
        points:
          type: integer
          description: Bodovi vraćeni tog dana u toj kategoriji.
    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: Kad je sesija odjavljena ili `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: Adresa s koje je obavljena prijava, briše se nakon `RETENTION_IP_DAYS` dana.
    SecurityEvent:
      type: object
      description: |-
        Sigurnosni događaj. `ip` se navodi samo za ono što je učinjeno u prijavljenom stanju, lozinkom računa (i drugim faktorom) ili poveznicom poslanom na njegovu adresu: `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` i `account_exported`; svaka druga vrsta ima `ip: null`, a `ip` se briše nakon `RETENTION_IP_DAYS` dana.

        `detail` zadržava samo ova polja: `login`: `method`; `sso_login`, `sso_account_created`: `provider`; `sso_linked`: `provider` i `method` (`password` ili `password+totp`); `login_failed`: `failures`; `login_lockout`: `retryAfterMs`; `mfa_failed`: `attempts`; `recovery_code_used`: `remaining`; `reauth_failed`: `factor`; `session_revoked` i `sessions_revoked_all`: `reason`; `email_change_refused`: `reason`; `sanction_auto`: `kind`, `gameId`, `until`. Događaj `moderator_action` zadržava samo `{ action }`, za radnje `ban`, `unban`, `reset_mfa`, `verify_email` i `revoke_sessions` (ostale radnje moderatora izostavljaju se). Svaka druga vrsta ima `detail: null`. Događaji `rating_refund` izostavljaju se: njihovi su bodovi u `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Vrsta događaja (`login`, `password_changed`...).
        at:
          type: integer
          format: int64
          description: Kad se dogodio.
        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: Trenutno javno ime igrača protiv kojeg je podnesena prijava.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Je li prijava još otvorena (ne navodi se je li igrač protiv kojeg je podnesena prijava sankcioniran).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Token poveznice iz e-poruke (skriveno polje obrasca stranice).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Token poveznice za ponovno postavljanje lozinke (skriveno polje obrasca).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Nova lozinka; vrijede pravila za lozinku pri registraciji.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ponovno nova lozinka; mora biti ista.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: HTML stranica u UTF-8 (`text/html; charset=utf-8`), bez JavaScripta i vanjskih resursa.
