18 — الرصيد التشغيلي: السائق يشحن سلفاً، الراكب يدفع له الأجرة كاملة، والعمولة تُخصم من الرصيد لا من الأجرة. مقارنة صريحة: هذا نموذج inDrive/ Yango/سيرو لا نموذج أوبر — وهو المعيار الفعلي في أسواق الكاش المستهدفة. ثمنه ثلاثة التزامات: الحجب عند نفاد الرصيد (بدونه ينهار)، باقات لكل عملة، ومعالجة حاجز دخول السائق الجديد. 19 — الاستحقاقات: علم الميزة في التطبيق قرار عرض لا حدّ أمني. الهندسة العكسية تكشف شاشة تنادي نقطة ترجع 403. الحدّ الحقيقي FeatureGuard على السيرفر + tenant_id من JWT موقَّع. الحقيقة التي تُقال صراحةً: ما يعمل كلياً على الجهاز لا يُحمى، ووضع السيادة لا يُحمى تقنياً أصلاً (المستأجر يملك السيرفر) — فليشمل سعره كل شيء بدل وهم حماية. تصحيحان على المجموعة B (كلاهما بُني على افتراض خاطئ مني): - B3: مشوار الوصول تعويضُ عدم حضور عند الإلغاء بعد 5 دقائق انتظار، لا بند في كل أجرة كما بنيته - B6: price_for_driver = price_for_passenger — العمولة لا تُقتطع من الأجرة Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.7 KiB
19 — الاستحقاقات (Entitlements) ومنع تفعيل الميزات بلا اشتراك
سؤال المالك (2026-07-17): «إذا نزّلنا التطبيق بشكل كامل، وجاء المشترك وعمل reverse engineering وفعّل الإضافات — كيف نحدّ منها؟»
1. الجواب في سطر واحد
لا تُفعَّل الميزة في التطبيق أبداً. تُفعَّل في السيرفر. التطبيق لا يملك ما يُسرَق: هو يعرض واجهة فقط. الميزة الحقيقية = نقاط API تحرسها المنصة.
2. القاعدة الحاكمة
علم الميزة في التطبيق (feature flag) هو قرار عرض، وليس حدّاً أمنياً — أبداً.
GET /tenant/config يرجع features كي يعرف التطبيق ماذا يُظهر. لو عدّل أحدهم الاستجابة أو فكّك التطبيق وفعّل كل الأعلام، فالنتيجة:
المهاجم يفعّل ميزة «البوتات» في التطبيق
→ تظهر له الشاشة والأزرار ✅ (لا ضرر — بكسلات فقط)
→ يضغط زراً → POST /bots/campaigns
→ الحارس يقرأ tenant_id من الـJWT الموقَّع
→ يسأل: هل اشتراك هذا المستأجر يشمل bots؟ → لا
→ 403 Forbidden ❌ لا شيء حدث
كسب المهاجم: شاشة فارغة. هذا هو المطلوب بالضبط.
3. لماذا لا يستطيع تزوير tenant_id؟
tenant_idيأتي من JWT موقَّع بمفتاح السيرفر، لا من هيدر يكتبه العميل.- الترويسة
x-tenant-idتُستعمل قبل الدخول فقط (اختيار المستأجر) — وبعد الدخول الحقيقة من التوكن. - تزوير التوكن يحتاج
JWT_SECRET— وهو على السيرفر لا في التطبيق.
4. التطبيق العملي: حارس استحقاقات
@RequiresFeature('bots') // ← الحدّ الأمني الحقيقي
@Post('bots/campaigns')
create(@CurrentUser() user: AuthUser, @Body() body: any) { … }
FeatureGuard:
- يقرأ
tenant_idمن التوكن. - يجلب استحقاقات المستأجر — من Redis خط أول (نمط G4)، والقاعدة احتياط.
- غير مستحقّة →
403برسالة واضحة (feature_not_in_plan) ليعرضها التطبيق بلطف.
كل نقطة تخصّ ميزة مدفوعة تحمل هذا الحارس. بلا استثناء واحد. نقطة واحدة منسيّة = الميزة مجانية للجميع. لذلك: الافتراض هو المنع — قائمة بيضاء لا سوداء.
5. الحد الذي لا يُتجاوز تقنياً
ما يعمل كلياً على الجهاز بلا نداء سيرفر — لا يمكن حمايته. نهائياً.
لا تشويش (obfuscation) ولا تشفير ولا فحص جذر يغيّر هذه الحقيقة؛ كلها ترفع الكلفة ولا تمنع. لذلك القاعدة المعمارية:
كل ميزة ذات قيمة تجارية يجب أن تمرّ بالسيرفر — ولو لم تحتج ذلك تقنياً.
مثال: «الاستخبار السوقي» لو حُسب في التطبيق من بيانات محليّة = مسروق بلا حيلة. ولو كان POST /market-intel/report = محميّ تماماً. هذا قرار تصميم يُتخذ عند بناء كل ميزة، لا ترقيع بعدها.
6. الثغرة الحقيقية: وضع السيادة (Sovereign)
هنا الخبر الذي يجب أن يُقال صراحةً:
في وضع السيادة (06-tenant-model) — المستأجر يشغّل نسختنا على خوادمه. عنده الكود والقاعدة والسيرفر. لا يوجد حارس يحرس ضدّه، لأنه هو صاحب الحارس.
يستطيع تعديل FeatureGuard ليرجع true دائماً. لا حلّ تقنيّ كامل. الخيارات الواقعية:
| الخيار | الجدوى |
|---|---|
| السيادة = كل الميزات، بسعرها | ✅ الموصى به. لا شيء يُسرق لأن لا شيء محجوب. يطابق أصلاً كون «سيادة» أعلى الباقات ($599 + $12k إعداد). |
| مفتاح ترخيص موقَّع + اتصال دوري بالـControl Plane | 🟡 يردع غير التقني، ويُنزع بتعديل الكود. مفيد للكشف والتوثيق العقدي لا للمنع. |
| مكوّن حرج يبقى عندنا (SaaS جزئي) | 🟡 فعّال لكنه يناقض وعد السيادة نفسه (البيانات داخل الدولة). |
| العقد والقانون | ✅ الحدّ الحقيقي في هذا الوضع. تدقيق + بند جزائي. |
التوصية المعمارية: في الوضع المشترك (انطلاقة/علامة/أسطول+) الحماية تقنية وكاملة. في وضع السيادة الحماية تعاقدية، فليشمل سعرُها كلَّ شيء ولا نتظاهر بحمايةٍ لا نملكها.
7. تدفّق السوبر-أدمن: اشتراك ← استحقاقات
سوبر-أدمن ينشئ مستأجراً
→ يختار الباقة (launch | brand | fleet | sovereign)
→ يختار country_pack (jo | sy | eg)
→ الباقة تولّد استحقاقات افتراضية
→ + إضافات مشتراة منفردة (market_intel, bots, ads, transit…)
→ تُحفظ في tenants.features (jsonb — موجود أصلاً في الكيان)
→ تُبطَل من كاش Redis فوراً (نمط G4)
- الميزة تُشترى منفردة فوق الباقة — لذا
featuresليست اشتقاقاً منplanبل قائمة صريحة.planيعطي الافتراضات، وfeaturesهي الحقيقة. - تغيير الاشتراك = تغيير
features+ إبطال الكاش → يسري خلال ثوانٍ بلا تحديث تطبيق.
8. شكل الاستحقاقات
// tenants.features
{
"dispatch": true,
"wallet": true,
"bots": false, // ← لم يشترِها
"market_intel": false,
"transit": false,
"ads": false,
"limits": { "drivers_max": 500, "cities_max": 3 }
}
- الحدود العددية (
limits) تُفرض على السيرفر أيضاً — سائق رقم 501 يُرفض بـ403. - الافتراض الصلب: مفتاح غير موجود = ممنوع (لا مسموح).
9. البنود المطلوبة (مجموعة K في الـ17-backend-backlog)
| # | البند |
|---|---|
| K1 | FeatureGuard + @RequiresFeature() — استحقاقات من Redis، القاعدة احتياط |
| K2 | كتالوج الميزات + استحقاقات افتراضية لكل باقة |
| K3 | نقاط سوبر-أدمن: إنشاء مستأجر باشتراك، تعديل الاستحقاقات، إبطال الكاش |
| K4 | فرض الحدود العددية (drivers_max, cities_max) على السيرفر |
| K5 | GET /tenant/config يرجع features للعرض فقط — موثّق صراحةً أنه ليس حدّاً أمنياً |
| K6 | اختبار عزل في CI: مستأجر بلا ميزة يأخذ 403 على كل نقاطها |
| K7 | قرار وضع السيادة: كل الميزات مشمولة + بند تعاقدي (لا وهم حماية تقنية) |
← ذو صلة: 06-tenant-model · 05-pricing-billing · 18-driver-credit-commission