feat: D5 — OTP متعدد المزوّدين حسب الدولة (من سيرو) + قرار بلا كلمة مرور

مراجعة سيرو (auth/otp/): مصر تستعمل Kazumi SMS مع failover لواتساب،
وسوريا/الأردن Nabeh. عندنا كان مزوّد واحد فقط.

- integrations/otp/: واجهة OtpProvider + OtpDispatcher يوجّه حسب
  tenant.countryPack بسلسلة failover (eg: [kazumi, nabeh] · jo/sy: [nabeh])
- KazumiSmsProvider (مصر SMS) + NabehOtpProvider (غلاف على NabehService)
- كل مزوّد يرسل رمزاً نولّده نحن (Redis) — استبعدنا نمط Intaleq (يولّد الرمز
  بنفسه) حفاظاً على مصدر واحد للرمز
- موحَّد: AuthService.sendOtp و PayoutsService.issueOtp يمرّان بنفس المُوزِّع
  الآن — أُزيل كل استدعاء Nabeh مباشر. أي مسار يحتاج رسالة يستدعي الخدمة
  الموحّدة (طلب المالك)
- فشل السلسلة كاملة = ServiceUnavailable صريح، لا صمت

قرار المالك (2026-07-17): بلا كلمة مرور إطلاقاً. المصادقة = هاتف + OTP مرة
→ جلسة مربوطة بالجهاز (D2). التسجيل بالهاتف لا Google/Apple (محظوران في بعض
الدول). سيرو نفسه لا كلمة مرور حقيقية له (password = hash(email) وهمي،
ومقارنة نصّية غير آمنة في مسار التجربة). يطابق أوبر/كريم/inDrive.

