الاصطلاحات
مغلّف الأخطاء
كل استجابة خطأ — أيًّا كانت حالة HTTP — تتبع نفس الشكل:
{
"error": {
"code": "STUDENT_NOT_FOUND",
"message": "Student not found.",
"details": {
"studentId": "0190a1b2-..."
},
"requestId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
}message دائمًا بالإنجليزية ومخصّصة للسجلّات لا للمستخدم النهائي — تَرجِم حسب code في واجهتك أنت. details تظهر فقط حين تضيف شيئًا يمكن للعميل التصرّف بناءً عليه (أي حقل فشل التحقّق منه، أي معرّف لم يُعثر عليه). requestId يطابق القيمة التي تسجّلها الواجهة أيضًا من جهة الخادم — أرفقه عند الإبلاغ عن مشكلة.
رموز الأخطاء
كل رمز يمكن أن تُعيده الواجهة، مُولَّد مباشرة من packages/shared/src/errors.ts — المصدر الوحيد الذي تُبنى منه كل دالة إنشاء خطأ في قاعدة الشيفرة، لذا لا يمكن لهذا الجدول أن ينحرف أبدًا عمّا ترسله الواجهة فعليًا.
| الرمز | حالة HTTP | الوصف |
|---|---|---|
| VALIDATION_ERROR | 422 | The request parsed, but its content is not acceptable: a schema failure at the boundary (body, query, path params or headers — always carrying `details.issues`) or a domain rule rejecting a well-formed value. Every validation failure in the API uses this one status; a 400 (`MALFORMED_REQUEST`) means only that the request could not be read at all. |
| MALFORMED_REQUEST | 400 | The request could not be read at all, before any schema ran: an unparseable body, an unusable content type, a payload over the size limit. Deliberately not `VALIDATION_ERROR`, which always means 422, so a client can tell "I could not read your request" apart from "I read it and its content is not acceptable" from the code alone. |
| UNAUTHORIZED | 401 | No valid credentials (JWT, API key, or session) were presented. |
| FORBIDDEN | 403 | The caller is authenticated but lacks the permission this action requires. |
| NOT_FOUND | 404 | The requested resource does not exist (or is not visible to this tenant). |
| CONFLICT | 409 | The request conflicts with the resource’s current state. |
| INTERNAL_ERROR | 500 | An unexpected server error. Never leaks a stack trace or SQL to the client. |
| PAYMENT_REQUIRED | 402 | The tenant's subscription does not currently permit this action. |
| SUBSCRIPTION_SUSPENDED | 402 | Subscription/license lifecycle has reached `suspended`/`stopped` (docs/ARCHITECTURE.md §1). |
| INSUFFICIENT_CREDIT | 402 | The messaging relay's atomic conditional debit found no row to update: the account's balance minus the message's cost would fall below `-overdraft_units`, the account is not `active`, or it does not exist yet (docs/specs/phase9-messaging-relay.md §10.3). Nothing is debited and nothing is sent when this is thrown — the caller falls back to the in-app inbox. Never used for a `system`-class message, which is never blocked by balance (§11.1). |
| RATE_LIMITED | 429 | The caller exceeded a rate limit (per-IP, per-user, or per-API-key sliding window). See the `RateLimit-*` response headers. |
| INVALID_CREDENTIALS | 401 | Email/password did not match any account. |
| ACCOUNT_LOCKED | 423 | Account temporarily locked out after repeated failed logins. |
| ACCOUNT_DISABLED | 403 | The user account itself has been disabled (distinct from a locked-out but active account). |
| SESSION_REVOKED | 401 | A refresh token that was already rotated away was presented again (reuse detected). |
| TOKEN_INVALID | 401 | A refresh/reset/verification token is missing, malformed, expired, or already used. |
| REGISTRATION_DISABLED | 403 | Self-registration is disabled for this tenant/deployment. |
| ACCOUNT_EMAIL_UNVERIFIED | 403 | `POST /accounts/sign-in` for an account whose `email_verified_at` is still null (docs/specs/phase13-portal-and-institflow-account.md §2.2). A distinct, actionable code so the web client can offer "resend the code" instead of showing a generic credentials error — the password was correct. |
| ROLE_IMMUTABLE | 403 | Attempted to edit the immutable system `owner` role. |
| PERMISSION_ESCALATION | 403 | Caller tried to grant a permission (directly, or via a role assignment) they do not themselves hold. |
| INVITATION_INVALID | 400 | The invitation link is invalid, expired, or already used. |
| MEMBERSHIP_INACTIVE | 403 | The caller's membership in the active tenant is not `active` (invited or disabled). |
| TENANT_SUSPENDED | 403 | The active tenant itself is not usable (archived). Not used for billing suspension — see `subscriptionSuspended`. |
| TENANT_NOT_FOUND | 404 | No tenant matches the given id/slug, or it is not visible to this caller. |
| TENANT_SLUG_TAKEN | 409 | The slug (or the subdomain built from it) already belongs to a tenant. `details`, when the owning tenant is known, is the same `{ tenantId, tenantName, status, endedAt }` shape `ownerEmailTaken` carries, so the console can name that institute and link to it (docs/specs/phase15-institute-delete.md §1). |
| OWNER_EMAIL_TAKEN | 409 | `POST /platform/tenants` (and the customer-request approval that reuses it): the owner email is already the owner of another institute — active, suspended or ended (archived). A deleted institute never matches: deleting it removed its owner user. `details` is `{ tenantId, tenantName, status, endedAt }` (`endedAt` is `null` unless the institute was ended) — docs/specs/phase15-institute-delete.md §1. |
| LICENSE_INVALID | 400 | A license JWT failed signature/shape verification (self-hosted `LicenseService`). |
| VERSION_CONFLICT | 409 | `common/optimistic-lock.ts`'s `updateWithVersion` matched zero rows: the caller's `expectedVersion` no longer matches the row's current `version` (someone else wrote it first). Distinct from the generic `conflict()` (`CONFLICT`) so clients can special-case "reload and retry" instead of showing a generic conflict message. |
| IDEMPOTENCY_KEY_REQUIRED | 428 | An API-key caller sent a POST/PUT/PATCH/DELETE with no `Idempotency-Key` header (docs/specs/phase4-integration-mcp.md §A). 428 (not 400) so clients can tell "you forgot a required precondition" apart from a generic validation failure — documented choice, see `docs/build-log/phase4-integration.md`. |
| IDEMPOTENCY_KEY_REUSED | 409 | The same `Idempotency-Key` was reused with a different method/path/body than the first request that used it. |
| OAUTH_CLIENT_NOT_FOUND | 404 | `POST /api/v1/oauth/authorize/decision` (or `/context`) referenced an unknown or disabled OAuth client. |
| OAUTH_INVALID_REDIRECT_URI | 400 | The `redirect_uri` does not exactly match one registered for this OAuth client. |
| OAUTH_ACCESS_DENIED | 403 | The tenant/membership selected on the consent screen does not hold `mcp.access`. |
| FEATURE_DISABLED | 403 | `FeatureGuard` (docs/specs/phase6-courses-video.md §3.1) found the resolved tenant's effective feature flags do not grant the `@RequireFeature(...)` key the route declares. Runs after `PermissionsGuard`, so a caller lacking the permission still sees `FORBIDDEN`, never this. |
| QUOTA_EXCEEDED | 403 | A plan-quota check (docs/specs/phase6-courses-video.md §3.2, mirrors the notification quota) rejected a create before any write happened. `details` carries `{ limit, current, key }`. |
| VIDEO_URL_INVALID | 422 | `parseVideoUrl()` (`packages/shared/src/video.ts`) could not recognize or validate a pasted video URL. `details.reason` names the specific cause (e.g. `'playlist_not_supported'`, `'unknown_host'`). |
| VIDEO_PROVIDER_UNSUPPORTED | 422 | The URL's provider was recognized but is not one `VIDEO_PROVIDERS` accepts: only `youtube` from 1.4.0 (ق-32, docs/DECISIONS-LOG.md — a second provider was removed entirely, not gated off; `upload`/`vimeo`/`bunny` remain declared-but-unbuilt, D8). |
| STORAGE_FULL | 409 | The server's disk has reached the reserve kept free for the database, backups and updates (docs/specs/phase20-course-files.md §3): the upload is refused **before** a signed URL is issued, so nothing is ever half-uploaded. A business refusal, not a server fault — hence 409, not 5xx. `details` carries `{ requestedBytes, remainingBytes }`. |
| FILE_TYPE_NOT_ALLOWED | 422 | A file's type is not one the course-files feature accepts (docs/specs/phase20-course-files.md §4): its extension or declared MIME type is off the allowlist, or — at `complete` — the object's first bytes do not match what its extension claims. `details.reason` names which. |
| MFA_STEP_UP_REQUIRED | 403 | The access token's `mfa_at` (last successful TOTP check) is older than the sensitive route's `@RequireRecentMfa(seconds)` window (docs/specs/phase11-vendor-console.md §2.4). The client is expected to prompt for the current 6-digit code and retry via `POST /auth/mfa/step-up`, which returns a fresh access token with a new `mfa_at`. |
400 مقابل 422
التحقّق من الصحّة يكون دائمًا 422. كل ما تقرأه الواجهة ولا تقبله — جسم الطلب أو سلسلة الاستعلام أو معامل المسار أو ترويسة تفشل في مخطّطها، أو قاعدة عمل ترفض قيمة سليمة الشكل — يعود بـ 422 VALIDATION_ERROR، وفشل المخطّط يحمل الحقول التي فشلت في details.issues:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input: expected string, received undefined",
"details": {
"issues": [
{
"path": [
"branchId"
],
"message": "Invalid input"
}
]
},
"requestId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
}أمّا 400 MALFORMED_REQUEST فيعني فقط أنّ الطلب تعذّرت قراءته أصلًا: JSON غير قابل للتحليل، أو Content-Type غير صالح، أو حمولة تتجاوز الحدّ. باختصار: 400 خلل في طريقة تسلسل الطلب، و422 خلل في محتواه. وتستخدم بضع نقاط نهاية 400 أيضًا لرفض محدّد باسمه (INVITATION_INVALID، LICENSE_INVALID، OAUTH_INVALID_REDIRECT_URI) — ولهذا تُفرّع على error.code لا على رمز الحالة.
التصفّح بالمؤشّر (Cursor)
كل نقطة نهاية لعرض قوائم تستخدم تصفّحًا بالمؤشّر — لا بالإزاحة أو رقم الصفحة أبدًا — لتبقى النتائج صحيحة حتى أثناء إدراج صفوف جديدة في الوقت نفسه. مرّر limit (بحدّ أقصى 100، والقيمة الافتراضية تختلف حسب نقطة النهاية)، ولكل صفحة بعد الأولى، cursor من استجابة الصفحة السابقة:
{
"data": [
"…"
],
"nextCursor": "eyJpZCI6IjAxOTAwMDAwLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwMDAwMSJ9"
}nextCursor من نوع string | null — null تعني أنك وصلت إلى الصفحة الأخيرة. تعامل مع المؤشّر كقيمة معتمة: هو مؤشّر مُرمَّز بـ base64url، وليس إزاحة صف، وقد يتغيّر شكله الداخلي.
المبالغ المالية
كل قيمة مالية هي كائن متداخل — { amountMinor, currency } — عدد صحيح بالوحدة الصغرى للعملة (سنتات، أغورات، فلوس) إلى جانب حقل currency خاص به على نفس ذلك الكائن، مثل total.amountMinor + total.currency للفاتورة، أو amountMinor + currency للدفعة. وهي ليست أبدًا رقمًا مجردًا مع وجود حقل العملة في مكان آخر على الكائن الأصل. لا تُستخدم الأعداد العشرية (float) أبدًا. القيمة { amountMinor: 1250, currency: "USD" } تعني 12.50 دولارًا.
الطوابع الزمنية
كل طابع زمني بصيغة ISO-8601 بتوقيت UTC (2026-03-02T09:00:00.000Z). التواريخ بلا مكوّن وقت (تاريخ استحقاق، تاريخ ميلاد) بصيغة YYYY-MM-DD بسيطة. حوِّل الأوقات إلى المنطقة الزمنية للمستأجر (افتراضيًا Asia/Jerusalem) عند العرض — الواجهة لا تفعل ذلك نيابة عنك.
تعدّد المستأجرين
لا تمرّر معرّف مستأجر بنفسك أبدًا. رمز JWT للتطبيق الأوّل يحمل المستأجر المُختار وقت select-tenant؛ ومفتاح API مرتبط بشكل دائم بالمستأجر الذي أُنشئ فيه. كل استعلام تُجريه الواجهة مُقيَّد بذلك المستأجر على مستوى طبقة التطبيق وعلى مستوى أمان الصفوف (Row-Level Security) في قاعدة البيانات معًا — لا توجد طريقة للوصول إلى بيانات مستأجر آخر بتخمين معرّف.
requestId صديقك
سجّل error.requestId إلى جانب سجلّاتك الخاصة. إنّها أسرع طريقة للحصول على إجابة سريعة ومحدّدة عندما يبدو أن هناك خللًا من جهة الخادم.