openapi: 3.1.1
info:
  title: HTTPS API του διακομιστή Scacelith
  version: "1.1.0"
  summary: Το HTTPS API κάθε διακομιστή Scacelith, του επίσημου και των κοινοτικών διακομιστών.
  description: |-
    Κάθε διακομιστής Scacelith, ο επίσημος `caissa.scacelith.com` και κάθε κοινοτικός διακομιστής, απαντά σε αυτό το HTTPS API. Το παιχνίδι το χρησιμοποιεί για ό,τι βρίσκεται έξω από την ίδια την παρτίδα: εγγραφή και σύνδεση (μαζί με τον έλεγχο ταυτότητας δύο παραγόντων και τη σύνδεση με Google), τη σελίδα του λογαριασμού, το ιστορικό παρτίδων, τις λήψεις PGN και τα κινούμενα GIF των παρτίδων, τις συνδεδεμένες συσκευές, τη λήψη των δεδομένων, τη διαγραφή του λογαριασμού και τις αναφορές.

    Το ζωντανό παιχνίδι (αναζήτηση αντιπάλου, προκλήσεις, κινήσεις, ρολόγια) περνά από το WebSocket του ίδιου διακομιστή (`wss://<host>/ws`), που ανοίγει με ένα token συνεδρίας αυτού του API· περιγράφεται στο `PROTOCOL.md`, όχι εδώ.

    Οι προεπιλεγμένες τιμές που αναφέρονται παρακάτω είναι αυτές ενός διακομιστή με ανέγγιχτη διαμόρφωση· ένας κοινοτικός διακομιστής μπορεί να τις αλλάξει (το `CONFIG.md` κατονομάζει κάθε ρύθμιση).

    ## Βασικό URL, θύρα και μεταφορά

    - Επίσημος διακομιστής: `https://caissa.scacelith.com/api/v1`, θύρα TCP 443.
    - Ένας κοινοτικός διακομιστής: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. Το `API_PORT` είναι 443 από προεπιλογή· πίσω από NAT ή proxy, η θύρα που χρησιμοποιούν οι παίκτες είναι το `PUBLIC_API_PORT`.
    - Το WebSocket του παιχνιδιού βρίσκεται από προεπιλογή στην ίδια θύρα (το `WS_PORT` το μετακινεί· το `GET /info` λέει στον πελάτη πού βρίσκεται).
    - HTTP/1.1 πάνω από TLS. Με `TLS_MODE=proxy`, ένα reverse proxy τερματίζει το TLS μπροστά από τον διακομιστή. Το `TLS_MODE=off` (σκέτο HTTP) προορίζεται μόνο για τοπική ανάπτυξη.
    - Οι σελίδες HTML που ανοίγουν από τους συνδέσμους των email (`/verify-email`, `/reset-password`, `/confirm-email-change`) βρίσκονται στη ρίζα του διακομιστή, έξω από το `/api/v1`. Τα endpoint κατάστασης λειτουργίας απαντούν και στη ρίζα και κάτω από το `/api/v1`. Η σύνδεση με Google δεν έχει σελίδα στον διακομιστή: η Google στέλνει το πρόγραμμα περιήγησης πίσω στο ίδιο το παιχνίδι, στο `127.0.0.1`, ή σε μια σελίδα του ιστότοπου που την ξεκίνησε (`GOOGLE_WEB_REDIRECT_URI`).
    - Μια τελική κάθετος αγνοείται (το `/api/v1/info/` είναι το `/api/v1/info`), και οι παράμετροι διαδρομής αποκωδικοποιούνται κατά URL (μια παράμετρος που δεν είναι έγκυρη κωδικοποίηση URL δίνει 400 `invalid_request`).

    ## Αιτήματα

    - **Σώματα JSON.** Τα αιτήματα `POST`, `PUT` και `DELETE` μεταφέρουν ένα αντικείμενο JSON με `Content-Type: application/json`. Ένα `charset` διαφορετικό από UTF-8 απορρίπτεται, όπως και κάθε άλλος τύπος περιεχομένου (415 `unsupported_media_type`). Ένα κενό σώμα μετρά ως `{}`, που είναι αυτό που δέχονται τα endpoint χωρίς παραμέτρους (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`).
    - **Μέγεθος και χρόνος.** Ένα σώμα περιορίζεται σε `HTTP_BODY_LIMIT` byte (16.384 από προεπιλογή· το `POST /gif` έχει δικό του όριο, 135.168 byte): 413 `payload_too_large`. Πρέπει να φτάσει μέσα σε 10 δευτερόλεπτα: 408 `request_timeout`. Μετά από οποιοδήποτε από τα δύο σφάλματα ο διακομιστής κλείνει τη σύνδεση.
    - **Αυστηρά σχήματα.** Ένα πεδίο που το endpoint δεν γνωρίζει απορρίπτεται, ένα πεδίο που δεν σημειώνεται ως προαιρετικό είναι υποχρεωτικό, και ελέγχονται οι τύποι και τα μήκη. Οποιαδήποτε από αυτές τις αποτυχίες δίνει 400 `invalid_request`, με το `field` να κατονομάζει το πεδίο (με τελείες για ένθετο πεδίο, όπως `pow.nonce`). Οι συμβολοσειρές δεν επιτρέπεται να περιέχουν χαρακτήρες ελέγχου. Τα μήκη μετρούν μονάδες κώδικα UTF-16, οπότε ένας χαρακτήρας εκτός του Βασικού Πολυγλωσσικού Επιπέδου (ένα emoji) μετρά διπλά. Ένα σώμα που δεν είναι JSON δίνει 400 `invalid_json`. Τα `POST /gif` και `POST /reports` ελέγχουν μόνα τους τα σώματά τους (βλ. κάθε endpoint).
    - **Συμβολοσειρές ερωτήματος.** Μετρά η πρώτη εμφάνιση μιας παραμέτρου, οι άγνωστες παράμετροι αγνοούνται, και ένα `+` αποκωδικοποιείται ως κενό διάστημα: τα endpoint που δέχονται κατηγορία χρόνου σκέψης δέχονται και το `3%2B2` και το `3+2`.
    - **Μέθοδοι.** Η `HEAD` είναι η `GET` χωρίς το σώμα. Η `OPTIONS` σε υπάρχουσα διαδρομή απαντά 204 με κεφαλίδα `Allow` (και τις κεφαλίδες ενός preflight CORS, βλ. παρακάτω). Κάθε άλλη μέθοδος που δεν έχει η διαδρομή απαντά 405 `method_not_allowed` με `Allow`.
    - **Στόχος του αιτήματος.** Δεν επιτρέπεται να υπερβαίνει τους 4096 χαρακτήρες (414 `uri_too_long`). Ένα αίτημα που δεν μπορεί καθόλου να αναλυθεί, ή του οποίου το τμήμα κεφαλίδων είναι πολύ μεγάλο ή φτάνει πολύ αργά, παίρνει μια κενή απάντηση 400, 431 ή 408 και η σύνδεση κλείνει.
    - **CORS: λίστα επιτρεπόμενων, ανενεργή από προεπιλογή.** Το API εξυπηρετεί το παιχνίδι, που δεν στέλνει `Origin`. Με κενό `CORS_ORIGINS` (η προεπιλογή) δεν στέλνεται ποτέ καμία κεφαλίδα `Access-Control-*`, οπότε καμία ιστοσελίδα δεν μπορεί να διαβάσει μια απάντηση. Το `CORS_ORIGINS` απαριθμεί τις προελεύσεις web των οποίων οι σελίδες μπορούν να καλούν το API από πρόγραμμα περιήγησης, την καθεμία ακριβώς όπως τη στέλνουν τα προγράμματα περιήγησης στο `Origin`: `https://host` ή `https://host:port`, με πεζά, χωρίς διαδρομή, τελική κάθετο ή την προεπιλεγμένη θύρα· `http://` μόνο για `localhost`, `127.0.0.1` και `[::1]` (τοπική ανάπτυξη)· χωρίς μπαλαντέρ. Ο επίσημος διακομιστής ορίζει `CORS_ORIGINS=https://scacelith.com,https://www.scacelith.com` για τον ιστότοπό του. Τότε:
      - Κάθε απάντηση για διαδρομή κάτω από το `/api/v1`, εκτός από τα endpoint κατάστασης λειτουργίας, επιτυχίες και σφάλματα εξίσου (μαζί με το 429 του επιπέδου ανά διεύθυνση), φέρει `Vary: Origin`. Όταν το `Origin` του αιτήματος είναι, byte προς byte, μία από τις προελεύσεις της λίστας, φέρει επίσης `Access-Control-Allow-Origin: <that origin>` και, εκτός από την απάντηση σε preflight, `Access-Control-Expose-Headers: Retry-After, Content-Disposition`, ώστε η σελίδα να διαβάζει τους κωδικούς σφάλματος, το `Retry-After` και τα ονόματα αρχείων των λήψεων PGN και GIF.
      - Ένα preflight (`OPTIONS` με `Access-Control-Request-Method`) από προέλευση της λίστας σε υπάρχουσα διαδρομή απαντά 204, χωρίς σώμα, με `Allow`, `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (οι μέθοδοι του `Allow`), `Access-Control-Allow-Headers: Authorization, Content-Type`, `Access-Control-Max-Age: 600` και `Vary: Origin`. Δεν χρειάζεται token και δεν μετρά ούτε στον προϋπολογισμό κάποιου λογαριασμού ούτε σε κάποιο όριο endpoint· όπως κάθε αίτημα, παίρνει μια μάρκα του επιπέδου ανά διεύθυνση. Ένα πρόγραμμα περιήγησης επαναχρησιμοποιεί ένα preflight για 10 λεπτά, αλλά μόνο για το ίδιο ακριβώς URL, μαζί με τη συμβολοσειρά ερωτήματος. Ένα αίτημα χωρίς `Authorization` και χωρίς σώμα JSON, όπως το `GET /info` ή το `GET /leaderboard`, δεν χρειάζεται preflight.
      - Κάθε άλλη απάντηση σε preflight είναι αποτυχημένο preflight, ακόμη και με τις κεφαλίδες CORS: ένα 404 για διαδρομή που δεν υπάρχει ή το 429 του επιπέδου ανά διεύθυνση. Το πρόγραμμα περιήγησης τότε δεν στέλνει το αίτημα, και η σελίδα βλέπει σφάλμα δικτύου, χωρίς την απάντηση ή το `Retry-After` της.
      - Δεν στέλνεται ποτέ `Access-Control-Allow-Credentials`, ούτε `Access-Control-Allow-Origin: *`: μια σελίδα στέλνει το token συνεδρίας της στο `Authorization: Bearer`, όπως το παιχνίδι, και το API δεν ορίζει κανένα cookie. Οι σελίδες HTML, τα endpoint κατάστασης λειτουργίας και οι κενές απαντήσεις σε μη αναγνώσιμα αιτήματα δεν φέρουν καμία κεφαλίδα CORS. Το WebSocket δεν χρησιμοποιεί CORS: το `WS_ALLOWED_ORIGINS` απαριθμεί τις σελίδες που μπορούν να το ανοίξουν.
      - Μια σελίδα με προέλευση που δεν είναι στη λίστα δεν διαβάζει τίποτα, και τα cross-site αιτήματα εγγραφής της δεν στέλνονται ποτέ: τα σώματα JSON και το `Authorization` χρειάζονται preflight, του οποίου η απάντηση δεν έχει `Access-Control-Allow-Origin` για εκείνη την προέλευση. Το `Cross-Origin-Resource-Policy: same-origin` παραμένει σε κάθε απάντηση: ισχύει μόνο για αιτήματα `no-cors` (άλλος ιστότοπος που ενσωματώνει μια απάντηση), ποτέ για τα αιτήματα `cors` μιας σελίδας της λίστας.

    ## Απαντήσεις και σφάλματα

    - Οι απαντήσεις είναι JSON σε UTF-8, εκτός από τη λήψη PGN (`application/x-chess-pgn`), τα κινούμενα GIF (`image/gif`) και τις σελίδες HTML. Οι χρόνοι είναι χιλιοστά του δευτερολέπτου από την 1970-01-01 UTC. Τα αναγνωριστικά (id) είναι ακέραιοι. Τα αναγνωριστικά παρτίδων έχουν έως 16 ψηφία αλλά μένουν κάτω από το 2^53, οπότε ένας αριθμός JSON (double) τα αναπαριστά ακριβώς.
    - Κάθε απάντηση του API φέρει `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin` και `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (οι σελίδες HTML έχουν δική τους πολιτική). Με `TLS_MODE=native` φέρει επίσης `Strict-Transport-Security: max-age=31536000`. Οι απαντήσεις που γράφει ο ίδιος ο listener, πριν από το API (οι `GET` και `HEAD` των endpoint κατάστασης λειτουργίας, το 429 του επιπέδου ανά διεύθυνση και ένα 500 `internal_error` όταν το API αποτυγχάνει ολοκληρωτικά), φέρουν τις ίδιες κεφαλίδες εκτός από το `Cross-Origin-Resource-Policy`, και το `Content-Type` τους είναι `application/json` χωρίς charset.
    - Μια απάντηση πρέπει να διαβαστεί μέσα σε 60 δευτερόλεπτα από τη στιγμή που ο διακομιστής την έχει έτοιμη, κάτι που έχει σημασία μόνο για τις μεγάλες (ένα GIF, η εξαγωγή δεδομένων, ένα μακρύ PGN). Ο χρόνος που χρειάζεται ο διακομιστής για να ετοιμάσει μια απάντηση δεν μετρά ούτε σε αυτό ούτε στα 30 δευτερόλεπτα χωρίς κανένα byte εισερχόμενο ή εξερχόμενο, μετά τα οποία μια σύνδεση κλείνει.

    Τα σφάλματα έχουν μία μορφή (το σχήμα `Error`):

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

    Το `message` προορίζεται για τα αρχεία καταγραφής και ως εναλλακτική λύση· ένας πελάτης επιλέγει τι θα εμφανίσει με βάση το `error`. Το `retryAfter` (σε δευτερόλεπτα) υπάρχει μόνο σε απορρίψεις που λήγουν με τον χρόνο, και η απάντηση τότε φέρει επίσης κεφαλίδα `Retry-After` με την ίδια τιμή· η μόνη εξαίρεση είναι το 503 `busy` των αναγνώσεων του ιστορικού, των παρτίδων, των παικτών και του πίνακα κατάταξης, που έχει μόνο το πεδίο. Ορισμένα σφάλματα προσθέτουν πεδία: `field` (μη έγκυρη είσοδος), `reason` (`weak_password`, `pow_required`), `pow` (`pow_required`), `until` (`banned`), `line` και `column` (`invalid_pgn`).

    Σφάλματα που μπορεί να δώσει οποιοδήποτε endpoint:

    | Κατάσταση | `error` | Πότε |
    |---|---|---|
    | 400 | `invalid_request` | Το σώμα παραβιάζει το σχήμα του endpoint (το `field` λέει πού), ο στόχος του αιτήματος ή το `Content-Length` είναι κακοσχηματισμένο, μια παράμετρος διαδρομής δεν είναι έγκυρη κωδικοποίηση URL, ή το σώμα κόπηκε. |
    | 400 | `invalid_json` | Το σώμα δεν είναι JSON. |
    | 401 | `unauthorized` | Καμία κεφαλίδα `Authorization` σε endpoint που απαιτεί συνεδρία. |
    | 401 | `invalid_token` | Το token είναι κακοσχηματισμένο, έχει λήξει, έχει ανακληθεί ή ανήκει σε διαγραμμένο λογαριασμό· αυτό ισχύει και στα endpoint όπου η συνεδρία είναι προαιρετική. |
    | 404 | `not_found` | Δεν υπάρχει τέτοιο endpoint. Το χρησιμοποιούν και ορισμένα endpoint: δεν υπάρχει τέτοια παρτίδα, τέτοιος παίκτης ή τέτοια συνεδρία. |
    | 405 | `method_not_allowed` | Η διαδρομή υπάρχει για άλλες μεθόδους (βλ. `Allow`). |
    | 408 | `request_timeout` | Το σώμα δεν έφτασε μέσα σε 10 δευτερόλεπτα. |
    | 413 | `payload_too_large` | Το σώμα υπερβαίνει το `HTTP_BODY_LIMIT` (135.168 byte για το `POST /gif`). |
    | 414 | `uri_too_long` | Ο στόχος του αιτήματος υπερβαίνει τους 4096 χαρακτήρες. |
    | 415 | `unsupported_media_type` | Το σώμα δεν είναι `application/json`, ή το charset του δεν είναι UTF-8. |
    | 429 | `rate_limited` | Ένα όριο ρυθμού αιτημάτων (βλ. παρακάτω): `retryAfter` και κεφαλίδα `Retry-After`. |
    | 500 | `internal_error` | Μια απρόσμενη αποτυχία. Ο διακομιστής την καταγράφει. |
    | 503 | `timeout` | Ο διακομιστής δεν απάντησε μέσα σε 30 δευτερόλεπτα (60 δευτερόλεπτα για την εξαγωγή, 45 δευτερόλεπτα για τα GIF με τις προεπιλεγμένες ρυθμίσεις). |
    | 503 | `server_busy` | Μια αναζήτηση συνεδρίας ή μια αλλαγή λογαριασμού βρήκε τη βάση δεδομένων κλειδωμένη (`retryAfter` 1), ή η ουρά κατακερματισμού κωδικών πρόσβασης είναι γεμάτη (βλ. παρακάτω). Επίσης, με `retryAfter` από 2 έως 5, όταν ο διακομιστής έχει μείνει πολύ πίσω: για `POST`, `PUT`, `PATCH` ή `DELETE`, η βάση δεδομένων έχει πάρα πολλές εγγραφές σε αναμονή· για οποιοδήποτε αίτημα, πάρα πολλά προηγούμενα αιτήματα εκτελούνται ακόμη πέρα από το χρονικό τους όριο, ή το token συνεδρίας δεν μπόρεσε να ελεγχθεί μέσα σε 5 δευτερόλεπτα. Το αίτημα δεν εκτελέστηκε, και τα όρια ρυθμού αιτημάτων του δεν πήραν τίποτα. |

    Τα endpoint ανάγνωσης (ιστορικό παρτίδων, παρτίδες, παίκτες, πίνακας κατάταξης) και η εξαγωγή απαντούν 503 `busy` με `retryAfter: 1` όταν η βάση δεδομένων παρέμεινε κλειδωμένη.

    Τα endpoint που ελέγχουν ή κατακερματίζουν έναν κωδικό πρόσβασης μπορούν επίσης να απαντήσουν ένα από τα παρακάτω, και τα δύο με τυχαίο `retryAfter` από 5 έως 15 δευτερόλεπτα:

    - 503 `server_busy`: η ουρά κατακερματισμού κωδικών πρόσβασης του διακομιστή (`PASSWORD_HASH_QUEUE_MAX`) είναι γεμάτη, ή ο χρόνος αναμονής εξαντλήθηκε.
    - 429 `rate_limited`: η ουρά είναι γεμάτη τουλάχιστον κατά το ήμισυ και αυτός ο πελάτης (μια διεύθυνση IPv4 ή ένα /48 IPv6) έχει ήδη `PASSWORD_HASH_WAITERS_PER_SOURCE` κατακερματισμούς σε αναμονή. Αυτό το 429 επιστρέφει τις μάρκες ορίων ρυθμού αιτημάτων που είχε πάρει το αίτημα.

    Δεν άλλαξε τίποτα και δεν μετρήθηκε καμία αποτυχημένη απόπειρα (εκτός από το `POST /auth/sso/google/link`, για το οποίο η δοκιμή του ticket και η αποτυχία του λογαριασμού είχαν ήδη μετρηθεί πριν από τον κατακερματισμό), και ένας σύνδεσμος επαναφοράς παραμένει έγκυρος.

    Οι σελίδες HTML απαντούν τα σφάλματά τους ως σελίδες HTML με τους ίδιους κωδικούς κατάστασης.

    ## Έλεγχος ταυτότητας

    Τα endpoint που απαιτούν συνεδρία δέχονται ένα bearer token στην κεφαλίδα `Authorization` (το σχήμα `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **Απόκτηση token.** Ένα token (`sct_` ακολουθούμενο από 43 χαρακτήρες base64url) προέρχεται από το `POST /auth/login`, και μετά από το `POST /auth/login/mfa` όταν ο έλεγχος ταυτότητας δύο παραγόντων είναι ενεργός, από τα `POST /auth/sso/google/finish` και `POST /auth/sso/google/link` (σύνδεση με Google), και από το `POST /auth/sso/complete`. Όλα απαντούν `{ token, expiresAt, user }`. Ο διακομιστής αποθηκεύει μόνο ένα SHA-256 του token.
    - **Υποχρεωτική συνεδρία.** Αν λείπει η κεφαλίδα, η απάντηση είναι 401 `unauthorized` με `WWW-Authenticate: Bearer realm="scacelith"`. Ένα μη έγκυρο token δίνει 401 `invalid_token` με `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. Ένας πελάτης θα πρέπει να ξεχνά ένα token που παίρνει `invalid_token` και να συνδέεται ξανά.
    - **Προαιρετική συνεδρία.** Στα `GET /games/{id}`, `GET /games/{id}/pgn`, `GET /players/{username}` και `GET /players/{username}/games` η συνεδρία είναι προαιρετική. Χωρίς την κεφαλίδα απαντούν με τη δημόσια προβολή· αν σταλεί κεφαλίδα, το token της πρέπει να είναι έγκυρο.
    - **Απασχολημένη βάση δεδομένων.** Όταν η βάση δεδομένων παραμένει κλειδωμένη ενώ ελέγχεται ένα token, ένα endpoint που απαιτεί ή δέχεται συνεδρία απαντά 503 `server_busy` με `retryAfter: 1` (και `Retry-After`)· όταν ο έλεγχος διαρκεί περισσότερο από 5 δευτερόλεπτα, απαντά 503 `server_busy` με `retryAfter` από 2 έως 5. Σε κάθε περίπτωση το token δεν κρίθηκε: κρατήστε το και στείλτε ξανά το αίτημα.
    - **Διάρκεια ζωής.** Μια συνεδρία λήγει με το πρώτο από τα δύο: `SESSION_MAX_DAYS` (90) ημέρες μετά τη σύνδεση (αυτό είναι το `expiresAt`) ή `SESSION_IDLE_DAYS` (30) ημέρες χωρίς χρήση. Κάθε χρήση μεταθέτει το όριο αδράνειας· ο διακομιστής γράφει τη νέα τιμή το πολύ κάθε 5 λεπτά, στο παρασκήνιο. Όσο η βάση δεδομένων του έχει πολλές εγγραφές σε αναμονή, αναβάλλει αυτή την εγγραφή σε μεταγενέστερο αίτημα, εκτός από την τελευταία ώρα πριν από το όριο αδράνειας, ώστε μια συνεδρία σε χρήση να μη λήγει. Ένας λογαριασμός κρατά το πολύ `MAX_SESSIONS_PER_USER` (10) συνεδρίες: μια νέα σύνδεση ανακαλεί την παλαιότερη πέρα από αυτόν τον αριθμό.
    - **Ανάκληση.** Η αποσύνδεση (`POST /auth/logout`, `POST /auth/logout-all`, `DELETE /auth/sessions/{id}`) ανακαλεί συνεδρίες· το ίδιο κάνουν μια αλλαγή κωδικού πρόσβασης (τις άλλες συνεδρίες), μια επαναφορά κωδικού πρόσβασης και η διαγραφή του λογαριασμού (κάθε συνεδρία), καθώς και ένας διαχειριστής. Μια ανάκληση από το API ισχύει αμέσως, και το WebSocket που άνοιξε με τη συνεδρία που ανακλήθηκε κλείνει. Μια αλλαγή ή επαναφορά κωδικού πρόσβασης και η διαγραφή του λογαριασμού ανακαλούν στην ίδια συναλλαγή με την ίδια την αλλαγή: όταν μία από αυτές αποτυγχάνει, δεν ανακλήθηκε καμία συνεδρία και δεν άλλαξε ούτε τίποτε άλλο. Ο διακομιστής κρατά στην κρυφή μνήμη τις αναζητήσεις συνεδριών για 30 δευτερόλεπτα, οπότε μια ανάκληση από την εντολή διαχείρισης, που είναι ξεχωριστή διεργασία, ισχύει μέσα σε 30 δευτερόλεπτα.
    - **Εμβέλεια.** Ένα token ανήκει σε έναν διακομιστή και ανοίγει και το WebSocket του. Μην το στέλνετε ποτέ σε άλλον διακομιστή.

    ## Όρια ρυθμού αιτημάτων και άλλοι περιορισμοί

    Τα όρια εφαρμόζονται σε ένα από δύο πεδία. **Πελάτης:** μια διεύθυνση IPv4, ή ένα /64 IPv6 (σε λειτουργία proxy, η διεύθυνση προέρχεται από το `X-Forwarded-For` που στέλνει μια διεύθυνση του `TRUSTED_PROXIES`)· ορισμένα όρια μετρούν επίσης κάθε /48 IPv6 ως σύνολο, επιπλέον καθενός από τα δίκτυα /64 του. **Παίκτης:** ο συνδεδεμένος λογαριασμός, όποια κι αν είναι η διεύθυνσή του· ένα όριο που μετριέται ανά παίκτη σε endpoint όπου η συνεδρία είναι προαιρετική μετριέται ανά πελάτη για αίτημα χωρίς token.

    Κάθε όριο είναι ένα token bucket που χωρά `limit` αιτήματα και ξαναγεμίζει συνεχώς με ρυθμό `limit / window`· το `retryAfter` είναι ο χρόνος ως την επόμενη μάρκα. Τα όρια που σημειώνονται ως *κοινά* μετρώνται επίσης σε κυλιόμενο παράθυρο ίδιας διάρκειας, ώστε κανένα παράθυρο να μην περιέχει πολύ περισσότερα από `limit` αιτήματα. Κάθε όριο μετριέται για ολόκληρο τον διακομιστή. Όταν ένα από τα όρια ενός endpoint απορρίπτει ένα αίτημα, οι μάρκες που πήραν τα άλλα όριά του για αυτό το αίτημα επιστρέφονται. Μια απόρριψη δίνει 429 `rate_limited` με `retryAfter` και `Retry-After`.

    - **Επίπεδο ανά διεύθυνση.** Κάθε αίτημα (οποιαδήποτε διαδρομή και μέθοδος, μαζί με τα endpoint κατάστασης λειτουργίας και τις αναβαθμίσεις σε WebSocket) παίρνει πρώτα μία μάρκα από τον προϋπολογισμό του πελάτη του: `HTTP_RATE_PER_IP` (600) ανά λεπτό με ριπή μισού λεπτού, και `HTTP_RATE_PER_PREFIX` (4 × `HTTP_RATE_PER_IP`) για ένα /48 IPv6. Ένας πελάτης μπορεί επίσης να έχει το πολύ `IP_MAX_INFLIGHT` (32 × `WORKERS`) αιτήματα σε εξέλιξη (`retryAfter` 1 πέρα από αυτό). Ένας πελάτης που συνεχίζει παρά τις απορρίψεις του αποκλείεται: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) απορρίψεις μέσα σε ένα λεπτό τον αποκλείουν για 1 λεπτό, και μετά για 4, 16 και 60 λεπτά σε κάθε νέο αποκλεισμό μέσα σε 6 ώρες. Μια απόρριψη των ορίων `auth`, `auth_*` και `reauth` μετρά για 5· οι απορρίψεις των ορίων που μετρώνται ανά παίκτη δεν μετρούν ποτέ. Οι διευθύνσεις του `ABUSE_EXEMPT` παρακάμπτουν αυτό το επίπεδο, αλλά όχι τον προϋπολογισμό του λογαριασμού ή τα όρια των endpoint.
    - **Προϋπολογισμός του λογαριασμού.** Κάθε αίτημα που φέρει έγκυρο token συνεδρίας μετρά επίσης στον λογαριασμό του: `USER_RATE_PER_MIN` (120) ανά λεπτό για όλα τα endpoint μαζί, όποια κι αν είναι η διεύθυνση, με ριπή μισού λεπτού.
    - **Όρια των endpoint:**

    | Όριο | Προεπιλογή | Μετριέται ανά | Endpoint |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 λεπτά, κοινό | πελάτη, και `AUTH_RATE_PER_PREFIX` (5 × `AUTH_RATE_PER_IP`) ανά /48 IPv6 | `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) / ώρα, κοινό | πελάτη, 3 φορές περισσότερα ανά /48 IPv6 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / ώρα, κοινό | πελάτη, 3 φορές περισσότερα ανά /48 IPv6 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / ώρα, κοινό | πελάτη, 3 φορές περισσότερα ανά /48 IPv6 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 ώρες, κοινό | πελάτη, 3 φορές περισσότερα ανά /48 IPv6 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / ώρα, κοινό | πελάτη, 3 φορές περισσότερα ανά /48 IPv6 | `POST /auth/password/reset`, `POST /reset-password` |
    | `reauth` | οι αριθμοί του `auth`, με δικό του bucket, κοινό | πελάτη και /48 IPv6 | `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 λεπτά, κοινό | παίκτη | τα ίδια endpoint με το `reauth` |
    | `account` | 60 / λεπτό | παίκτη | `GET /account/me`, `PUT /account/preferences` |
    | `account_games` | 60 / λεπτό | παίκτη | `GET /account/games` |
    | `account_export` | 5 / ώρα, κοινό | παίκτη | `POST /account/export` (ελέγχεται πριν από το `reauth`· κάθε απόπειρα μετρά) |
    | `sessions` | 60 / λεπτό | παίκτη | `POST /auth/logout`, `/auth/logout-all`, `GET /auth/sessions`, `DELETE /auth/sessions/{id}` |
    | `public_read` | 60 / λεπτό | παίκτη (πελάτη χωρίς token) | `GET /players/{username}`, `/players/{username}/games`, `/games/{id}`, `/games/{id}/pgn` (ένα κοινό bucket και για τα τέσσερα) |
    | `gif` | 30 / λεπτό | παίκτη | `GET /games/{id}/gif`, `POST /gif` (ένα κοινό bucket και για τα δύο) |
    | `gif_user_min`, `gif_user_hour` | `GIF_USER_RENDERS_PER_MIN` (4) / λεπτό και `GIF_USER_RENDERS_PER_HOUR` (30) / ώρα, κοινά | παίκτη | τα ίδια δύο, μόνο όταν το GIF πρέπει να σχεδιαστεί (όχι από την κρυφή μνήμη) |
    | `gif_ip_min`, `gif_ip_hour` | `GIF_IP_RENDERS_PER_MIN` (12) / λεπτό και `GIF_IP_RENDERS_PER_HOUR` (120) / ώρα, κοινά | πελάτη (όλοι οι λογαριασμοί του μαζί), 3 φορές περισσότερα ανά /48 IPv6 | τα ίδια, όπως παραπάνω |
    | `reports` | 30 / ώρα | παίκτη | `POST /reports` |
    | `sso_start` | 30 / 10 λεπτά, κοινό | πελάτη, και 90 ανά /48 IPv6 | `POST /auth/sso/google/start`, `POST /auth/sso/google/web/start` (μαζί) |
    | `sso_finish` | 30 / λεπτό | πελάτη | `POST /auth/sso/google/finish` |
    | `page` | 60 / λεπτό | πελάτη | `GET /verify-email`, `/reset-password`, `/confirm-email-change` |

    Τα `GET /info` και `GET /leaderboard` δεν έχουν δικό τους όριο: μόνο το επίπεδο ανά διεύθυνση. Ένα endpoint με πολλά όρια τα ελέγχει με τη σειρά που δίνεται στην περιγραφή του.

    Ο διακομιστής χειρίζεται ένα αίτημα με αυτή τη σειρά: το επίπεδο ανά διεύθυνση, και μετά τα endpoint κατάστασης λειτουργίας· η αντιστοίχιση του endpoint· οι έλεγχοι ότι ο διακομιστής δεν έχει μείνει πολύ πίσω (503 `server_busy`): πάρα πολλά προηγούμενα αιτήματα που εκτελούνται ακόμη πέρα από το χρονικό τους όριο και, για `POST`, `PUT`, `PATCH` ή `DELETE`, οι εγγραφές που περιμένουν στη βάση δεδομένων· ο έλεγχος ταυτότητας, μέσα σε 5 δευτερόλεπτα (503 `server_busy` πέρα από αυτά)· ο προϋπολογισμός του λογαριασμού, όταν το αίτημα φέρει συνεδρία· τα όρια του endpoint· το σώμα· το ίδιο το endpoint (τα endpoint των GIF παίρνουν από τα όρια σχεδίασής τους μόνο όταν έχουν να σχεδιάσουν ένα GIF). Έτσι, ένα αίτημα που απορρίπτεται λόγω του token του δεν ξοδεύει καμία μάρκα του endpoint, ενώ ένα αίτημα με μη έγκυρο σώμα τις ξοδεύει.

    Άλλοι περιορισμοί, που απαντώνται από τα ίδια τα endpoint:

    - **Αποτυχημένες συνδέσεις σε ένα όνομα σύνδεσης** (όνομα χρήστη ή email): από τις `AUTH_FAILURES_PER_ACCOUNT` (5) αποτυχίες και μετά, κάθε απόπειρα πρέπει να περιμένει διπλάσιο χρόνο από την προηγούμενη (2 s, 4 s κ.ο.κ., έως 15 λεπτά): 429 `too_many_attempts` με `retryAfter`. Ο μετρητής μηδενίζεται μετά από μία ώρα χωρίς αποτυχίες.
    - **Αποτυχημένοι δεύτεροι παράγοντες κατά τη σύνδεση:** ο ίδιος κανόνας από τον 5ο λάθος κωδικό του λογαριασμού. Ένα βήμα σύνδεσης δέχεται το πολύ 5 λάθος κωδικούς.
    - **Κωδικοί δεύτερου παράγοντα ενός λογαριασμού:** το πολύ `AUTH_MFA_PER_ACCOUNT` (10) κωδικοί (κωδικοί της εφαρμογής ελέγχου ταυτότητας ή κωδικοί ανάκτησης, σωστοί ή λάθος) ανά 15 λεπτά, από οποιαδήποτε διεύθυνση, κατά τη σύνδεση και στους επανελέγχους ταυτότητας· πέρα από αυτό, 429 `too_many_attempts` πριν ελεγχθεί ο κωδικός, ώστε να μην αναλώνεται ένας κωδικός ανάκτησης.
    - **Αποτυχημένοι επανέλεγχοι ταυτότητας ενός λογαριασμού** (λάθος κωδικός πρόσβασης ή κωδικός): ο ίδιος κανόνας (429 `too_many_attempts`), κοινός για όλα τα endpoint που κάνουν επανέλεγχο ταυτότητας.
    - **Email:** ένα email επιβεβαίωσης ή επαναφοράς ανά διεύθυνση κάθε 5 λεπτά (η απάντηση μένει ίδια)· μία ειδοποίηση «κάποιος προσπάθησε να χρησιμοποιήσει τη διεύθυνσή σας» ανά διεύθυνση ανά ώρα.
    - **Αναφορές:** `REPORTS_PER_DAY` (5) ανά παίκτη σε 24 ώρες: 429 `report_limit`.

    ## Απόδειξη εργασίας

    Το `POST /auth/register` χρειάζεται πάντα απόδειξη εργασίας όταν το `POW_REGISTER_BITS` είναι πάνω από 0 (18 από προεπιλογή· το `GET /info` το δίνει ως `pow.register`). Τα `POST /auth/login` και `POST /auth/sso/google/link` (ένα είδος πρόκλησης και για τα δύο) τη χρειάζονται μόνο για 5 λεπτά αφού ο διακομιστής δει ένα κύμα αποτυχημένων συνδέσεων (`POW_LOGIN_TRIGGER_PER_MIN`, μετά `POW_LOGIN_BITS`)· ένας πελάτης το μαθαίνει από την απάντηση.

    1. Το αίτημα χωρίς απόδειξη (ή με απόδειξη που απορρίφθηκε) δίνει 428 `pow_required` με `reason` και μια πρόκληση `pow` `{ challenge, bits, expiresAt }`.
    2. Βρείτε ένα nonce: μια δεκαδική συμβολοσειρά έως 20 ψηφίων τέτοια ώστε το `SHA-256(challenge + ":" + nonce)` να ξεκινά με `bits` μηδενικά bit (πρώτα το πιο σημαντικό bit του πρώτου byte). Για 18 bit χρειάζονται κατά μέσο όρο περίπου 260.000 κατακερματισμοί.
    3. Στείλτε ξανά το ίδιο αίτημα με `"pow": { "challenge": "...", "nonce": "123456" }` στο σώμα.

    Μια πρόκληση ισχύει για 2 λεπτά και μόνο μία φορά, για ένα endpoint και ένα δίκτυο πελάτη (μια διεύθυνση IPv4 ή ένα /64 IPv6). Είναι υπογεγραμμένη, οπότε ο διακομιστής δεν κρατά τίποτα γι’ αυτήν μέχρι να επιστρέψει. Το `reason` λέει γιατί απορρίφθηκε μια απόδειξη: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` ή `replayed`.

    ## Επανέλεγχος ταυτότητας

    Οι αλλαγές λογαριασμού ζητούν ξανά τον κωδικό πρόσβασης και, όταν ο έλεγχος ταυτότητας δύο παραγόντων είναι ενεργός, έναν δεύτερο παράγοντα:

    - μόνο τον κωδικό πρόσβασης: `POST /account/password` και `POST /account/mfa/totp/setup`·
    - τον κωδικό πρόσβασης και έναν κωδικό της εφαρμογής ελέγχου ταυτότητας, ενώ ένας κωδικός ανάκτησης απορρίπτεται: `POST /account/mfa/recovery-codes`·
    - τον κωδικό πρόσβασης και έναν κωδικό της εφαρμογής ελέγχου ταυτότητας ή έναν κωδικό ανάκτησης: `POST /account/mfa/totp/disable`, `POST /account/email`, `POST /account/export` και `POST /account/delete`.

    Στα σώματα, το `code` περιέχει έναν 6ψήφιο κωδικό της εφαρμογής ελέγχου ταυτότητας και το `recoveryCode` έναν κωδικό ανάκτησης (`xxxx-xxxx-xx`· τα κεφαλαία ή πεζά, τα κενά και οι παύλες δεν έχουν σημασία). Ένας κωδικός ανάκτησης μπορεί επίσης να σταλεί στο `code` όπου γίνονται δεκτοί κωδικοί ανάκτησης. Κάθε κωδικός λειτουργεί μία φορά: ένας χρησιμοποιημένος κωδικός της εφαρμογής απορρίπτεται μέχρι το επόμενο βήμα των 30 δευτερολέπτων, και ένας κωδικός ανάκτησης χάνεται μόλις χρησιμοποιηθεί.

    | Κατάσταση | `error` | Πότε |
    |---|---|---|
    | 403 | `invalid_password` | Λάθος κωδικός πρόσβασης, ή ο κωδικός πρόσβασης επαναφέρθηκε ή άλλαξε όσο εκτελούνταν το αίτημα (δεν άλλαξε τίποτα). |
    | 403 | `mfa_code_required` | Ο έλεγχος ταυτότητας δύο παραγόντων είναι ενεργός και δεν στάλθηκε ούτε `code` ούτε `recoveryCode`. |
    | 403 | `invalid_code` | Λάθος ή ήδη χρησιμοποιημένος κωδικός. |
    | 400 | `password_not_set` | Ένας λογαριασμός μόνο με Google δεν έχει ακόμη κωδικό πρόσβασης (το «Ξεχάσατε τον κωδικό;» ορίζει έναν). |
    | 429 | `too_many_attempts` | Πάρα πολλές αποτυχίες, ή πάρα πολλοί κωδικοί που δοκιμάστηκαν, σε αυτόν τον λογαριασμό. |
    | 503 / 429 | `server_busy` / `rate_limited` | Η ουρά κατακερματισμού κωδικών πρόσβασης είναι απασχολημένη. |

    Οι αποτυχημένοι κωδικοί πρόσβασης και κωδικοί μετρούν στον μετρητή αποτυχιών του λογαριασμού και καταγράφονται ως συμβάντα ασφαλείας.

    ## Θύρα μετρικών

    Εκτός από τη θύρα του API, ο διακομιστής απαντά σε σκέτο HTTP στη θύρα μετρικών (`METRICS_PORT`, 9464, δεσμευμένη στο `METRICS_BIND`, 127.0.0.1 από προεπιλογή· κρατήστε την ιδιωτική). Δεν είναι μέρος αυτού του API: το `GET /healthz` απαντά `ok`· το `GET /readyz` απαντά `ready` μόλις ολοκληρωθεί η εκκίνηση (κάθε shard έχει αναπαραγάγει το ημερολόγιό του και οι listener έχουν δεσμευτεί) και μέχρι να αρχίσει ο τερματισμός, αλλιώς 503 `not ready`· το `GET /metrics` σερβίρει τις μετρικές Prometheus και, όταν έχει οριστεί το `METRICS_TOKEN`, απαιτεί `Authorization: Bearer <token>` με αυτό ακριβώς το token.
  contact:
    name: Scacelith
    url: https://github.com/DarkCenobyte/scacelith-chess-server
  license:
    name: GPL-3.0-or-later
    identifier: GPL-3.0-or-later
externalDocs:
  description: Ο πηγαίος κώδικας του διακομιστή Scacelith και η τεκμηρίωση αναφοράς του (API.md, PROTOCOL.md, CONFIG.md).
  url: https://github.com/DarkCenobyte/scacelith-chess-server
servers:
  - url: https://caissa.scacelith.com/api/v1
    description: Ο επίσημος διακομιστής.
  - url: https://{host}:{port}/api/v1
    description: Οποιοσδήποτε διακομιστής Scacelith, όπως ένας κοινοτικός διακομιστής.
    variables:
      host:
        default: caissa.scacelith.com
        description: Το δημόσιο όνομα κεντρικού υπολογιστή του διακομιστή (`SERVER_PUBLIC_HOST`).
      port:
        default: "443"
        description: Η δημόσια θύρα του API (`PUBLIC_API_PORT`, αλλιώς `API_PORT`).
tags:
  - name: server-info
    x-displayName: Πληροφορίες διακομιστή
    description: Τι χρειάζεται να γνωρίζει ένας πελάτης πριν συνδεθεί σε λογαριασμό ή στον διακομιστή.
  - name: health
    x-displayName: Κατάσταση λειτουργίας
    description: |-
      Αν ο διακομιστής λειτουργεί (liveness) και αν είναι έτοιμος (readiness), για την παρακολούθησή του. Απαντούν στη θύρα του API πριν από τον έλεγχο ταυτότητας και πριν από κάθε όριο endpoint, αλλά όπως κάθε αίτημα παίρνουν μια μάρκα του επιπέδου ανά διεύθυνση· ένας υπολογιστής παρακολούθησης μπορεί να προστεθεί στο `ABUSE_EXEMPT`. Και οι δύο διαδρομές λειτουργούν για κάθε endpoint: στη ρίζα του διακομιστή και κάτω από το `/api/v1`.

      Η θύρα μετρικών έχει δικά της endpoint κατάστασης λειτουργίας, που περιγράφονται στην εισαγωγή.
  - name: auth
    x-displayName: Εγγραφή και σύνδεση
    description: |-
      Δημιουργία λογαριασμού, σύνδεση με κωδικό πρόσβασης (και δεύτερο παράγοντα), email επιβεβαίωσης και επαναφοράς κωδικού πρόσβασης.

      Τα `POST /auth/login`, `POST /auth/login/mfa`, `POST /auth/sso/google/finish`, `POST /auth/sso/google/link` και `POST /auth/sso/complete` απαντούν με μια συνεδρία `{ token, expiresAt, user }` (ή, τα πρώτα από αυτά, με ένα δεύτερο βήμα).
  - name: google-sign-in
    x-displayName: Σύνδεση με Google
    description: |-
      Προσφέρεται όταν το `GET /info` λέει `sso.google: true`· διαφορετικά, κάθε endpoint παρακάτω απαντά 404 `sso_disabled`. Ένας ιστότοπος συνδέεται με τον ίδιο τρόπο, με δικό του πελάτη, όταν το `sso.googleWeb` είναι true (παρακάτω, **Σε ιστότοπο**). Το παιχνίδι συνδέεται μέσω του προγράμματος περιήγησης του συστήματος με τη ροή εγκατεστημένων εφαρμογών του RFC 8252 (πελάτης τύπου «Desktop app»): η Google στέλνει το πρόγραμμα περιήγησης πίσω σε έναν listener του παιχνιδιού στο `127.0.0.1`, ποτέ σε αυτόν τον διακομιστή, και το παιχνίδι δεν βλέπει ποτέ κανένα διαπιστευτήριο Google.

      1. Ο πελάτης ακούει στο `127.0.0.1:0` (το σύστημα επιλέγει τη θύρα) και δημιουργεί ένα ζεύγος PKCE: ένα `codeVerifier` 43 έως 128 χαρακτήρων `[A-Za-z0-9._~-]` και `codeChallenge = BASE64URL(SHA-256(codeVerifier))`, που έχει 43 χαρακτήρες και κανένα padding.
      2. Το `POST /auth/sso/google/start` με την πρόκληση και τη θύρα επιστρέφει το URL της Google και το `state` της απόπειρας. Ο πελάτης ελέγχει το URL (βλ. παρακάτω) και το ανοίγει στο πρόγραμμα περιήγησης.
      3. Η Google στέλνει το πρόγραμμα περιήγησης στο `http://127.0.0.1:<port>/oauth2/google/<tag>?code=...&state=...`. Ο πελάτης δέχεται μόνο το `state` της απάντησης του βήματος έναρξης και δεν στέλνει τίποτα μετά από ανακατεύθυνση `error=`.
      4. `POST /auth/sso/google/finish` με το αναγνωριστικό της απόπειρας, το `codeVerifier`, το `state` και το `code` (και το `iss`, όταν το έστειλε η Google).
      5. Η απάντηση είναι μια συνεδρία, ένα βήμα ελέγχου ταυτότητας δύο παραγόντων (συνεχίστε με `POST /auth/login/mfa`), `needsUsername` για νέο παίκτη (συνεχίστε με `POST /auth/sso/complete`) ή `needsPassword` όταν τη διεύθυνση χρησιμοποιεί ένας λογαριασμός με κωδικό πρόσβασης (συνεχίστε με `POST /auth/sso/google/link`).

      **Η ετικέτα προέλευσης.** Το URI ανακατεύθυνσης φέρει μια ετικέτα του διακομιστή που πρόσθεσε ο παίκτης, ώστε ένα URL που πήρε ένας άλλος διακομιστής για τους δικούς του παίκτες να απορρίπτεται από το παιχνίδι. Η προέλευση είναι ο host με πεζά (μια κυριολεκτική διεύθυνση IPv6 σε αγκύλες), `:` και η δεκαδική θύρα του API, γραμμένη πάντα, ακόμη και η 443: ο διακομιστής παίρνει το `SERVER_PUBLIC_HOST` και τη δημόσια θύρα του API του, το παιχνίδι τη διεύθυνση στην οποία είναι συνδεδεμένο. Η ετικέτα είναι οι πρώτοι 22 χαρακτήρες του base64url (χωρίς padding) του SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + προέλευση). Το URI ανακατεύθυνσης είναι `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: πάντα η κυριολεκτική διεύθυνση IPv4, και η θύρα του listener του παιχνιδιού (1024 έως 65535). Ο διακομιστής δεν παίρνει ποτέ URI, host ή διαδρομή από τον πελάτη.

      | Προέλευση | Ετικέτα |
      |---|---|
      | `play.scacelith.example:443` | `IhcScoV7eDOzTEcSnqPUPt` |
      | `localhost:8443` | `TFGx7zQ_8QlGZW5zpqznCr` |
      | `[::1]:8443` | `XToJm0DG5PjciEVmZa9Cho` |
      | `127.0.0.1:50443` | `3r653wM5ZjYsHcAJljmCwY` |

      Έτσι, η σύνδεση με Google λειτουργεί μόνο για παίκτες που πρόσθεσαν τον διακομιστή ακριβώς με το `SERVER_PUBLIC_HOST` και τη δημόσια θύρα του API του.

      **Τι ελέγχει το παιχνίδι πριν ανοίξει το πρόγραμμα περιήγησης.** Το `authUrl` ξεκινά ακριβώς με `https://accounts.google.com/o/oauth2/v2/auth?`, είναι εκτυπώσιμο ASCII κάτω από 4096 χαρακτήρες, και η συμβολοσειρά ερωτήματός του έχει ακριβώς ένα `response_type=code`, ακριβώς ένα `redirect_uri` ίσο με το URI που υπολογίζει το παιχνίδι από τη θύρα του και την ετικέτα προέλευσής του, ακριβώς ένα `state` ίσο με το `state` της απάντησης, `code_challenge_method=S256` και ένα `code_challenge` 43 χαρακτήρων. Διαφορετικά, το παιχνίδι σταματά τον listener του και δεν ανοίγει τίποτα. Στέλνει τα αιτήματα `finish` και `link` μόνο στον διακομιστή που απάντησε στο `start`.

      **Σε ιστότοπο.** Ένας ιστότοπος (ο επίσημος: <https://scacelith.com/>) συνδέεται με έναν πελάτη τύπου «Web application» του ίδιου έργου Google (`GOOGLE_WEB_CLIENT_ID`). Η Google στέλνει το πρόγραμμα περιήγησης πίσω σε μία σελίδα του ιστότοπου που όρισε ο διαχειριστής (`GOOGLE_WEB_REDIRECT_URI`, καταχωρισμένη στα «Authorized redirect URIs» του πελάτη), και αυτή η σελίδα στέλνει τον κωδικό στο `finish`· ούτε εδώ ο διακομιστής παίρνει URI ανακατεύθυνσης από τον πελάτη.

      1. Η σελίδα δημιουργεί ένα ζεύγος PKCE όπως παραπάνω, κρατά τον επαληθευτή (verifier) στην καρτέλα (`sessionStorage`) και στέλνει `POST /auth/sso/google/web/start` με την πρόκληση.
      2. Ελέγχει το `authUrl` όπως το παιχνίδι, με τη διαφορά ότι το `redirect_uri` του πρέπει να είναι σελίδα της δικής της προέλευσης, και μετά πλοηγείται σε αυτό. Δεν φορτώνεται κανένα script της Google.
      3. Η Google στέλνει το πρόγραμμα περιήγησης στο `GOOGLE_WEB_REDIRECT_URI?code=...&state=...`. Η σελίδα δέχεται μόνο το `state` της απάντησης του βήματος έναρξης και δεν στέλνει τίποτα μετά από ανακατεύθυνση `error=`.
      4. Τα `finish`, `link` και `complete` ακολουθούν όπως για το παιχνίδι. Η απόπειρα θυμάται ότι την ξεκίνησε ιστότοπος: ο κωδικός ανταλλάσσεται με τον πελάτη και τη σελίδα του ιστότοπου, και το ID token πρέπει να κατονομάζει αυτόν τον πελάτη (ένας κωδικός ή ένα ID token του πελάτη του παιχνιδιού αποτυγχάνει με 502 `sso_failed`, και αντίστροφα).

      Ένας λογαριασμός Google οδηγεί στον ίδιο λογαριασμό Scacelith από το παιχνίδι και από τον ιστότοπο: η διασύνδεση κρατά το αναγνωριστικό subject της Google (`sub`), που ταυτοποιεί τον λογαριασμό Google όποιος κι αν είναι ο πελάτης. Η προέλευση του ιστότοπου πρέπει να βρίσκεται στο `CORS_ORIGINS` (το `check-config` προειδοποιεί σε αντίθετη περίπτωση).

      **Σε ποιον λογαριασμό οδηγεί η σύνδεση με Google:** στον λογαριασμό που είναι ήδη διασυνδεδεμένος με αυτόν τον λογαριασμό Google· αλλιώς σε έναν ενεργό λογαριασμό με τη διεύθυνση που επιβεβαίωσε η Google (αν έχει κωδικό πρόσβασης, το `finish` απαντά `needsPassword` και η διασύνδεση αποθηκεύεται μόνο αφού περάσουν ο κωδικός πρόσβασής του και, όταν είναι ενεργός, ο δεύτερος παράγοντάς του· αν δεν έχει κωδικό πρόσβασης, 409 `sso_account_exists`)· αλλιώς σε νέο λογαριασμό (`needsUsername`). Ένας λογαριασμός Google δεν διασυνδέεται ποτέ με υπάρχοντα λογαριασμό μόνο με βάση τη διεύθυνσή του. Η διεύθυνση του λογαριασμού λαμβάνει ένα email όταν η σύνδεση με Google δημιουργεί λογαριασμό και όταν προστίθεται σε υπάρχοντα λογαριασμό.
  - name: sessions
    x-displayName: Συνεδρίες
    description: Οι συνδεδεμένες συσκευές του λογαριασμού, και η αποσύνδεση.
  - name: account
    x-displayName: Λογαριασμός
    description: Η προβολή του λογαριασμού, οι προτιμήσεις του και ο κωδικός πρόσβασής του.
  - name: two-step-verification
    x-displayName: Έλεγχος ταυτότητας δύο παραγόντων
    description: Εφαρμογές ελέγχου ταυτότητας (TOTP, RFC 6238), με SHA-1, 6 ψηφία, βήματα των 30 δευτερολέπτων και ανοχή ενός βήματος προς κάθε κατεύθυνση, καθώς και κωδικοί ανάκτησης μίας χρήσης.
  - name: email-change
    x-displayName: Αλλαγή διεύθυνσης email
    description: Αλλαγή της διεύθυνσης του λογαριασμού, με επιβεβαίωση μέσω συνδέσμου που στέλνεται στη νέα διεύθυνση.
  - name: data-export
    x-displayName: Εξαγωγή δεδομένων
    description: Όλα όσα κρατά ο διακομιστής για τον λογαριασμό, σε ένα αρχείο JSON.
  - name: account-deletion
    x-displayName: Διαγραφή λογαριασμού
    description: Οριστική διαγραφή του λογαριασμού.
  - name: game-history
    x-displayName: Ιστορικό παρτίδων
    description: Οι παρτίδες του ίδιου του συνδεδεμένου παίκτη, με φίλτρα και σελιδοποίηση.
  - name: games
    x-displayName: Παρτίδες και PGN
    description: |-
      Οι καταγραφές των παρτίδων με τις κινήσεις και τα ρολόγια τους, και τα αρχεία PGN τους.

      **Κωδικοί παρτίδων.** `status`: 1 `WhiteWins`, 2 `BlackWins`, 3 `Draw`, 4 `Aborted` (αποθηκεύονται μόνο οι ολοκληρωμένες παρτίδες). `result`: `1-0`, `0-1`, `1/2-1/2` ή `*` (ακυρωμένη). `reason`, με το όνομά του (`termination`) και τις λέξεις με τις οποίες τελειώνει το κείμενο κινήσεων του αρχείου PGN (στα αγγλικά μέσα στο αρχείο, με τη μετάφρασή τους παρακάτω)· οι κωδικοί 7, 14 και 21 είναι ισοπαλίες (ο παίκτης που έμεινε χωρίς χρόνο, εγκατέλειψε ή αποχώρησε από την παρτίδα είχε απέναντί του αντίπαλο που δεν μπορούσε να κάνει ματ: FIDE 6.9 και 5.1.2· το `DESIGN.md` 6.3 δίνει τον κανόνα), και ο διακομιστής δεν τερματίζει ποτέ μια παρτίδα με τους κωδικούς 4 και 13 (ανήκουν στην κοινή λίστα λόγων λήξης):

      | Κωδικός | `termination` | Λέξεις στο PGN |
      |---|---|---|
      | 1 | `Checkmate` | Checkmate (ματ) |
      | 2 | `Resignation` | Resignation (εγκατάλειψη) |
      | 3 | `Timeout` | Loss on time (ήττα λόγω χρόνου) |
      | 4 | `IllegalMoves` | Second illegal move (forfeit) (δεύτερη μη νόμιμη κίνηση, ήττα) |
      | 5 | `Stalemate` | Stalemate (πατ) |
      | 6 | `InsufficientMaterial` | Dead position (insufficient material) (νεκρή θέση, ανεπαρκές υλικό) |
      | 7 | `TimeoutVsInsufficient` | Flag fall, but the opponent cannot checkmate (λήξη χρόνου, αλλά ο αντίπαλος δεν μπορεί να κάνει ματ) |
      | 8 | `FivefoldRepetition` | Fivefold repetition (πενταπλή επανάληψη) |
      | 9 | `SeventyFiveMoves` | 75-move rule (κανόνας των 75 κινήσεων) |
      | 10 | `ThreefoldClaim` | Threefold repetition (claimed) (τριπλή επανάληψη, με διεκδίκηση) |
      | 11 | `FiftyMoveClaim` | 50-move rule (claimed) (κανόνας των 50 κινήσεων, με διεκδίκηση) |
      | 12 | `Agreement` | Draw by agreement (ισοπαλία με συμφωνία) |
      | 13 | `IllegalMovesVsInsufficient` | Second illegal move, but the opponent cannot checkmate (δεύτερη μη νόμιμη κίνηση, αλλά ο αντίπαλος δεν μπορεί να κάνει ματ) |
      | 14 | `ResignationVsInsufficient` | Resignation, but the opponent cannot checkmate (εγκατάλειψη, αλλά ο αντίπαλος δεν μπορεί να κάνει ματ) |
      | 20 | `Abandonment` | Abandoned (disconnected for too long) (αποχώρηση από την παρτίδα: αποσύνδεση για πολλή ώρα) |
      | 21 | `AbandonmentVsInsufficient` | Abandoned, but the opponent cannot checkmate (αποχώρηση από την παρτίδα, αλλά ο αντίπαλος δεν μπορεί να κάνει ματ) |
      | 22 | `Aborted` | Game aborted (η παρτίδα ακυρώθηκε) |
      | 23 | `NoShow` | Aborted: first move not played in time (ακύρωση: η πρώτη κίνηση δεν παίχτηκε εγκαίρως) |
      | 24 | `Forfeit` | Forfeit (fair play violation) (ήττα με μηδενισμό λόγω παραβίασης του ευ αγωνίζεσθαι) |
      | 25 | `ServerAborted` | Aborted by the server (ακυρώθηκε από τον διακομιστή) |
      | 26 | `BothDisconnected` | Aborted: both players disconnected (ακύρωση: αποσυνδέθηκαν και οι δύο παίκτες) |

      Ο κωδικός 14 είναι νέος στη δευτερεύουσα έκδοση (minor) 1 του πρωτοκόλλου: ένας πελάτης γραμμένος πριν από αυτήν θα πρέπει να εμφανίζει έναν άγνωστο κωδικό ως απλό τέλος παρτίδας με το `result` του.
  - name: gifs
    x-displayName: Κινούμενα GIF
    description: |-
      Μια παρτίδα ως κινούμενο GIF, για να την κρατήσετε ή να τη μοιραστείτε: η σκακιέρα από πάνω, ένα καρέ ανά θέση από την αρχική ως την τελική θέση, τα ονόματα και οι βαθμολογίες των παικτών πάνω από τη σκακιέρα, η τελευταία κίνηση κάτω από αυτήν, και στο τελευταίο καρέ το αποτέλεσμα και ο τρόπος που τελείωσε η παρτίδα.

      **Κόστος, κρυφή μνήμη και ποσοστώσεις.** Ένα GIF σχεδιάζεται σε δικό του νήμα σχεδίασης, ποτέ στα νήματα που εκτελούν τις παρτίδες, και με τη χαμηλότερη προτεραιότητα CPU: `GIF_THREADS` νήματα (`WORKERS` από προεπιλογή), που ξεκινούν με το πρώτο GIF και σταματούν μετά από ένα λεπτό χωρίς GIF. Έως `GIF_QUEUE_MAX` (4 × `WORKERS`) GIF περιμένουν νήμα, το καθένα το πολύ `GIF_QUEUE_TIMEOUT_MS` (10 s)· μια σχεδίαση μπορεί να διαρκέσει `GIF_RENDER_TIMEOUT_MS` (30 s). Ο διακομιστής κρατά τα GIF που σχεδίασε σε κρυφή μνήμη `GIF_CACHE_MB` MB (32 × `WORKERS`), από την οποία φεύγουν πρώτα όσα χρησιμοποιήθηκαν λιγότερο πρόσφατα· ένα GIF από την κρυφή μνήμη, ή ένα που σχεδιάζεται ήδη για άλλο αίτημα, δεν κοστίζει καμία σχεδίαση. Το κλειδί της κρυφής μνήμης καλύπτει ό,τι αλλάζει την εικόνα, μαζί με τα ονόματα.

      Κάθε αίτημα μετρά στο `gif` (30 ανά λεπτό ανά παίκτη και για τα δύο endpoint μαζί). Ένα GIF που πρέπει να σχεδιαστεί μετρά επίσης στα όρια σχεδίασης: ανά παίκτη, `GIF_USER_RENDERS_PER_MIN` (4) ανά λεπτό και `GIF_USER_RENDERS_PER_HOUR` (30) ανά ώρα· ανά πελάτη, για όλους τους λογαριασμούς του μαζί, `GIF_IP_RENDERS_PER_MIN` (12) ανά λεπτό και `GIF_IP_RENDERS_PER_HOUR` (120) ανά ώρα, και 3 φορές περισσότερα ανά /48 IPv6. Ένας πελάτης θα πρέπει να κρατά το αρχείο που κατέβασε αντί να το ζητά ξανά, και να περιμένει `retryAfter` μετά από ένα 429 ή ένα 503.

      Μεγέθη: `small` (τετράγωνα 32 px: 284 × 350 pixel), `medium` (48 px: 424 × 515), `large` (72 px: 628 × 762)· χωρίς συντεταγμένες 268 × 342, 400 × 503 και 600 × 748. Η αρχική θέση μένει τουλάχιστον 1 s (ή όσο η καθυστέρηση, όταν αυτή είναι μεγαλύτερη), η τελική θέση 3 s, και το GIF επαναλαμβάνεται συνεχώς· μετά το πρώτο καρέ, αποθηκεύεται μόνο το τμήμα της εικόνας που αλλάζει. Μια παρτίδα 40 κινήσεων πιάνει περίπου 135, 205 και 325 KiB (small, medium, large), μια παρτίδα 150 κινήσεων 0,5 MiB, 0,8 MiB και 1,2 MiB.
  - name: players
    x-displayName: Παίκτες
    description: Δημόσια προφίλ και πρόσφατες παρτίδες. Μόνο δημόσια δεδομένα, ποτέ διεύθυνση email, συνεδρία, κύρωση ή επίπεδο ακεραιότητας. Ένας διαγραμμένος λογαριασμός δεν έχει προφίλ.
  - name: leaderboard
    x-displayName: Πίνακας κατάταξης
    description: Οι κορυφαίοι παίκτες κάθε επίσημης κατηγορίας.
  - name: reports
    x-displayName: Αναφορές
    description: Αναφορά του αντιπάλου μιας πρόσφατης παρτίδας στους συντονιστές.
  - name: pages
    x-displayName: Σελίδες HTML
    description: |-
      Σελίδες για πρόγραμμα περιήγησης, που ανοίγουν από τους συνδέσμους των email. Οι σύνδεσμοί τους δείχνουν στο `https://<SERVER_PUBLIC_HOST>` (με `:<PUBLIC_API_PORT>` όταν η θύρα δεν είναι 443), έξω από το `/api/v1`.

      - Οι σελίδες δεν εκτελούν JavaScript και δεν φορτώνουν κανέναν εξωτερικό πόρο. Σερβίρονται με `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`.
      - Ένα `GET` απλώς εμφανίζει ένα κουμπί ή μια φόρμα, ώστε ένας σαρωτής email που ανοίγει τον σύνδεσμο να μην τον αναλώνει. Η αλλαγή γίνεται με `POST`: μια φόρμα που στέλνεται ως `application/x-www-form-urlencoded` (γίνεται δεκτό και σώμα JSON), με το token του συνδέσμου σε κρυφό πεδίο. Ένα πεδίο που δίνεται δύο φορές απορρίπτεται.
      - Τα σφάλματα (όρια ρυθμού αιτημάτων, μη έγκυρα πεδία) είναι επίσης σελίδες HTML, με τίτλο «Request refused» (το αίτημα απορρίφθηκε) ή, από το 500 και πάνω, «Server error» (σφάλμα διακομιστή). Μόνο οι αποτυχίες που εντοπίζονται πριν από την αντιστοίχιση της σελίδας (ένας στόχος αιτήματος πολύ μεγάλος ή κακοσχηματισμένος, το επίπεδο ανά διεύθυνση) απαντώνται σε JSON.
x-tagGroups:
  - name: server
    x-displayName: Διακομιστής
    tags:
      - server-info
      - health
  - name: accounts
    x-displayName: Λογαριασμοί
    tags:
      - auth
      - google-sign-in
      - sessions
      - account
      - two-step-verification
      - email-change
      - data-export
      - account-deletion
  - name: games
    x-displayName: Παρτίδες
    tags:
      - game-history
      - games
      - gifs
  - name: community
    x-displayName: Παίκτες και αναφορές
    tags:
      - players
      - leaderboard
      - reports
  - name: browser-pages
    x-displayName: Σελίδες για πρόγραμμα περιήγησης
    tags:
      - pages
paths:
  /info:
    get:
      operationId: getServerInfo
      tags:
        - server-info
      summary: Το όνομα, οι εκδόσεις, οι θύρες και οι κανόνες εγγραφής του διακομιστή
      description: |-
        Ό,τι χρειάζεται ένας πελάτης πριν συνδεθεί σε λογαριασμό ή στον διακομιστή: το όνομα και το αναγνωριστικό του διακομιστή, οι εκδόσεις του πρωτοκόλλου WebSocket και πού βρίσκεται το WebSocket, οι κανόνες εγγραφής, οι επίσημοι χρόνοι σκέψης και τα όρια που μπορεί να ελέγξει ένας πελάτης πριν στείλει μια φόρμα.

        **Όρια:** μόνο το επίπεδο ανά διεύθυνση.
      security: []
      responses:
        '200':
          description: Η περιγραφή του διακομιστή.
          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`: το επίπεδο ανά διεύθυνση.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: Δημιουργία λογαριασμού
      description: |-
        Δημιουργεί έναν λογαριασμό: μόλις επιβεβαιωθεί η διεύθυνση email του με τον σύνδεσμο που στέλνεται σε αυτήν, ή αμέσως, χωρίς επιβεβαίωση email.

        **Με επιβεβαίωση email** (`REQUIRE_EMAIL_VERIFICATION`, η προεπιλογή): 202 `verification_sent`. Δεν υπάρχει ακόμη λογαριασμός: η εγγραφή περιμένει για 24 ώρες και στο μεταξύ κρατά δεσμευμένο το όνομα χρήστη της. Ένας σύνδεσμος που ισχύει για αυτές τις 24 ώρες στέλνεται στη διεύθυνση, το πολύ ένας ανά διεύθυνση κάθε 5 λεπτά (το `POST /auth/verify-email/resend` στέλνει νέο)· ο λογαριασμός δημιουργείται, με επιβεβαιωμένη τη διεύθυνσή του, όταν χρησιμοποιηθεί ο σύνδεσμος (το κουμπί της σελίδας `/verify-email`), και τότε ο παίκτης μπορεί να συνδεθεί. Μέχρι τότε, μια απόπειρα σύνδεσης με αυτό το όνομα χρήστη παίρνει `invalid_credentials`, όπως για άγνωστο λογαριασμό, και το δημόσιο προφίλ δεν υπάρχει. Μια νέα εγγραφή με την ίδια διεύθυνση αντικαθιστά την εκκρεμή. Η απάντηση είναι η ίδια όταν τη διεύθυνση χρησιμοποιεί ήδη άλλος λογαριασμός: τότε δεν στέλνεται σύνδεσμος, ο κάτοχός του λαμβάνει αντί γι’ αυτό μια ειδοποίηση (το πολύ μία ανά ώρα), και το όνομα χρήστη δεσμεύεται με τον ίδιο τρόπο, ώστε τίποτα να μη φανερώνει αν η διεύθυνση έχει λογαριασμό. Μια εγγραφή της οποίας ο σύνδεσμος δεν χρησιμοποιήθηκε διαγράφεται μετά από 24 ώρες, και το όνομα χρήστη της γίνεται ξανά ελεύθερο.

        **Χωρίς επιβεβαίωση email** (`REQUIRE_EMAIL_VERIFICATION=false`): 201 `ready`, ο λογαριασμός δημιουργείται αμέσως και μπορεί να συνδεθεί.

        **Κανόνες.** `username`: `USERNAME_MIN` έως `USERNAME_MAX` χαρακτήρες (3 έως 20), γράμματα, ψηφία, `_` και `-`, με πρώτο χαρακτήρα γράμμα ή ψηφίο (το `GET /info` τα δίνει ως `limits`)· τα δεσμευμένα ονόματα (`admin`, `moderator`, `deleted`...) και ορισμένα προθέματα απορρίπτονται· μοναδικό χωρίς διάκριση πεζών-κεφαλαίων. `email`: χωρίς κενά στην αρχή και στο τέλος, αποθηκεύεται με πεζά, σκέτο ASCII με τομέα που περιέχει τελεία. `password`: τουλάχιστον `PASSWORD_MIN_LENGTH` (10) χαρακτήρες και το πολύ 256 byte σε UTF-8· δεν πρέπει να περιέχει το όνομα χρήστη ή το τοπικό μέρος του email, και δεν πρέπει να είναι συνηθισμένος κωδικός πρόσβασης.

        **Σφάλματα**, με αυτή τη σειρά ελέγχου: 403 `registration_closed`· 400 `invalid_username`· 400 `invalid_email`· 400 `weak_password`· 409 `username_taken`· 428 `pow_required`· τα σφάλματα της ουράς κατακερματισμού κωδικών πρόσβασης· 409 `email_taken` (μόνο χωρίς επιβεβαίωση email).

        **Όρια:** `auth`, μετά `auth_register` (10 εγγραφές ανά ώρα ανά πελάτη). **Απόδειξη εργασίας:** πάντα, όταν το `POW_REGISTER_BITS` είναι πάνω από 0.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
            examples:
              first:
                summary: Πρώτη απόπειρα, χωρίς απόδειξη εργασίας
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
              withPow:
                summary: Νέα αποστολή με την απόδειξη εργασίας
                value:
                  username: alice
                  email: alice@example.org
                  password: correct horse battery
                  pow:
                    challenge: eyJ2IjoxLCJlIjoicmVnaXN0ZXIiLCJiIjoxOCwieCI6MTc5MDg4Mjk5MTIwMH0.6Ku_D7kw3S69OYxj-9KxsXszRCwz6ECnOQ-B6-v2erM
                    nonce: '123456'
      responses:
        '201':
          description: Ο λογαριασμός δημιουργήθηκε και μπορεί να συνδεθεί (χωρίς επιβεβαίωση email σε αυτόν τον διακομιστή).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    const: ready
              example:
                status: ready
        '202':
          description: Η εγγραφή περιμένει τον σύνδεσμο επιβεβαίωσής της (ή η διεύθυνση έχει ήδη λογαριασμό· η απάντηση δεν το φανερώνει).
          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` (με `field`), `invalid_json`, `invalid_username`, `invalid_email` ή `weak_password` με `reason`: `too_short`, `too_long`, `contains_username`, `contains_email` ή `too_common`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`: η εγγραφή είναι κλειστή σε αυτόν τον διακομιστή (το `GET /info` λέει `registration: closed`).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken`: ένας λογαριασμός έχει το όνομα χρήστη, ή το κρατά δεσμευμένο μια εκκρεμής εγγραφή άλλης διεύθυνσης. `email_taken`: άλλος λογαριασμός χρησιμοποιεί τη διεύθυνση (μόνο χωρίς επιβεβαίωση email).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '428':
          $ref: '#/components/responses/PowRequired'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `auth` ή `auth_register`, ή η ουρά κατακερματισμού κωδικών πρόσβασης.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η ουρά κατακερματισμού κωδικών πρόσβασης ή η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: Σύνδεση με κωδικό πρόσβασης
      description: |-
        Σύνδεση με όνομα χρήστη ή διεύθυνση email και κωδικό πρόσβασης. Υπάρχουν δύο πιθανές απαντήσεις, και οι δύο με κατάσταση 200: μια συνεδρία ή, όταν ο έλεγχος ταυτότητας δύο παραγόντων είναι ενεργός, ένα δεύτερο βήμα που πρέπει να ολοκληρωθεί μέσα σε 5 λεπτά με `POST /auth/login/mfa`.

        Ένας άγνωστος λογαριασμός, ένας λάθος κωδικός πρόσβασης και ένας λογαριασμός χωρίς κωδικό πρόσβασης παίρνουν την ίδια απάντηση, μετά από τον ίδιο χρόνο: 401 `invalid_credentials`. Οι έλεγχοι του λογαριασμού (`banned`, `email_unverified`) γίνονται μόνο μετά από σωστό κωδικό πρόσβασης. Από τις `AUTH_FAILURES_PER_ACCOUNT` (5) αποτυχίες σε ένα όνομα σύνδεσης και μετά, κάθε απόπειρα πρέπει να περιμένει (429 `too_many_attempts`).

        **Όριο:** `auth`. **Απόδειξη εργασίας:** μόνο κατά τη διάρκεια ενός κύματος αποτυχημένων συνδέσεων (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: Μια συνεδρία ή το δεύτερο βήμα μιας σύνδεσης με έλεγχο ταυτότητας δύο παραγόντων.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginAnswer'
              examples:
                session:
                  summary: Επιτυχής σύνδεση
                  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: Ο έλεγχος ταυτότητας δύο παραγόντων είναι ενεργός
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_credentials`: άγνωστος λογαριασμός, λάθος κωδικός πρόσβασης ή λογαριασμός χωρίς κωδικό πρόσβασης.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: 'Μόνο μετά από σωστό κωδικό πρόσβασης: `banned`, με `until` (ms από την εποχή Unix, `null` για μόνιμη αναστολή), ή `email_unverified` (ένας παλαιότερος λογαριασμός του οποίου η διεύθυνση δεν έχει επιβεβαιωθεί ακόμη).'
        '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` (η καθυστέρηση μετά από αποτυχημένες προσπάθειες για αυτό το όνομα σύνδεσης), ή `rate_limited` (το όριο `auth`, η ουρά κατακερματισμού κωδικών πρόσβασης).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η ουρά κατακερματισμού κωδικών πρόσβασης ή η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/login/mfa:
    post:
      operationId: logInWithSecondFactor
      tags:
        - auth
      summary: Ολοκλήρωση σύνδεσης με δεύτερο παράγοντα
      description: |-
        Το δεύτερο βήμα μιας σύνδεσης με έλεγχο ταυτότητας δύο παραγόντων, αφού το `POST /auth/login` ή μια σύνδεση με Google (`POST /auth/sso/google/finish` ή `POST /auth/sso/google/link`) απάντησε `mfaRequired`. Στείλτε `code` (έναν 6ψήφιο κωδικό της εφαρμογής ελέγχου ταυτότητας ή έναν κωδικό ανάκτησης) ή `recoveryCode`. Ένας κωδικός ανάκτησης που χρησιμοποιείται εδώ χάνεται.

        Ένα βήμα δέχεται το πολύ 5 λάθος κωδικούς· το βήμα λήγει επίσης όταν ο κωδικός πρόσβασης επαναφέρεται ή αλλάζει. Μετά το βήμα κωδικού πρόσβασης μιας διασύνδεσης Google, η διασύνδεση αποθηκεύεται μόνο όταν ο κωδικός περάσει εδώ.

        **Όρια:** `auth`, και το πολύ `AUTH_MFA_PER_ACCOUNT` (10) κωδικοί ανά 15 λεπτά για τον λογαριασμό, από οποιαδήποτε διεύθυνση· πέρα από αυτό ο κωδικός δεν ελέγχεται, οπότε ένας κωδικός ανάκτησης δεν αναλώνεται.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MfaLoginRequest'
            examples:
              authenticator:
                summary: Ένας κωδικός της εφαρμογής ελέγχου ταυτότητας
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  code: '123456'
              recovery:
                summary: Ένας κωδικός ανάκτησης
                value:
                  mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                  recoveryCode: j7v5-3ezx-zn
      responses:
        '200':
          description: Η σύνδεση έγινε.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_request`: δεν στάλθηκε ούτε `code` ούτε `recoveryCode`, ή το σώμα παραβιάζει το σχήμα· `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_mfa_token`: το βήμα έληξε, χρησιμοποιήθηκε ή τερματίστηκε μετά από 5 λάθος κωδικούς, ή ο κωδικός πρόσβασης επαναφέρθηκε ή άλλαξε μετά το πρώτο βήμα (συνδεθείτε ξανά). `invalid_code`: λάθος ή ήδη χρησιμοποιημένος κωδικός.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (με `until`), `email_unverified`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: ο λογαριασμός Google διασυνδέθηκε στο μεταξύ με άλλον λογαριασμό (μόνο μετά το βήμα κωδικού πρόσβασης μιας διασύνδεσης Google).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: ο λογαριασμός άλλαξε στο μεταξύ (μόνο μετά το βήμα κωδικού πρόσβασης μιας διασύνδεσης Google)· ξεκινήστε ξανά τη σύνδεση.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts`: η καθυστέρηση μετά από αποτυχημένες προσπάθειες του λογαριασμού, ή εξαντλήθηκαν οι `AUTH_MFA_PER_ACCOUNT` κωδικοί του των τελευταίων 15 λεπτών. `rate_limited`: το όριο `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` με `retryAfter: 1`: η βάση δεδομένων παρέμεινε κλειδωμένη (μετά από βήμα διασύνδεσης Google, δεν διασυνδέθηκε τίποτα: ξεκινήστε ξανά τη σύνδεση). `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/logout:
    post:
      operationId: logOut
      tags:
        - sessions
      summary: Αποσύνδεση αυτής της συνεδρίας
      description: |-
        Ανακαλεί τη συνεδρία του token που χρησιμοποιήθηκε. Το WebSocket που άνοιξε με αυτήν κλείνει. Χωρίς σώμα (ή `{}`).

        **Όριο:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Η αποσύνδεση έγινε.
          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`: σώμα διαφορετικό από `{}`· `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `sessions` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/logout-all:
    post:
      operationId: logOutEverywhere
      tags:
        - sessions
      summary: Αποσύνδεση όλων των συνεδριών
      description: |-
        Ανακαλεί κάθε συνεδρία του λογαριασμού, μαζί και αυτήν. Τα WebSocket που άνοιξαν με αυτές κλείνουν. Χωρίς σώμα (ή `{}`).

        **Όριο:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Όλες οι συνεδρίες αποσυνδέθηκαν.
          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`: σώμα διαφορετικό από `{}`· `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `sessions` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: Νέα αποστολή του συνδέσμου επιβεβαίωσης
      description: |-
        Στέλνει ξανά τον σύνδεσμο επιβεβαίωσης email. Η απάντηση είναι 202 `accepted` όποια κι αν είναι η διεύθυνση, ώστε να μη φανερώνει ποτέ αν τη χρησιμοποιεί κάποιος λογαριασμός ή κάποια εγγραφή.

        Ένα αίτημα ενεργεί το πολύ μία φορά κάθε 5 λεπτά ανά διεύθυνση. Μια εκκρεμής εγγραφή με αυτή τη διεύθυνση παίρνει ξανά τις 24 ώρες της, είτε χρησιμοποιεί άλλος λογαριασμός τη διεύθυνση είτε όχι, ώστε το όνομα χρήστη της να μένει δεσμευμένο για το ίδιο διάστημα και στις δύο περιπτώσεις. Σύνδεσμος στέλνεται μόνο για αυτή την εγγραφή όταν η διεύθυνση δεν έχει λογαριασμό (ένας νέος σύνδεσμος, που ισχύει 24 ώρες, αντικαθιστά τον προηγούμενο), ή για έναν ενεργό, μη επιβεβαιωμένο λογαριασμό με αυτή τη διεύθυνση.

        **Όρια:** `auth`, μετά `auth_mail` (10 ανά ώρα ανά πελάτη).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Έγινε δεκτό (όποια κι αν είναι η διεύθυνση).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedStatus'
              example:
                status: accepted
        '400':
          $ref: '#/components/responses/BadRequest'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `auth` ή `auth_mail` (όποια κι αν είναι η διεύθυνση).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` με `retryAfter: 1`: η βάση δεδομένων παρέμεινε κλειδωμένη ενώ αναζητούνταν ο λογαριασμός ή αποθηκευόταν ο σύνδεσμος ενός μη επιβεβαιωμένου λογαριασμού (η ανανέωση μιας εκκρεμούς εγγραφής γίνεται στο μέτρο του δυνατού και δεν κάνει ποτέ το αίτημα να αποτύχει)· τα 5 λεπτά της διεύθυνσης ξεκινούν παρ’ όλα αυτά, οπότε ένα νέο αίτημα μέσα σε αυτά δεν κάνει τίποτα. `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: Αποστολή συνδέσμου επαναφοράς κωδικού πρόσβασης
      description: |-
        Στέλνει έναν σύνδεσμο επαναφοράς κωδικού πρόσβασης, που ισχύει για μία ώρα και ανοίγει τη σελίδα `/reset-password`. Η απάντηση είναι 202 `accepted` όποια κι αν είναι η διεύθυνση. Σύνδεσμος στέλνεται μόνο σε ενεργό λογαριασμό, το πολύ μία φορά κάθε 5 λεπτά ανά διεύθυνση. Ένας λογαριασμός μόνο με Google ορίζει έτσι τον πρώτο κωδικό πρόσβασής του.

        Η ανάκτηση κωδικού πρόσβασης έχει τα αυστηρότερα όρια του API, όλα μετρημένα για ολόκληρο τον διακομιστή: 3 αιτήματα ανά ώρα (`AUTH_FORGOT_PER_HOUR`) και 10 ανά 24 ώρες (`AUTH_FORGOT_PER_DAY`) ανά πελάτη (μια διεύθυνση IPv4 ή ένα /64 IPv6), τριπλάσιοι αριθμοί ανά /48 IPv6, επιπλέον του ορίου `auth` (20 ανά 10 λεπτά) και του ενός email ανά διεύθυνση κάθε 5 λεπτά. Ούτε το 202 ούτε το 429 φανερώνει αν κάποιος λογαριασμός χρησιμοποιεί τη διεύθυνση. Ο ορισμός του νέου κωδικού πρόσβασης έχει δικό του όριο (`auth_reset`).

        **Όρια:** `auth`, `auth_forgot`, μετά `auth_forgot_day`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              email: alice@example.org
      responses:
        '202':
          description: Έγινε δεκτό (όποια κι αν είναι η διεύθυνση).
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptedStatus'
              example:
                status: accepted
        '400':
          $ref: '#/components/responses/BadRequest'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `auth`, `auth_forgot` ή `auth_forgot_day` (όποια κι αν είναι η διεύθυνση).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` με `retryAfter: 1`: η βάση δεδομένων παρέμεινε κλειδωμένη ενώ αναζητούνταν ο λογαριασμός ή αποθηκευόταν ο σύνδεσμος· τα 5 λεπτά της διεύθυνσης ξεκινούν παρ’ όλα αυτά, οπότε ένα νέο αίτημα μέσα σε αυτά δεν στέλνει σύνδεσμο. `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: Ορισμός νέου κωδικού πρόσβασης με το token ενός συνδέσμου επαναφοράς
      description: |-
        Ορίζει νέο κωδικό πρόσβασης με το token ενός συνδέσμου επαναφοράς (η σελίδα `/reset-password` κάνει το ίδιο). Ο νέος κωδικός πρόσβασης ακολουθεί τους κανόνες της εγγραφής.

        Η επαναφορά έχει τα εξής αποτελέσματα, σε μία συναλλαγή (όλα ή κανένα): ανακαλεί κάθε συνεδρία και ακυρώνει μια εκκρεμή αλλαγή email· οι άλλοι σύνδεσμοι επαναφοράς του λογαριασμού παύουν να λειτουργούν· η διεύθυνση θεωρείται επιβεβαιωμένη (ο σύνδεσμος το απέδειξε)· ο κάτοχος λαμβάνει ένα email. Ο έλεγχος ταυτότητας δύο παραγόντων δεν αλλάζει. Μετά από σφάλμα της ουράς κατακερματισμού κωδικών πρόσβασης, 503 ή 500, δεν άλλαξε τίποτα: ο παλιός κωδικός πρόσβασης και οι συνεδρίες παραμένουν, και ο σύνδεσμος παραμένει έγκυρος.

        **Όρια:** `auth`, μετά `auth_reset` (10 ανά ώρα ανά πελάτη, 30 ανά /48 IPv6, κοινό με τη σελίδα: κάθε απόπειρα κατακερματίζει έναν κωδικό πρόσβασης).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordResetRequest'
            example:
              token: A0ATfmXCDKFC8UkSxLJCD85K7au5nMIrZ2P3K-1VWQg
              newPassword: a much better passphrase
      responses:
        '200':
          description: Ο κωδικός πρόσβασης άλλαξε και όλες οι συνεδρίες αποσυνδέθηκαν.
          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`: ο σύνδεσμος δεν είναι έγκυρος, έχει χρησιμοποιηθεί ή έχει λήξει, ή στάλθηκε σε διεύθυνση που ο λογαριασμός δεν έχει πια. `weak_password` (με `reason`). `invalid_request`, `invalid_json`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `auth` ή `auth_reset`, ή η ουρά κατακερματισμού κωδικών πρόσβασης.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: η ουρά κατακερματισμού κωδικών πρόσβασης ή η βάση δεδομένων παρέμεινε κλειδωμένη (`retryAfter: 1`, δεν άλλαξε τίποτα). `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: Έναρξη σύνδεσης με Google
      description: |-
        Ξεκινά μια απόπειρα σύνδεσης με Google για την πρόκληση PKCE και τη θύρα του listener του παιχνιδιού στο `127.0.0.1`. Η απάντηση δίνει το URL της Google που πρέπει να ανοίξει στο πρόγραμμα περιήγησης του συστήματος και το `state` της απόπειρας· η απόπειρα ισχύει για 10 λεπτά.

        Το `authUrl` περιέχει `client_id`, `redirect_uri` (που σχηματίζεται από το `redirectPort` και την ετικέτα προέλευσης του διακομιστή), `response_type=code`, `scope=openid email profile`, `state`, `nonce`, `code_challenge` (το S256 του επαληθευτή που έχει ο ίδιος ο διακομιστής για τη Google) με `code_challenge_method=S256`, και `prompt=select_account`. Το παιχνίδι το ελέγχει πριν το ανοίξει (βλ. την περιγραφή της ενότητας).

        **Όριο:** `sso_start`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
              redirectPort: 51234
      responses:
        '200':
          description: Η απόπειρα.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoStartAnswer'
              example:
                attemptId: sso_ogvVEL75n7q90NIFsh-4zXVt-izc3WRbghsd6HDOtNQ
                authUrl: https://accounts.google.com/o/oauth2/v2/auth?client_id=1234567890-abc.apps.googleusercontent.com&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2Foauth2%2Fgoogle%2FIhcScoV7eDOzTEcSnqPUPt&response_type=code&scope=openid%20email%20profile&state=yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ&nonce=gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU&code_challenge=ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc&code_challenge_method=S256&prompt=select_account
                state: yBjjEhGgqHdeqhUAzyx9jE2IhQ56bec53iRA7RvKRzQ
                expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: η σύνδεση με Google δεν προσφέρεται σε αυτόν τον διακομιστή.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `sso_start`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/sso/google/web/start:
    post:
      operationId: startGoogleWebSignIn
      tags:
        - google-sign-in
      summary: Έναρξη σύνδεσης με Google σε ιστότοπο
      description: |-
        Ξεκινά μια απόπειρα σύνδεσης με Google ενός ιστότοπου για την πρόκληση PKCE της σελίδας του. Η απάντηση είναι η ίδια με του παιχνιδιού: το URL της Google όπου πρέπει να πλοηγηθεί η σελίδα, και το `state` της απόπειρας· η απόπειρα ισχύει για 10 λεπτά. Προσφέρεται όταν το `GET /info` λέει `sso.googleWeb: true`.

        Το `authUrl` περιέχει το `client_id` του ιστότοπου και `redirect_uri=GOOGLE_WEB_REDIRECT_URI`, με τις ίδιες υπόλοιπες παραμέτρους όπως για το παιχνίδι. Η σελίδα το ελέγχει πριν πλοηγηθεί σε αυτό (βλ. **Σε ιστότοπο** στην περιγραφή της ενότητας).

        **Όριο:** `sso_start`, κοινό με την έναρξη του παιχνιδιού.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoWebStartRequest'
            example:
              codeChallenge: 6e7diXEYxG7OTYw7STfNOltEeLxAilPthC_txzaE0xA
      responses:
        '200':
          description: Η απόπειρα.
          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`, ή υπάρχει οποιοδήποτε άλλο πεδίο (μαζί και το `redirectPort`).'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: η σύνδεση με Google δεν προσφέρεται σε αυτόν τον διακομιστή ή δεν προσφέρεται σε ιστότοπους.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `sso_start` (κοινό με την έναρξη του παιχνιδιού).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: Παράδοση της απάντησης της Google στον διακομιστή
      description: |-
        Παραδίδει στον διακομιστή το `code` και το `state` που έστειλε η Google στον listener του παιχνιδιού ή στη σελίδα του ιστότοπου, μαζί με το αναγνωριστικό της απόπειρας και τον επαληθευτή PKCE. Ένα αναγνωριστικό απόπειρας είναι άχρηστο χωρίς τον επαληθευτή, και ο κωδικός είναι άχρηστος χωρίς τον επαληθευτή PKCE και το client secret του ίδιου του διακομιστή.

        Ο διακομιστής ελέγχει την απόπειρα (άγνωστη, χρησιμοποιημένη ή ληγμένη: 410), μετά τον επαληθευτή (ένας λάθος επαληθευτής αφήνει την απόπειρα χρησιμοποιήσιμη), μετά χρησιμοποιεί την απόπειρα μία φορά, ελέγχει τα `state` και `iss`, ανταλλάσσει τον κωδικό με τη Google και επαληθεύει το ID token. Η απάντηση (200) είναι ένα από τα εξής:

        - `{ token, expiresAt, user }`: σύνδεση στον διασυνδεδεμένο λογαριασμό·
        - `{ mfaRequired, mfaToken, expiresIn }`: συνεχίστε με `POST /auth/login/mfa`·
        - `{ needsUsername, ssoTicket, suggestedUsername }`: νέος λογαριασμός· συνεχίστε με `POST /auth/sso/complete` μέσα σε 10 λεπτά. Το `suggestedUsername` προέρχεται από το όνομα στη Google ή από τη διεύθυνση, και είναι `""` όταν δεν ταιριάζει τίποτα·
        - `{ needsPassword, linkTicket, username, expiresIn }`: ο λογαριασμός με αυτή τη διεύθυνση έχει κωδικό πρόσβασης· συνεχίστε με `POST /auth/sso/google/link`. Δεν έχει διασυνδεθεί ακόμη τίποτα.

        **Όριο:** `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: Μια συνεδρία, ένα δεύτερο βήμα ή το επόμενο βήμα μιας πρώτης σύνδεσης με Google.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoFinishAnswer'
              examples:
                session:
                  summary: Επιτυχής σύνδεση στον διασυνδεδεμένο λογαριασμό
                  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: Ο έλεγχος ταυτότητας δύο παραγόντων είναι ενεργός
                  value:
                    mfaRequired: true
                    mfaToken: mfa_m4wXAVhYCMWG0PX7MLcIpBeccBFg0oxPvoRujzGWjO8
                    expiresIn: 300
                needsUsername:
                  summary: Ένας νέος παίκτης
                  value:
                    needsUsername: true
                    ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
                    suggestedUsername: alice
                needsPassword:
                  summary: Τη διεύθυνση χρησιμοποιεί ένας λογαριασμός με κωδικό πρόσβασης
                  value:
                    needsPassword: true
                    linkTicket: sso_ZfEI6iZjePTHbnbevupaD9O7GjBCzcL-GJu2XGOdFGc
                    username: alice
                    expiresIn: 600
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_verifier`: ο επαληθευτής δεν αντιστοιχεί στην πρόκληση της απόπειρας (η απόπειρα παραμένει χρησιμοποιήσιμη). `sso_email_unverified`: η Google δεν έχει επιβεβαιώσει τη διεύθυνση. `registration_closed`. `account_disabled`. Μόνο για διασυνδεδεμένο λογαριασμό: `banned` (με `until`), `email_unverified`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: η σύνδεση με Google δεν προσφέρεται σε αυτόν τον διακομιστή.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_account_exists`: ένας ενεργός λογαριασμός χωρίς κωδικό πρόσβασης χρησιμοποιεί τη διεύθυνση (δεν κατονομάζεται).'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: άγνωστη, χρησιμοποιημένη ή ληγμένη απόπειρα.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `sso_finish`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/sso/google/link:
    post:
      operationId: linkGoogleAccount
      tags:
        - google-sign-in
      summary: Διασύνδεση της Google με υπάρχοντα λογαριασμό μέσω του κωδικού πρόσβασής του
      description: |-
        Αφού το `finish` απάντησε `needsPassword`: διασυνδέει τη Google με τον υπάρχοντα λογαριασμό χάρη στον κωδικό πρόσβασης αυτού του λογαριασμού, που πληκτρολογείται στο παιχνίδι. Η απάντηση (200) είναι μια συνεδρία (η διασύνδεση αποθηκεύεται και ο παίκτης συνδέεται) ή, όταν ο έλεγχος ταυτότητας δύο παραγόντων είναι ενεργός, `{ mfaRequired, mfaToken, expiresIn }`: συνεχίστε με `POST /auth/login/mfa`, και η διασύνδεση αποθηκεύεται μόνο όταν περάσει εκεί ένας κωδικός.

        Ένας λάθος κωδικός πρόσβασης αφήνει το ticket χρησιμοποιήσιμο, για 5 δοκιμές συνολικά. Μια δοκιμή αφαιρείται ακριβώς πριν ελεγχθεί ο κωδικός πρόσβασης: ένα 429 `too_many_attempts` ή ένα 428 `pow_required` δεν αφαιρεί καμία, ενώ μια απόρριψη από την ουρά κατακερματισμού κωδικών πρόσβασης (503 `server_busy`, 429 `rate_limited`) έχει ήδη αφαιρέσει μία και μετρά ως αποτυχία του λογαριασμού. Η καθυστέρηση μετά από αποτυχημένες προσπάθειες χρησιμοποιεί τον ίδιο μετρητή με το `POST /auth/login`.

        **Όριο:** `auth` (με τη μέτρησή του ανά /48 IPv6). **Απόδειξη εργασίας:** όπως για το `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: Μια συνεδρία ή το δεύτερο βήμα της σύνδεσης.
          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`: λάθος κωδικός πρόσβασης· το ticket διατηρείται, για 5 προσπάθειες συνολικά.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`banned` (με `until`), μόνο μετά από σωστό κωδικό πρόσβασης.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: η σύνδεση με Google δεν προσφέρεται σε αυτόν τον διακομιστή.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`sso_already_linked`: ο λογαριασμός Google διασυνδέθηκε στο μεταξύ με άλλον λογαριασμό.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: άγνωστο, χρησιμοποιημένο ή ληγμένο ticket, ο 5ος λάθος κωδικός πρόσβασης, λογαριασμός του οποίου η κατάσταση ή η διεύθυνση άλλαξε μετά το `finish` ή λογαριασμός του οποίου η κατάσταση, η διεύθυνση, ο κωδικός πρόσβασης ή ο έλεγχος ταυτότητας δύο παραγόντων άλλαξε ενώ αποθηκευόταν η διασύνδεση. Ξεκινήστε ξανά τη σύνδεση.'
        '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` (η καθυστέρηση του λογαριασμού μετά από αποτυχημένες προσπάθειες) ή `rate_limited` (το όριο `auth`, η ουρά κατακερματισμού κωδικών πρόσβασης).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: η ουρά κατακερματισμού κωδικών πρόσβασης ή η βάση δεδομένων παρέμεινε κλειδωμένη (`retryAfter: 1`, δεν διασυνδέθηκε τίποτα). `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: Δημιουργία του λογαριασμού μιας πρώτης σύνδεσης με Google
      description: |-
        Αφού το `finish` απαντήσει `needsUsername`: δημιουργεί τον λογαριασμό με το επιλεγμένο όνομα χρήστη (ισχύουν οι κανόνες της εγγραφής) και τον συνδέει. Ο λογαριασμός δεν έχει κωδικό πρόσβασης: το `hasPassword` είναι `false`, και η επιλογή «Ξεχάσατε τον κωδικό;» (`POST /auth/password/forgot`) του δίνει έναν.

        **Όριο:** `auth` (με τη μέτρησή του ανά /48 IPv6).
      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: Ο λογαριασμός δημιουργήθηκε και συνδέθηκε.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionAnswer'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_username`, `invalid_request`, `invalid_json`.'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`registration_closed`.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`sso_disabled`: η σύνδεση με Google δεν προσφέρεται σε αυτόν τον διακομιστή.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`username_taken` (κάποιος λογαριασμός έχει το όνομα χρήστη ή το δεσμεύει μια εκκρεμής εγγραφή άλλης διεύθυνσης), `sso_already_linked`, `email_taken`.'
        '410':
          $ref: '#/components/responses/Gone'
          description: '`sso_expired`: άγνωστο, χρησιμοποιημένο ή ληγμένο ticket.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `auth`.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/sessions:
    get:
      operationId: listSessions
      tags:
        - sessions
      summary: Λίστα των συνδεδεμένων συσκευών
      description: |-
        Οι ενεργές συνεδρίες του λογαριασμού (συνδεδεμένες συσκευές), πρώτα εκείνες που χρησιμοποιήθηκαν πιο πρόσφατα. Το `current` επισημαίνει τη συνεδρία που κάνει το αίτημα. Το `lastSeenAt` ενημερώνεται το πολύ μία φορά ανά 5 λεπτά (αργότερα όσο ο διακομιστής έχει μείνει πολύ πίσω)· το `expiresAt` είναι το απόλυτο τέλος, και το όριο αδράνειας μπορεί να τερματίσει τη συνεδρία νωρίτερα.

        **Όριο:** `sessions`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Οι ενεργές συνεδρίες.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionList'
              example:
                sessions:
                  - id: 3
                    createdAt: 1790882839708
                    lastSeenAt: 1790882839708
                    expiresAt: 1798658839708
                    clientLabel: Laptop
                    current: false
                  - id: 1
                    createdAt: 1790882839708
                    lastSeenAt: 1790882839708
                    expiresAt: 1798658839708
                    clientLabel: Scacelith 1.4 (Windows)
                    current: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `sessions` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /auth/sessions/{id}:
    delete:
      operationId: revokeSession
      tags:
        - sessions
      summary: Αποσύνδεση μίας συσκευής
      description: |-
        Αποσυνδέει μία συνεδρία του λογαριασμού· μπορεί να αποσυνδεθεί και η τρέχουσα. Το WebSocket που άνοιξε με αυτήν κλείνει. Χωρίς σώμα (ή `{}`).

        **Όριο:** `sessions`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Η συνεδρία αποσυνδέθηκε.
          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`: σώμα διαφορετικό από `{}` ή `id` που δεν είναι έγκυρη κωδικοποίηση URL· `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: καμία ενεργή συνεδρία με αυτό το αναγνωριστικό σε αυτόν τον λογαριασμό.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `sessions` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /account/me:
    get:
      operationId: getAccount
      tags:
        - account
      summary: Λήψη του λογαριασμού, των βαθμολογιών του και των ενεργών κυρώσεων
      description: |-
        Ο λογαριασμός όπως τον βλέπει ο παίκτης του: η προβολή του λογαριασμού (και το `user` κάθε απάντησης σύνδεσης), μία καταχώριση βαθμολογίας για κάθε κατηγορία στην οποία ο παίκτης έχει παίξει παρτίδες με βαθμολογία, οι ενεργές κυρώσεις και η αναστολή. Το επίπεδο ακεραιότητας του συστήματος κατά της απάτης δεν εμφανίζεται ποτέ.

        **Όριο:** `account`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Ο λογαριασμός.
          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` ή `invalid_token` (και όταν ο λογαριασμός έχει διαγραφεί).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `account` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /account/preferences:
    put:
      operationId: updatePreferences
      tags:
        - account
      summary: Αποδοχή ή απόρριψη άμεσων προκλήσεων
      description: |-
        Ορίζει αν οι άλλοι παίκτες μπορούν να προκαλούν αυτόν τον παίκτη μέσω του ονόματός του. Με `none`, οι άμεσες προκλήσεις απορρίπτονται: όποιος προκαλεί ενημερώνεται ότι ο παίκτης δεν είναι διαθέσιμος. Χωρίς επανέλεγχο ταυτότητας.

        **Όριο:** `account`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Preferences'
            example:
              acceptChallenges: none
      responses:
        '200':
          description: Οι προτιμήσεις που ισχύουν πλέον.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                type: object
                required:
                  - preferences
                properties:
                  preferences:
                    $ref: '#/components/schemas/Preferences'
              example:
                preferences:
                  acceptChallenges: none
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `account` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: Αλλαγή κωδικού πρόσβασης
      description: |-
        Αλλάζει τον κωδικό πρόσβασης. Χρειάζεται ο τρέχων κωδικός πρόσβασης, αλλά όχι δεύτερος παράγοντας, ακόμη και όταν ο έλεγχος ταυτότητας δύο παραγόντων είναι ενεργός. Ο νέος κωδικός πρόσβασης ακολουθεί τους κανόνες της εγγραφής (ελέγχεται μετά τον τρέχοντα κωδικό πρόσβασης).

        Η αλλαγή έχει τα εξής αποτελέσματα, σε μία συναλλαγή (όλα ή κανένα): ανακαλεί κάθε άλλη συνεδρία (αυτή εδώ παραμένει συνδεδεμένη), ακυρώνει μια εκκρεμή αλλαγή email, κάνει τους συνδέσμους επαναφοράς κωδικού πρόσβασης του λογαριασμού να παύσουν να λειτουργούν και στέλνει ένα email στον κάτοχο.

        **Επανέλεγχος ταυτότητας:** μόνο ο κωδικός πρόσβασης. **Όρια:** `reauth`, μετά `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: Ο κωδικός πρόσβασης άλλαξε.
          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` (λογαριασμός μόνο με Google), `weak_password` (με `reason`), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`: λάθος τρέχων κωδικός πρόσβασης ή επαναφορά ή αλλαγή του κωδικού πρόσβασης που ολοκληρώθηκε ενώ ελεγχόταν το αίτημα.'
        '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` (αποτυχημένοι επανέλεγχοι ταυτότητας του λογαριασμού) ή `rate_limited` (το όριο `reauth` ή `reauth_user`, ο προϋπολογισμός του λογαριασμού, η ουρά κατακερματισμού κωδικών πρόσβασης).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: η ουρά κατακερματισμού κωδικών πρόσβασης ή η βάση δεδομένων παρέμεινε κλειδωμένη (`retryAfter: 1`, δεν άλλαξε τίποτα). `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /account/mfa/totp/setup:
    post:
      operationId: startTotpSetup
      tags:
        - two-step-verification
      summary: Έναρξη ενεργοποίησης του ελέγχου ταυτότητας δύο παραγόντων
      description: |-
        Αποθηκεύει ένα νέο εκκρεμές μυστικό ελέγχου ταυτότητας, που αντικαθιστά οποιοδήποτε προηγούμενο εκκρεμές, και το επιστρέφει για την εφαρμογή ελέγχου ταυτότητας (ως κείμενο και ως URI `otpauth://` για εμφάνιση ως κωδικός QR). Ο έλεγχος ταυτότητας δύο παραγόντων δεν είναι ακόμη ενεργός: το `POST /account/mfa/totp/enable` τον ενεργοποιεί με έναν κωδικό αυτού του μυστικού.

        **Επανέλεγχος ταυτότητας:** μόνο ο κωδικός πρόσβασης. **Όρια:** `reauth`, μετά `reauth_user`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordOnlyRequest'
            example:
              password: correct horse battery
      responses:
        '200':
          description: Το εκκρεμές μυστικό.
          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` (λογαριασμός μόνο με Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled` (ελέγχεται πριν από τον κωδικό πρόσβασης).'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (αποτυχημένοι επανέλεγχοι ταυτότητας του λογαριασμού) ή `rate_limited` (το όριο `reauth` ή `reauth_user`, ο προϋπολογισμός του λογαριασμού, η ουρά κατακερματισμού κωδικών πρόσβασης).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η ουρά κατακερματισμού κωδικών πρόσβασης ή η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /account/mfa/totp/enable:
    post:
      operationId: enableTotp
      tags:
        - two-step-verification
      summary: Ολοκλήρωση ενεργοποίησης του ελέγχου ταυτότητας δύο παραγόντων και λήψη κωδικών ανάκτησης
      description: |-
        Ενεργοποιεί τον έλεγχο ταυτότητας δύο παραγόντων με έναν κωδικό του εκκρεμούς μυστικού (ακριβώς 6 ψηφία). Εδώ δεν ζητείται κωδικός πρόσβασης· δόθηκε κατά τη ρύθμιση. Η απάντηση περιέχει 10 κωδικούς ανάκτησης, που εμφανίζονται μόνο αυτή τη φορά.

        **Όρια:** `reauth`, μετά `reauth_user`· ένας λάθος κωδικός μετράει στις αποτυχίες επανελέγχου ταυτότητας του λογαριασμού.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TotpEnableRequest'
            example:
              code: '123456'
      responses:
        '200':
          description: Ο έλεγχος ταυτότητας δύο παραγόντων είναι ενεργός.
          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` δεν έχει ακριβώς 6 ψηφία ή το σώμα παραβιάζει το σχήμα· `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_code`: λάθος κωδικός (ελέγξτε την ώρα της συσκευής).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`mfa_already_enabled`· `mfa_setup_required`: δεν υπάρχει εκκρεμές μυστικό (καλέστε πρώτα το `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` (αποτυχημένοι επανέλεγχοι ταυτότητας του λογαριασμού) ή `rate_limited` (το όριο `reauth` ή `reauth_user`, ο προϋπολογισμός του λογαριασμού).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: Απενεργοποίηση του ελέγχου ταυτότητας δύο παραγόντων
      description: |-
        Απενεργοποιεί τον έλεγχο ταυτότητας δύο παραγόντων. Το μυστικό και οι κωδικοί ανάκτησης διαγράφονται, και ο κάτοχος λαμβάνει ένα email.

        **Επανέλεγχος ταυτότητας:** ο κωδικός πρόσβασης και ένας κωδικός της εφαρμογής ελέγχου ταυτότητας ή ένας κωδικός ανάκτησης (απαιτείται ένα από τα `code` ή `recoveryCode`). **Όρια:** `reauth`, μετά `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: Ο έλεγχος ταυτότητας δύο παραγόντων είναι ανενεργός.
          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` (λογαριασμός μόνο με Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`mfa_code_required` (ούτε `code` ούτε `recoveryCode`, ελέγχεται πριν από τον κωδικό πρόσβασης), `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` (αποτυχημένοι επανέλεγχοι ταυτότητας ή υπερβολικά πολλοί κωδικοί που δοκιμάστηκαν σε αυτόν τον λογαριασμό) ή `rate_limited` (το όριο `reauth` ή `reauth_user`, ο προϋπολογισμός του λογαριασμού, η ουρά κατακερματισμού κωδικών πρόσβασης).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η ουρά κατακερματισμού κωδικών πρόσβασης ή η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /account/mfa/recovery-codes:
    post:
      operationId: regenerateRecoveryCodes
      tags:
        - two-step-verification
      summary: Αντικατάσταση των κωδικών ανάκτησης
      description: |-
        Αντικαθιστά τους κωδικούς ανάκτησης με 10 νέους· οι παλιοί παύουν να λειτουργούν.

        **Επανέλεγχος ταυτότητας:** ο κωδικός πρόσβασης και ένας κωδικός της εφαρμογής ελέγχου ταυτότητας στο `code` (ένας κωδικός ανάκτησης απορρίπτεται με 403 `invalid_code`). **Όρια:** `reauth`, μετά `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: Οι νέοι κωδικοί ανάκτησης, που εμφανίζονται μόνο αυτή τη φορά.
          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` (λογαριασμός μόνο με Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code` (και για κωδικό ανάκτησης).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '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` (αποτυχημένοι επανέλεγχοι ταυτότητας ή υπερβολικά πολλοί κωδικοί που δοκιμάστηκαν σε αυτόν τον λογαριασμό) ή `rate_limited` (το όριο `reauth` ή `reauth_user`, ο προϋπολογισμός του λογαριασμού, η ουρά κατακερματισμού κωδικών πρόσβασης).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η ουρά κατακερματισμού κωδικών πρόσβασης ή η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: Αλλαγή διεύθυνσης email
      description: |-
        Αλλάζει τη διεύθυνση email του λογαριασμού. Από το `newEmail` αφαιρούνται τα κενά στην αρχή και στο τέλος και τα γράμματα γίνονται πεζά, και μετά ελέγχεται όπως μια διεύθυνση εγγραφής.

        **Με επιβεβαίωση email** (η προεπιλογή): 202 `verification_sent`. Ένας σύνδεσμος που ισχύει για 24 ώρες στέλνεται στη νέα διεύθυνση· ανοίγει το `/confirm-email-change`, και η διεύθυνση αλλάζει μόνο όταν ο παίκτης πατήσει το κουμπί αυτής της σελίδας. Μέχρι τότε, το `GET /account/me` δείχνει τη νέα διεύθυνση ως `pendingEmail`. Ένα νέο αίτημα αντικαθιστά το εκκρεμές· μια αλλαγή ή επαναφορά κωδικού πρόσβασης το ακυρώνει. Το πολύ ένας σύνδεσμος στέλνεται σε μια δεδομένη νέα διεύθυνση ανά 5 λεπτά, όποιος κι αν το ζητήσει (ένα αίτημα για την αλλαγή που ήδη εκκρεμεί διατηρεί τον σύνδεσμο που στάλθηκε νωρίτερα, ο οποίος παραμένει έγκυρος)· η απάντηση είναι η ίδια. Η τρέχουσα διεύθυνση λαμβάνει ειδοποίηση ότι ζητήθηκε αλλαγή σε μια καλυμμένη διεύθυνση (`a***@example.org`). Η απάντηση και το `pendingEmail` είναι τα ίδια όταν άλλος λογαριασμός χρησιμοποιεί ήδη τη νέα διεύθυνση: τότε δεν στέλνεται σύνδεσμος, οπότε αυτή η αλλαγή δεν ολοκληρώνεται ποτέ, και ο κάτοχος εκείνης της διεύθυνσης λαμβάνει αντί γι’ αυτό μια ειδοποίηση (το πολύ μία ανά ώρα).

        Όταν επιβεβαιωθεί ο σύνδεσμος, η διεύθυνση αλλάζει και θεωρείται επιβεβαιωμένη, οι συσκευές παραμένουν συνδεδεμένες, οι σύνδεσμοι που στάλθηκαν νωρίτερα (επιβεβαίωση, επαναφορά κωδικού πρόσβασης, άλλες αλλαγές) παύουν να λειτουργούν, και η προηγούμενη διεύθυνση ενημερώνεται, με τη νέα καλυμμένη.

        **Χωρίς επιβεβαίωση email** (`REQUIRE_EMAIL_VERIFICATION=false`): η διεύθυνση αλλάζει αμέσως (200 `email_changed`), και η προηγούμενη διεύθυνση ενημερώνεται. Αν άλλος λογαριασμός χρησιμοποιεί τη διεύθυνση, η απάντηση είναι 409 `email_taken`, και ο κάτοχος εκείνης της διεύθυνσης λαμβάνει την ειδοποίηση.

        **Επανέλεγχος ταυτότητας:** ο κωδικός πρόσβασης και, με έλεγχο ταυτότητας δύο παραγόντων, ένας κωδικός της εφαρμογής ελέγχου ταυτότητας ή ένας κωδικός ανάκτησης. Τα `invalid_email` και `same_email` ελέγχονται πριν από τον κωδικό πρόσβασης, οπότε δεν μετρούν ως αποτυχία. **Όρια:** `reauth`, μετά `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: Η διεύθυνση άλλαξε αμέσως (χωρίς επιβεβαίωση email σε αυτόν τον διακομιστή).
          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: Η νέα διεύθυνση, όπως αποθηκεύτηκε.
              example:
                status: email_changed
                email: alice.new@example.org
        '202':
          description: Στάλθηκε σύνδεσμος επιβεβαίωσης στη νέα διεύθυνση (ή η διεύθυνση ανήκει σε άλλον λογαριασμό· η απάντηση δεν το αποκαλύπτει).
          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` (η τρέχουσα διεύθυνση του λογαριασμού), `password_not_set` (λογαριασμός μόνο με Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password` (και όταν μια αλλαγή ή επαναφορά κωδικού πρόσβασης προηγήθηκε του αιτήματος: δεν στέλνεται σύνδεσμος), `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '409':
          $ref: '#/components/responses/Conflict'
          description: '`email_taken`: μόνο χωρίς επιβεβαίωση email.'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (αποτυχημένοι επανέλεγχοι ταυτότητας ή υπερβολικά πολλοί κωδικοί που δοκιμάστηκαν σε αυτόν τον λογαριασμό) ή `rate_limited` (το όριο `reauth` ή `reauth_user`, ο προϋπολογισμός του λογαριασμού, η ουρά κατακερματισμού κωδικών πρόσβασης).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy`: η ουρά κατακερματισμού κωδικών πρόσβασης ή η βάση δεδομένων παρέμεινε κλειδωμένη (`retryAfter: 1`: δεν άλλαξε τίποτα, και το ίδιο αίτημα μπορεί να σταλεί ξανά). `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: Λήψη των δεδομένων του λογαριασμού
      description: |-
        Όλα όσα κρατά ο διακομιστής για τον λογαριασμό, ως ένα αρχείο JSON για αποθήκευση (`format` `scacelith-account-export`, `version` 1). Η εξαγωγή καταγράφει ένα συμβάν ασφαλείας (`account_exported`). Ο πίνακας `notes` του εγγράφου εξηγεί στον παίκτη σε απλά αγγλικά τι παραλείπεται.

        **Ποτέ στην εξαγωγή:** το hash του κωδικού πρόσβασης, το μυστικό του ελέγχου ταυτότητας δύο παραγόντων και οι κωδικοί ανάκτησης· κανένα token συνεδρίας ή συνδέσμου, ούτε το hash του· τα δεδομένα του συστήματος κατά της απάτης (επίπεδο και βαθμός ακεραιότητας, ανωμαλίες, η ανάλυση των παρτίδων, η βαρύτητα μιας αναφοράς)· οι αναφορές που έκαναν άλλοι παίκτες για τον παίκτη· οι ταυτότητες των συντονιστών· τα ιδιωτικά δεδομένα άλλων παικτών (οι αντίπαλοι εμφανίζονται με το δημόσιο όνομα και τη βαθμολογία τους, τίποτα δεν δείχνει αν επιβλήθηκε κύρωση σε άλλον παίκτη, και δεν περιλαμβάνεται καμία διεύθυνση IP που μπορεί να ανήκει σε άλλο άτομο).

        **Επανέλεγχος ταυτότητας:** ο κωδικός πρόσβασης και, με έλεγχο ταυτότητας δύο παραγόντων, ένας κωδικός της εφαρμογής ελέγχου ταυτότητας ή ένας κωδικός ανάκτησης. **Όρια:** `account_export` (5 ανά ώρα ανά παίκτη, για ολόκληρο τον διακομιστή· μετράει κάθε προσπάθεια, μαζί και οι αποτυχημένες· ελέγχεται πρώτο), μετά `reauth` και `reauth_user`. **Χρονικό όριο:** 60 δευτερόλεπτα.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReauthCredentials'
            example:
              password: correct horse battery
              code: '123456'
      responses:
        '200':
          description: Το έγγραφο εξαγωγής, ως συνημμένο.
          headers:
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
            Content-Disposition:
              description: '`attachment; filename="scacelith-account-<username>.json"`. Οι χαρακτήρες του ονόματος χρήστη εκτός από γράμματα, ψηφία, `_`, `.` και `-` γίνονται `_`.'
              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` (λογαριασμός μόνο με Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (αποτυχημένοι επανέλεγχοι ταυτότητας ή υπερβολικά πολλοί κωδικοί που δοκιμάστηκαν σε αυτόν τον λογαριασμό) ή `rate_limited` (το όριο `account_export`, `reauth` ή `reauth_user`, ο προϋπολογισμός του λογαριασμού, η ουρά κατακερματισμού κωδικών πρόσβασης).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (η βάση δεδομένων παρέμεινε κλειδωμένη, με κεφαλίδα `Retry-After: 1`), `server_busy` (η ουρά κατακερματισμού κωδικών πρόσβασης), `timeout` (60 δευτερόλεπτα). Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: Διαγραφή λογαριασμού
      description: |-
        Διαγράφει τον λογαριασμό· αυτό δεν μπορεί να αναιρεθεί.

        - Κάθε συνεδρία ανακαλείται αμέσως: από εκεί και πέρα το token λαμβάνει 401 `invalid_token`.
        - Το όνομα χρήστη γίνεται `deleted#<id>`, στον λογαριασμό και σε κάθε καταγραφή παρτίδας.
        - Διαγράφονται: η διεύθυνση email, το hash του κωδικού πρόσβασης, το μυστικό του ελέγχου ταυτότητας δύο παραγόντων και οι κωδικοί ανάκτησης, οι συνεδρίες και τα token συνδέσμων, η διασύνδεση με τον λογαριασμό Google, η καταχώριση ακεραιότητας του συστήματος κατά της απάτης και οι διευθύνσεις IP που αποθηκεύτηκαν με συμβάντα ασφαλείας.
        - Οι βαθμολογίες και οι παρτίδες διατηρούνται. Οι παρτίδες παραμένουν αναγνώσιμες με το ανώνυμο όνομα, και το `GET /players/{username}` απαντά 404 για το προηγούμενο όνομα.

        **Επανέλεγχος ταυτότητας:** ο κωδικός πρόσβασης και, με έλεγχο ταυτότητας δύο παραγόντων, ένας κωδικός της εφαρμογής ελέγχου ταυτότητας ή ένας κωδικός ανάκτησης. **Όρια:** `reauth`, μετά `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: Ο λογαριασμός διαγράφηκε.
          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` (λογαριασμός μόνο με Google), `invalid_request`, `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`invalid_password`, `mfa_code_required`, `invalid_code`.'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`too_many_attempts` (αποτυχημένοι επανέλεγχοι ταυτότητας ή υπερβολικά πολλοί κωδικοί που δοκιμάστηκαν σε αυτόν τον λογαριασμό) ή `rate_limited` (το όριο `reauth` ή `reauth_user`, ο προϋπολογισμός του λογαριασμού, η ουρά κατακερματισμού κωδικών πρόσβασης).'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η ουρά κατακερματισμού κωδικών πρόσβασης ή η βάση δεδομένων παρέμεινε κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /account/games:
    get:
      operationId: listAccountGames
      tags:
        - game-history
      summary: Λίστα των παρτίδων του παίκτη, με φίλτρα και σελιδοποίηση
      description: |-
        Οι παρτίδες του συνδεδεμένου παίκτη, πρώτα οι νεότερες, με φίλτρα και σελιδοποίηση, μαζί με τον αριθμό των παρτίδων που ταιριάζουν στο φίλτρο. Κάθε παράμετρος ερωτήματος είναι προαιρετική, και μια κενή τιμή θεωρείται απούσα.

        Σελιδοποίηση: δώστε το `next` της προηγούμενης σελίδας ως `before`· το `next` είναι `null` στην τελευταία σελίδα. Το `total` μετρά τις παρτίδες που ταιριάζουν στο φίλτρο, σε όλες τις σελίδες.

        **Όριο:** `account_games`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
        - name: category
          in: query
          required: false
          description: Αναγνωριστικό επίσημης κατηγορίας (`3+2` ή `3%2B2`) ή `custom` για κάθε παρτίδα με άλλον χρόνο σκέψης.
          schema:
            type: string
            pattern: '^\s*([0-9]+[+ ][0-9]+|custom)\s*$'
          example: '3+2'
        - name: rated
          in: query
          required: false
          description: Μόνο παρτίδες με βαθμολογία (`true`) ή φιλικές (`false`).
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
        - name: result
          in: query
          required: false
          description: Μόνο οι παρτίδες που κερδήθηκαν, χάθηκαν ή έληξαν ισόπαλες, από την πλευρά του παίκτη. Οι ακυρωμένες παρτίδες εμφανίζονται μόνο χωρίς αυτό το φίλτρο.
          schema:
            type: string
            enum:
              - win
              - loss
              - draw
      responses:
        '200':
          description: Μία σελίδα του ιστορικού.
          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` δεν είναι αναγνωριστικό παρτίδας), `invalid_limit` ή `invalid_filter` (`category`, `rated` ή `result`), το καθένα με `field` που κατονομάζει την παράμετρο.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `account_games` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (η βάση δεδομένων παρέμεινε κλειδωμένη· `retryAfter: 1` στο σώμα, χωρίς κεφαλίδα `Retry-After`), `server_busy` (η αναζήτηση της συνεδρίας βρήκε τη βάση δεδομένων κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: Λήψη της καταγραφής μιας παρτίδας με τις κινήσεις και τα ρολόγια της
      description: |-
        Η καταγραφή μιας παρτίδας, με τις κινήσεις και τα ρολόγια της. Χωρίς token ή με το token ενός παίκτη που δεν έπαιξε την παρτίδα, η απάντηση είναι η δημόσια. Όταν ο παίκτης του token έπαιξε την παρτίδα, η απάντηση προσθέτει τα `you` και `reportable`.

        **Όριο:** `public_read` (ανά παίκτη με token, ανά πελάτη χωρίς).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Η καταγραφή της παρτίδας.
          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`: δεν είναι θετικός ακέραιος με το πολύ 16 ψηφία (κάτω από 2^53)· `invalid_request`: μη έγκυρη κωδικοποίηση URL.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: στάλθηκε token και δεν είναι έγκυρο.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: η παρτίδα δεν υπάρχει.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `public_read` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (η βάση δεδομένων παρέμεινε κλειδωμένη· `retryAfter: 1` στο σώμα, χωρίς κεφαλίδα `Retry-After`), `server_busy` (η αναζήτηση της συνεδρίας βρήκε τη βάση δεδομένων κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: Λήψη μιας παρτίδας ως αρχείο PGN
      description: |-
        Η ίδια παρτίδα ως αρχείο PGN: μία παρτίδα με αλλαγές γραμμής `\n`, και το κείμενο των κινήσεων σε γραμμές κάτω των 80 στηλών. Η απάντηση είναι η ίδια με ή χωρίς token.

        Ετικέτες, με αυτή τη σειρά: `Event` (`<SERVER_NAME> rated <category>` ή `<SERVER_NAME> casual <category>`), `Site` (`SERVER_PUBLIC_HOST`), `Date` (η ημερομηνία έναρξης σε UTC), `Round`, `White`, `Black`, `Result` (`*` για ακυρωμένη παρτίδα), `UTCDate` και `UTCTime` (η έναρξη), `WhiteElo` και `BlackElo` (οι βαθμολογίες κατά την έναρξη, ή `-`), `WhiteRatingDiff` και `BlackRatingDiff` (οι μεταβολές, όπως `+10` και `-10`, σε κάθε παρτίδα με βαθμολογία, `+0` όταν οι κανόνες βαθμολογίας αφήνουν τη βαθμολογία όπου ήταν· μια φιλική, προσαρμοσμένη ή ακυρωμένη παρτίδα δεν έχει καμία από τις δύο), `TimeControl` (σε δευτερόλεπτα), `Termination` (μια τυπική τιμή PGN: `normal`· `time forfeit`, πτώση σημαίας, ακόμη και όταν η παρτίδα καταλήγει σε ισοπαλία· `abandoned`· `rules infraction`, δεύτερη μη νόμιμη κίνηση ή ήττα λόγω παραβίασης του ευ αγωνίζεσθαι· `unterminated`, ακυρωμένη παρτίδα), `PlyCount`, `ScacelithGameId` (το δεκαδικό αναγνωριστικό).

        Κάθε κίνηση φέρει `{[%clk h:mm:ss.f] [%emt h:mm:ss.f]}`: το ρολόι του παίκτη που έκανε την κίνηση, μετά από αυτήν, και τον χρόνο που χρεώθηκε γι’ αυτήν, σε δέκατα του δευτερολέπτου (με αποκοπή). Μια τιμή παραλείπεται όταν λείπει από την καταγραφή. Μετά την τελευταία κίνηση ακολουθεί ο λόγος λήξης ολογράφως, και μετά το αποτέλεσμα.

        **Όριο:** `public_read` (ανά παίκτη με token, ανά πελάτη χωρίς).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
      responses:
        '200':
          description: Το αρχείο PGN.
          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, σε 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`: δεν είναι θετικός ακέραιος με το πολύ 16 ψηφία (κάτω από 2^53)· `invalid_request`: μη έγκυρη κωδικοποίηση URL.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: στάλθηκε token και δεν είναι έγκυρο.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: η παρτίδα δεν υπάρχει.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `public_read` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`internal_error`: μεταξύ άλλων, οι αποθηκευμένες κινήσεις δεν μπορούν να αναπαραχθούν μέχρι την αποθηκευμένη κατάληξη (ο διακομιστής το καταγράφει).'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (η βάση δεδομένων παρέμεινε κλειδωμένη· `retryAfter: 1` στο σώμα, χωρίς κεφαλίδα `Retry-After`), `server_busy` (η αναζήτηση της συνεδρίας βρήκε τη βάση δεδομένων κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /games/{id}/gif:
    get:
      operationId: getGameGif
      tags:
        - gifs
      summary: Λήψη μιας παρτίδας αυτού του διακομιστή ως κινούμενο GIF
      description: |-
        Η παρτίδα ως κινούμενο GIF. Τα ονόματα και οι βαθμολογίες είναι εκείνα της καταγραφής της παρτίδας (οι βαθμολογίες κατά την έναρξη, ένας διαγραμμένος λογαριασμός ως `deleted#<id>`), όπως και το αποτέλεσμα και η κατάληξη (`Resignation`, `Loss on time`…).

        Οι έλεγχοι γίνονται με αυτή τη σειρά: `gif_disabled`, οι επιλογές της εικόνας (`size`, `orientation`, `delay`, `coords`), το αναγνωριστικό της παρτίδας, η παρτίδα, το μήκος της.

        **Απαιτείται συνεδρία:** οι ποσοστώσεις μετρούν ανά λογαριασμό. **Όρια:** `gif` σε κάθε αίτημα· `gif_user_min`, `gif_user_hour`, `gif_ip_min` και `gif_ip_hour` μόνο όταν το GIF πρέπει να δημιουργηθεί (βλ. την περιγραφή της ενότητας). **Χρονικό όριο:** `GIF_QUEUE_TIMEOUT_MS` + `GIF_RENDER_TIMEOUT_MS` + 5 δευτερόλεπτα (45 δευτερόλεπτα από προεπιλογή).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/GameId'
        - name: size
          in: query
          required: false
          description: Το μέγεθος της εικόνας, `small` (τετράγωνα 32 px), `medium` (48 px) ή `large` (72 px).
          schema:
            type: string
            enum:
              - small
              - medium
              - large
            default: medium
        - name: orientation
          in: query
          required: false
          description: Η πλευρά στο κάτω μέρος της σκακιέρας.
          schema:
            type: string
            enum:
              - white
              - black
            default: white
        - name: delay
          in: query
          required: false
          description: Χιλιοστά του δευτερολέπτου ανά κίνηση (1 έως 6 ψηφία στο δεκαδικό σύστημα).
          schema:
            type: integer
            minimum: 100
            maximum: 3000
            default: 500
        - name: coords
          in: query
          required: false
          description: Αν τα γράμματα των στηλών και οι αριθμοί των γραμμών σχεδιάζονται γύρω από τη σκακιέρα (`1`) ή όχι (`0`).
          schema:
            type: string
            enum:
              - '1'
              - '0'
            default: '1'
      responses:
        '200':
          $ref: '#/components/responses/GifFile'
        '400':
          $ref: '#/components/responses/BadRequest'
          description: '`invalid_option` με `field` (`size`, `orientation`, `delay` ή `coords`): τιμή εκτός των επιτρεπόμενων. `invalid_game_id`. `invalid_request`: μη έγκυρη κωδικοποίηση URL.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: η παρτίδα δεν υπάρχει. `gif_disabled`: ο διακομιστής έχει απενεργοποιήσει τα GIF (`GIF_ENABLED=false`· η μάρκα `gif` επιστρέφεται).'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `gif`, ένα όριο σχεδίασης ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: το GIF δεν μπόρεσε να δημιουργηθεί (ο διακομιστής καταγράφει τον λόγο). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` με `retryAfter` (3 έως 10 δευτερόλεπτα) και `Retry-After`: η ουρά σχεδίασης είναι γεμάτη ή το GIF περίμενε `GIF_QUEUE_TIMEOUT_MS` (10 δευτερόλεπτα) για ελεύθερο νήμα· κάθε μάρκα ορίου που πήρε το αίτημα επιστρέφεται. `busy`: η βάση δεδομένων παρέμεινε κλειδωμένη (`retryAfter: 1` μόνο στο σώμα). `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /gif:
    post:
      operationId: renderPgnGif
      tags:
        - gifs
      summary: Δημιουργία κινούμενου GIF οποιασδήποτε παρτίδας σταλεί ως PGN
      description: |-
        Η ίδια εικόνα με το `GET /games/{id}/gif` για οποιαδήποτε παρτίδα σταλεί ως κείμενο PGN: μια παρτίδα που αποθήκευσε το παιχνίδι, μια εξαγωγή από άλλον ιστότοπο, μια παρτίδα πληκτρολογημένη με το χέρι. Χρησιμοποιείται μόνο η πρώτη παρτίδα του κειμένου.

        - Ο αναγνώστης PGN δέχεται ό,τι δέχεται και ο αναγνώστης του ίδιου του παιχνιδιού: κάθε PGN που γράφει αυτός ο διακομιστής και τις συνήθεις εξαγωγές άλλων ιστότοπων (τα σχόλια, οι παραλλαγές, τα NAG και οι σημειώσεις ρολογιού παραλείπονται· οι αριθμοί κινήσεων και το SAN διαβάζονται με ανεκτικότητα· μια ετικέτα `FEN` δίνει την αρχική θέση εκτός αν το `SetUp` είναι `"0"`).
        - Τα ονόματα και οι βαθμολογίες προέρχονται από τις ετικέτες `White`, `Black`, `WhiteElo` και `BlackElo`. Τα γράμματα με διακριτικά χάνουν τα διακριτικά τους, οι άλλοι χαρακτήρες εκτός του εκτυπώσιμου ASCII γίνονται `?`, και τα μεγάλα ονόματα περικόπτονται (48 χαρακτήρες). Το αποτέλεσμα προέρχεται από την ετικέτα `Result`, αλλιώς από το τέλος του κειμένου των κινήσεων. Η ετικέτα `Termination` εμφανίζεται εκτός αν είναι `normal`: τότε η τελική θέση λέει την ιστορία (ματ, πατ).
        - Αυτό το endpoint ελέγχει μόνο του το σώμα του: ένα άγνωστο πεδίο ή ένα `pgn` που λείπει ή δεν είναι συμβολοσειρά απαντά 400 `invalid_request` με `field`· μια λάθος επιλογή απαντά 400 `invalid_option` (το `delayMs` πρέπει να είναι αριθμός JSON, το `coords` boolean· το `null` απορρίπτεται).

        **Απαιτείται συνεδρία:** οι ποσοστώσεις μετρούν ανά λογαριασμό. **Όρια:** όπως για το `GET /games/{id}/gif`. **Μέγιστο μέγεθος σώματος:** 135.168 byte, ανεξάρτητα από το `HTTP_BODY_LIMIT` (το PGN ως συμβολοσειρά JSON, μαζί με τις ακολουθίες διαφυγής του). **Χρονικό όριο:** 45 δευτερόλεπτα από προεπιλογή.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GifRequest'
            examples:
              short:
                summary: Μια σύντομη παρτίδα πληκτρολογημένη, χωρίς συντεταγμένες
                value:
                  pgn: 1. f3 e5 2. g4 Qh4# 0-1
                  coords: false
              options:
                summary: Ένα αρχείο PGN με όλες τις επιλογές
                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` με `field` (άγνωστο πεδίο ή `pgn` που λείπει ή δεν είναι συμβολοσειρά· χωρίς `field` όταν το σώμα δεν είναι αντικείμενο)· `invalid_json`· `invalid_option` με `field` (`size`, `orientation`, `delayMs` ή `coords`)· `invalid_pgn` με `line` και `column` (από 1, οι στήλες σε χαρακτήρες) και το `message` του αναγνώστη: μη νόμιμη ή διφορούμενη κίνηση, χαλασμένη ετικέτα, άγνωστη παραλλαγή σκακιού, περισσότερα από 65.536 byte.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`gif_disabled`: ο διακομιστής έχει απενεργοποιήσει τα GIF (`GIF_ENABLED=false`· η μάρκα `gif` επιστρέφεται).'
        '408':
          $ref: '#/components/responses/RequestTimeout'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
          description: '`payload_too_large`: σώμα μεγαλύτερο από 135.168 byte.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `gif`, ένα όριο σχεδίασης ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
          description: '`render_failed`: το GIF δεν μπόρεσε να δημιουργηθεί (ο διακομιστής καταγράφει τον λόγο). `internal_error`.'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` με `retryAfter` (3 έως 10 δευτερόλεπτα) και `Retry-After`: η ουρά σχεδίασης είναι γεμάτη ή το GIF περίμενε υπερβολικά πολύ για ελεύθερο νήμα· κάθε μάρκα ορίου που πήρε το αίτημα επιστρέφεται. `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: Λήψη του δημόσιου προφίλ ενός παίκτη
      description: |-
        Το δημόσιο προφίλ ενός παίκτη: οι βαθμολογίες στις επίσημες κατηγορίες (με τη σειρά του διακομιστή) και οι αριθμοί παρτίδων. Το `games.total` μετρά κάθε αποθηκευμένη παρτίδα, μαζί με τις φιλικές και τις ακυρωμένες· τα `games.rated`, `wins`, `draws` και `losses` αθροίζονται από τις καταχωρίσεις βαθμολογίας, άρα μόνο από παρτίδες με βαθμολογία. Η απάντηση είναι η ίδια με ή χωρίς token.

        **Όριο:** `public_read` (ανά παίκτη με token, ανά πελάτη χωρίς).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
      responses:
        '200':
          description: Το προφίλ.
          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`: δεν αποτελείται από 2 έως 24 χαρακτήρες του `[A-Za-z0-9_.-]`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: στάλθηκε token και δεν είναι έγκυρο.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: ο παίκτης δεν υπάρχει ή ο λογαριασμός έχει διαγραφεί.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `public_read` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (η βάση δεδομένων παρέμεινε κλειδωμένη· `retryAfter: 1` στο σώμα, χωρίς κεφαλίδα `Retry-After`), `server_busy` (η αναζήτηση της συνεδρίας βρήκε τη βάση δεδομένων κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: Λίστα των πρόσφατων παρτίδων ενός παίκτη
      description: |-
        Οι πρόσφατες παρτίδες του παίκτη, πρώτα οι νεότερες, με σελιδοποίηση όπως το ιστορικό αλλά χωρίς φίλτρα ή σύνολο. Το `color` είναι η πλευρά αυτού του παίκτη. Το `next` είναι το αναγνωριστικό της τελευταίας παρτίδας όποτε η σελίδα είναι γεμάτη, οπότε η επόμενη σελίδα μπορεί να είναι κενή. Ο παίκτης αναζητείται πρώτα: ένας άγνωστος παίκτης δίνει 404 ό,τι κι αν λέει το ερώτημα.

        **Όριο:** `public_read` (ανά παίκτη με token, ανά πελάτη χωρίς).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/Username'
        - $ref: '#/components/parameters/BeforeQuery'
        - $ref: '#/components/parameters/LimitQuery'
      responses:
        '200':
          description: Μία σελίδα των παρτίδων του παίκτη.
          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` δεν είναι αναγνωριστικό παρτίδας), `invalid_limit` (αυτά τα δύο χωρίς `field`).'
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: '`invalid_token`: στάλθηκε token και δεν είναι έγκυρο.'
        '404':
          $ref: '#/components/responses/NotFound'
          description: '`not_found`: ο παίκτης δεν υπάρχει ή ο λογαριασμός έχει διαγραφεί.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το όριο `public_read` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (η βάση δεδομένων παρέμεινε κλειδωμένη· `retryAfter: 1` στο σώμα, χωρίς κεφαλίδα `Retry-After`), `server_busy` (η αναζήτηση της συνεδρίας βρήκε τη βάση δεδομένων κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /leaderboard:
    get:
      operationId: getLeaderboard
      tags:
        - leaderboard
      summary: Λήψη των κορυφαίων παικτών μιας κατηγορίας
      description: |-
        Οι 100 κορυφαίες καταχωρίσεις βαθμολογίας μιας επίσημης κατηγορίας με τουλάχιστον `minGames` (`PROVISIONAL_GAMES`) καταμετρημένες παρτίδες, χωρίς τους διαγραμμένους λογαριασμούς και τους επιβεβαιωμένους απατεώνες. Ο διακομιστής υπολογίζει ξανά κάθε πίνακα κατάταξης το πολύ μία φορά ανά 10 δευτερόλεπτα· το `updatedAt` λέει πότε. Για κάθε κατηγορία εκτελείται ένας μόνο υπολογισμός τη φορά: στο μεταξύ τα άλλα αιτήματα λαμβάνουν την προηγούμενη λίστα ή περιμένουν τον υπολογισμό όταν δεν υπάρχει ακόμη καμία.

        **Όρια:** μόνο το επίπεδο ανά διεύθυνση.
      security: []
      parameters:
        - name: category
          in: query
          required: true
          description: Αναγνωριστικό επίσημης κατηγορίας (`3+2` ή `3%2B2`).
          schema:
            type: string
            pattern: '^\s*[0-9]+[+ ][0-9]+\s*$'
          example: '3+2'
        - name: limit
          in: query
          required: false
          description: Πόσοι παίκτες, από 1 έως 100. Ένας μεγαλύτερος αριθμός με έως 3 ψηφία θεωρείται 100· μια κενή τιμή θεωρείται απούσα.
          schema:
            type: integer
            minimum: 1
            maximum: 999
            default: 100
      responses:
        '200':
          description: Ο πίνακας κατάταξης.
          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` (λείπει ή δεν είναι επίσημη κατηγορία), `invalid_limit`.'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/TooManyRequests'
          description: '`rate_limited`: το επίπεδο ανά διεύθυνση.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`busy` (η βάση δεδομένων παρέμεινε κλειδωμένη· `retryAfter: 1` στο σώμα, χωρίς κεφαλίδα `Retry-After`), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: Αναφορά του αντιπάλου μιας πρόσφατης παρτίδας
      description: |-
        Αναφέρει τον αντίπαλο μιας από τις παρτίδες του ίδιου του παίκτη που τελείωσε μέσα στις τελευταίες 7 ημέρες. Μια αναφορά δεν αλλάζει ποτέ από μόνη της μια βαθμολογία, μια κύρωση ή ένα επίπεδο ακεραιότητας. Αυξάνει την προτεραιότητα εξέτασης που βλέπουν οι συντονιστές και, εκτός από την κατηγορία `abuse`, ζητά την ανάλυση της παρτίδας από τη μηχανή.

        Μια αναφορά του ίδιου αντιπάλου για την ίδια παρτίδα λαμβάνει την ίδια απάντηση και δεν αλλάζει τίποτα· η απάντηση δεν αποκαλύπτει ποτέ τίποτα για τον λογαριασμό που αναφέρθηκε. Το `GET /games/{id}` λέει από πριν στους παίκτες της παρτίδας αν μια αναφορά θα γινόταν δεκτή (`reportable`).

        Αυτό το endpoint ελέγχει μόνο του το σώμα του, με τη σειρά `gameId`, `reported`, `category`, `comment`: μια αποτυχία απαντά 400 `invalid_request` χωρίς `field`. Τα πεδία που δεν γνωρίζει αγνοούνται.

        **Όρια:** `reports` (ανά παίκτη), μετά `REPORTS_PER_DAY` (5) αναφορές ανά παίκτη σε 24 ώρες (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: Η αναφορά παραλήφθηκε (ή είχε ήδη υποβληθεί).
          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` (χωρίς `field`), `invalid_json`.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
          description: '`report_not_allowed`: δεν πρόκειται για τον αντίπαλο του παίκτη που κάνει την αναφορά σε παρτίδα που τελείωσε μέσα στις τελευταίες 7 ημέρες.'
        '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`, με κεφαλίδα `Retry-After`): `REPORTS_PER_DAY` αναφορές τις τελευταίες 24 ώρες. `rate_limited`: το όριο `reports` ή ο προϋπολογισμός του λογαριασμού.'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: '`server_busy` (η αναζήτηση της συνεδρίας βρήκε τη βάση δεδομένων κλειδωμένη), `timeout`. Επίσης `server_busy` με `retryAfter` από 2 έως 5 όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή): το αίτημα δεν εκτελέστηκε.'
  /verify-email:
    servers:
      - url: https://caissa.scacelith.com
        description: Ο επίσημος διακομιστής (οι σελίδες βρίσκονται στη ρίζα, εκτός του `/api/v1`).
      - url: https://{host}:{port}
        description: Οποιοσδήποτε διακομιστής Scacelith (οι σελίδες βρίσκονται στη ρίζα, εκτός του `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Το δημόσιο όνομα κεντρικού υπολογιστή του διακομιστή (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Η δημόσια θύρα του API (`PUBLIC_API_PORT`, αλλιώς `API_PORT`).
    get:
      operationId: showVerifyEmailPage
      tags:
        - pages
      summary: Εμφάνιση της σελίδας επιβεβαίωσης email
      description: |-
        Η σελίδα ενός συνδέσμου επιβεβαίωσης email (εγγραφή ή email επιβεβαίωσης που στάλθηκε ξανά). Εμφανίζει μόνο ένα κουμπί «Confirm my e-mail address» (επιβεβαίωση της διεύθυνσης email μου), ώστε ένας σαρωτής email που ανοίγει τον σύνδεσμο να μην τον αναλώνει· το κουμπί υποβάλλει τη φόρμα στο `POST /verify-email`.

        **Όριο:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Η σελίδα με το κουμπί επιβεβαίωσης.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ο σύνδεσμος δεν είναι έγκυρος ή έχει λήξει (σελίδα HTML).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Το όριο `page` (σελίδα HTML) ή το επίπεδο ανά διεύθυνση (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: Επιβεβαίωση της διεύθυνσης email
      description: |-
        Η φόρμα της σελίδας επιβεβαίωσης. Επιβεβαιώνει τη διεύθυνση· για μια νέα εγγραφή, δημιουργεί τότε τον λογαριασμό, και ο παίκτης μπορεί να συνδεθεί.

        **Όριο:** `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: Η διεύθυνση επιβεβαιώθηκε (και δημιουργήθηκε ο λογαριασμός μιας νέας εγγραφής).
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ο σύνδεσμος δεν είναι έγκυρος, έχει χρησιμοποιηθεί ή έχει λήξει, ή η φόρμα δεν είναι έγκυρη (σελίδα HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Ο σύνδεσμος μιας νέας εγγραφής της οποίας το όνομα χρήστη ή τη διεύθυνση πήρε στο μεταξύ άλλος λογαριασμός· δεν δημιουργείται λογαριασμός (σελίδα HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Το όριο `auth` (σελίδα HTML) ή το επίπεδο ανά διεύθυνση (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Η βάση δεδομένων παρέμεινε κλειδωμένη (`Retry-After: 1`) ή ο διακομιστής έχει μείνει πολύ πίσω (`Retry-After` από 2 έως 5, βλ. την εισαγωγή): δεν άλλαξε τίποτα και ο σύνδεσμος εξακολουθεί να λειτουργεί. Επίσης σε υπέρβαση του χρονικού ορίου του χειριστή αιτημάτων.'
  /reset-password:
    servers:
      - url: https://caissa.scacelith.com
        description: Ο επίσημος διακομιστής (οι σελίδες βρίσκονται στη ρίζα, εκτός του `/api/v1`).
      - url: https://{host}:{port}
        description: Οποιοσδήποτε διακομιστής Scacelith (οι σελίδες βρίσκονται στη ρίζα, εκτός του `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Το δημόσιο όνομα κεντρικού υπολογιστή του διακομιστή (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Η δημόσια θύρα του API (`PUBLIC_API_PORT`, αλλιώς `API_PORT`).
    get:
      operationId: showResetPasswordPage
      tags:
        - pages
      summary: Εμφάνιση της φόρμας επαναφοράς κωδικού πρόσβασης
      description: |-
        Η σελίδα ενός συνδέσμου επαναφοράς κωδικού πρόσβασης: η φόρμα νέου κωδικού πρόσβασης (ο κωδικός πρόσβασης δύο φορές). Η φόρμα υποβάλλεται στο `POST /reset-password`.

        **Όριο:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Η φόρμα νέου κωδικού πρόσβασης.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ο σύνδεσμος δεν είναι έγκυρος (και όταν στάλθηκε σε διεύθυνση που ο λογαριασμός δεν έχει πλέον).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Το όριο `page` (σελίδα HTML) ή το επίπεδο ανά διεύθυνση (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: Ορισμός νέου κωδικού πρόσβασης από τη φόρμα επαναφοράς
      description: |-
        Η φόρμα της σελίδας επαναφοράς· κάνει ό,τι και το `POST /auth/password/reset`, σε μία συναλλαγή: κάθε συσκευή αποσυνδέεται, μια εκκρεμής αλλαγή email ακυρώνεται, οι άλλοι σύνδεσμοι επαναφοράς παύουν να λειτουργούν, η διεύθυνση θεωρείται επιβεβαιωμένη και ο κάτοχος λαμβάνει ένα email.

        Ελέγχεται πρώτα ο σύνδεσμος, μετά ότι οι δύο κωδικοί πρόσβασης είναι ίδιοι και μετά οι κανόνες κωδικού πρόσβασης. Όταν ο διακομιστής είναι απασχολημένος, η φόρμα επιστρέφει με `Retry-After` και ο σύνδεσμος παραμένει έγκυρος.

        **Όρια:** `auth`, μετά `auth_reset` (κοινό με το `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: Ο κωδικός πρόσβασης άλλαξε, και κάθε συσκευή αποσυνδέθηκε.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ξανά η φόρμα με το σφάλμα (οι κωδικοί πρόσβασης διαφέρουν, αδύναμος κωδικός πρόσβασης), ο σύνδεσμος δεν είναι έγκυρος ή η φόρμα δεν είναι έγκυρη (σελίδα HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Το όριο `auth` ή `auth_reset` (σελίδα HTML), ξανά η φόρμα με `Retry-After` όταν αυτός ο πελάτης έχει υπερβολικά πολλούς κωδικούς πρόσβασης σε αναμονή κατακερματισμού (ο σύνδεσμος παραμένει έγκυρος) ή το επίπεδο ανά διεύθυνση (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Ξανά η φόρμα με `Retry-After` όταν ο διακομιστής είναι απασχολημένος (η ουρά κατακερματισμού κωδικών πρόσβασης ή η βάση δεδομένων παρέμεινε κλειδωμένη)· ο σύνδεσμος παραμένει έγκυρος. Όταν ο διακομιστής έχει μείνει πολύ πίσω (βλ. την εισαγωγή), αντί γι’ αυτό μια σελίδα «Server error» (σφάλμα διακομιστή) με `Retry-After` (από 2 έως 5): δεν εκτελέστηκε τίποτα και ο σύνδεσμος παραμένει έγκυρος. Επίσης σε υπέρβαση του χρονικού ορίου του χειριστή αιτημάτων.'
  /confirm-email-change:
    servers:
      - url: https://caissa.scacelith.com
        description: Ο επίσημος διακομιστής (οι σελίδες βρίσκονται στη ρίζα, εκτός του `/api/v1`).
      - url: https://{host}:{port}
        description: Οποιοσδήποτε διακομιστής Scacelith (οι σελίδες βρίσκονται στη ρίζα, εκτός του `/api/v1`).
        variables:
          host:
            default: caissa.scacelith.com
            description: Το δημόσιο όνομα κεντρικού υπολογιστή του διακομιστή (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Η δημόσια θύρα του API (`PUBLIC_API_PORT`, αλλιώς `API_PORT`).
    get:
      operationId: showConfirmEmailChangePage
      tags:
        - pages
      summary: Εμφάνιση της σελίδας επιβεβαίωσης αλλαγής email
      description: |-
        Η σελίδα ενός συνδέσμου αλλαγής email: εμφανίζει τη νέα διεύθυνση και το όνομα του λογαριασμού, με ένα κουμπί «Use this e-mail address» (χρήση αυτής της διεύθυνσης email) που υποβάλλει τη φόρμα στο `POST /confirm-email-change`.

        **Όριο:** `page`.
      security: []
      parameters:
        - $ref: '#/components/parameters/LinkTokenQuery'
      responses:
        '200':
          $ref: '#/components/responses/HtmlPage'
          description: Η σελίδα με τη νέα διεύθυνση και το κουμπί επιβεβαίωσής της.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ο σύνδεσμος δεν είναι έγκυρος ή έχει λήξει (και όταν η διεύθυνση του λογαριασμού άλλαξε μετά το αίτημα).
        '414':
          $ref: '#/components/responses/UriTooLong'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Το όριο `page` (σελίδα HTML) ή το επίπεδο ανά διεύθυνση (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: Επιβεβαίωση της νέας διεύθυνσης email
      description: |-
        Η φόρμα της σελίδας αλλαγής email. Η διεύθυνση αλλάζει και θεωρείται επιβεβαιωμένη· οι συσκευές παραμένουν συνδεδεμένες· οι σύνδεσμοι που στάλθηκαν νωρίτερα (επιβεβαίωση, επαναφορά κωδικού πρόσβασης, άλλες αλλαγές) παύουν να λειτουργούν· η προηγούμενη διεύθυνση ενημερώνεται, με τη νέα καλυμμένη.

        **Όριο:** `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: Η διεύθυνση άλλαξε.
        '400':
          $ref: '#/components/responses/HtmlBadRequest'
          description: Ο σύνδεσμος δεν είναι έγκυρος, έχει χρησιμοποιηθεί ή έχει λήξει, ή η φόρμα δεν είναι έγκυρη (σελίδα HTML).
        '408':
          $ref: '#/components/responses/HtmlRequestTimeout'
        '409':
          $ref: '#/components/responses/HtmlConflict'
          description: Άλλος λογαριασμός πήρε τη διεύθυνση στο μεταξύ (σελίδα HTML).
        '413':
          $ref: '#/components/responses/HtmlPayloadTooLarge'
        '414':
          $ref: '#/components/responses/UriTooLong'
        '415':
          $ref: '#/components/responses/HtmlUnsupportedMediaType'
        '429':
          $ref: '#/components/responses/HtmlTooManyRequests'
          description: Το όριο `auth` (σελίδα HTML) ή το επίπεδο ανά διεύθυνση (JSON `rate_limited`).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'Η βάση δεδομένων παρέμεινε κλειδωμένη (`Retry-After: 1`) ή ο διακομιστής έχει μείνει πολύ πίσω (`Retry-After` από 2 έως 5, βλ. την εισαγωγή): δεν άλλαξε τίποτα και ο σύνδεσμος εξακολουθεί να λειτουργεί. Επίσης σε υπέρβαση του χρονικού ορίου του χειριστή αιτημάτων.'
  /healthz:
    servers:
      - url: https://caissa.scacelith.com
        description: Ο επίσημος διακομιστής, στη ρίζα.
      - url: https://caissa.scacelith.com/api/v1
        description: Ο επίσημος διακομιστής, κάτω από το `/api/v1`.
      - url: https://{host}:{port}
        description: Οποιοσδήποτε διακομιστής Scacelith, στη ρίζα.
        variables:
          host:
            default: caissa.scacelith.com
            description: Το δημόσιο όνομα κεντρικού υπολογιστή του διακομιστή (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Η δημόσια θύρα του API (`PUBLIC_API_PORT`, αλλιώς `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Οποιοσδήποτε διακομιστής Scacelith, κάτω από το `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Το δημόσιο όνομα κεντρικού υπολογιστή του διακομιστή (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Η δημόσια θύρα του API (`PUBLIC_API_PORT`, αλλιώς `API_PORT`).
    get:
      operationId: getLiveness
      tags:
        - health
      summary: Έλεγχος ότι η διεργασία εκτελείται
      description: |-
        Απαντά 200 `ok` όσο η διεργασία εκτελείται. Λειτουργεί και το `HEAD`. Οποιαδήποτε άλλη μέθοδος απαντά 405 `method_not_allowed` με `Allow: GET, HEAD` (μαζί και το `OPTIONS`).

        **Όρια:** μόνο το επίπεδο ανά διεύθυνση.
      security: []
      responses:
        '200':
          description: Η διεργασία εκτελείται.
          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`: το επίπεδο ανά διεύθυνση.'
  /readyz:
    servers:
      - url: https://caissa.scacelith.com
        description: Ο επίσημος διακομιστής, στη ρίζα.
      - url: https://caissa.scacelith.com/api/v1
        description: Ο επίσημος διακομιστής, κάτω από το `/api/v1`.
      - url: https://{host}:{port}
        description: Οποιοσδήποτε διακομιστής Scacelith, στη ρίζα.
        variables:
          host:
            default: caissa.scacelith.com
            description: Το δημόσιο όνομα κεντρικού υπολογιστή του διακομιστή (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Η δημόσια θύρα του API (`PUBLIC_API_PORT`, αλλιώς `API_PORT`).
      - url: https://{host}:{port}/api/v1
        description: Οποιοσδήποτε διακομιστής Scacelith, κάτω από το `/api/v1`.
        variables:
          host:
            default: caissa.scacelith.com
            description: Το δημόσιο όνομα κεντρικού υπολογιστή του διακομιστή (`SERVER_PUBLIC_HOST`).
          port:
            default: "443"
            description: Η δημόσια θύρα του API (`PUBLIC_API_PORT`, αλλιώς `API_PORT`).
    get:
      operationId: getReadiness
      tags:
        - health
      summary: Έλεγχος ότι ο διακομιστής δέχεται παίκτες
      description: |-
        Απαντά 200 `ready` όταν ο διακομιστής δέχεται παίκτες, και 503 `not_ready` όσο ξεκινά ή σταματά. Λειτουργεί και το `HEAD`. Οποιαδήποτε άλλη μέθοδος απαντά 405 `method_not_allowed` με `Allow: GET, HEAD` (μαζί και το `OPTIONS`).

        **Όρια:** μόνο το επίπεδο ανά διεύθυνση.
      security: []
      responses:
        '200':
          description: Ο διακομιστής δέχεται παίκτες.
          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`: το επίπεδο ανά διεύθυνση.'
        '503':
          description: Ο διακομιστής ξεκινά ή σταματά.
          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 συνεδρίας (`sct_` ακολουθούμενο από 43 χαρακτήρες base64url) από μια απάντηση σύνδεσης, στην κεφαλίδα `Authorization`: `Authorization: Bearer <token>`. Το πρόθεμα είναι ακριβώς `Bearer` (με διάκριση πεζών-κεφαλαίων) και ένα κενό· μια τιμή που δεν το έχει ή ένα token που δεν αποτελείται από 1 έως 512 εκτυπώσιμους χαρακτήρες ASCII απαντά 401 `invalid_token` όπως κάθε άλλο μη έγκυρο token. Μια κενή κεφαλίδα θεωρείται ως απουσία κεφαλίδας.
  parameters:
    GameId:
      name: id
      in: path
      required: true
      description: Το αναγνωριστικό της παρτίδας, θετικός ακέραιος σε δεκαδική γραφή με το πολύ 16 ψηφία χωρίς αρχικά μηδενικά, κάτω από 2^53.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000001
    SessionId:
      name: id
      in: path
      required: true
      description: Το αναγνωριστικό της συνεδρίας, όπως το δίνει το `GET /auth/sessions`, γραμμένο χωρίς αρχικά μηδενικά (το `03` δεν είναι η συνεδρία 3).
      schema:
        type: integer
        format: int64
        minimum: 1
      example: 3
    Username:
      name: username
      in: path
      required: true
      description: Το όνομα χρήστη του παίκτη, χωρίς διάκριση πεζών-κεφαλαίων. Ο διακομιστής δέχεται οποιοδήποτε όνομα αποτελείται από 2 έως 24 χαρακτήρες του `[A-Za-z0-9_.-]` (ο ευρύτερος κανόνας που επέτρεψε ποτέ).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.-]{2,24}$'
      example: alice
    BeforeQuery:
      name: before
      in: query
      required: false
      description: Ένα αναγνωριστικό παρτίδας· παρατίθενται μόνο παλαιότερες παρτίδες. Δώστε το `next` της προηγούμενης σελίδας. Μια κενή τιμή θεωρείται απούσα.
      schema:
        type: integer
        format: int64
        minimum: 1
        maximum: 9007199254740991
      example: 4100000000002
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Το μέγεθος της σελίδας, από 1 έως 50. Ένας μεγαλύτερος αριθμός με έως 3 ψηφία θεωρείται 50· μια κενή τιμή θεωρείται απούσα.
      schema:
        type: integer
        minimum: 1
        maximum: 999
        default: 20
    LinkTokenQuery:
      name: token
      in: query
      required: false
      description: Το token του συνδέσμου email (43 χαρακτήρες base64url). Αν λείπει ή είναι λάθος, εμφανίζεται η σελίδα «link invalid or expired» (μη έγκυρος ή ληγμένος σύνδεσμος).
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{43}$'
      example: asfioO4P0r_Zn5DxWzcQuF6Drgxu7wXGjCyS-UFbTaw
  headers:
    CacheControl:
      description: Κάθε απάντηση του διακομιστή απαγορεύει την προσωρινή αποθήκευση.
      schema:
        type: string
        const: no-store
    RetryAfter:
      description: Δευτερόλεπτα αναμονής πριν από νέα προσπάθεια· η ίδια τιμή με το `retryAfter` στο σώμα.
      schema:
        type: integer
        minimum: 1
      example: 30
    WwwAuthenticate:
      description: Η πρόκληση ελέγχου ταυτότητας Bearer, με `error="invalid_token"` για μη έγκυρο token.
      schema:
        type: string
        enum:
          - Bearer realm="scacelith"
          - Bearer realm="scacelith", error="invalid_token"
    ConnectionClose:
      description: Ο διακομιστής κλείνει τη σύνδεση μετά από αυτή την απάντηση.
      schema:
        type: string
        const: close
  responses:
    BadRequest:
      description: '`invalid_request` (με `field` όταν φταίει ένα πεδίο του σώματος) ή `invalid_json`.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidRequest:
              summary: Ένα πεδίο παραβιάζει το σχήμα
              value:
                error: invalid_request
                message: '"password" is required'
                field: password
            invalidJson:
              summary: Το σώμα δεν είναι JSON
              value:
                error: invalid_json
                message: The body is not valid JSON.
            weakPassword:
              summary: Αδύναμος κωδικός πρόσβασης
              value:
                error: weak_password
                message: The password must have at least 10 characters.
                reason: too_short
            invalidPgn:
              summary: Ένα PGN που δεν μπορεί να διαβαστεί
              value:
                error: invalid_pgn
                message: illegal move 'Ke3'
                line: 1
                column: 13
    Unauthorized:
      description: '`unauthorized` (χωρίς κεφαλίδα `Authorization`) ή `invalid_token` (το token είναι κακοσχηματισμένο, έχει λήξει, έχει ανακληθεί ή ανήκει σε διαγραμμένο λογαριασμό).'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        WWW-Authenticate:
          $ref: '#/components/headers/WwwAuthenticate'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            unauthorized:
              summary: Χωρίς συνεδρία
              value:
                error: unauthorized
                message: Log in first.
            invalidToken:
              summary: Μη έγκυρο token
              value:
                error: invalid_token
                message: The session is invalid or has expired; log in again.
    Forbidden:
      description: Το αίτημα απορρίπτεται.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPassword:
              summary: Λάθος κωδικός πρόσβασης κατά τον επανέλεγχο ταυτότητας
              value:
                error: invalid_password
                message: Wrong password.
            banned:
              summary: Λογαριασμός σε αναστολή
              value:
                error: banned
                message: This account is banned.
                until: 1791487639708
    NotFound:
      description: '`not_found` ή μια λειτουργία που αυτός ο διακομιστής έχει απενεργοποιήσει.'
      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`: το σώμα δεν έφτασε μέσα σε 10 δευτερόλεπτα. Ο διακομιστής κλείνει τη σύνδεση.'
      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: Το αίτημα έρχεται σε σύγκρουση με την κατάσταση του διακομιστή.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: username_taken
            message: This username is already taken.
    Gone:
      description: '`sso_expired`: η απόπειρα σύνδεσης με Google ή το ticket δεν είναι γνωστά, έχουν ήδη χρησιμοποιηθεί ή έχουν λήξει· ξεκινήστε ξανά τη σύνδεση.'
      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`: το σώμα υπερβαίνει το `HTTP_BODY_LIMIT` (16.384 byte από προεπιλογή). Ο διακομιστής κλείνει τη σύνδεση.'
      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`: ο στόχος του αιτήματος υπερβαίνει τους 4096 χαρακτήρες.'
      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`: το σώμα δεν είναι `application/json` ή το σύνολο χαρακτήρων του δεν είναι 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`: η παρτίδα έχει περισσότερες από `GIF_MAX_PLIES` (600) ημικινήσεις.'
      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`: λύστε την πρόκληση `pow` και στείλτε ξανά το ίδιο αίτημα με `pow: { challenge, nonce }`. Το `reason` λέει γιατί: `required`, `malformed`, `signature`, `endpoint`, `network`, `expired`, `bits`, `work` ή `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` (ένα όριο ρυθμού αιτημάτων, ο προϋπολογισμός του λογαριασμού ή η ουρά κατακερματισμού κωδικών πρόσβασης) ή `too_many_attempts` (επιβράδυνση του λογαριασμού), με `retryAfter` και κεφαλίδα `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: Όριο ρυθμού αιτημάτων
              value:
                error: rate_limited
                message: Too many requests; try again later.
                retryAfter: 30
            tooManyAttempts:
              summary: Υπερβολικά πολλές αποτυχίες σε αυτόν τον λογαριασμό
              value:
                error: too_many_attempts
                message: Too many attempts; wait before trying again.
                retryAfter: 4
    InternalError:
      description: '`internal_error`: απρόσμενη αποτυχία. Ο διακομιστής την καταγράφει.'
      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`: λάθος `state` ή `iss`, ή η Google απέρριψε τον κωδικό ή έστειλε ένα ID token που δεν επαληθεύεται (συμπεριλαμβανομένου ενός κωδικού ή ID token του άλλου πελάτη, του παιχνιδιού ή του ιστότοπου). Δεν μεταφέρει ποτέ το κείμενο της Google.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: sso_failed
            message: Google sign-in could not be completed.
    ServiceUnavailable:
      description: '`server_busy`, `busy` ή `timeout`: δοκιμάστε ξανά μετά από `retryAfter` όταν δίνεται.'
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            serverBusy:
              summary: Ο διακομιστής είναι απασχολημένος
              value:
                error: server_busy
                message: The server is busy; try again in a few seconds.
                retryAfter: 9
            busy:
              summary: Η βάση δεδομένων παρέμεινε κλειδωμένη (ανάγνωση)
              value:
                error: busy
                message: Try again shortly.
                retryAfter: 1
            timeout:
              summary: Ο διακομιστής άργησε υπερβολικά
              value:
                error: timeout
                message: The server took too long to answer; try again.
    GifFile:
      description: Το κινούμενο GIF, ως συνημμένο.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Content-Disposition:
          description: '`attachment; filename="scacelith-<id>.gif"` για μια παρτίδα αυτού του διακομιστή, `attachment; filename="scacelith-game.gif"` για ένα PGN που στάλθηκε με `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: Το μέγεθος του αρχείου σε byte.
          schema:
            type: integer
            minimum: 1
      content:
        image/gif:
          schema:
            type: string
            format: binary
    HtmlPage:
      description: Μια σελίδα HTML.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlBadRequest:
      description: Μια σελίδα HTML που εξηγεί την απόρριψη.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlRequestTimeout:
      description: Η φόρμα δεν έφτασε μέσα σε 10 δευτερόλεπτα (σελίδα HTML). Ο διακομιστής κλείνει τη σύνδεση.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlConflict:
      description: Μια σελίδα HTML που εξηγεί τη σύγκρουση.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlPayloadTooLarge:
      description: Η φόρμα υπερβαίνει το `HTTP_BODY_LIMIT` (σελίδα HTML). Ο διακομιστής κλείνει τη σύνδεση.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Connection:
          $ref: '#/components/headers/ConnectionClose'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlUnsupportedMediaType:
      description: Το σώμα δεν είναι ούτε φόρμα (`application/x-www-form-urlencoded`) ούτε JSON, ή το σύνολο χαρακτήρων του δεν είναι UTF-8 (σελίδα HTML).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlTooManyRequests:
      description: Όριο ρυθμού αιτημάτων (σελίδα HTML), με `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: Απρόσμενη αποτυχία (σελίδα HTML με τίτλο «Server error», σφάλμα διακομιστή). Ο διακομιστής την καταγράφει.
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
    HtmlServiceUnavailable:
      description: Ο διακομιστής είναι απασχολημένος (η βάση δεδομένων παρέμεινε κλειδωμένη ή ο διακομιστής έχει μείνει πολύ πίσω· με `Retry-After`) ή άργησε υπερβολικά (σελίδα HTML με τίτλο «Server error», σφάλμα διακομιστή).
      headers:
        Cache-Control:
          $ref: '#/components/headers/CacheControl'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        text/html:
          schema:
            $ref: '#/components/schemas/HtmlDocument'
  schemas:
    Error:
      type: object
      description: Η μοναδική μορφή κάθε απάντησης σφάλματος του JSON API.
      required:
        - error
        - message
      properties:
        error:
          type: string
          pattern: '^[a-z][a-z0-9_]*$'
          description: Ο κωδικός σφάλματος σε `snake_case`. Από αυτόν ένας πελάτης επιλέγει τι θα εμφανίσει.
          examples:
            - rate_limited
        message:
          type: string
          description: Μια πρόταση στα αγγλικά, για τα αρχεία καταγραφής και ως εναλλακτική λύση.
        retryAfter:
          type: integer
          minimum: 1
          description: Δευτερόλεπτα αναμονής πριν από νέα προσπάθεια, μόνο στις απορρίψεις που λήγουν με τον χρόνο. Η απάντηση φέρει τότε και κεφαλίδα `Retry-After` με την ίδια τιμή, εκτός από το 503 `busy` των αναγνώσεων ιστορικού, παρτίδας, παίκτη και πίνακα κατάταξης.
        field:
          type: string
          description: Το πεδίο ή η παράμετρος ερωτήματος που φταίει (`invalid_request`, `invalid_option`, και τα `invalid_cursor`, `invalid_limit` και `invalid_filter` του `GET /account/games`). Ένα ένθετο πεδίο γράφεται με τελείες (`pow.nonce`).
        reason:
          type: string
          description: 'Γιατί: ο κανόνας που παραβιάζει ένα `weak_password` ή γιατί ζητήθηκε ή απορρίφθηκε μια απόδειξη εργασίας (`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: 'Μόνο για `banned`: το τέλος της αναστολής (ms από την εποχή Unix), `null` για μόνιμη αναστολή.'
        line:
          type: integer
          minimum: 1
          description: 'Μόνο για `invalid_pgn`: η γραμμή του σφάλματος, από 1.'
        column:
          type: integer
          minimum: 1
          description: 'Μόνο για `invalid_pgn`: η στήλη του σφάλματος, από 1, σε χαρακτήρες.'
    PowChallenge:
      type: object
      description: Μια πρόκληση απόδειξης εργασίας (το `pow` ενός 428 `pow_required`).
      required:
        - challenge
        - bits
        - expiresAt
      properties:
        challenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,400}\.[A-Za-z0-9_-]{43}$'
          description: Η υπογεγραμμένη πρόκληση, για να σταλεί πίσω όπως είναι.
        bits:
          type: integer
          minimum: 1
          maximum: 26
          description: Ο αριθμός των αρχικών μηδενικών bit που πρέπει να έχει το `SHA-256(challenge + ":" + nonce)`.
        expiresAt:
          type: integer
          format: int64
          description: Πότε λήγει η πρόκληση (ms από την εποχή Unix), 2 λεπτά μετά την έκδοσή της.
    PowAnswer:
      type: object
      description: Η απάντηση σε μια πρόκληση απόδειξης εργασίας.
      additionalProperties: false
      required:
        - challenge
        - nonce
      properties:
        challenge:
          type: string
          minLength: 16
          maxLength: 512
          description: Το `challenge` της απάντησης 428, όπως δόθηκε.
        nonce:
          type: string
          pattern: '^[0-9]{1,20}$'
          description: Μια συμβολοσειρά με το πολύ 20 ψηφία (στο δεκαδικό σύστημα), τέτοια ώστε το `SHA-256(challenge + ":" + nonce)` να αρχίζει με `bits` μηδενικά bit.
    ServerInfo:
      type: object
      description: Ό,τι χρειάζεται ένας πελάτης πριν συνδεθεί σε λογαριασμό ή στον διακομιστή.
      required:
        - name
        - serverId
        - motd
        - protocol
        - wsPort
        - wsPath
        - registration
        - emailVerification
        - sso
        - mfa
        - pow
        - categories
        - limits
      properties:
        name:
          type: string
          description: Το όνομα του διακομιστή (`SERVER_NAME`).
        serverId:
          type:
            - string
            - 'null'
          format: uuid
          description: Ένα UUID που λαμβάνει η βάση δεδομένων στην πρώτη της εκκίνηση. Παραμένει ίδιο μετά από επανεκκινήσεις. Είναι `null` όταν η βάση δεδομένων δεν μπορεί να το δώσει.
        motd:
          type: string
          description: Το μήνυμα της ημέρας (`SERVER_MOTD`), ενδεχομένως κενό.
        protocol:
          type: object
          description: Το πρωτόκολλο WebSocket (`PROTOCOL.md`).
          required:
            - min
            - max
            - schema
            - subprotocol
          properties:
            min:
              type: integer
              minimum: 1
              description: Η παλαιότερη έκδοση του πρωτοκόλλου που υποστηρίζει ο διακομιστής.
            max:
              type: integer
              minimum: 1
              description: Η νεότερη έκδοση του πρωτοκόλλου που υποστηρίζει ο διακομιστής.
            schema:
              type: integer
              format: int64
              minimum: 0
              maximum: 4294967295
              description: 'Το αποτύπωμα του σχήματος του πρωτοκόλλου (τα 4 πρώτα byte του SHA-256 της κανονικής του μορφής, ως ακέραιος χωρίς πρόσημο: `0xc649140f`, 3326678031, για την ελάσσονα έκδοση 2 του πρωτοκόλλου)· ενημερωτικό, δεν συγκρίνεται ποτέ.'
            subprotocol:
              type: string
              description: Το token του υποπρωτοκόλλου WebSocket (`Sec-WebSocket-Protocol`).
        wsPort:
          type: integer
          minimum: 0
          maximum: 65535
          description: Η θύρα WebSocket που χρησιμοποιούν οι παίκτες (`PUBLIC_WS_PORT`, αλλιώς `WS_PORT`, αλλιώς `API_PORT`).
        wsPath:
          type: string
          const: /ws
          description: Η διαδρομή του WebSocket.
        registration:
          type: string
          enum:
            - open
            - closed
          description: Αν μπορούν να δημιουργηθούν νέοι λογαριασμοί.
        emailVerification:
          type: boolean
          description: Αν οι νέοι λογαριασμοί επιβεβαιώνουν τη διεύθυνσή τους (`REQUIRE_EMAIL_VERIFICATION`).
        sso:
          type: object
          required:
            - google
            - googleWeb
          properties:
            google:
              type: boolean
              description: Αν η σύνδεση με Google προσφέρεται στο παιχνίδι.
            googleWeb:
              type: boolean
              description: Αν η σύνδεση με Google προσφέρεται και σε ιστότοπο (`POST /auth/sso/google/web/start`).
        mfa:
          type: boolean
          const: true
          description: Ο έλεγχος ταυτότητας δύο παραγόντων είναι πάντα διαθέσιμος.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: Τα bit απόδειξης εργασίας που χρειάζεται η εγγραφή (0 για καθόλου).
        categories:
          type: array
          description: Οι επίσημοι χρόνοι σκέψης, που βαθμολογούνται (`RATED_CATEGORIES`), με τη σειρά του διακομιστή. Κάθε άλλος χρόνος σκέψης είναι `custom`.
          items:
            $ref: '#/components/schemas/Category'
        limits:
          type: object
          description: Οι κανόνες που μπορεί να ελέγξει ένας πελάτης πριν στείλει μια φόρμα.
          required:
            - usernameMin
            - usernameMax
            - usernamePattern
            - passwordMinLength
            - passwordMaxBytes
            - customTimeControls
            - reportsPerDay
            - wsMaxMessageBytes
          properties:
            usernameMin:
              type: integer
              description: Το συντομότερο όνομα χρήστη (`USERNAME_MIN`).
            usernameMax:
              type: integer
              description: Το μεγαλύτερο όνομα χρήστη (`USERNAME_MAX`).
            usernamePattern:
              type: string
              description: Η κανονική έκφραση με την οποία πρέπει να ταιριάζει ένα νέο όνομα χρήστη.
            passwordMinLength:
              type: integer
              description: Ο συντομότερος κωδικός πρόσβασης, σε χαρακτήρες (`PASSWORD_MIN_LENGTH`).
            passwordMaxBytes:
              type: integer
              description: Ο μεγαλύτερος κωδικός πρόσβασης, σε byte UTF-8.
            customTimeControls:
              type: boolean
              description: Αν οι προκλήσεις και οι ιδιωτικές παρτίδες μπορούν να χρησιμοποιούν προσαρμοσμένους χρόνους σκέψης.
            reportsPerDay:
              type: integer
              description: Πόσες αναφορές μπορεί να υποβάλει ένας παίκτης ανά 24 ώρες (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: Το μεγαλύτερο μήνυμα WebSocket που μπορεί να στείλει ένας πελάτης, σε byte.
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 3326678031
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
          googleWeb: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: Ένας επίσημος χρόνος σκέψης.
      required:
        - id
        - baseSec
        - incSec
      properties:
        id:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Το αναγνωριστικό της κατηγορίας, λεπτά και δευτερόλεπτα προσαύξησης (`3+2`).
        baseSec:
          type: number
          minimum: 0
          description: Ο βασικός χρόνος, σε δευτερόλεπτα.
        incSec:
          type: number
          minimum: 0
          description: Η προσαύξηση ανά κίνηση, σε δευτερόλεπτα.
    AccountView:
      type: object
      description: Ο λογαριασμός όπως τον βλέπει ο παίκτης του (`user` των απαντήσεων σύνδεσης και του `GET /account/me`).
      required:
        - id
        - username
        - email
        - emailVerified
        - mfaEnabled
        - googleLinked
        - hasPassword
        - acceptChallenges
        - createdAt
        - lastLoginAt
        - pendingEmail
      properties:
        id:
          type: integer
          format: int64
          description: Το αναγνωριστικό του λογαριασμού.
        username:
          type: string
          description: Το όνομα χρήστη.
        email:
          type: string
          format: email
          description: Η διεύθυνση email.
        emailVerified:
          type: boolean
          description: Αν η διεύθυνση έχει επιβεβαιωθεί.
        mfaEnabled:
          type: boolean
          description: Αν ο έλεγχος ταυτότητας δύο παραγόντων είναι ενεργός.
        googleLinked:
          type: boolean
          description: Αν υπάρχει διασυνδεδεμένος λογαριασμός Google.
        hasPassword:
          type: boolean
          description: '`false` για λογαριασμό που δημιουργήθηκε μέσω Google και δεν έχει ορίσει ακόμη κωδικό πρόσβασης.'
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: Αν γίνονται δεκτές άμεσες προκλήσεις μέσω ονόματος (βλ. `PUT /account/preferences`).
        createdAt:
          type: integer
          format: int64
          description: Πότε δημιουργήθηκε ο λογαριασμός (ms από την εποχή Unix).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Η τελευταία σύνδεση (ms από την εποχή Unix) ή `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: Η νέα διεύθυνση μιας αλλαγής email που περιμένει τον σύνδεσμό της ή `null`.
    SessionAnswer:
      type: object
      description: Μια νέα συνεδρία.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: Το token συνεδρίας, για την κεφαλίδα `Authorization` και το WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: Το απόλυτο τέλος της συνεδρίας (ms από την εποχή Unix)· το όριο αδράνειας μπορεί να την τερματίσει νωρίτερα.
        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: Το δεύτερο βήμα μιας σύνδεσης με έλεγχο ταυτότητας δύο παραγόντων· συνεχίστε με `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 του βήματος, για το `POST /auth/login/mfa`.
        expiresIn:
          type: integer
          const: 300
          description: Δευτερόλεπτα που απομένουν για να ολοκληρωθεί το βήμα.
    LoginAnswer:
      description: Μια συνεδρία ή το δεύτερο βήμα μιας σύνδεσης με έλεγχο ταυτότητας δύο παραγόντων.
      oneOf:
        - $ref: '#/components/schemas/SessionAnswer'
        - $ref: '#/components/schemas/MfaChallenge'
    SsoNeedsUsername:
      type: object
      description: Μια πρώτη σύνδεση με Google· συνεχίστε με `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: Το ticket για το `POST /auth/sso/complete`, έγκυρο για 10 λεπτά.
        suggestedUsername:
          type: string
          description: Ένα όνομα χρήστη φτιαγμένο από το όνομα Google ή τη διεύθυνση, ή `""` όταν δεν ταιριάζει τίποτα.
    SsoNeedsPassword:
      type: object
      description: Ένας λογαριασμός με κωδικό πρόσβασης χρησιμοποιεί τη διεύθυνση· συνεχίστε με `POST /auth/sso/google/link`. Δεν έχει διασυνδεθεί ακόμη τίποτα.
      required:
        - needsPassword
        - linkTicket
        - username
        - expiresIn
      properties:
        needsPassword:
          type: boolean
          const: true
        linkTicket:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Το ticket για το `POST /auth/sso/google/link`.
        username:
          type: string
          description: Το όνομα χρήστη εκείνου του λογαριασμού (το βλέπει μόνο όποιος απέδειξε στη Google ότι κατέχει τη διεύθυνση).
        expiresIn:
          type: integer
          const: 600
          description: Δευτερόλεπτα για τα οποία το ticket παραμένει έγκυρο.
    SsoFinishAnswer:
      description: Η απάντηση του `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: Μια απόπειρα σύνδεσης με Google.
      required:
        - attemptId
        - authUrl
        - state
        - expiresIn
      properties:
        attemptId:
          type: string
          pattern: '^sso_[A-Za-z0-9_-]{43}$'
          description: Η απόπειρα, για το `POST /auth/sso/google/finish`.
        authUrl:
          type: string
          format: uri
          description: Το URL της Google για άνοιγμα στο πρόγραμμα περιήγησης του συστήματος, αφού ελεγχθεί.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Το `state` που πρέπει να στείλει πίσω η Google.
        expiresIn:
          type: integer
          const: 600
          description: Δευτερόλεπτα για τα οποία η απόπειρα παραμένει έγκυρη.
    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: Το όνομα χρήστη· στη συνέχεια ισχύουν οι κανόνες του διακομιστή (βλ. την περιγραφή).
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Η διεύθυνση email. Αφαιρούνται τα κενά στην αρχή και στο τέλος και αποθηκεύεται με πεζά γράμματα.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ο κωδικός πρόσβασης· στη συνέχεια ισχύουν οι κανόνες κωδικού πρόσβασης.
        pow:
          $ref: '#/components/schemas/PowAnswer'
    LoginRequest:
      type: object
      additionalProperties: false
      required:
        - login
        - password
      properties:
        login:
          type: string
          minLength: 1
          maxLength: 254
          description: Το όνομα χρήστη ή η διεύθυνση email (οποιοδήποτε κείμενο με `@`).
        password:
          type: string
          minLength: 1
          maxLength: 1024
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
        pow:
          $ref: '#/components/schemas/PowAnswer'
    ClientLabel:
      type: string
      maxLength: 64
      description: Προαιρετικό. Εμφανίζεται στη λίστα των συνδεδεμένων συσκευών (όπως `Scacelith 1.4 (Windows)`).
    MfaLoginRequest:
      type: object
      additionalProperties: false
      required:
        - mfaToken
      properties:
        mfaToken:
          type: string
          minLength: 1
          maxLength: 64
          description: Το `mfaToken` της απάντησης σύνδεσης (ή της απάντησης μιας σύνδεσης με Google).
        code:
          type: string
          maxLength: 32
          description: Προαιρετικό. Ένας 6ψήφιος κωδικός της εφαρμογής ελέγχου ταυτότητας ή ένας κωδικός ανάκτησης.
        recoveryCode:
          type: string
          maxLength: 32
          description: Προαιρετικό. Ένας κωδικός ανάκτησης. Χρειάζεται ένα από τα `code` και `recoveryCode`.
    EmailRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          minLength: 1
          maxLength: 254
          description: Η διεύθυνση email.
    PasswordResetRequest:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Η παράμετρος `token` του συνδέσμου επαναφοράς.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ο νέος κωδικός πρόσβασης· ισχύουν οι κανόνες κωδικού πρόσβασης της εγγραφής.
    SsoStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
        - redirectPort
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Η πρόκληση PKCE S256 του `codeVerifier` του πελάτη (`BASE64URL(SHA-256(codeVerifier))`).
        redirectPort:
          type: integer
          minimum: 1024
          maximum: 65535
          description: Η θύρα του listener του πελάτη στο `127.0.0.1`.
    SsoWebStartRequest:
      type: object
      additionalProperties: false
      required:
        - codeChallenge
      properties:
        codeChallenge:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Η πρόκληση PKCE S256 του `codeVerifier` της σελίδας (`BASE64URL(SHA-256(codeVerifier))`).
    SsoFinishRequest:
      type: object
      additionalProperties: false
      required:
        - attemptId
        - codeVerifier
        - state
        - code
      properties:
        attemptId:
          type: string
          minLength: 1
          maxLength: 64
          description: Το `attemptId` του `POST /auth/sso/google/start`.
        codeVerifier:
          type: string
          pattern: '^[A-Za-z0-9._~-]{43,128}$'
          description: Ο επαληθευτής PKCE· το SHA-256 του πρέπει να ταιριάζει με την πρόκληση που δόθηκε στο `start`.
        state:
          type: string
          pattern: '^[A-Za-z0-9_-]{43}$'
          description: Το `state` όπως το έστειλε πίσω η Google.
        code:
          type: string
          minLength: 1
          maxLength: 2048
          pattern: '^[\x21-\x7E]+$'
          description: Το `code` όπως το έστειλε πίσω η Google (εκτυπώσιμοι χαρακτήρες ASCII χωρίς κενά).
        iss:
          type: string
          minLength: 1
          maxLength: 256
          description: Προαιρετικό. Το `iss` όπως το έστειλε πίσω η Google, όταν το έστειλε.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SsoLinkRequest:
      type: object
      additionalProperties: false
      required:
        - linkTicket
        - password
      properties:
        linkTicket:
          type: string
          minLength: 1
          maxLength: 64
          description: Το `linkTicket` του `finish`, έγκυρο για 10 λεπτά.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ο κωδικός πρόσβασης του λογαριασμού.
        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` του `finish`, έγκυρο για 10 λεπτά.
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: Το όνομα χρήστη· ισχύουν οι κανόνες της εγγραφής.
        clientLabel:
          $ref: '#/components/schemas/ClientLabel'
    SessionList:
      type: object
      required:
        - sessions
      properties:
        sessions:
          type: array
          description: Οι ενεργές συνεδρίες, πρώτα εκείνες που χρησιμοποιήθηκαν πιο πρόσφατα.
          items:
            $ref: '#/components/schemas/SessionEntry'
    SessionEntry:
      type: object
      description: Μια ενεργή συνεδρία (μια συνδεδεμένη συσκευή).
      required:
        - id
        - createdAt
        - lastSeenAt
        - expiresAt
        - clientLabel
        - current
      properties:
        id:
          type: integer
          format: int64
          description: Το αναγνωριστικό της συνεδρίας, για το `DELETE /auth/sessions/{id}`.
        createdAt:
          type: integer
          format: int64
          description: Η στιγμή της σύνδεσης (ms από την εποχή Unix).
        lastSeenAt:
          type: integer
          format: int64
          description: Η τελευταία χρήση (ms από την εποχή Unix), που ενημερώνεται το πολύ μία φορά ανά 5 λεπτά (αργότερα όσο ο διακομιστής έχει μείνει πολύ πίσω).
        expiresAt:
          type: integer
          format: int64
          description: Το απόλυτο τέλος (ms από την εποχή Unix)· το όριο αδράνειας μπορεί να τερματίσει τη συνεδρία νωρίτερα.
        clientLabel:
          type:
            - string
            - 'null'
          description: Το `clientLabel` της σύνδεσης ή `null` όταν δεν στάλθηκε κανένα.
        current:
          type: boolean
          description: Αν αυτή είναι η συνεδρία που κάνει το αίτημα.
    AccountMe:
      type: object
      required:
        - user
        - ratings
        - sanctions
        - ban
      properties:
        user:
          $ref: '#/components/schemas/AccountView'
        ratings:
          type: array
          description: Μία καταχώριση για κάθε κατηγορία στην οποία ο παίκτης έχει παίξει παρτίδες με βαθμολογία.
          items:
            $ref: '#/components/schemas/RatingSummary'
        sanctions:
          type: array
          description: Οι ενεργές κυρώσεις.
          items:
            $ref: '#/components/schemas/ActiveSanction'
        ban:
          description: '`{ until }` όσο ο λογαριασμός είναι σε αναστολή, αλλιώς `null`.'
          oneOf:
            - $ref: '#/components/schemas/Ban'
            - type: 'null'
    RatingSummary:
      type: object
      description: Η καταχώριση βαθμολογίας μιας κατηγορίας.
      required:
        - category
        - rating
        - games
        - wins
        - draws
        - losses
        - peak
        - provisional
      properties:
        category:
          type: string
          description: Το αναγνωριστικό της κατηγορίας.
        rating:
          type: integer
          description: Η βαθμολογία.
        games:
          type: integer
          minimum: 0
          description: Οι παρτίδες που παίχτηκαν στην κατηγορία (καταμετρημένες ή όχι).
        wins:
          type: integer
          minimum: 0
        draws:
          type: integer
          minimum: 0
        losses:
          type: integer
          minimum: 0
        peak:
          type: integer
          description: Η υψηλότερη βαθμολογία που επιτεύχθηκε.
        provisional:
          type: boolean
          description: '`true` όσο ο παίκτης είναι ακόμη αβαθμολόγητος ή η βαθμολογία στηρίζεται σε λιγότερες από `PROVISIONAL_GAMES` καταμετρημένες παρτίδες (το παιχνίδι τη δείχνει ως `1510?`).'
    ActiveSanction:
      type: object
      required:
        - kind
        - reason
        - startsAt
        - endsAt
      properties:
        kind:
          type: string
          enum:
            - ban
            - mm_block
            - warning
          description: Το είδος της κύρωσης.
        reason:
          type:
            - string
            - 'null'
          description: Ο λόγος που δόθηκε, αν υπάρχει.
        startsAt:
          type: integer
          format: int64
          description: Η έναρξη (ms από την εποχή Unix).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: Η λήξη (ms από την εποχή Unix), `null` όταν η κύρωση είναι μόνιμη.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: Το τέλος της αναστολής (ms από την εποχή Unix), `null` όταν είναι μόνιμη.
    Preferences:
      type: object
      additionalProperties: false
      required:
        - acceptChallenges
      properties:
        acceptChallenges:
          type: string
          enum:
            - all
            - none
          description: '`all` για αποδοχή άμεσων προκλήσεων μέσω ονόματος, `none` για απόρριψή τους.'
    PasswordChangeRequest:
      type: object
      additionalProperties: false
      required:
        - currentPassword
        - newPassword
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ο τρέχων κωδικός πρόσβασης.
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ο νέος κωδικός πρόσβασης· ισχύουν οι κανόνες κωδικού πρόσβασης της εγγραφής.
    PasswordOnlyRequest:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ο κωδικός πρόσβασης του λογαριασμού.
    TotpEnableRequest:
      type: object
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          pattern: '^[0-9]{6}$'
          description: Ένας κωδικός του εκκρεμούς μυστικού, ακριβώς 6 ψηφία.
    ReauthCredentials:
      type: object
      additionalProperties: false
      required:
        - password
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ο κωδικός πρόσβασης του λογαριασμού.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Προαιρετικό· απαιτείται με έλεγχο ταυτότητας δύο παραγόντων (ή `recoveryCode`). Ένας κωδικός της εφαρμογής ελέγχου ταυτότητας ή ένας κωδικός ανάκτησης.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Προαιρετικό. Ένας κωδικός ανάκτησης (`xxxx-xxxx-xx`· τα πεζά-κεφαλαία, τα κενά και οι παύλες δεν έχουν σημασία).
    RecoveryCodesRequest:
      type: object
      additionalProperties: false
      required:
        - password
        - code
      properties:
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ο κωδικός πρόσβασης του λογαριασμού.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Ένας κωδικός της εφαρμογής ελέγχου ταυτότητας (ένας κωδικός ανάκτησης απορρίπτεται).
    EmailChangeRequest:
      type: object
      additionalProperties: false
      required:
        - newEmail
        - password
      properties:
        newEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Η νέα διεύθυνση· αφαιρούνται τα κενά στην αρχή και στο τέλος και τα γράμματα γίνονται πεζά, και μετά ελέγχεται όπως μια διεύθυνση εγγραφής.
        password:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ο κωδικός πρόσβασης του λογαριασμού.
        code:
          type: string
          minLength: 1
          maxLength: 32
          description: Προαιρετικό· απαιτείται με έλεγχο ταυτότητας δύο παραγόντων (ή `recoveryCode`). Ένας κωδικός της εφαρμογής ελέγχου ταυτότητας ή ένας κωδικός ανάκτησης.
        recoveryCode:
          type: string
          minLength: 1
          maxLength: 32
          description: Προαιρετικό. Ένας κωδικός ανάκτησης.
    TotpSetup:
      type: object
      description: Το εκκρεμές μυστικό για την εφαρμογή ελέγχου ταυτότητας.
      required:
        - secret
        - uri
        - algorithm
        - digits
        - period
      properties:
        secret:
          type: string
          pattern: '^[A-Z2-7]+$'
          description: Το μυστικό σε base32, για πληκτρολόγηση στην εφαρμογή.
        uri:
          type: string
          format: uri
          description: Το URI `otpauth://totp/...`, για εμφάνιση ως κωδικός QR.
        algorithm:
          type: string
          const: SHA1
        digits:
          type: integer
          const: 6
        period:
          type: integer
          const: 30
          description: Δευτερόλεπτα ανά χρονικό βήμα.
    RecoveryCodeList:
      type: array
      description: Οι 10 κωδικοί ανάκτησης, που εμφανίζονται μόνο αυτή τη φορά. Ο καθένας λειτουργεί μία φορά.
      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: Η μία πλευρά μιας παρτίδας.
      required:
        - name
        - rating
        - ratingAfter
        - ratingDiff
      properties:
        name:
          type: string
          description: Το όνομα στην καταγραφή της παρτίδας (`deleted#<id>` για διαγραμμένο λογαριασμό).
        rating:
          type:
            - integer
            - 'null'
          description: Η βαθμολογία κατά την έναρξη, `null` όταν είναι άγνωστη.
        ratingAfter:
          type:
            - integer
            - 'null'
          description: Η βαθμολογία μετά την παρτίδα. Μια παρτίδα με βαθμολογία την έχει πάντα· `null` μόνο για παρτίδα που δεν μετράει για τις βαθμολογίες (φιλική, προσαρμοσμένη, ακυρωμένη).
        ratingDiff:
          type:
            - integer
            - 'null'
          description: Η μεταβολή της βαθμολογίας, `0` όταν οι κανόνες βαθμολογίας αφήνουν τη βαθμολογία όπου ήταν (μηδενικό σκορ κατά τη FIDE, αβαθμολόγητος αντίπαλος, οι πρώτες παρτίδες ενός αβαθμολόγητου παίκτη)· `null` στις ίδιες περιπτώσεις με το `ratingAfter`.
    GameSummary:
      type: object
      description: Η σύνοψη μιας αποθηκευμένης παρτίδας.
      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: Το αναγνωριστικό της παρτίδας.
        category:
          type: string
          description: Το αναγνωριστικό της επίσημης κατηγορίας (`3+2`) ή `custom`.
        rated:
          type: boolean
          description: Αν η παρτίδα μετράει για τις βαθμολογίες.
        timeControl:
          type: string
          pattern: '^[0-9]+\+[0-9]+$'
          description: Ο χρόνος σκέψης σε δευτερόλεπτα (`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` (βλ. τους κωδικούς παρτίδων).'
        reason:
          type: integer
          minimum: 0
          maximum: 255
          description: Ο κωδικός του λόγου λήξης (βλ. τους κωδικούς παρτίδων).
        result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
          description: Το αποτέλεσμα, `*` για ακυρωμένη παρτίδα.
        termination:
          $ref: '#/components/schemas/Termination'
        plies:
          type: integer
          minimum: 0
          description: Ο αριθμός των ημικινήσεων.
        startedAt:
          type: integer
          format: int64
          description: Η έναρξη (ms από την εποχή Unix).
        endedAt:
          type: integer
          format: int64
          description: Η λήξη (ms από την εποχή Unix).
    Termination:
      type: string
      description: Το όνομα του λόγου λήξης (βλ. τους κωδικούς παρτίδων)· `Unknown` για κωδικό που το πρωτόκολλο δεν γνωρίζει.
      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: Σύνοψη παρτίδας από την πλευρά ενός παίκτη.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - color
          properties:
            color:
              type: string
              enum:
                - white
                - black
              description: Η πλευρά του παίκτη.
    HistoryGameSummary:
      description: Μια παρτίδα του ιστορικού του συνδεδεμένου παίκτη.
      allOf:
        - $ref: '#/components/schemas/PlayerGameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - outcome
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Ο βασικός χρόνος σε χιλιοστά του δευτερολέπτου.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Η προσαύξηση σε χιλιοστά του δευτερολέπτου.
            outcome:
              type: string
              enum:
                - win
                - loss
                - draw
                - aborted
              description: Η έκβαση από την πλευρά του παίκτη.
    HistoryPage:
      type: object
      required:
        - games
        - next
        - total
      properties:
        games:
          type: array
          description: Οι παρτίδες της σελίδας, πρώτα οι νεότερες.
          items:
            $ref: '#/components/schemas/HistoryGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Το αναγνωριστικό που δίνεται ως `before` για την επόμενη σελίδα ή `null` στην τελευταία σελίδα.
        total:
          type: integer
          minimum: 0
          description: Ο αριθμός των παρτίδων που ταιριάζουν στο φίλτρο, σε όλες τις σελίδες.
    MoveRecord:
      type: object
      description: Μία ημικίνηση μιας παρτίδας.
      required:
        - uci
        - spentMs
        - clockMs
      properties:
        uci:
          type: string
          pattern: '^[a-h][1-8][a-h][1-8][nbrq]?$'
          description: Η κίνηση σε σημειογραφία UCI, με προσθήκη `n`, `b`, `r` ή `q` για προαγωγή.
        spentMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Ο χρόνος που χρεώθηκε για την κίνηση (ms), `null` όταν δεν υπάρχει στην καταγραφή.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: Το ρολόι του παίκτη που έκανε την κίνηση, μετά από αυτήν (ms), `null` όταν δεν υπάρχει στην καταγραφή.
    PgnTagsView:
      type: object
      description: Οι κύριες ετικέτες PGN, για εμφάνιση. Το `Termination` εδώ είναι το όνομα του λόγου λήξης· το αρχείο PGN χρησιμοποιεί τις τυπικές τιμές PGN.
      required:
        - Event
        - Site
        - Date
        - Round
        - White
        - Black
        - Result
        - WhiteElo
        - BlackElo
        - TimeControl
        - Termination
        - PlyCount
      properties:
        Event:
          type: string
          description: '`<SERVER_NAME> rated <category>` ή `<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: Η ημερομηνία έναρξης σε UTC.
        Round:
          type: string
          const: '-'
        White:
          type: string
        Black:
          type: string
        Result:
          type: string
          enum:
            - 1-0
            - 0-1
            - 1/2-1/2
            - '*'
        WhiteElo:
          description: Η βαθμολογία κατά την έναρξη ή `"-"`.
          oneOf:
            - type: integer
            - type: string
              const: '-'
        BlackElo:
          description: Η βαθμολογία κατά την έναρξη ή `"-"`.
          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: Η καταγραφή μιας παρτίδας με τις κινήσεις και τα ρολόγια της. Έχει τα πεδία μιας σύνοψης παρτίδας, χωρίς `color` και `outcome`.
      allOf:
        - $ref: '#/components/schemas/GameSummary'
        - type: object
          required:
            - baseMs
            - incMs
            - statusName
            - rematchOf
            - moves
            - pgn
          properties:
            baseMs:
              type: integer
              format: int64
              minimum: 0
              description: Ο βασικός χρόνος σε χιλιοστά του δευτερολέπτου.
            incMs:
              type: integer
              format: int64
              minimum: 0
              description: Η προσαύξηση σε χιλιοστά του δευτερολέπτου.
            statusName:
              type: string
              enum:
                - WhiteWins
                - BlackWins
                - Draw
                - Aborted
              description: Το όνομα του `status`.
            rematchOf:
              type:
                - integer
                - 'null'
              format: int64
              description: Το αναγνωριστικό της παρτίδας της οποίας αυτή είναι ρεβάνς ή `null`.
            moves:
              type: array
              description: Ένα στοιχείο ανά ημικίνηση.
              items:
                $ref: '#/components/schemas/MoveRecord'
            pgn:
              $ref: '#/components/schemas/PgnTagsView'
            you:
              type: string
              enum:
                - white
                - black
              description: Μόνο όταν ο παίκτης του token έπαιξε αυτή την παρτίδα· η πλευρά του.
            reportable:
              type: boolean
              description: Μόνο όταν ο παίκτης του token έπαιξε αυτή την παρτίδα· `true` όταν το `POST /reports` θα δεχόταν τώρα μια αναφορά του αντιπάλου για αυτή την παρτίδα (η παρτίδα τελείωσε μέσα σε 7 ημέρες, η ημερήσια ποσόστωση δεν έχει εξαντληθεί και ο αντίπαλος δεν έχει ήδη αναφερθεί για αυτή την παρτίδα).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: Το όνομα χρήστη, όπως το έγραψε ο παίκτης.
        createdAt:
          type: integer
          format: int64
          description: Πότε δημιουργήθηκε ο λογαριασμός (ms από την εποχή Unix).
        ratings:
          type: array
          description: Μόνο οι επίσημες κατηγορίες, με τη σειρά του διακομιστή.
          items:
            $ref: '#/components/schemas/RatingSummary'
        games:
          type: object
          required:
            - total
            - rated
            - wins
            - draws
            - losses
          properties:
            total:
              type: integer
              minimum: 0
              description: Κάθε αποθηκευμένη παρτίδα, μαζί με τις φιλικές και τις ακυρωμένες.
            rated:
              type: integer
              minimum: 0
              description: Οι παρτίδες με βαθμολογία, αθροισμένες από τις καταχωρίσεις βαθμολογίας.
            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: Το όνομα χρήστη, όπως το έγραψε ο παίκτης.
        games:
          type: array
          description: Οι παρτίδες της σελίδας, πρώτα οι νεότερες.
          items:
            $ref: '#/components/schemas/PlayerGameSummary'
        next:
          type:
            - integer
            - 'null'
          format: int64
          description: Το αναγνωριστικό της τελευταίας παρτίδας όποτε η σελίδα είναι γεμάτη (η επόμενη σελίδα μπορεί τότε να είναι κενή), αλλιώς `null`.
    Leaderboard:
      type: object
      required:
        - category
        - minGames
        - updatedAt
        - players
      properties:
        category:
          type: string
          description: Το αναγνωριστικό της κατηγορίας.
        minGames:
          type: integer
          minimum: 0
          description: Οι καταμετρημένες παρτίδες που χρειάζεται μια καταχώριση για να εμφανιστεί στη λίστα (`PROVISIONAL_GAMES`).
        updatedAt:
          type: integer
          format: int64
          description: Πότε υπολογίστηκε ο πίνακας κατάταξης (ms από την εποχή Unix).
        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, το πολύ 65.536 byte UTF-8. Χρησιμοποιείται μόνο η πρώτη παρτίδα.
        size:
          type: string
          enum:
            - small
            - medium
            - large
          default: medium
          description: Προαιρετικό. Το μέγεθος της εικόνας.
        orientation:
          type: string
          enum:
            - white
            - black
          default: white
          description: Προαιρετικό. Η πλευρά στο κάτω μέρος της σκακιέρας.
        delayMs:
          type: integer
          minimum: 100
          maximum: 3000
          default: 500
          description: Προαιρετικό. Χιλιοστά του δευτερολέπτου ανά κίνηση, ένας αριθμός JSON με ακέραια τιμή.
        coords:
          type: boolean
          default: true
          description: Προαιρετικό. Αν τα γράμματα των στηλών και οι αριθμοί των γραμμών σχεδιάζονται γύρω από τη σκακιέρα.
    ReportRequest:
      type: object
      required:
        - gameId
        - reported
        - category
      properties:
        gameId:
          description: Η παρτίδα, ως ακέραιος ή ως συμβολοσειρά 1 έως 16 ψηφίων.
          oneOf:
            - type: integer
              format: int64
              minimum: 1
              maximum: 9007199254740991
            - type: string
              pattern: '^[0-9]{1,16}$'
        reported:
          type: string
          minLength: 1
          maxLength: 24
          description: Το όνομα χρήστη του αντιπάλου, όπως στην καταγραφή της παρτίδας ή όπως είναι τώρα, χωρίς διάκριση πεζών-κεφαλαίων. Δεν μπορεί να είναι κενό.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
          description: Το θέμα της αναφοράς.
        comment:
          type:
            - string
            - 'null'
          description: Προαιρετικό. Το πολύ 500 χαρακτήρες, αφού αφαιρεθούν οι χαρακτήρες ελέγχου (εκτός από το tab και την αλλαγή γραμμής) και τα κενά στην αρχή και στο τέλος του κειμένου.
    AccountExport:
      type: object
      description: Όλα όσα κρατά ο διακομιστής για τον λογαριασμό (οι χρόνοι σε ms από την εποχή Unix).
      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: Πότε έγινε η εξαγωγή.
        server:
          type: object
          required:
            - name
            - host
          properties:
            name:
              type: string
              description: '`SERVER_NAME`.'
            host:
              type: string
              description: '`SERVER_PUBLIC_HOST`.'
        notes:
          type: array
          description: Τι περιέχει και τι παραλείπει το αρχείο, σε απλά αγγλικά για τον παίκτη.
          items:
            type: string
        account:
          description: Η προβολή λογαριασμού του `GET /account/me`, με `googleEmail`.
          allOf:
            - $ref: '#/components/schemas/AccountView'
            - type: object
              required:
                - googleEmail
              properties:
                googleEmail:
                  type:
                    - string
                    - 'null'
                  description: Η διεύθυνση του διασυνδεδεμένου λογαριασμού Google ή `null`.
        ratings:
          type: array
          description: Οι πλήρεις καταχωρίσεις βαθμολογίας.
          items:
            $ref: '#/components/schemas/ExportRating'
        ratingRefunds:
          type: array
          description: Μονάδες βαθμολογίας που επιστράφηκαν αφού διαπιστώθηκε ότι ένας αντίπαλος έκανε απάτη, αθροισμένες ανά ημέρα UTC και κατηγορία, πρώτα οι νεότερες. Δεν κατονομάζονται ούτε οι παρτίδες ούτε οι απατεώνες.
          items:
            $ref: '#/components/schemas/RatingRefund'
        sessions:
          type: array
          description: Κάθε αποθηκευμένη συνεδρία, πρώτα οι νεότερες, χωρίς κανένα token. Η εκκαθάριση διατήρησης δεδομένων διαγράφει μια συνεδρία που έχει λήξει, και μια αποσυνδεδεμένη μία ημέρα μετά την αποσύνδεση.
          items:
            $ref: '#/components/schemas/ExportSession'
        securityEvents:
          type: array
          description: Τα συμβάντα ασφαλείας, πρώτα τα νεότερα, που διατηρούνται `RETENTION_SECURITY_DAYS` ημέρες.
          items:
            $ref: '#/components/schemas/SecurityEvent'
        sanctions:
          type: array
          description: Κάθε κύρωση, μαζί και όσες έχουν αρθεί. Το όνομα του συντονιστή δεν περιλαμβάνεται ποτέ.
          items:
            $ref: '#/components/schemas/ExportSanction'
        conduct:
          type: array
          description: Τα συμβάντα συμπεριφοράς που καταγράφηκαν για τις παρτίδες του παίκτη που εγκαταλείφθηκαν, ακυρώθηκαν ή στις οποίες δεν εμφανίστηκε, που διατηρούνται 30 ημέρες.
          items:
            $ref: '#/components/schemas/ConductEvent'
        reportsFiled:
          type: array
          description: Οι αναφορές που έκανε ο παίκτης.
          items:
            $ref: '#/components/schemas/FiledReport'
        games:
          type: object
          required:
            - total
            - list
          properties:
            total:
              type: integer
              minimum: 0
              description: Ο αριθμός των παρτίδων.
            list:
              type: array
              description: Κάθε παρτίδα, πρώτα οι νεότερες, ως συνόψεις του `GET /account/games`. Οι κινήσεις βρίσκονται στο `GET /games/{id}` και το PGN στο `GET /games/{id}/pgn`.
              items:
                $ref: '#/components/schemas/HistoryGameSummary'
    ExportRating:
      description: Μια πλήρης καταχώριση βαθμολογίας.
      allOf:
        - $ref: '#/components/schemas/RatingSummary'
        - type: object
          required:
            - rated
            - countedGames
            - updatedAt
          properties:
            rated:
              type: boolean
              description: Αν ο παίκτης δεν είναι πλέον αβαθμολόγητος.
            countedGames:
              type: integer
              minimum: 0
              description: Οι παρτίδες που υπολογίστηκαν στη βαθμολογία.
            updatedAt:
              type: integer
              format: int64
              description: Πότε άλλαξε τελευταία φορά η καταχώριση.
    RatingRefund:
      type: object
      required:
        - day
        - category
        - points
      properties:
        day:
          type: integer
          format: int64
          description: Η ώρα 00:00 UTC της ημέρας (ms από την εποχή Unix).
        category:
          type: string
        points:
          type: integer
          description: Οι μονάδες που επιστράφηκαν εκείνη την ημέρα σε εκείνη την κατηγορία.
    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: Πότε αποσυνδέθηκε η συνεδρία ή `null`.
        clientLabel:
          type:
            - string
            - 'null'
        ip:
          type:
            - string
            - 'null'
          description: Η διεύθυνση της σύνδεσης, που διαγράφεται μετά από `RETENTION_IP_DAYS`.
    SecurityEvent:
      type: object
      description: |-
        Ένα συμβάν ασφαλείας. Το `ip` δίνεται μόνο για ό,τι έγινε με συνδεδεμένη συνεδρία, με τον κωδικό πρόσβασης του λογαριασμού (και τον δεύτερο παράγοντα) ή με σύνδεσμο που στάλθηκε στη διεύθυνσή του: `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` και `account_exported`· κάθε άλλο είδος έχει `ip: null`, και το `ip` διαγράφεται μετά από `RETENTION_IP_DAYS`.

        Το `detail` κρατά μόνο αυτά τα πεδία: `login`: `method`· `sso_login`, `sso_account_created`: `provider`· `sso_linked`: `provider` και `method` (`password` ή `password+totp`)· `login_failed`: `failures`· `login_lockout`: `retryAfterMs`· `mfa_failed`: `attempts`· `recovery_code_used`: `remaining`· `reauth_failed`: `factor`· `session_revoked` και `sessions_revoked_all`: `reason`· `email_change_refused`: `reason`· `sanction_auto`: `kind`, `gameId`, `until`. Ένα συμβάν `moderator_action` κρατά μόνο το `{ action }`, για τις ενέργειες `ban`, `unban`, `reset_mfa`, `verify_email` και `revoke_sessions` (οι άλλες ενέργειες των συντονιστών παραλείπονται). Κάθε άλλο είδος έχει `detail: null`. Τα συμβάντα `rating_refund` παραλείπονται: οι μονάδες τους βρίσκονται στο `ratingRefunds`.
      required:
        - kind
        - at
        - ip
        - detail
      properties:
        kind:
          type: string
          description: Το είδος του συμβάντος (`login`, `password_changed`…).
        at:
          type: integer
          format: int64
          description: Πότε συνέβη.
        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: Το τρέχον δημόσιο όνομα του παίκτη που αναφέρθηκε.
        category:
          type: string
          enum:
            - cheating
            - abuse
            - other
        comment:
          type:
            - string
            - 'null'
        createdAt:
          type: integer
          format: int64
        status:
          type: string
          enum:
            - open
            - closed
          description: Αν η αναφορά είναι ακόμη ανοιχτή (δεν αποκαλύπτεται αν επιβλήθηκε κύρωση στον παίκτη που αναφέρθηκε).
    LinkTokenForm:
      type: object
      additionalProperties: false
      required:
        - token
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Το token του συνδέσμου email (το κρυφό πεδίο της φόρμας της σελίδας).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: Το token του συνδέσμου επαναφοράς (το κρυφό πεδίο της φόρμας).
        newPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ο νέος κωδικός πρόσβασης· ισχύουν οι κανόνες κωδικού πρόσβασης της εγγραφής.
        confirmPassword:
          type: string
          minLength: 1
          maxLength: 1024
          description: Ξανά ο νέος κωδικός πρόσβασης· πρέπει να είναι ίδιος.
    HtmlDocument:
      type: string
      contentMediaType: text/html
      description: Μια σελίδα HTML σε UTF-8 (`text/html; charset=utf-8`), χωρίς JavaScript ή εξωτερικούς πόρους.
