diff --git a/backend/.env.example b/backend/.env.example index 719b442..ac32f80 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index b410a78..a515a96 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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 (وثائق + وجه) diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 5ddb0cc..69ad797 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -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 ?? '', diff --git a/backend/src/integrations/otp/otp-dispatcher.service.spec.ts b/backend/src/integrations/otp/otp-dispatcher.service.spec.ts new file mode 100644 index 0000000..1294d56 --- /dev/null +++ b/backend/src/integrations/otp/otp-dispatcher.service.spec.ts @@ -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); + }); +}); diff --git a/backend/src/integrations/otp/otp-dispatcher.service.ts b/backend/src/integrations/otp/otp-dispatcher.service.ts new file mode 100644 index 0000000..95dbcd4 --- /dev/null +++ b/backend/src/integrations/otp/otp-dispatcher.service.ts @@ -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 = { + 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(); + + 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 { + 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; + } +} diff --git a/backend/src/integrations/otp/otp-provider.interface.ts b/backend/src/integrations/otp/otp-provider.interface.ts new file mode 100644 index 0000000..1faea57 --- /dev/null +++ b/backend/src/integrations/otp/otp-provider.interface.ts @@ -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; +} + +export const OTP_PROVIDERS = Symbol('OTP_PROVIDERS'); diff --git a/backend/src/integrations/otp/otp.module.ts b/backend/src/integrations/otp/otp.module.ts new file mode 100644 index 0000000..686c8c7 --- /dev/null +++ b/backend/src/integrations/otp/otp.module.ts @@ -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 {} diff --git a/backend/src/integrations/otp/providers/kazumi.provider.ts b/backend/src/integrations/otp/providers/kazumi.provider.ts new file mode 100644 index 0000000..b66d1c3 --- /dev/null +++ b/backend/src/integrations/otp/providers/kazumi.provider.ts @@ -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 { + const username = this.config.get('otp.kazumi.username'); + const password = this.config.get('otp.kazumi.password'); + const sender = this.config.get('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('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; + } + } +} diff --git a/backend/src/integrations/otp/providers/nabeh.provider.ts b/backend/src/integrations/otp/providers/nabeh.provider.ts new file mode 100644 index 0000000..cc38e23 --- /dev/null +++ b/backend/src/integrations/otp/providers/nabeh.provider.ts @@ -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 { + try { + await this.nabeh.sendOtp(phone, code); + return true; + } catch (e: any) { + this.logger.warn(`send failed: ${e?.message}`); + return false; + } + } +} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 0bb648a..a0c16d1 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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) { diff --git a/backend/src/modules/payments/payments.module.ts b/backend/src/modules/payments/payments.module.ts index f7d5b07..e0ad00c 100644 --- a/backend/src/modules/payments/payments.module.ts +++ b/backend/src/modules/payments/payments.module.ts @@ -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], diff --git a/backend/src/modules/payments/payouts.service.ts b/backend/src/modules/payments/payouts.service.ts index fddabbc..4429fff 100644 --- a/backend/src/modules/payments/payouts.service.ts +++ b/backend/src/modules/payments/payouts.service.ts @@ -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; } diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index c492f9f..df2b3ca 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -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`) — ✅ منفَّذة