openapi: 3.1.1
info:
  title: واجهة HTTPS API لخادم Scacelith
  version: "0.9.0"
  summary: 'واجهة HTTPS API المشتركة بين كل خوادم Scacelith: الخادم الرسمي والخوادم المجتمعية.'
  description: |-
    يستجيب لواجهة HTTPS API هذه كلُّ خادم Scacelith، سواء الخادم الرسمي `caissa.scacelith.com` أو أي خادم مجتمعي. وتستخدمها اللعبة في كل ما يقع خارج المباراة نفسها: إنشاء الحساب وتسجيل الدخول (بما في ذلك المصادقة الثنائية وGoogle)، وصفحة الحساب، وسجل المباريات، وتنزيل ملفات PGN وصور GIF المتحركة للمباريات، والأجهزة المسجَّل الدخول منها، وتنزيل البيانات، وحذف الحساب، والبلاغات.

    أما اللعب المباشر (البحث عن خصم، والتحديات، والنقلات، والساعات) فيمرّ عبر WebSocket الخاص بالخادم نفسه (`wss://<host>/ws`)، الذي يُفتح برمز جلسة مميَّز صادر عن هذه الواجهة؛ وهو موصوف في `PROTOCOL.md` لا هنا.

    القيم الافتراضية الواردة أدناه هي قيم خادم لم تُمَسّ إعداداته؛ وقد يغيّرها الخادم المجتمعي (يسمّي `CONFIG.md` كل إعداد).

    ## العنوان الأساسي والمنفذ وبروتوكول النقل

    - الخادم الرسمي: `https://caissa.scacelith.com/api/v1`، منفذ TCP رقم 443.
    - خادم مجتمعي: `https://<SERVER_PUBLIC_HOST>[:<port>]/api/v1`. قيمة `API_PORT` الافتراضية 443؛ وخلف NAT أو خادم وكيل، يكون المنفذ الذي يستخدمه اللاعبون هو `PUBLIC_API_PORT`.
    - يقع WebSocket الخاص باللعبة على المنفذ نفسه افتراضيًا (ينقله `WS_PORT` إلى منفذ آخر؛ ويُعلم `GET /info` العميلَ بمكانه).
    - HTTP/1.1 عبر TLS. مع `TLS_MODE=proxy` يُنهي خادمٌ وكيل عكسي اتصالَ TLS أمام الخادم. أما `TLS_MODE=off` (HTTP غير مشفَّر) فهو للتطوير المحلي فقط.
    - صفحات HTML التي تُفتح من روابط رسائل البريد الإلكتروني (`/verify-email` و`/reset-password` و`/confirm-email-change`) موجودة في جذر الخادم، خارج `/api/v1`. وتستجيب نقاط نهاية فحص السلامة في الجذر وتحت `/api/v1` كليهما. وليست لتسجيل الدخول عبر Google صفحة على الخادم: يعيد Google المتصفح إلى اللعبة نفسها، على `127.0.0.1`.
    - تُتجاهَل الشرطة المائلة في آخر المسار (`/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`). ويُعدّ الجسم الفارغ بمنزلة `{}`، وهو ما تتلقاه نقاط النهاية التي لا معاملات لها (`POST /auth/logout` و`POST /auth/logout-all` و`DELETE /auth/sessions/{id}`).
    - **الحجم والوقت.** يقتصر الجسم على `HTTP_BODY_LIMIT` بايت (16,384 افتراضيًا؛ ولـ `POST /gif` حدّه الخاص البالغ 135,168 بايت): 413 `payload_too_large`. ويجب أن يصل خلال 10 ثوانٍ: 408 `request_timeout`. وبعد أيٍّ من الخطأين يغلق الخادم الاتصال.
    - **مخططات صارمة.** يُرفض الحقل الذي لا تعرفه نقطة النهاية، والحقل غير الموسوم بأنه اختياري مطلوب، وتُفحص الأنواع والأطوال. وأيّ إخفاق من هذه يكون الرد عليه 400 `invalid_request`، مع `field` الذي يسمّي الحقل (بصيغة نقطية للحقل المتداخل، مثل `pow.nonce`). ولا يجوز أن تحتوي السلاسل النصية على محارف تحكّم. وتُحسب الأطوال بوحدات ترميز UTF-16، لذا يُحسب المحرف الواقع خارج المستوى متعدد اللغات الأساسي (كالرموز التعبيرية) مرتين. والجسم الذي ليس JSON يكون الرد عليه 400 `invalid_json`. أما `POST /gif` و`POST /reports` فتفحصان جسميهما بنفسيهما (راجع كل نقطة نهاية).
    - **سلاسل الاستعلام.** يُعتدّ بأول ظهور للمعامل، وتُتجاهل المعاملات غير المعروفة، ويُفكّ `+` إلى مسافة: فنقاط النهاية التي تتلقى فئة نظام وقت تقبل `3%2B2` و`3+2` كليهما.
    - **الطرق.** `HEAD` هو `GET` من دون الجسم. ويكون الرد على `OPTIONS` لمسار موجود 204 مع ترويسة `Allow`. وأي طريقة أخرى لا يدعمها المسار يكون الرد عليها 405 `method_not_allowed` مع `Allow`.
    - **هدف الطلب.** لا يجوز أن يتجاوز 4096 محرفًا (414 `uri_too_long`). والطلب الذي يتعذّر تحليله أصلًا، أو الذي يكون رأسه كبيرًا جدًا أو يصل ببطء شديد، يتلقى ردًا فارغًا بالحالة 400 أو 431 أو 408 ويُغلق الاتصال.
    - **بلا CORS.** تخدم الواجهة اللعبةَ لا صفحات الويب. لا تُرسَل أي ترويسة `Access-Control-*` أبدًا، فلا تستطيع صفحة ويب قراءة الرد؛ ولأن الواجهة لا تقبل إلا أجسام JSON، فإن أي كتابة عابرة للمواقع تحتاج إلى طلب تمهيدي، وهذا الطلب التمهيدي يفشل.

    ## الردود والأخطاء

    - الردود بصيغة JSON بترميز UTF-8، باستثناء تنزيل PGN (`application/x-chess-pgn`) وصور GIF المتحركة (`image/gif`) وصفحات HTML. والأوقات بالملّي ثانية منذ 1970-01-01 UTC. والمعرّفات أعداد صحيحة. وتصل معرّفات المباريات إلى 16 رقمًا لكنها تبقى دون 2^53، فيمثّلها عدد JSON (عدد عشري مزدوج الدقة) تمثيلًا دقيقًا.
    - يحمل كل رد من الواجهة `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`.
    - يجب أن يُقرأ الرد خلال 60 ثانية من لحظة جاهزيته لدى الخادم، وهذا لا يهم إلا في الردود الكبيرة (صورة GIF، وتصدير البيانات، وملف PGN طويل). ولا يُحتسب الوقت الذي يستغرقه الخادم في إعداد الرد ضمن هذه المهلة، ولا ضمن مهلة الـ 30 ثانية التي يُغلق بعدها الاتصال إن لم يمرّ فيها أي بايت واردًا أو صادرًا.

    للأخطاء شكل واحد (المخطط `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`).

    الأخطاء التي قد تصدر عن أي نقطة نهاية:

    | الحالة | `error` | متى |
    |---|---|---|
    | 400 | `invalid_request` | يخالف الجسمُ مخطط نقطة النهاية (ويبيّن `field` الموضع)، أو يكون هدف الطلب أو `Content-Length` مشوّهًا، أو لا يكون معامل المسار ترميز URL صالحًا، أو يكون الجسم مبتورًا. |
    | 400 | `invalid_json` | الجسم ليس JSON. |
    | 401 | `unauthorized` | لا توجد ترويسة `Authorization` في نقطة نهاية تتطلب جلسة. |
    | 401 | `invalid_token` | الرمز المميَّز مشوّه، أو منتهي الصلاحية، أو مُبطَل، أو يخص حسابًا محذوفًا؛ ويصدر أيضًا في نقاط النهاية التي تكون فيها الجلسة اختيارية. |
    | 404 | `not_found` | لا توجد نقطة نهاية كهذه. وتستخدمه بعض نقاط النهاية أيضًا: لا توجد مباراة أو لاعب أو جلسة كهذه. |
    | 405 | `method_not_allowed` | المسار موجود لطرق أخرى (راجع `Allow`). |
    | 408 | `request_timeout` | لم يصل الجسم خلال 10 ثوانٍ. |
    | 413 | `payload_too_large` | يتجاوز الجسم `HTTP_BODY_LIMIT` (135,168 بايت لـ `POST /gif`). |
    | 414 | `uri_too_long` | يتجاوز هدف الطلب 4096 محرفًا. |
    | 415 | `unsupported_media_type` | الجسم ليس `application/json`، أو ترميز محارفه ليس UTF-8. |
    | 429 | `rate_limited` | حدّ معدّل (انظر أدناه): `retryAfter` مع ترويسة `Retry-After`. |
    | 500 | `internal_error` | إخفاق غير متوقع. يسجّله الخادم. |
    | 503 | `timeout` | لم يردّ الخادم خلال 30 ثانية (60 ثانية للتصدير، و45 ثانية لصور GIF مع الإعدادات الافتراضية). |
    | 503 | `server_busy` | وجد بحثٌ عن جلسة أو تعديلٌ على حساب قاعدةَ البيانات مقفلة (`retryAfter` 1)، أو أن طابور تجزئة كلمات المرور ممتلئ (انظر أدناه). |

    تردّ نقاط نهاية القراءة (سجل المباريات، والمباريات، واللاعبون، ولوحة المتصدرين) ونقطة نهاية التصدير بالخطأ 503 `busy` مع `retryAfter: 1` عندما تظل قاعدة البيانات مقفلة.

    ويمكن لنقاط النهاية التي تتحقق من كلمة مرور أو تجزّئها أن تردّ أيضًا بأحد الخطأين التاليين، وكلاهما مع `retryAfter` عشوائي بين 5 و15 ثانية:

    - 503 `server_busy`: طابور تجزئة كلمات المرور في الخادم (`PASSWORD_HASH_QUEUE_MAX`) ممتلئ، أو نفدت مهلة الانتظار.
    - 429 `rate_limited`: بعد أن يمتلئ نصف الطابور، يكون لهذا العميل (عنوان IPv4 أو نطاق IPv6 /48) بالفعل `PASSWORD_HASH_WAITERS_PER_SOURCE` عملية تجزئة في الانتظار. ويعيد هذا الخطأ 429 رموز حدود المعدّل التي أخذها الطلب.

    في الحالتين لا يتغير شيء ولا تُحتسب أي محاولة فاشلة (باستثناء `POST /auth/sso/google/link`، الذي تكون محاولة التذكرة وإخفاق الحساب فيه قد احتُسبا قبل التجزئة)، ويبقى رابط إعادة التعيين صالحًا.

    وتعرض صفحات HTML أخطاءها على شكل صفحات HTML برموز الحالة نفسها.

    ## المصادقة

    تتلقى نقاط النهاية التي تتطلب جلسة رمزًا مميَّزًا من نوع Bearer في الترويسة `Authorization` (مخطط الأمان `bearerAuth`):

    ```
    Authorization: Bearer sct_L_8GDd7uzfQ3QQWtqrsWXDTsFWzRwIvJcwIGHhjWPS8
    ```

    - **الحصول على رمز مميَّز.** يأتي الرمز المميَّز (`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 للرمز المميَّز.
    - **الجلسة المطلوبة.** غياب الترويسة يكون الرد عليه 401 `unauthorized` مع `WWW-Authenticate: Bearer realm="scacelith"`. والرمز المميَّز غير الصالح يكون الرد عليه 401 `invalid_token` مع `WWW-Authenticate: Bearer realm="scacelith", error="invalid_token"`. وعلى العميل أن ينسى الرمز المميَّز الذي يتلقى `invalid_token` وأن يسجّل الدخول من جديد.
    - **الجلسة الاختيارية.** الجلسة اختيارية في `GET /games/{id}` و`GET /games/{id}/pgn` و`GET /players/{username}` و`GET /players/{username}/games`. فمن دون الترويسة تردّ هذه النقاط بالعرض العام؛ وإذا أُرسلت الترويسة، فيجب أن يكون رمزها المميَّز صالحًا.
    - **مدة الصلاحية.** تنتهي الجلسة عند أسبق الأجلين: `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}`) الجلسات؛ وكذلك يفعل تغيير كلمة المرور (الجلسات الأخرى)، وإعادة تعيين كلمة المرور وحذف الحساب (كل الجلسات)، والمسؤول. ويسري الإبطال عبر الواجهة فورًا، ويُغلق WebSocket الذي فُتح بجلسة مُبطَلة. ويخزّن الخادم نتائج البحث عن الجلسات مؤقتًا مدة 30 ثانية، لذا فإن الإبطال بأمر الإدارة، وهو عملية منفصلة، يسري خلال 30 ثانية.
    - **النطاق.** ينتمي الرمز المميَّز إلى خادم واحد، ويفتح WebSocket الخاص به أيضًا. لا ترسله أبدًا إلى خادم آخر.

    ## حدود المعدّل وأشكال التقييد الأخرى

    تنطبق الحدود على أحد نطاقين. **العميل:** عنوان IPv4، أو شبكة IPv6 /64 (في وضع الخادم الوكيل، يُؤخذ العنوان من `X-Forwarded-For` المرسَل من عنوان مُدرَج في `TRUSTED_PROXIES`)؛ وتحتسب بعض الحدود أيضًا كل نطاق IPv6 /48 ككل، إضافةً إلى كل شبكة /64 فيه. **اللاعب:** الحساب المسجَّل الدخول، أيًّا كان عنوانه؛ والحدّ المحتسب لكل لاعب في نقطة نهاية تكون فيها الجلسة اختيارية يُحتسب لكل عميل في الطلب الذي لا يحمل رمزًا مميَّزًا.

    كل حدّ هو دلو رموز يتسع لـ `limit` طلبًا ويُعاد ملؤه باستمرار بمعدّل `limit / window`؛ و`retryAfter` هو الوقت المتبقي حتى الرمز التالي. وتُحتسب الحدود الموسومة بأنها *مشتركة* أيضًا على نافذة منزلقة بالطول نفسه، كي لا تضم أي نافذة أكثر من `limit` طلبًا بكثير. ويُحتسب كل حدّ على مستوى الخادم بأكمله. وعندما يرفض أحد حدود نقطة النهاية طلبًا، تُعاد الرموز التي أخذتها حدودها الأخرى لهذا الطلب. ويكون الرد على الرفض 429 `rate_limited` مع `retryAfter` و`Retry-After`.

    - **طبقة الحدّ حسب العنوان.** يأخذ كل طلب (أيًّا كان مساره وطريقته، بما في ذلك نقاط نهاية فحص السلامة وترقيات WebSocket) أولًا رمزًا واحدًا من رصيد عميله: `HTTP_RATE_PER_IP` (600) في الدقيقة مع دفعة تعادل نصف دقيقة، و`HTTP_RATE_PER_PREFIX` (4 x `HTTP_RATE_PER_IP`) لنطاق IPv6 /48. ولا يجوز أن يكون للعميل أكثر من `IP_MAX_INFLIGHT` (32 x `WORKERS`) طلبًا قيد المعالجة (`retryAfter` 1 عند التجاوز). والعميل الذي يواصل بعد رفض طلباته يُحجب: `ABUSE_BLOCK_REFUSALS_PER_MIN` (600) حالة رفض في دقيقة واحدة تحجبه 1 دقيقة، ثم 4 و16 و60 دقيقة عند كل حجب جديد خلال 6 ساعات. ويُحتسب رفض الحدود `auth` و`auth_*` و`reauth` بـ 5؛ أما الحدود المحتسبة لكل لاعب فلا تُحتسب أبدًا. وتتخطى العناوين المدرجة في `ABUSE_EXEMPT` هذه الطبقة، لكنها لا تتخطى رصيد الحساب ولا حدود نقاط النهاية.
    - **رصيد الحساب.** كل طلب يحمل رمز جلسة مميَّزًا صالحًا يُحتسب أيضًا على حسابه: `USER_RATE_PER_MIN` (120) في الدقيقة لكل نقاط النهاية مجتمعة، أيًّا كان العنوان، مع دفعة تعادل نصف دقيقة.
    - **حدود نقاط النهاية:**

    | الحدّ | القيمة الافتراضية | يُحتسب لكل | نقاط النهاية |
    |---|---|---|---|
    | `auth` | `AUTH_RATE_PER_IP` (20) / 10 دقائق، مشترك | عميل، و`AUTH_RATE_PER_PREFIX` (5 x `AUTH_RATE_PER_IP`) لكل IPv6 /48 | `POST /auth/register`، `/auth/login`، `/auth/login/mfa`، `/auth/verify-email/resend`، `/auth/password/forgot`، `/auth/password/reset`، `/auth/sso/google/link`، `/auth/sso/complete`؛ `POST /verify-email`، `/reset-password`، `/confirm-email-change` |
    | `auth_register` | `AUTH_REGISTER_PER_HOUR` (10) / ساعة، مشترك | عميل، و3 أضعاف ذلك لكل IPv6 /48 | `POST /auth/register` |
    | `auth_mail` | `AUTH_MAIL_PER_HOUR` (10) / ساعة، مشترك | عميل، و3 أضعاف ذلك لكل IPv6 /48 | `POST /auth/verify-email/resend` |
    | `auth_forgot` | `AUTH_FORGOT_PER_HOUR` (3) / ساعة، مشترك | عميل، و3 أضعاف ذلك لكل IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_forgot_day` | `AUTH_FORGOT_PER_DAY` (10) / 24 ساعة، مشترك | عميل، و3 أضعاف ذلك لكل IPv6 /48 | `POST /auth/password/forgot` |
    | `auth_reset` | `AUTH_RESET_PER_HOUR` (10) / ساعة، مشترك | عميل، و3 أضعاف ذلك لكل IPv6 /48 | `POST /auth/password/reset`، `POST /reset-password` |
    | `reauth` | أرقام `auth` نفسها، بدلو مستقل، مشترك | عميل ونطاق IPv6 /48 | تعديلات الحساب التي تطلب كلمة المرور (راجع «إعادة المصادقة»)، و`POST /account/export` |
    | `reauth_user` | `AUTH_REAUTH_PER_USER` (10) / 10 دقائق، مشترك | لاعب | نقاط النهاية نفسها الخاضعة لـ `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 / دقيقة | لاعب (أو عميل من دون رمز مميَّز) | `GET /players/{username}`، `/players/{username}/games`، `/games/{id}`، `/games/{id}/pgn` (دلو واحد للأربع) |
    | `gif` | 30 / دقيقة | لاعب | `GET /games/{id}/gif`، `POST /gif` (دلو واحد للاثنتين) |
    | `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 أضعاف ذلك لكل IPv6 /48 | نقطتا النهاية نفسهما، كما سبق |
    | `reports` | 30 / ساعة | لاعب | `POST /reports` |
    | `sso_start` | 30 / 10 دقائق، مشترك | عميل، و90 لكل IPv6 /48 | `POST /auth/sso/google/start` |
    | `sso_finish` | 30 / دقيقة | عميل | `POST /auth/sso/google/finish` |
    | `page` | 60 / دقيقة | عميل | `GET /verify-email`، `/reset-password`، `/confirm-email-change` |

    ليس لـ `GET /info` و`GET /leaderboard` حدّ خاص بهما: لا تنطبق عليهما إلا طبقة الحدّ حسب العنوان. ونقطة النهاية ذات الحدود المتعددة تفحصها بالترتيب الوارد في وصفها.

    يعالج الخادم الطلب بهذا الترتيب: طبقة الحدّ حسب العنوان، ثم نقاط نهاية فحص السلامة؛ مطابقة نقطة النهاية؛ المصادقة؛ رصيد الحساب، إذا كان الطلب يحمل جلسة؛ حدود نقطة النهاية؛ الجسم؛ نقطة النهاية نفسها (لا تأخذ نقاط نهاية GIF حدود الرسم إلا حين تكون لديها صورة GIF عليها رسمها). وبذلك فإن الطلب المرفوض بسبب رمزه المميَّز لا يستهلك شيئًا من رموز نقطة النهاية، أما الطلب ذو الجسم غير الصالح فيستهلكها.

    أشكال تقييد أخرى، تردّ بها نقاط النهاية نفسها:

    - **محاولات تسجيل الدخول الفاشلة على اسم دخول واحد** (اسم المستخدم أو البريد الإلكتروني): ابتداءً من `AUTH_FAILURES_PER_ACCOUNT` (5) إخفاقات، يجب أن تنتظر كل محاولة ضعف مدة سابقتها (2 ث، 4 ث، وهكذا، حتى 15 دقيقة): 429 `too_many_attempts` مع `retryAfter`. ويُصفَّر العدّاد بعد ساعة بلا إخفاقات.
    - **العوامل الثانية الفاشلة عند تسجيل الدخول:** القاعدة نفسها ابتداءً من الرمز الخاطئ رقم 5 للحساب. ولا تقبل خطوة تسجيل الدخول الواحدة أكثر من 5 رموز خاطئة.
    - **رموز العامل الثاني لحساب واحد:** `AUTH_MFA_PER_ACCOUNT` (10) رموز على الأكثر (رموز تطبيق المصادقة أو رموز الاسترداد، صحيحةً كانت أو خاطئة) كل 15 دقيقة، من أي عنوان، عند تسجيل الدخول وفي عمليات إعادة المصادقة؛ وبعد تجاوزها يكون الرد 429 `too_many_attempts` قبل فحص الرمز، فلا يُستهلك رمز الاسترداد.
    - **عمليات إعادة المصادقة الفاشلة لحساب** (كلمة مرور أو رمز خاطئ): القاعدة نفسها (429 `too_many_attempts`)، مشتركةً بين كل نقاط النهاية التي تعيد المصادقة.
    - **رسائل البريد الإلكتروني:** رسالة تأكيد أو إعادة تعيين واحدة لكل عنوان كل 5 دقائق (ويبقى الرد كما هو)؛ وإشعار واحد من نوع "someone tried to use your address" (حاول أحدهم استخدام عنوانك) لكل عنوان كل ساعة.
    - **البلاغات:** `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. ابحث عن قيمة استخدام واحد: سلسلة عشرية من 20 رقمًا على الأكثر بحيث تبدأ `SHA-256(challenge + ":" + nonce)` بعدد `bits` من البتات الصفرية (البت الأعلى أهميةً من البايت الأول أولًا). وتتطلب 18 بتًا نحو 260,000 عملية تجزئة في المتوسط.
    3. أرسل الطلب نفسه مرة أخرى مع `"pow": { "challenge": "...", "nonce": "123456" }` في الجسم.

    يصلح التحدي مدة 2 دقيقة ولمرة واحدة فقط، لنقطة نهاية واحدة وشبكة عميل واحدة (عنوان IPv4 أو شبكة IPv6 /64). وهو موقَّع، فلا يحتفظ الخادم بأي شيء عنه حتى يعود إليه. ويبيّن `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 افتراضيًا؛ أبقِه خاصًا). وهو ليس جزءًا من هذه الواجهة: يردّ `GET /healthz` بـ `ok`؛ ويردّ `GET /readyz` بـ `ready` بعد اكتمال بدء التشغيل (أي بعد أن تعيد كل شريحة تنفيذ سجل عملياتها وتُربط المنافذ المستمعة) وحتى بدء الإيقاف، وإلا فيردّ بـ 503 `not ready`؛ ويقدّم `GET /metrics` مقاييس Prometheus، ويتطلب، عند تعيين `METRICS_TOKEN`، الترويسة `Authorization: Bearer <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: |-
      حيوية الخادم وجاهزيته، لأغراض المراقبة. تستجيب نقاط النهاية هذه على منفذ API قبل المصادقة وقبل كل حدود نقاط النهاية، لكنها كأي طلب تأخذ رمزًا من طبقة الحدّ حسب العنوان؛ ويمكن إدراج مضيف المراقبة في `ABUSE_EXEMPT`. ويعمل المساران كلاهما لكل نقطة نهاية: في جذر الخادم وتحت `/api/v1`.

      ولمنفذ المقاييس نقاط نهاية خاصة به لفحص السلامة، موصوفة في المقدمة.
  - name: auth
    x-displayName: إنشاء الحساب وتسجيل الدخول
    description: |-
      إنشاء حساب، وتسجيل الدخول بكلمة مرور (وعامل ثانٍ)، ورسائل التأكيد وإعادة تعيين كلمة المرور.

      تردّ نقاط النهاية `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`؛ وإلا فإن كل نقطة نهاية أدناه تردّ بـ 404 `sso_disabled`. تسجّل اللعبة الدخول عبر متصفح النظام بتدفق التطبيقات المثبّتة الموصوف في RFC 8252 (عميل من نوع «تطبيق سطح المكتب»): يعيد Google المتصفح إلى مستمعٍ تابع للعبة على `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 محرفًا بلا حشو.
      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 حصل عليه خادم آخر للاعبيه. والأصل هو اسم المضيف بأحرف صغيرة (وعنوان IPv6 الحرفي بين قوسين معقوفين)، ثم `:`، ثم منفذ API بالنظام العشري، مكتوبًا دائمًا، بما في ذلك 443: يأخذ الخادم `SERVER_PUBLIC_HOST` ومنفذ API العام الخاص به، وتأخذ اللعبة العنوان الذي تتصل به. والوسم هو أول 22 محرفًا من ترميز base64url (بلا حشو) لـ SHA-256(UTF-8 `"scacelith-sso-origin-v1\n"` + الأصل). وعنوان URI لإعادة التوجيه هو `"http://127.0.0.1:" + port + "/oauth2/google/" + tag`: دائمًا عنوان IPv4 الحرفي، ومنفذ مستمع اللعبة (من 1024 إلى 65535). ولا يأخذ الخادم أبدًا أي URI أو مضيف أو مسار من العميل.

      | الأصل | الوسم |
      |---|---|
      | `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 محرفًا. وإلا فإن اللعبة توقف مستمعها ولا تفتح شيئًا. ولا ترسل `finish` و`link` إلا إلى الخادم الذي ردّ على `start`.

      **الحساب الذي يصل إليه تسجيل الدخول عبر Google:** الحساب المرتبط بالفعل بحساب Google هذا؛ وإلا فحساب نشط يحمل العنوان الذي أكّده Google (إن كانت له كلمة مرور، يردّ `finish` بـ `needsPassword` ولا يُخزَّن الربط إلا بعد اجتياز كلمة مروره، ثم عامله الثاني إن كان مفعّلًا؛ وإن لم تكن له كلمة مرور، فالرد 409 `sso_account_exists`)؛ وإلا فحساب جديد (`needsUsername`). ولا يُربط حساب Google أبدًا بحساب موجود بناءً على عنوانه وحده. ويتلقى عنوان الحساب رسالة عندما ينشئ تسجيل الدخول عبر 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: تغيير عنوان البريد الإلكتروني
    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 و21 تعادلان (اللاعب الذي نفد وقته أو ترك المباراة كان يواجه خصمًا لا يستطيع الإماتة)، ولا ينهي الخادم أي مباراة بالرمزين 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 (نقلة غير قانونية ثانية، لكن الخصم لا يستطيع الإماتة) |
      | 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 (أُلغيت: انقطع اتصال اللاعبَين كليهما) |
  - name: gifs
    x-displayName: صور GIF المتحركة
    description: |-
      المباراة على شكل صورة GIF متحركة، للاحتفاظ بها أو لمشاركتها: الرقعة من الأعلى، وإطار لكل وضعية من البداية حتى الوضعية الأخيرة، واسما اللاعبَين وتصنيفاهما فوق الرقعة، والنقلة الأخيرة تحتها، وفي الإطار الأخير النتيجة وكيف انتهت المباراة.

      **التكلفة والتخزين المؤقت والحصص.** تُنشأ صورة GIF على خيط رسم خاص بها، لا على الخيوط التي تدير المباريات أبدًا، وبأدنى أولوية للمعالج: `GIF_THREADS` خيطًا (`WORKERS` افتراضيًا)، تبدأ مع أول صورة GIF وتتوقف بعد دقيقة من دون أي صورة. وتنتظر حتى `GIF_QUEUE_MAX` (4 x `WORKERS`) صورة GIF دورها للحصول على خيط، كلٌّ منها `GIF_QUEUE_TIMEOUT_MS` (10 ث) على الأكثر؛ ويجوز أن يستغرق الرسم `GIF_RENDER_TIMEOUT_MS` (30 ث). ويحتفظ الخادم بصور GIF التي أنشأها في ذاكرة تخزين مؤقت سعتها `GIF_CACHE_MB` ميغابايت (32 x `WORKERS`)، ويُستبعد منها الأقل استخدامًا مؤخرًا أولًا؛ ولا تكلّف صورة GIF المأخوذة من ذاكرة التخزين المؤقت، أو التي يجري إنشاؤها لطلب آخر، أي عملية رسم. ويعتمد مفتاح التخزين المؤقت على كل ما يغيّر الصورة، بما في ذلك الأسماء.

      يُحتسب كل طلب في `gif` (30 في الدقيقة لكل لاعب لنقطتي النهاية معًا). وصورة 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 أضعاف ذلك لكل IPv6 /48. وعلى العميل أن يحتفظ بالملف الذي نزّله بدلًا من طلبه مرة أخرى، وأن ينتظر `retryAfter` بعد 429 أو 503.

      الأحجام: `small` (مربعات 32 بكسل: 284 x 350 بكسل)، و`medium` (48 بكسل: 424 x 515)، و`large` (72 بكسل: 628 x 762)؛ ومن دون الإحداثيات 268 x 342 و400 x 503 و600 x 748. وتبقى وضعية البداية 1 ث على الأقل (أو مدة التأخير بين الإطارات إن كانت أطول)، والوضعية الأخيرة 3 ث، وتُعاد صورة GIF في حلقة؛ وبعد الإطار الأول لا يُخزَّن إلا الجزء المتغير من الصورة. وتشغل مباراة من 40 نقلة نحو 135 و205 و325 كيبيبايت (صغير، متوسط، كبير)، ومباراة من 150 نقلة 0.5 و0.8 و1.2 ميبيبايت.
  - name: players
    x-displayName: اللاعبون
    description: الملفات الشخصية العامة والمباريات الأخيرة. بيانات عامة فقط، ولا تتضمن أبدًا عنوان بريد إلكتروني ولا جلسة ولا عقوبة ولا مستوى نزاهة. وليس للحساب المحذوف ملف شخصي.
  - name: leaderboard
    x-displayName: لوحة المتصدرين
    description: أفضل اللاعبين في كل فئة رسمية.
  - name: reports
    x-displayName: البلاغات
    description: الإبلاغ عن خصم مباراة حديثة إلى المشرفين.
  - name: pages
    x-displayName: صفحات HTML
    description: |-
      صفحات للمتصفح، تُفتح من روابط رسائل البريد الإلكتروني. تشير روابطها إلى `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` إلا زرًّا أو نموذجًا، كي لا يستهلك ماسحُ البريد الذي يفتح الرابطَ هذا الرابط. ويحدث التغيير عند `POST`: نموذج يُرسَل بصيغة `application/x-www-form-urlencoded` (ويُقبل جسم JSON أيضًا)، مع الرمز المميَّز للرابط في حقل مخفي. ويُرفض الحقل المكرر.
      - الأخطاء (حدود المعدّل، والحقول غير الصالحة) هي أيضًا صفحات 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`.'
  /auth/register:
    post:
      operationId: registerAccount
      tags:
        - auth
      summary: إنشاء حساب
      description: |-
        ينشئ حسابًا: بعد تأكيد عنوان بريده الإلكتروني بالرابط المرسَل إليه، أو فورًا إذا لم يكن تأكيد البريد الإلكتروني مطلوبًا.

        **مع تأكيد البريد الإلكتروني** (`REQUIRE_EMAIL_VERIFICATION`، وهو الإعداد الافتراضي): 202 `verification_sent`. لا يوجد حساب بعد: ينتظر طلب التسجيل 24 ساعة ويحجز اسم المستخدم خلالها. ويُرسَل إلى العنوان رابط صالح طوال هذه الساعات الـ 24، بحد أقصى رابط واحد لكل عنوان كل 5 دقائق (يرسل `POST /auth/verify-email/resend` رابطًا جديدًا)؛ ويُنشأ الحساب، بعنوان مؤكَّد، عند استخدام الرابط (زر الصفحة `/verify-email`)، ويستطيع اللاعب عندئذٍ تسجيل الدخول. وحتى ذلك الحين يكون الرد على تسجيل الدخول باسم المستخدم هذا `invalid_credentials`، كما في الحساب غير المعروف، ولا يوجد ملف شخصي عام. وطلب التسجيل الجديد بالعنوان نفسه يحل محل الطلب المنتظر. ويكون الرد نفسه عندما يستخدم حساب آخر العنوان بالفعل: لا يُرسَل رابط حينئذٍ، بل يتلقى صاحب ذلك الحساب إشعارًا بدلًا منه (إشعارًا واحدًا في الساعة على الأكثر)، ويُحجز اسم المستخدم بالطريقة نفسها، كي لا يكشف شيءٌ هل للعنوان حساب. وطلب التسجيل الذي لم يُستخدم رابطه يُسقَط بعد 24 ساعة، ويصبح اسم المستخدم متاحًا من جديد.

        **من دون تأكيد البريد الإلكتروني** (`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 بايت من UTF-8 على الأكثر؛ ويجب ألا تحتوي على اسم المستخدم ولا على الجزء المحلي من عنوان البريد الإلكتروني، وألا تكون كلمة مرور شائعة.

        **الأخطاء**، وتُفحص بهذا الترتيب: 403 `registration_closed`؛ 400 `invalid_username`؛ 400 `invalid_email`؛ 400 `weak_password`؛ 409 `username_taken`؛ 428 `pow_required`؛ أخطاء طابور تجزئة كلمات المرور؛ 409 `email_taken` (من دون تأكيد البريد الإلكتروني فقط).

        **الحدود:** `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: أُنشئ الحساب ويمكنه تسجيل الدخول (لا تأكيد للبريد الإلكتروني على هذا الخادم).
          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`: يستخدم حساب آخر هذا العنوان (من دون تأكيد البريد الإلكتروني فقط).'
        '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`.'
  /auth/login:
    post:
      operationId: logIn
      tags:
        - auth
      summary: تسجيل الدخول بكلمة مرور
      description: |-
        يسجّل الدخول باسم مستخدم أو عنوان بريد إلكتروني وكلمة مرور. هناك ردّان ممكنان، كلاهما بالحالة 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` (بالملّي ثانية منذ حقبة يونكس، و`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`.'
  /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`.'
  /auth/logout:
    post:
      operationId: logOut
      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`.'
  /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`.'
  /auth/verify-email/resend:
    post:
      operationId: resendVerificationEmail
      tags:
        - auth
      summary: إعادة إرسال رابط التأكيد
      description: |-
        يعيد إرسال رابط تأكيد البريد الإلكتروني. ويكون الرد 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`: بقيت قاعدة البيانات مقفلة أثناء البحث عن الحساب (تجديد طلب التسجيل المنتظر يتم بقدر الإمكان ولا يُفشل الطلب أبدًا). `timeout`.'
  /auth/password/forgot:
    post:
      operationId: requestPasswordReset
      tags:
        - auth
      summary: إرسال رابط لإعادة تعيين كلمة المرور
      description: |-
        يرسل رابطًا لإعادة تعيين كلمة المرور، صالحًا مدة ساعة واحدة، يفتح الصفحة `/reset-password`. ويكون الرد 202 `accepted` أيًّا كان العنوان. ولا يُرسَل الرابط إلا إلى حساب نشط، مرة كل 5 دقائق على الأكثر لكل عنوان. وبهذه الطريقة يعيّن الحساب المعتمد على Google وحده كلمة مروره الأولى.

        لاستعادة كلمة المرور أشد حدود الواجهة صرامة، وكلها تُحتسب على مستوى الخادم بأكمله: 3 طلبات في الساعة (`AUTH_FORGOT_PER_HOUR`) و10 طلبات في 24 ساعة (`AUTH_FORGOT_PER_DAY`) لكل عميل (عنوان IPv4 أو شبكة IPv6 /64)، و3 أضعاف هذه الأعداد لكل IPv6 /48، إضافةً إلى الحدّ `auth` (20 كل 10 دقائق) ورسالة واحدة لكل عنوان كل 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`: بقيت قاعدة البيانات مقفلة. `timeout`.'
  /auth/password/reset:
    post:
      operationId: resetPassword
      tags:
        - auth
      summary: تعيين كلمة مرور جديدة بالرمز المميَّز لرابط إعادة التعيين
      description: |-
        يعيّن كلمة مرور جديدة بالرمز المميَّز لرابط إعادة التعيين (والصفحة `/reset-password` تفعل الشيء نفسه). وتخضع كلمة المرور الجديدة لقواعد التسجيل.

        تُبطل إعادة التعيين كل الجلسات وتلغي أي تغيير معلّق للبريد الإلكتروني؛ وتتوقف روابط إعادة التعيين الأخرى للحساب عن العمل؛ ويُعدّ العنوان مؤكَّدًا (فقد أثبته الرابط)؛ ويتلقى صاحب الحساب رسالة. ولا تُمَسّ المصادقة الثنائية. وبعد خطأ في طابور تجزئة كلمات المرور أو 503، يبقى الرابط صالحًا.

        **الحدود:** `auth`، ثم `auth_reset` (10 في الساعة لكل عميل، و30 لكل IPv6 /48، مشترك مع الصفحة: فكل محاولة تجزّئ كلمة مرور).
      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`.'
  /auth/sso/google/start:
    post:
      operationId: startGoogleSignIn
      tags:
        - google-sign-in
      summary: بدء تسجيل الدخول عبر Google
      description: |-
        يبدأ محاولة تسجيل دخول عبر Google لتحدي PKCE ولمنفذ مستمع اللعبة على `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`.'
  /auth/sso/google/finish:
    post:
      operationId: finishGoogleSignIn
      tags:
        - google-sign-in
      summary: تسليم رد Google إلى الخادم
      description: |-
        يسلّم إلى الخادم قيمتي `code` و`state` اللتين أرسلهما Google إلى مستمع اللعبة، مع معرّف المحاولة ومُحقِّق PKCE. ولا فائدة من معرّف المحاولة من دون المُحقِّق، ولا فائدة من رمز التفويض من دون مُحقِّق PKCE الخاص بالخادم وسرّ العميل.

        يتحقق الخادم من المحاولة (غير معروفة أو مستخدَمة أو منتهية الصلاحية: 410)، ثم من المُحقِّق (المُحقِّق الخاطئ يُبقي المحاولة صالحة للاستخدام)، ثم يستخدم المحاولة مرة واحدة، ويتحقق من `state` و`iss`، ويستبدل رمز التفويض لدى Google، ويتحقق من الرمز المميَّز للهوية. والرد (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`.'
  /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`، ولا يُخزَّن الربط إلا عندما يجتاز رمزٌ الفحصَ هناك.

        كلمة المرور الخاطئة تُبقي التذكرة صالحة للاستخدام، بما مجموعه 5 محاولات. وتُحتسب المحاولة قُبيل فحص كلمة المرور مباشرةً: فالرد 429 `too_many_attempts` أو 428 `pow_required` لا يستهلك أي محاولة، أما رفض طابور تجزئة كلمات المرور (503 `server_busy`، 429 `rate_limited`) فيكون قد استهلك محاولة ويُحتسب إخفاقًا للحساب. ومهلة الانتظار بعد الإخفاقات تستخدم العدّاد نفسه الذي يستخدمه `POST /auth/login`.

        **الحدّ:** `auth` (مع احتسابه لكل IPv6 /48). **إثبات العمل:** كما في `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`: كلمة مرور خاطئة؛ وتبقى التذكرة صالحة، بما مجموعه 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`: تذكرة غير معروفة أو مستخدَمة أو منتهية الصلاحية، أو كلمة المرور الخاطئة رقم 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`.'
  /auth/sso/complete:
    post:
      operationId: completeGoogleSignUp
      tags:
        - google-sign-in
      summary: إنشاء الحساب عند أول تسجيل دخول عبر Google
      description: |-
        بعد أن يردّ `finish` بـ `needsUsername`: ينشئ الحساب باسم المستخدم المختار (وتنطبق قواعد التسجيل) ويسجّل دخوله. وليس للحساب كلمة مرور: قيمة `hasPassword` هي `false`، و«نسيت كلمة المرور» (`POST /auth/password/forgot`) يمنحه واحدة.

        **الحدّ:** `auth` (مع احتسابه لكل IPv6 /48).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoCompleteRequest'
            example:
              ssoTicket: sso_gYIKKhwnz1AqWfFxOC3R-ZvCACw06q74fEMtGEOJQWU
              username: alice
              clientLabel: Scacelith 1.4 (Windows)
      responses:
        '200':
          description: أُنشئ الحساب وسُجّل دخوله.
          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`: تذكرة غير معروفة أو مستخدَمة أو منتهية الصلاحية.'
        '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`.'
  /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`.'
  /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`.'
  /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`.'
  /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`.'
  /account/password:
    post:
      operationId: changePassword
      tags:
        - account
      summary: تغيير كلمة المرور
      description: |-
        يغيّر كلمة المرور. وتلزم كلمة المرور الحالية، لكن لا يلزم عامل ثانٍ، حتى إن كانت المصادقة الثنائية مفعّلة. وتخضع كلمة المرور الجديدة لقواعد التسجيل (وتُفحص بعد كلمة المرور الحالية).

        يُبطل التغيير كل الجلسات الأخرى (وتبقى هذه الجلسة مسجَّلة الدخول)، ويلغي أي تغيير معلّق للبريد الإلكتروني، ويوقف عمل روابط إعادة تعيين كلمة المرور الخاصة بالحساب، ويرسل رسالة إلى صاحب الحساب.

        **إعادة المصادقة:** كلمة المرور فقط. **الحدود:** `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` (طابور تجزئة كلمات المرور، أو بقيت قاعدة البيانات مقفلة)، `timeout`.'
  /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`.'
  /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`.'
  /account/mfa/totp/disable:
    post:
      operationId: disableTotp
      tags:
        - two-step-verification
      summary: تعطيل المصادقة الثنائية
      description: |-
        يعطّل المصادقة الثنائية. ويُحذف السر ورموز الاسترداد، ويتلقى صاحب الحساب رسالة.

        **إعادة المصادقة:** كلمة المرور ورمز من تطبيق المصادقة أو رمز استرداد (يلزم أحد `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`.'
  /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`.'
  /account/email:
    post:
      operationId: changeEmail
      tags:
        - email-change
      summary: تغيير عنوان البريد الإلكتروني
      description: |-
        يغيّر عنوان البريد الإلكتروني للحساب. تُزال المسافات من طرفي `newEmail` ويُحوَّل إلى أحرف صغيرة، ثم يُفحص كما يُفحص عنوان التسجيل.

        **مع تأكيد البريد الإلكتروني** (الإعداد الافتراضي): 202 `verification_sent`. يُرسَل إلى العنوان الجديد رابط صالح 24 ساعة؛ يفتح `/confirm-email-change`، ولا يتغير العنوان إلا حين يضغط اللاعب زر تلك الصفحة. وحتى ذلك الحين يعرض `GET /account/me` العنوان الجديد في `pendingEmail`. والطلب الجديد يحل محل الطلب المعلّق؛ ويلغيه تغيير كلمة المرور أو إعادة تعيينها. ولا يُرسَل إلى عنوان جديد بعينه أكثر من رابط واحد كل 5 دقائق، أيًّا كان الطالب (وطلبُ التغيير المعلّق أصلًا يُبقي الرابط المرسَل سابقًا، الذي يظل صالحًا)؛ ويبقى الرد كما هو. ويتلقى العنوان الحالي إشعارًا بأن تغييرًا إلى عنوان مُقنَّع (`a***@example.org`) قد طُلب. ويكون الرد و`pendingEmail` كما هما عندما يستخدم حساب آخر العنوان الجديد بالفعل: لا يُرسَل رابط حينئذٍ، فلا يكتمل ذلك التغيير أبدًا، ويتلقى صاحب ذلك العنوان إشعارًا (واحدًا في الساعة على الأكثر) بدلًا منه.

        عند تأكيد الرابط، يتغير العنوان ويُعدّ مؤكَّدًا، وتبقى الأجهزة مسجَّلة الدخول، وتتوقف الروابط المرسَلة سابقًا (التأكيد، وإعادة تعيين كلمة المرور، والتغييرات الأخرى) عن العمل، ويُبلَّغ العنوان السابق، مع إظهار العنوان الجديد مُقنَّعًا.

        **من دون تأكيد البريد الإلكتروني** (`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: تغيّر العنوان فورًا (لا تأكيد للبريد الإلكتروني على هذا الخادم).
          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`: من دون تأكيد البريد الإلكتروني فقط.'
        '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`.'
  /account/export:
    post:
      operationId: exportAccountData
      tags:
        - data-export
      summary: تنزيل بيانات الحساب
      description: |-
        كل ما يحتفظ به الخادم عن الحساب، في ملف JSON واحد للحفظ (قيمة `format` هي `scacelith-account-export`، وقيمة `version` هي 1). ويسجّل التصدير حدث أمان (`account_exported`). وتشرح المصفوفة `notes` في المستند للاعب، بلغة إنجليزية بسيطة، ما الذي استُبعد منه.

        **لا يتضمن التصدير أبدًا:** تجزئة كلمة المرور، وسرّ المصادقة الثنائية ورموز الاسترداد؛ وأي رمز مميَّز لجلسة أو رابط، أو تجزئةً له؛ وبيانات نظام مكافحة الغش (مستوى النزاهة ودرجتها، والحالات الشاذة، وتحليل المباريات، ووزن البلاغ)؛ والبلاغات التي قدّمها لاعبون آخرون عن اللاعب؛ وهويات المشرفين؛ والبيانات الخاصة باللاعبين الآخرين (يظهر الخصوم باسمهم العام وتصنيفهم، ولا شيء يكشف هل عوقب لاعب آخر، ولا يُدرج أي عنوان 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 ثانية).'
  /account/delete:
    post:
      operationId: deleteAccount
      tags:
        - account-deletion
      summary: حذف الحساب
      description: |-
        يحذف الحساب؛ ولا يمكن التراجع عن ذلك.

        - تُبطَل كل الجلسات فورًا: ويتلقى الرمز المميَّز 401 `invalid_token` منذ ذلك الحين.
        - يصبح اسم المستخدم `deleted#<id>`، في الحساب وفي كل سجل مباراة.
        - تُمحى البيانات التالية: عنوان البريد الإلكتروني، وتجزئة كلمة المرور، وسرّ المصادقة الثنائية ورموز الاسترداد، والجلسات والرموز المميَّزة للروابط، والربط بـ 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`.'
  /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`.'
  /games/{id}:
    get:
      operationId: getGame
      tags:
        - games
      summary: الحصول على سجل مباراة بنقلاتها وساعاتها
      description: |-
        سجل مباراة واحدة، بنقلاتها وساعاتها. من دون رمز مميَّز، أو برمز لاعب لم يلعب المباراة، يكون الرد هو الرد العام. وعندما يكون صاحب الرمز المميَّز قد لعب المباراة، يضيف الرد `you` و`reportable`.

        **الحدّ:** `public_read` (لكل لاعب مع رمز مميَّز، ولكل عميل من دونه).
      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`: أُرسل رمز مميَّز وهو غير صالح.'
        '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`.'
  /games/{id}/pgn:
    get:
      operationId: getGamePgn
      tags:
        - games
      summary: تنزيل مباراة كملف PGN
      description: |-
        المباراة نفسها كملف PGN: مباراة واحدة بنهايات أسطر `\n`، ونص النقلات في أسطر أقل من 80 عمودًا. والرد نفسه مع رمز مميَّز أو من دونه.

        الوسوم، بهذا الترتيب: `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` (لكل لاعب مع رمز مميَّز، ولكل عميل من دونه).
      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`: أُرسل رمز مميَّز وهو غير صالح.'
        '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`.'
  /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 بكسل)، أو `medium` (48 بكسل)، أو `large` (72 بكسل).'
          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`.'
  /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`: إذ تروي الوضعية الأخيرة حينئذٍ ما حدث (كش مات، جمود).
        - تفحص نقطة النهاية هذه جسمها بنفسها: الحقل غير المعروف، أو غياب `pgn` أو كونه غير نصي، يكون الرد عليه 400 `invalid_request` مع `field`؛ والخيار الخاطئ يكون الرد عليه 400 `invalid_option` (يجب أن يكون `delayMs` عددًا في JSON، و`coords` قيمة منطقية؛ وتُرفض `null`).

        **الجلسة مطلوبة:** تُحتسب الحصص لكل حساب. **الحدود:** كما في `GET /games/{id}/gif`. **حدّ الجسم:** 135,168 بايت، أيًّا كانت قيمة `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 بايت.'
        '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 بايت.'
        '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`.'
  /players/{username}:
    get:
      operationId: getPlayer
      tags:
        - players
      summary: الحصول على الملف الشخصي العام للاعب
      description: |-
        الملف الشخصي العام للاعب: التصنيفات في الفئات الرسمية (بترتيب الخادم) وأعداد المباريات. يعدّ `games.total` كل مباراة مخزّنة، بما فيها الودية والملغاة؛ أما `games.rated` و`wins` و`draws` و`losses` فتُجمع من سجلات التصنيف، أي من المباريات المحتسبة فقط. والرد نفسه مع رمز مميَّز أو من دونه.

        **الحدّ:** `public_read` (لكل لاعب مع رمز مميَّز، ولكل عميل من دونه).
      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`: أُرسل رمز مميَّز وهو غير صالح.'
        '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`.'
  /players/{username}/games:
    get:
      operationId: listPlayerGames
      tags:
        - players
      summary: عرض المباريات الأخيرة للاعب
      description: |-
        المباريات الأخيرة للاعب، بدءًا بالأحدث، مقسّمة إلى صفحات كالسجل لكن بلا عوامل تصفية ولا عدد إجمالي. و`color` هو جهة هذا اللاعب. وتكون `next` معرّف آخر مباراة كلما امتلأت الصفحة، لذا قد تكون الصفحة التالية فارغة. ويُبحث عن اللاعب أولًا: فاللاعب غير المعروف يعني 404 أيًّا كان الاستعلام.

        **الحدّ:** `public_read` (لكل لاعب مع رمز مميَّز، ولكل عميل من دونه).
      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`: أُرسل رمز مميَّز وهو غير صالح.'
        '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`.'
  /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`.'
  /reports:
    post:
      operationId: reportPlayer
      tags:
        - reports
      summary: الإبلاغ عن خصم مباراة حديثة
      description: |-
        يبلّغ عن الخصم في إحدى مباريات اللاعب نفسه انتهت خلال آخر 7 أيام. ولا يغيّر البلاغ وحده أبدًا تصنيفًا ولا عقوبة ولا مستوى نزاهة. بل يرفع أولوية المراجعة التي يراها المشرفون، ويطلب، باستثناء `abuse`، تحليل المباراة بالمحرّك.

        والبلاغ عن الخصم نفسه في المباراة نفسها يتلقى الرد نفسه ولا يغيّر شيئًا؛ ولا يكشف الرد أبدًا أي شيء عن الحساب المبلَّغ عنه. ويُعلم `GET /games/{id}` لاعبَي المباراة مسبقًا هل سيُقبل البلاغ (`reportable`).

        تفحص نقطة النهاية هذه جسمها بنفسها، بالترتيب `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`.'
  /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: عرض صفحة تأكيد البريد الإلكتروني
      description: |-
        صفحة رابط تأكيد البريد الإلكتروني (عند التسجيل، أو في رسالة تأكيد أُعيد إرسالها). لا تعرض إلا زر "Confirm my e-mail address" (تأكيد عنوان بريدي الإلكتروني)، كي لا يستهلك ماسحُ البريد الذي يفتح الرابطَ هذا الرابط؛ ويرسل الزر النموذج إلى `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)، أو طبقة الحدّ حسب العنوان (`rate_limited` بصيغة JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitVerifyEmailPage
      tags:
        - pages
      summary: تأكيد عنوان البريد الإلكتروني
      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)، أو طبقة الحدّ حسب العنوان (`rate_limited` بصيغة JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'بقيت قاعدة البيانات مقفلة (`Retry-After: 1`): لم يتغير شيء والرابط ما زال يعمل. وكذلك عند انتهاء مهلة معالج الطلب.'
  /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)، أو طبقة الحدّ حسب العنوان (`rate_limited` بصيغة JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitResetPasswordPage
      tags:
        - pages
      summary: تعيين كلمة مرور جديدة من نموذج إعادة التعيين
      description: |-
        نموذج صفحة إعادة التعيين؛ يفعل ما يفعله `POST /auth/password/reset`: يُسجَّل خروج كل الأجهزة، ويُلغى أي تغيير معلّق للبريد الإلكتروني، وتتوقف روابط إعادة التعيين الأخرى عن العمل، ويُعدّ العنوان مؤكَّدًا، ويتلقى صاحب الحساب رسالة.

        يُفحص الرابط أولًا، ثم تطابق كلمتي المرور، ثم قواعد كلمة المرور. وعندما يكون الخادم مشغولًا، يعود النموذج مع `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` عندما يكون لهذا العميل عدد كبير جدًا من عمليات تجزئة كلمات المرور في الانتظار (ويبقى الرابط صالحًا)، أو طبقة الحدّ حسب العنوان (`rate_limited` بصيغة JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: النموذج من جديد مع `Retry-After` عندما يكون الخادم مشغولًا (طابور تجزئة كلمات المرور، أو بقيت قاعدة البيانات مقفلة)؛ ويبقى الرابط صالحًا. وكذلك عند انتهاء مهلة معالج الطلب.
  /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: عرض صفحة تأكيد تغيير البريد الإلكتروني
      description: |-
        صفحة رابط تغيير البريد الإلكتروني: تعرض العنوان الجديد واسم الحساب، مع زر "Use this e-mail address" (استخدام عنوان البريد الإلكتروني هذا) الذي يرسل النموذج إلى `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)، أو طبقة الحدّ حسب العنوان (`rate_limited` بصيغة JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
    post:
      operationId: submitConfirmEmailChangePage
      tags:
        - pages
      summary: تأكيد عنوان البريد الإلكتروني الجديد
      description: |-
        نموذج صفحة تغيير البريد الإلكتروني. يتغير العنوان ويُعدّ مؤكَّدًا؛ وتبقى الأجهزة مسجَّلة الدخول؛ وتتوقف الروابط المرسَلة سابقًا (التأكيد، وإعادة تعيين كلمة المرور، والتغييرات الأخرى) عن العمل؛ ويُبلَّغ العنوان السابق، مع إظهار العنوان الجديد مُقنَّعًا.

        **الحدّ:** `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)، أو طبقة الحدّ حسب العنوان (`rate_limited` بصيغة JSON).
        '500':
          $ref: '#/components/responses/HtmlInternalError'
        '503':
          $ref: '#/components/responses/HtmlServiceUnavailable'
          description: 'بقيت قاعدة البيانات مقفلة (`Retry-After: 1`): لم يتغير شيء والرابط ما زال يعمل. وكذلك عند انتهاء مهلة معالج الطلب.'
  /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: |-
        رمز جلسة مميَّز (`sct_` متبوعًا بـ 43 محرفًا بترميز base64url) من رد تسجيل الدخول، في الترويسة `Authorization`: `Authorization: Bearer <token>`. والبادئة هي `Bearer` بالضبط (مع مراعاة حالة الأحرف) تليها مسافة واحدة؛ والقيمة التي لا تحملها، أو الرمز المميَّز الذي ليس من 1 إلى 512 محرفًا من محارف ASCII القابلة للطباعة، يكون الرد عليهما 401 `invalid_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: الرمز المميَّز لرابط البريد الإلكتروني (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"` للرمز المميَّز غير الصالح.
      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` (الرمز المميَّز مشوّه، أو منتهي الصلاحية، أو مُبطَل، أو يخص حسابًا محذوفًا).'
      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: رمز مميَّز غير صالح
              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 أو تذكرتها غير معروفة أو مستخدَمة أو منتهية الصلاحية؛ ابدأ من جديد من اللعبة.'
      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 بايت افتراضيًا). ويغلق الخادم الاتصال.'
      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 رمز التفويض أو أرسل رمزًا مميَّزًا للهوية لا يجتاز التحقق. ولا يحمل الرد أبدًا نص 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: حجم الملف بالبايت.
          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: الشكل الوحيد لكل ردود الأخطاء في واجهة API بصيغة JSON.
      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` فقط: نهاية الحظر (بالملّي ثانية منذ حقبة يونكس)، و`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: عدد البتات الصفرية البادئة التي يجب أن تكون في `SHA-256(challenge + ":" + nonce)`.
        expiresAt:
          type: integer
          format: int64
          description: وقت انتهاء صلاحية التحدي (بالملّي ثانية منذ حقبة يونكس)، بعد 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` من البتات الصفرية.
    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 بايتات من تجزئة SHA-256 لصيغته المعيارية، كعدد صحيح بلا إشارة)؛ للعلم فقط.
            subprotocol:
              type: string
              description: معرّف البروتوكول الفرعي لـ 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
          properties:
            google:
              type: boolean
              description: هل يُتاح تسجيل الدخول عبر Google.
        mfa:
          type: boolean
          const: true
          description: المصادقة الثنائية متاحة دائمًا.
        pow:
          type: object
          required:
            - register
          properties:
            register:
              type: integer
              minimum: 0
              maximum: 26
              description: عدد بتات إثبات العمل التي يتطلبها التسجيل (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: الحد الأقصى لطول كلمة المرور، بالبايتات في ترميز UTF-8.
            customTimeControls:
              type: boolean
              description: هل يجوز للتحديات والمباريات الخاصة استخدام أنظمة وقت مخصّصة.
            reportsPerDay:
              type: integer
              description: عدد البلاغات التي يجوز للاعب تقديمها كل 24 ساعة (`REPORTS_PER_DAY`).
            wsMaxMessageBytes:
              type: integer
              description: أكبر حجم لرسالة WebSocket يجوز للعميل إرسالها، بالبايت.
      example:
        name: Scacelith
        serverId: 07dd26af-672a-43af-a8af-34011c7e977b
        motd: ''
        protocol:
          min: 1
          max: 1
          schema: 97842216
          subprotocol: scacelith.rt1
        wsPort: 443
        wsPath: /ws
        registration: open
        emailVerification: true
        sso:
          google: false
        mfa: true
        pow:
          register: 18
        categories:
          - id: '1+0'
            baseSec: 60
            incSec: 0
          - id: '3+2'
            baseSec: 180
            incSec: 2
        limits:
          usernameMin: 3
          usernameMax: 20
          usernamePattern: ^[A-Za-z0-9][A-Za-z0-9_-]*$
          passwordMinLength: 10
          passwordMaxBytes: 256
          customTimeControls: true
          reportsPerDay: 5
          wsMaxMessageBytes: 512
    Category:
      type: object
      description: نظام وقت رسمي.
      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: عنوان البريد الإلكتروني.
        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: وقت إنشاء الحساب (بالملّي ثانية منذ حقبة يونكس).
        lastLoginAt:
          type:
            - integer
            - 'null'
          format: int64
          description: آخر تسجيل دخول (بالملّي ثانية منذ حقبة يونكس)، أو `null`.
        pendingEmail:
          type:
            - string
            - 'null'
          format: email
          description: العنوان الجديد في تغيير للبريد الإلكتروني ينتظر استخدام رابطه، أو `null`.
    SessionAnswer:
      type: object
      description: جلسة جديدة.
      required:
        - token
        - expiresAt
        - user
      properties:
        token:
          type: string
          pattern: '^sct_[A-Za-z0-9_-]{43}$'
          description: رمز الجلسة المميَّز، للترويسة `Authorization` ولـ WebSocket.
        expiresAt:
          type: integer
          format: int64
          description: النهاية المطلقة للجلسة (بالملّي ثانية منذ حقبة يونكس)؛ وقد يُنهيها حدّ الخمول قبل ذلك.
        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: الرمز المميَّز للخطوة، لاستخدامه في `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: التذكرة لاستخدامها في `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: التذكرة لاستخدامها في `POST /auth/sso/google/link`.
        username:
          type: string
          description: اسم المستخدم لذلك الحساب (لا يراه إلا من أثبت ملكيته للعنوان لدى Google).
        expiresIn:
          type: integer
          const: 600
          description: عدد الثواني التي تبقى فيها التذكرة صالحة.
    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: عنوان البريد الإلكتروني. تُزال المسافات من طرفيه ويُخزَّن بأحرف صغيرة.
        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: اسم المستخدم، أو عنوان البريد الإلكتروني (أي نص يحتوي على `@`).
        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: عنوان البريد الإلكتروني.
    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: منفذ مستمع العميل على `127.0.0.1`.
    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: وقت تسجيل الدخول (بالملّي ثانية منذ حقبة يونكس).
        lastSeenAt:
          type: integer
          format: int64
          description: آخر استخدام (بالملّي ثانية منذ حقبة يونكس)، ويُحدَّث مرة كل 5 دقائق على الأكثر.
        expiresAt:
          type: integer
          format: int64
          description: النهاية المطلقة (بالملّي ثانية منذ حقبة يونكس)؛ وقد يُنهي حدّ الخمول الجلسة قبل ذلك.
        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: البداية (بالملّي ثانية منذ حقبة يونكس).
        endsAt:
          type:
            - integer
            - 'null'
          format: int64
          description: النهاية (بالملّي ثانية منذ حقبة يونكس)، و`null` عندما تكون العقوبة دائمة.
    Ban:
      type: object
      required:
        - until
      properties:
        until:
          type:
            - integer
            - 'null'
          format: int64
          description: نهاية الحظر (بالملّي ثانية منذ حقبة يونكس)، و`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: البداية (بالملّي ثانية منذ حقبة يونكس).
        endedAt:
          type: integer
          format: int64
          description: النهاية (بالملّي ثانية منذ حقبة يونكس).
    Termination:
      type: string
      description: اسم سبب النهاية (راجع رموز المباريات)؛ و`Unknown` للرمز الذي لا يعرفه البروتوكول.
      enum:
        - Checkmate
        - Resignation
        - Timeout
        - IllegalMoves
        - Stalemate
        - InsufficientMaterial
        - TimeoutVsInsufficient
        - FivefoldRepetition
        - SeventyFiveMoves
        - ThreefoldClaim
        - FiftyMoveClaim
        - Agreement
        - IllegalMovesVsInsufficient
        - 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: الوقت المحتسب على النقلة (بالملّي ثانية)، و`null` إن لم يكن في السجل.
        clockMs:
          type:
            - integer
            - 'null'
          minimum: 0
          description: ساعة صاحب النقلة بعد النقلة (بالملّي ثانية)، و`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: فقط عندما يكون صاحب الرمز المميَّز قد لعب هذه المباراة؛ وهو جهته.
            reportable:
              type: boolean
              description: فقط عندما يكون صاحب الرمز المميَّز قد لعب هذه المباراة؛ و`true` عندما يقبل `POST /reports` الآن بلاغًا عن الخصم في هذه المباراة (انتهت المباراة خلال 7 أيام، ولم تُستنفد الحصة اليومية، ولم يُبلَّغ عن الخصم بعد في هذه المباراة).
    PlayerProfile:
      type: object
      required:
        - username
        - createdAt
        - ratings
        - games
      properties:
        username:
          type: string
          description: اسم المستخدم، كما كتبه اللاعب.
        createdAt:
          type: integer
          format: int64
          description: وقت إنشاء الحساب (بالملّي ثانية منذ حقبة يونكس).
        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: وقت حساب اللوحة (بالملّي ثانية منذ حقبة يونكس).
        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 بايت من 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 محرف على الأكثر بعد إزالة محارف التحكم (عدا الجدولة ومحرف السطر الجديد) وإزالة المسافات من طرفي النص.
    AccountExport:
      type: object
      description: كل ما يحتفظ به الخادم عن الحساب (والأوقات بالملّي ثانية منذ حقبة يونكس).
      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: كل جلسة مخزّنة، بدءًا بالأحدث، من دون أي رمز مميَّز. وتحذف عملية التنظيف الدورية عند انتهاء مدة الاحتفاظ الجلسةَ المنتهية الصلاحية، والجلسةَ التي سُجّل الخروج منها بعد يوم من تسجيل الخروج.
          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 من اليوم (بالملّي ثانية منذ حقبة يونكس).
        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: الرمز المميَّز لرابط البريد الإلكتروني (الحقل المخفي في نموذج الصفحة).
    ResetPasswordForm:
      type: object
      additionalProperties: false
      required:
        - token
        - newPassword
        - confirmPassword
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 128
          description: الرمز المميَّز لرابط إعادة التعيين (الحقل المخفي في النموذج).
        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 ولا موارد خارجية.
