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
@@ -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;
}
}
}