Add partner SMS gateway via Gama SendQuick and document it.

Expose POST /public/sms/send with API key + domain allowlist for external backends like Balout, wire Meshkee OTP/message sends to Gama, and publish Partner SMS docs on /docs/website.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-04 11:09:21 +03:30
co-authored by Cursor
parent 267a218c26
commit f4295e780c
22 changed files with 803 additions and 16 deletions
+19
View File
@@ -0,0 +1,19 @@
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
export class SendPublicSmsDto {
@IsString()
@IsNotEmpty()
@MaxLength(253)
domain!: string;
@IsString()
@IsNotEmpty()
@MaxLength(32)
to!: string;
@IsString()
@IsNotEmpty()
@MinLength(1)
@MaxLength(700)
message!: string;
}
+20
View File
@@ -0,0 +1,20 @@
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { SendPublicSmsDto } from './dto/send-public-sms.dto';
import { PublicSmsService } from './public-sms.service';
import { SMS_PARTNER_DOMAIN_KEY, SmsPartnerGuard } from './sms-partner.guard';
@Controller('public/sms')
export class PublicSmsController {
constructor(private readonly service: PublicSmsService) {}
@Post('send')
@UseGuards(SmsPartnerGuard)
send(
@Body() dto: SendPublicSmsDto,
@Req() req: Request & { [SMS_PARTNER_DOMAIN_KEY]?: string },
) {
const partnerDomain = req[SMS_PARTNER_DOMAIN_KEY] ?? dto.domain;
return this.service.send(partnerDomain, dto);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PublicSmsController } from './public-sms.controller';
import { PublicSmsService } from './public-sms.service';
import { SmsPartnerGuard } from './sms-partner.guard';
@Module({
imports: [AuthModule],
controllers: [PublicSmsController],
providers: [PublicSmsService, SmsPartnerGuard],
})
export class PublicSmsModule {}
+73
View File
@@ -0,0 +1,73 @@
import {
HttpException,
HttpStatus,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
import { SmsService, toGamaMsisdn } from '../auth/sms.service';
import { SendPublicSmsDto } from './dto/send-public-sms.dto';
const PARTNER_LIMIT_PER_MINUTE = 30;
const DESTINATION_LIMIT_PER_MINUTE = 5;
const WINDOW_SECONDS = 60;
@Injectable()
export class PublicSmsService {
private readonly logger = new Logger(PublicSmsService.name);
constructor(
private readonly sms: SmsService,
private readonly redis: RedisService,
) {}
async send(partnerDomain: string, dto: SendPublicSmsDto) {
if (!this.sms.isEnabled()) {
throw new ServiceUnavailableException('SMS is currently disabled');
}
const msisdn = toGamaMsisdn(dto.to);
if (!msisdn) {
throw new HttpException('Invalid cellphone number', HttpStatus.BAD_REQUEST);
}
await this.assertRateLimits(partnerDomain, msisdn);
const result = await this.sms.sendMessage(msisdn, dto.message);
this.logger.log(
`Partner SMS accepted domain=${partnerDomain} serverId=${result.serverId}`,
);
return {
success: true,
serverId: result.serverId,
};
}
private async assertRateLimits(domain: string, msisdn: string): Promise<void> {
const partnerOk = await this.redis.incrementWithLimit(
`sms:rl:partner:${domain}`,
PARTNER_LIMIT_PER_MINUTE,
WINDOW_SECONDS,
);
if (!partnerOk) {
throw new HttpException(
'Too many SMS requests for this partner. Try again later.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
const destOk = await this.redis.incrementWithLimit(
`sms:rl:dest:${msisdn}`,
DESTINATION_LIMIT_PER_MINUTE,
WINDOW_SECONDS,
);
if (!destOk) {
throw new HttpException(
'Too many SMS requests for this destination. Try again later.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import {
normalizePartnerDomain,
parseSmsPartners,
timingSafeEqualString,
} from './sms-partners.util';
export const SMS_PARTNER_DOMAIN_KEY = 'smsPartnerDomain';
@Injectable()
export class SmsPartnerGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const partners = parseSmsPartners(this.config.get<string>('SMS_PARTNERS'));
if (partners.size === 0) {
throw new UnauthorizedException('SMS partner gateway is not configured');
}
const req = context.switchToHttp().getRequest<Request>();
const providedKey = String(req.headers['x-api-key'] ?? '').trim();
if (!providedKey) {
throw new UnauthorizedException('Missing X-Api-Key');
}
const body = (req.body ?? {}) as { domain?: unknown };
const domain = normalizePartnerDomain(String(body.domain ?? ''));
if (!domain) {
throw new UnauthorizedException('Missing or invalid domain');
}
const expectedKey = partners.get(domain);
if (!expectedKey || !timingSafeEqualString(providedKey, expectedKey)) {
throw new UnauthorizedException('Invalid API key or domain');
}
(req as Request & { [SMS_PARTNER_DOMAIN_KEY]?: string })[SMS_PARTNER_DOMAIN_KEY] =
domain;
return true;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { timingSafeEqual } from 'crypto';
/** Lowercase host, strip protocol/path/port, strip leading www. */
export function normalizePartnerDomain(raw: string): string {
let value = raw.trim().toLowerCase();
if (!value) return '';
value = value.replace(/^https?:\/\//, '');
value = value.split('/')[0] ?? '';
value = value.split(':')[0] ?? '';
if (value.startsWith('www.')) {
value = value.slice(4);
}
return value;
}
/** Parse `domain:apiKey,domain2:apiKey2` into a map keyed by normalized domain. */
export function parseSmsPartners(raw: string | undefined | null): Map<string, string> {
const map = new Map<string, string>();
if (!raw?.trim()) return map;
for (const part of raw.split(',')) {
const entry = part.trim();
if (!entry) continue;
const idx = entry.indexOf(':');
if (idx <= 0) continue;
const domain = normalizePartnerDomain(entry.slice(0, idx));
const apiKey = entry.slice(idx + 1).trim();
if (domain && apiKey) {
map.set(domain, apiKey);
}
}
return map;
}
export function timingSafeEqualString(a: string, b: string): boolean {
const left = Buffer.from(a);
const right = Buffer.from(b);
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}