مراجعة حدّ الطلبات في سيرو (RateLimiter.php): حدود مسمّاة لكل نوع + fallback
بملف عند تعطّل Redis (fail-closed). عندنا مسار OTP fail-closed أصلاً؛
تخزين Throttler على Redis مؤجَّل للتوسّع الأفقي الفعلي (موثّق في D4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 19:24:01 +03:00
co-authored by Claude Opus 4.8
parent c258a8c4b8
commit 8f6c46f0ac
13 changed files with 343 additions and 15 deletions
+10 -2
View File
@@ -42,14 +42,22 @@ MAPS_API_KEY_JO=
MAPS_API_KEY_SY=
MAPS_PLACES_API_KEY=
# ---- OTP via Nabeh (WhatsApp) ----
# اتركه dev لتفعيل الرمز الثابت 1234 بلا إرسال؛ اجعله false للإرسال الحقيقي عبر Nabeh
# ---- OTP: توجيه متعدد المزوّدين حسب دولة المستأجر (docs/17 — D5) ----
# اتركه dev لتفعيل الرمز الثابت 1234 بلا إرسال؛ اجعله false للإرسال الحقيقي
OTP_DEV_MODE=true
OTP_SMS_TEMPLATE=Your Tripz code is {code}
# سوريا/الأردن — Nabeh (واتساب)
NABEH_BASE_URL=https://nabeh.intaleqapp.com
NABEH_EMAIL=
NABEH_PASSWORD=
NABEH_OTP_TYPE=text
# مصر — Kazumi (SMS)، مع failover تلقائي إلى Nabeh لو فشل
SMS_USERNAME=
SMS_PASSWORD_EGYPT=
SMS_SENDER=
# ---- FCM push (اتركه فارغاً لتعطيل الإرسال) ----
FCM_SERVER_KEY=
FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send
+3 -1
View File
@@ -30,6 +30,7 @@ import { FraudModule } from './modules/fraud/fraud.module';
import { ChatModule } from './modules/chat/chat.module';
import { RatingsModule } from './modules/ratings/ratings.module';
import { NabehModule } from './integrations/nabeh/nabeh.module';
import { OtpModule } from './integrations/otp/otp.module';
import { RideTypesModule } from './modules/ride-types/ride-types.module';
import { DispatchModule } from './modules/dispatch/dispatch.module';
import { WalletModule } from './modules/wallet/wallet.module';
@@ -73,7 +74,8 @@ import { GeminiModule } from './integrations/gemini/gemini.module';
SigningModule, // عالمي — توقيع HMAC للعمليات المالية
PhoneModule, // عالمي — تطبيع أرقام الهاتف لكل دولة
DeviceModule, // عالمي — ربط الجلسة بالجهاز
NabehModule, // عالمي — إرسال OTP واتساب
NabehModule, // عالمي — إرسال OTP واتساب (سوريا/الأردن)
OtpModule, // عالمي — توجيه OTP متعدد المزوّدين حسب الدولة + failover
NotificationsModule, // عالمي — FCM
StorageModule, // عالمي — تخزين ملفات الوثائق
GeminiModule, // عالمي — رؤية AI (وثائق + وجه)
+11
View File
@@ -59,6 +59,17 @@ export default () => ({
},
// Nabeh — إرسال OTP عبر واتساب.
// توجيه OTP متعدد المزوّدين حسب الدولة (docs/17 — D5).
otp: {
// نصّ رسالة SMS (Kazumi/مصر). {code} يُستبدل بالرمز.
smsTemplate: process.env.OTP_SMS_TEMPLATE ?? 'Your Tripz code is {code}',
kazumi: {
username: process.env.SMS_USERNAME ?? '',
password: process.env.SMS_PASSWORD_EGYPT ?? '',
sender: process.env.SMS_SENDER ?? '',
},
},
nabeh: {
baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com',
email: process.env.NABEH_EMAIL ?? '',
@@ -0,0 +1,73 @@
import { OtpDispatcher } from './otp-dispatcher.service';
import { OtpProvider } from './otp-provider.interface';
function provider(name: string, result: boolean | (() => boolean)): OtpProvider {
return {
name,
supports: () => true,
send: jest.fn(async () => (typeof result === 'function' ? result() : result)),
};
}
describe('OtpDispatcher — توجيه حسب الدولة + failover (docs/17 D5)', () => {
it('مصر تجرّب Kazumi أولاً', async () => {
const kazumi = provider('kazumi', true);
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
expect(kazumi.send).toHaveBeenCalledTimes(1);
expect(nabeh.send).not.toHaveBeenCalled(); // نجح الأول فلا failover
});
it('مصر: فشل Kazumi → يتحوّل إلى Nabeh', async () => {
const kazumi = provider('kazumi', false);
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
expect(kazumi.send).toHaveBeenCalledTimes(1);
expect(nabeh.send).toHaveBeenCalledTimes(1); // جرّب البديل
});
it('الأردن يستعمل Nabeh فقط — لا يلمس Kazumi', async () => {
const kazumi = provider('kazumi', true);
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('962790000000', '1234', { countryPack: 'jo' })).toBe(true);
expect(nabeh.send).toHaveBeenCalledTimes(1);
expect(kazumi.send).not.toHaveBeenCalled();
});
it('سوريا تستعمل Nabeh', async () => {
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([nabeh]);
expect(await d.send('963944000000', '1234', { countryPack: 'sy' })).toBe(true);
});
it('فشل السلسلة كاملة يرجع false — المُستدعي يُفشل الطلب', async () => {
const kazumi = provider('kazumi', false);
const nabeh = provider('nabeh', false);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(false);
expect(kazumi.send).toHaveBeenCalledTimes(1);
expect(nabeh.send).toHaveBeenCalledTimes(1);
});
it('دولة غير معروفة تسقط لـNabeh (السلسلة الافتراضية)', async () => {
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([nabeh]);
expect(await d.send('491234567', '1234', { countryPack: 'de' })).toBe(true);
expect(nabeh.send).toHaveBeenCalledTimes(1);
});
it('مزوّد مفقود من التسجيل لا يُسقط السلسلة — يتخطّاه', async () => {
// Kazumi غير مسجَّل، فمصر تسقط لـNabeh وحده.
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
expect(nabeh.send).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,55 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { OTP_PROVIDERS, OtpProvider, OtpSendContext } from './otp-provider.interface';
/**
* سلسلة الـfailover لكل دولة (docs/17 — D5؛ نمط سيرو auth/otp/request.php).
* الأول أساسي، والبقية بدائل تُجرَّب بالترتيب. مصر: Kazumi SMS ثم Nabeh واتساب
* (نفس منطق سيرو). الأردن/سوريا: Nabeh فقط.
*/
const ROUTING: Record<string, string[]> = {
eg: ['kazumi', 'nabeh'],
jo: ['nabeh'],
sy: ['nabeh'],
};
const DEFAULT_CHAIN = ['nabeh'];
/**
* يوجّه إرسال OTP للمزوّد المناسب حسب دولة المستأجر، مع failover.
*
* السبب في وجوده: خدمة واحدة تُستدعى من كل مكان يحتاج إرسال رمز (تسجيل دخول،
* تحقّق سحب لاحقاً) بلا أن يعرف المُستدعي أي مزوّد لأي دولة — تُضاف دولة أو
* مزوّد جديد هنا وحده.
*/
@Injectable()
export class OtpDispatcher {
private readonly logger = new Logger('OtpDispatcher');
private readonly byName = new Map<string, OtpProvider>();
constructor(@Inject(OTP_PROVIDERS) providers: OtpProvider[]) {
for (const p of providers) this.byName.set(p.name, p);
}
/**
* يرسل الرمز عبر أول مزوّد ينجح في سلسلة الدولة.
* يرجع `true` إن نجح أي مزوّد؛ `false` إن فشلت السلسلة كاملة (يقرّر المُستدعي
* حينها إفشال الطلب — طلبٌ بلا رمز يصل = مستخدم عالق).
*/
async send(phone: string, code: string, ctx: OtpSendContext): Promise<boolean> {
const chain = ROUTING[ctx.countryPack] ?? DEFAULT_CHAIN;
for (const name of chain) {
const provider = this.byName.get(name);
if (!provider) {
this.logger.warn(`provider "${name}" not registered — skipping`);
continue;
}
const ok = await provider.send(phone, code, ctx);
if (ok) {
this.logger.log(`sent via ${name} (country=${ctx.countryPack})`);
return true;
}
this.logger.warn(`${name} failed for ${ctx.countryPack} — trying next in chain`);
}
this.logger.error(`all providers failed for country=${ctx.countryPack}`);
return false;
}
}
@@ -0,0 +1,31 @@
/**
* مزوّد إرسال OTP واحد (docs/17 — D5؛ نمط سيرو auth/otp/providers.php).
*
* كل مزوّد يرسل رمزاً **نولّده نحن** (لا يولّده المزوّد) — فالرمز يبقى مصدره
* السيرفر ومخزَّناً في Redis، والمزوّد قناة توصيل فقط. هذا يختلف عن مزوّد
* Intaleq عند سيرو الذي يولّد الرمز بنفسه ويرجعه — استبعدناه عمداً حفاظاً على
* مصدر واحد للرمز.
*/
export interface OtpSendContext {
/** دولة المستأجر (jo | sy | eg) — تحدّد المزوّد. */
countryPack: string;
/** passenger | driver | admin | service — لتخصيص نصّ الرسالة. */
userType?: string;
}
export interface OtpProvider {
/** اسم قصير للتشخيص واللوغ. */
readonly name: string;
/** الدول التي يخدمها هذا المزوّد (رموز countryPack). */
supports(countryPack: string): boolean;
/**
* يرسل الرمز. يرجع `true` عند النجاح المؤكَّد فقط — أي فشل أو استجابة
* غامضة = `false` كي يجرّب المُوزِّع المزوّد التالي في السلسلة.
* **لا يرمي**: الرمي يقطع سلسلة الـfailover.
*/
send(phone: string, code: string, ctx: OtpSendContext): Promise<boolean>;
}
export const OTP_PROVIDERS = Symbol('OTP_PROVIDERS');
@@ -0,0 +1,28 @@
import { Global, Module } from '@nestjs/common';
import { NabehModule } from '../nabeh/nabeh.module';
import { OtpDispatcher } from './otp-dispatcher.service';
import { OTP_PROVIDERS } from './otp-provider.interface';
import { NabehOtpProvider } from './providers/nabeh.provider';
import { KazumiSmsProvider } from './providers/kazumi.provider';
/**
* توجيه OTP متعدد المزوّدين (docs/17 — D5). عالمي: `OtpDispatcher` يُستدعى من
* كل مسار يرسل رمزاً. لإضافة مزوّد: أضِف موفّره هنا وسجّله في مصفوفة
* `OTP_PROVIDERS` وأضِف اسمه لسلسلة دولته في `otp-dispatcher.service.ts`.
*/
@Global()
@Module({
imports: [NabehModule],
providers: [
NabehOtpProvider,
KazumiSmsProvider,
{
provide: OTP_PROVIDERS,
useFactory: (nabeh: NabehOtpProvider, kazumi: KazumiSmsProvider) => [nabeh, kazumi],
inject: [NabehOtpProvider, KazumiSmsProvider],
},
OtpDispatcher,
],
exports: [OtpDispatcher],
})
export class OtpModule {}
@@ -0,0 +1,58 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OtpProvider, OtpSendContext } from '../otp-provider.interface';
/**
* مزوّد Kazumi (SMS) — مصر (docs/17 — D5؛ من سيرو auth/otp/providers.php).
*
* لماذا SMS لا واتساب في مصر: انتشار واتساب أقل هناك، وSMS أضمن وصولاً.
* لو فشل، يتولّى المُوزِّع الرجوع لمزوّد آخر (Nabeh واتساب) في السلسلة.
*/
@Injectable()
export class KazumiSmsProvider implements OtpProvider {
readonly name = 'kazumi';
private readonly logger = new Logger('KazumiSms');
constructor(private readonly config: ConfigService) {}
supports(countryPack: string): boolean {
return countryPack === 'eg';
}
async send(phone: string, code: string, _ctx: OtpSendContext): Promise<boolean> {
const username = this.config.get<string>('otp.kazumi.username');
const password = this.config.get<string>('otp.kazumi.password');
const sender = this.config.get<string>('otp.kazumi.sender');
if (!username || !password || !sender) {
this.logger.warn('missing Kazumi credentials — skipping (failover will handle)');
return false;
}
try {
const res = await fetch('https://sms.kazumi.me/api/sms/send-sms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
language: 'e',
sender,
receiver: phone,
message: this.config.get<string>('otp.smsTemplate')?.replace('{code}', code)
?? `Your Tripz code is ${code}`,
}),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
this.logger.warn(`HTTP ${res.status}`);
return false;
}
const json: any = await res.json().catch(() => null);
// Kazumi يرجع { message: 'Success' } عند النجاح.
return json?.message === 'Success';
} catch (e: any) {
this.logger.warn(`send failed: ${e?.message}`);
return false;
}
}
}
@@ -0,0 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { NabehService } from '../../nabeh/nabeh.service';
import { OtpProvider, OtpSendContext } from '../otp-provider.interface';
/**
* مزوّد Nabeh (واتساب) — سوريا والأردن (docs/17 — D5).
* غلاف رقيق حول `NabehService` القائم يحوّل الرمي إلى `false` ليعمل ضمن سلسلة
* الـfailover.
*/
@Injectable()
export class NabehOtpProvider implements OtpProvider {
readonly name = 'nabeh';
private readonly logger = new Logger('NabehOtp');
constructor(private readonly nabeh: NabehService) {}
supports(countryPack: string): boolean {
return countryPack === 'jo' || countryPack === 'sy';
}
async send(phone: string, code: string, _ctx: OtpSendContext): Promise<boolean> {
try {
await this.nabeh.sendOtp(phone, code);
return true;
} catch (e: any) {
this.logger.warn(`send failed: ${e?.message}`);
return false;
}
}
}
+18 -6
View File
@@ -1,4 +1,10 @@
import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import {
Inject,
Injectable,
Logger,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
@@ -8,7 +14,7 @@ import { UsersService } from '../users/users.service';
import { User } from '../users/entities/user.entity';
import { TenantsService } from '../tenants/tenants.service';
import { Tenant } from '../../database/entities/tenant.entity';
import { NabehService } from '../../integrations/nabeh/nabeh.service';
import { OtpDispatcher } from '../../integrations/otp/otp-dispatcher.service';
import { PhoneService } from '../../common/phone/phone.service';
import { DeviceService } from '../../common/device/device.service';
@@ -21,7 +27,7 @@ export class AuthService {
private jwtService: JwtService,
private config: ConfigService,
private tenantsService: TenantsService,
private nabeh: NabehService,
private readonly otp: OtpDispatcher,
private readonly signing: SigningService,
private readonly phones: PhoneService,
private readonly device: DeviceService,
@@ -76,9 +82,15 @@ export class AuthService {
return { success: true, message: 'OTP sent (dev)', dev_code: code };
}
// إرسال حقيقي عبر واتساب (Nabeh)
await this.nabeh.sendOtp(this.phones.toWhatsApp(canonical), code);
return { success: true, message: 'OTP sent via WhatsApp' };
// إرسال حقيقي — المُوزِّع يختار المزوّد حسب دولة المستأجر مع failover
// (docs/17 — D5). فشل السلسلة كاملة = لا رمز يصل، فنُفشل الطلب صراحةً.
const sent = await this.otp.send(this.phones.toWhatsApp(canonical), code, {
countryPack: tenant.countryPack,
});
if (!sent) {
throw new ServiceUnavailableException('Failed to send verification code — please try again');
}
return { success: true, message: 'OTP sent' };
}
async verifyOtp(tenantSlug: string, phone: string, code: string, deviceId?: string) {
@@ -8,10 +8,11 @@ import { PaymentsController } from './payments.controller';
import { PayoutsController } from './payouts.controller';
import { WalletModule } from '../wallet/wallet.module';
import { UsersModule } from '../users/users.module';
import { TenantsModule } from '../tenants/tenants.module';
@Module({
// NabehModule و AuditModule عالميان.
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule],
// OtpModule و AuditModule عالميان.
imports: [TypeOrmModule.forFeature([Payment, Payout]), WalletModule, UsersModule, TenantsModule],
controllers: [PaymentsController, PayoutsController],
providers: [PaymentsService, PayoutsService],
exports: [PaymentsService, PayoutsService],
@@ -16,7 +16,8 @@ import { REDIS } from '../../common/redis/redis.module';
import { Payout } from './entities/payout.entity';
import { WalletService } from '../wallet/wallet.service';
import { UsersService } from '../users/users.service';
import { NabehService } from '../../integrations/nabeh/nabeh.service';
import { OtpDispatcher } from '../../integrations/otp/otp-dispatcher.service';
import { TenantsService } from '../tenants/tenants.service';
import { AuditService } from '../../common/audit/audit.service';
export interface PayoutRequestDto {
@@ -52,7 +53,8 @@ export class PayoutsService {
@Inject(REDIS) private readonly redis: Redis,
private readonly wallet: WalletService,
private readonly users: UsersService,
private readonly nabeh: NabehService,
private readonly tenants: TenantsService,
private readonly otp: OtpDispatcher,
private readonly audit: AuditService,
private readonly config: ConfigService,
) {}
@@ -253,8 +255,14 @@ export class PayoutsService {
}
const user = await this.users.findById(tenantId, driverUserId);
if (!user?.phone) throw new BadRequestException('no phone on file');
// فشل الإرسال يُفشل الطلب: طلبٌ بلا رمز يصل = سائق عالق بلا سبيل للتأكيد.
await this.nabeh.sendOtp(user.phone, code);
// نفس مُوزِّع OTP الموحّد المستعمل في تسجيل الدخول (docs/17 — D5): يوجّه
// حسب دولة المستأجر ويطبّق failover. فشل السلسلة = سائق عالق بلا رمز.
const tenant = await this.tenants.resolve(tenantId);
const sent = await this.otp.send(user.phone, code, {
countryPack: tenant?.countryPack ?? 'jo',
userType: 'driver',
});
if (!sent) throw new BadRequestException('failed to send verification code');
return code;
}
+11
View File
@@ -74,6 +74,15 @@
| D2 | **بصمة الجهاز** | 🟡 **مبنيّة ومطفأة** (`AUTH_REQUIRE_DEVICE_BINDING=false`) حتى يرسل فلاتر `x-device-id`. **مُنفَّذة داخل `JwtStrategy` نفسها لا كحارس منفصل** — يُفرض على كل نقطة محميّة تلقائياً، فلا نقطة منسيّة. توكن الدخول يحمل `hash(deviceId)` لا القيمة الخام؛ توكن مسروق من جهاز آخر يُرفض بمجرد تفعيل العلم. |
| D3 | **HMAC للعمليات الحساسة** | ✅ **منفَّذ ضمن I6** — `SigningService`/`SignatureGuard`، نفس البند لا تكرار. |
| D4 | **حدّ الطلبات (rate limiting)** | ✅ **إصلاح ثغرة قائمة** — راجع أدناه. |
| D5 | **OTP متعدد المزوّدين حسب الدولة** | ✅ — راجع أدناه. |
### D5 — توجيه OTP حسب الدولة + failover (من سيرو `auth/otp/`)
مراجعة سيرو أظهرت: مصر تستعمل **Kazumi SMS** (`sms.kazumi.me`) مع failover لواتساب، وسوريا/الأردن **Nabeh** (واتساب، توكن مُخزَّن 24س). عندنا كان مزوّد واحد (Nabeh) فقط.
- `integrations/otp/` — واجهة `OtpProvider` + `OtpDispatcher` يوجّه حسب `tenant.countryPack` بسلسلة failover (`eg: [kazumi, nabeh]` · `jo/sy: [nabeh]`). إضافة دولة/مزوّد = صنف موفّر + سطر في السلسلة.
- كل مزوّد يرسل **رمزاً نولّده نحن** (مخزَّن في Redis) — لا يولّده المزوّد. استبعدنا نمط Intaleq (يولّد الرمز ويرجعه) حفاظاً على مصدر واحد.
- **موحَّد لكل المسارات**: تسجيل الدخول (`AuthService`) والسحب (`PayoutsService`) يمرّان بنفس المُوزِّع الآن — لا استدعاء Nabeh مباشر في أي مكان.
- **قرار المالك (2026-07-17): بلا كلمة مرور إطلاقاً.** المصادقة = هاتف + OTP مرة → جلسة مربوطة بالجهاز (D2). لا حقل `password`، لا «نسيت كلمة المرور»، لا عبء دعم. سيرو نفسه لا كلمة مرور حقيقية له (`password = hash(email)` وهمي). يطابق أوبر/كريم/inDrive — المعيار في هذه الأسواق. **التسجيل بالهاتف لا Google/Apple** (محظوران في بعض الدول).
- **تصحيح لسيرو**: رمزه من **3 خانات** (1000 احتمال — ضعيف). عندنا 4 (قابل للرفع عبر `OTP_LENGTH`)، وعدّاد 5 محاولات (D4) يغلق التخمين.
### D4 — حدّ الطلبات: كان مُعطَّلاً كلياً رغم أنه يبدو مفعَّلاً
`ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }])` كان مسجَّلاً في `app.module.ts` منذ البداية — لكن **بلا أي حارس يطبّقه**. لا `APP_GUARD`، ولا `@UseGuards(ThrottlerGuard)` في أي متحكّم. أي أن كل نقطة في الـAPI، بما فيها `verify-otp` و`payouts/*`، كانت **بلا أي حدّ طلبات إطلاقاً** — التسجيل وحده لا يفعل شيئاً في NestJS.
@@ -84,6 +93,8 @@
- ✅ `HealthController` مُستثنى (`@SkipThrottle()`) — مراقبة التشغيل بلا بيانات حساسة.
- 🟡 **قرار وعي بالمخاطرة**: فكّرت في تتبّع بالمستخدم المصادَق لا بالـIP وحده (مهم لتطبيق موبايل — عناوين NAT عند مشغّلي الجوّال تجمع آلاف المستخدمين خلف IP واحد). التنفيذ يحتاج حارساً مخصَّصاً بحقن يدوي دقيق (`InjectThrottlerOptions`/`InjectThrottlerStorage`)، وخطأ فيه **يمنع إقلاع التطبيق كاملاً** — ولا بيئة هنا لتشغيل `NestFactory.create()` والتحقق قبل الدفع (اختبارات jest تُنشئ الخدمات يدوياً، فلا تكشف أخطاء DI للحرّاس العالميين). رجّحت الأمان: تُرك بالتتبّع الافتراضي (IP)، والفكرة موثّقة هنا لتُنفَّذ حين يمكن اختبارها فعلياً على السيرفر قبل الدفع.
**مقارنة بسيرو** (`core/Auth/RateLimiter.php`): سيرو يتتبّع بـ`IP:userId` (النمط الذي أجّلناه أعلاه) وله حدود مسمّاة لكل نوع (login 5/د · otp 3/5د · register 3/ساعة · api 120/د)، **وأهم ميزة فيه: fallback بملف مؤقّت عند تعطّل Redis بدل تمرير كل الطلبات** (fail-closed). عندنا **مسار OTP fail-closed أصلاً**: تخزين الرمز والعدّاد عبر Redis مباشرةً، فتعطّل Redis يرمي ويُفشل الطلب لا يمرّره. لكن حارس الطلبات العام (ThrottlerGuard) يستعمل ذاكرة داخلية لكل نسخة — لا يشارك بين النسخ. **بند مؤجَّل**: تخزين Throttler على Redis + fallback fail-closed عند الحاجة للتوسّع الأفقي الفعلي.
---
## المجموعة E — كشف الاحتيال (من `driver_ride_scam`) — ✅ منفَّذة