Found by running the delivery flow against the actual built container rather
than a local dev process.
DEPLOY-02 (Critical) — ADMIN LOGIN RETURNED 500 FOR EVERY USER IN DOCKER.
docker-compose.yml passes each optional variable as ${X:-}, so an unconfigured
one arrives as "" rather than undefined. The code read them with
`process.env.X ?? 'default'`, which only falls back on undefined, so the empty
string reached jsonwebtoken and it threw
"expiresIn" should be a number of seconds or string representing a timespan
on every sign. Invisible to every existing suite, all of which run against a
local process where the variable is genuinely absent.
Added src/common/env.ts (envOr / envOrNull), which treat blank as unset, and
applied it to every auth, port, upload and SMS read. Three were not cosmetic:
a blank UPLOAD_DIR would have served static files from the FILESYSTEM ROOT, a
blank PORT would have bound a random ephemeral port (Number('') === 0), and a
blank JWT_SECRET would have become the verifying key. Recorded as D25, with a
standing rule to use these readers instead of `??`.
Pinned by src/common/env.spec.ts (9 tests), which reproduces the original throw
against the real jsonwebtoken and asserts the reader can no longer produce it.
DEPLOY-03 (process, documented) — the API image is `COPY release/ ./`, so it
ships a bundle pre-built on the developer machine. `docker compose build` will
happily produce an image from a STALE release/app.js and report success; at the
start of this run that bundle was four days old and the rebuilt container was
missing every route added since. DELIVERY.md's deploy section now leads with
`pnpm build:release`.
New: scripts/test-compose-e2e.cjs — the whole delivery flow against the built
container, over HTTP only, as a black box: a sales expert and an ordinary
customer both sign in with the master code, both place an order, both orders
appear in the admin grid and open there, and the three token boundaries still
hold in the deployed image. 32/32.
Verified: compose e2e 32/32 · jest 54/54 · build 5/5 ·
against the CONTAINER: complete-profile 38/38, report 40/40, expert login 36/36 ·
locally: apps smoke 43/43, admin smoke 43/43, B2B scan 34 ok/0 broken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
521 lines
20 KiB
TypeScript
521 lines
20 KiB
TypeScript
import { HttpException, HttpStatus, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { envOr, envOrNull } from '../../common/env';
|
|
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
|
import { faToEn } from './digits';
|
|
import { SmsService, smsConfigured } from '../sms/sms.service';
|
|
|
|
/** Nest 10 has no TooManyRequestsException; 429 with a Persian message. */
|
|
class TooManyRequestsException extends HttpException {
|
|
constructor(message: string) {
|
|
super(message, HttpStatus.TOO_MANY_REQUESTS);
|
|
}
|
|
}
|
|
|
|
/** How long a code stays usable. The reference had no expiry at all (D6). */
|
|
const OTP_TTL_MS = 2 * 60 * 1000;
|
|
/** Wrong guesses allowed against one challenge before it is burned. */
|
|
const MAX_ATTEMPTS = 5;
|
|
/** Sends allowed per mobile number inside the window. */
|
|
const MAX_SENDS_PER_MOBILE = 5;
|
|
/** Sends allowed per source IP inside the window. */
|
|
const MAX_SENDS_PER_IP = 20;
|
|
const SEND_WINDOW_MS = 10 * 60 * 1000;
|
|
/** Minimum gap between two sends to the same number. */
|
|
const RESEND_COOLDOWN_MS = 60 * 1000;
|
|
|
|
/**
|
|
* D20 — the delivery master OTP.
|
|
*
|
|
* Read from `OTP_MASTER_CODE` and NEVER hardcoded. Unset or empty disables it
|
|
* completely, which is the state every real deployment must be in.
|
|
*
|
|
* Read through a function rather than a module constant so tests (and a
|
|
* restart-free config change) see the current value instead of whatever was in
|
|
* the environment when this module was first imported.
|
|
*/
|
|
export function masterOtpCode(): string | null {
|
|
const raw = process.env.OTP_MASTER_CODE?.trim();
|
|
return raw ? raw : null;
|
|
}
|
|
|
|
/** Whether the master code is currently armed. */
|
|
export function masterOtpEnabled(): boolean {
|
|
return masterOtpCode() !== null;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CustomerAuthService {
|
|
private readonly logger = new Logger(CustomerAuthService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly jwt: JwtService,
|
|
private readonly sms: SmsService,
|
|
) {}
|
|
|
|
/**
|
|
* Step 1 — port of ApiMobileController::userAuth (henza_server:39-49).
|
|
*
|
|
* Reference: validate `username`, find an ACTIVE customer by mobile, generate
|
|
* rand(1000,9999), store it, send it by SMS, return `true`.
|
|
*
|
|
* Kept: the active-customer filter, the 4-digit range, the opaque `true`
|
|
* response (it never reveals whether the number exists).
|
|
* Changed: the code is hashed, it expires, and sends are rate limited.
|
|
*/
|
|
async requestOtp(rawMobile: string, ip: string | undefined) {
|
|
const mobile = faToEn(rawMobile);
|
|
|
|
await this.assertSendAllowed(mobile, ip);
|
|
|
|
// D21 — a sales expert signs in with the SAME flow, on the mobile of their
|
|
// AdminUser. Checked first: the owner ruled that if a number somehow
|
|
// existed on both sides, the expert wins.
|
|
//
|
|
// Their trading Customer row is created here rather than at verify, because
|
|
// CustomerOtp.customerId is a required FK and the challenge has to hang off
|
|
// a real row. Creating it costs nothing for a number that never completes
|
|
// login: it is that expert's own account either way, and it is created once.
|
|
const expertAccount = await this.resolveExpertTradingAccount(mobile);
|
|
|
|
// Legacy used Customer::active(), and the archived filter matches the
|
|
// customer-list lookup the app login previously relied on.
|
|
const customer =
|
|
expertAccount ??
|
|
(await this.prisma.customer.findFirst({
|
|
where: { mobile, isActive: true, isArchived: false },
|
|
select: { id: true },
|
|
}));
|
|
|
|
// Deliberately opaque: the reference used firstOrFail() and leaked a 404
|
|
// for unknown numbers, which let anyone enumerate registered customers.
|
|
// Recorded as a deviation in docs/parity/DECISIONS.md.
|
|
if (!customer) {
|
|
this.logger.warn(`OTP requested for unknown or inactive mobile ${mobile}`);
|
|
return { sent: true };
|
|
}
|
|
|
|
const code = String(Math.floor(1000 + Math.random() * 9000));
|
|
|
|
await this.prisma.customerOtp.create({
|
|
data: {
|
|
mobile,
|
|
customerId: customer.id,
|
|
codeHash: await bcrypt.hash(code, 10),
|
|
expiresAt: new Date(Date.now() + OTP_TTL_MS),
|
|
requestIp: ip ?? null,
|
|
},
|
|
});
|
|
|
|
await this.deliver(mobile, code);
|
|
return { sent: true };
|
|
}
|
|
|
|
/**
|
|
* D21 — find an active sales expert by mobile and return their trading
|
|
* Customer account, creating it on first sign-in.
|
|
*
|
|
* WHY A TRADING ACCOUNT EXISTS
|
|
* In the reference a sales agent WAS a Customer (type=1), so `setOrder` did
|
|
* `Customer::findOrFail(auth()->id())` and read that row's `gram_level`
|
|
* (ApiMobileController.php:534-535) — the agent's own order, own credit
|
|
* limit, own ledger. Here the expert is an AdminUser (D17), which has no
|
|
* ledger and cannot be an Order's subject, so this one linked Customer row
|
|
* plays that part. It is NOT one of their referred customers.
|
|
*
|
|
* Seeded from what the reference used for exactly these two numbers:
|
|
* gram_level -> goldCreditLimitGrams (SalesExpertProfile.gramLevel)
|
|
* percent -> makingChargeOffsetPercent (AdminUser.wagePercent)
|
|
*
|
|
* Returns null for any mobile that is not an active sales expert, so an
|
|
* ordinary customer's login is completely unaffected.
|
|
*/
|
|
private async resolveExpertTradingAccount(mobile: string): Promise<{ id: string } | null> {
|
|
const admin = await this.prisma.adminUser.findFirst({
|
|
where: { mobile, isActive: true, role: AdminRole.SALES_EXPERT },
|
|
select: {
|
|
id: true,
|
|
fullName: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
mobile: true,
|
|
wagePercent: true,
|
|
salesExpertProfile: {
|
|
select: { id: true, gramLevel: true, nationalCode: true, tradingCustomerId: true },
|
|
},
|
|
},
|
|
});
|
|
if (!admin) return null;
|
|
|
|
const existingId = admin.salesExpertProfile?.tradingCustomerId;
|
|
if (existingId) {
|
|
// Re-activate nothing and change nothing — just confirm it is usable.
|
|
const existing = await this.prisma.customer.findUnique({
|
|
where: { id: existingId },
|
|
select: { id: true, isActive: true, isArchived: true },
|
|
});
|
|
if (existing && existing.isActive && !existing.isArchived) return { id: existing.id };
|
|
// Deliberately does NOT silently create a second account when the first
|
|
// was deactivated: an operator disabled it, and that decision stands.
|
|
this.logger.warn(
|
|
`Sales expert ${admin.id} has a trading account that is inactive or archived; refusing to create another.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (!admin.salesExpertProfile) {
|
|
// Every SALES_EXPERT gets a profile on create and on role change (D17),
|
|
// so this means something bypassed both paths.
|
|
this.logger.warn(`Sales expert ${admin.id} has no SalesExpertProfile; cannot sign in to the app.`);
|
|
return null;
|
|
}
|
|
|
|
// The mobile is unique on Customer, so a pre-existing customer row with
|
|
// this number would collide. That cannot normally happen — the owner
|
|
// stated expert and customer numbers are never the same — but if it does,
|
|
// adopt that row rather than fail the login or create a duplicate.
|
|
const clash = await this.prisma.customer.findFirst({
|
|
where: { mobile },
|
|
select: { id: true, isActive: true, isArchived: true },
|
|
});
|
|
if (clash) {
|
|
this.logger.warn(
|
|
`Mobile ${mobile} belongs to both sales expert ${admin.id} and customer ${clash.id}; ` +
|
|
`adopting the existing customer row as the expert's trading account.`,
|
|
);
|
|
if (!clash.isActive || clash.isArchived) return null;
|
|
await this.prisma.salesExpertProfile.update({
|
|
where: { id: admin.salesExpertProfile.id },
|
|
data: { tradingCustomerId: clash.id },
|
|
});
|
|
return { id: clash.id };
|
|
}
|
|
|
|
const created = await this.prisma.customer.create({
|
|
data: {
|
|
fullName: admin.fullName,
|
|
firstName: admin.firstName,
|
|
lastName: admin.lastName,
|
|
mobile: admin.mobile,
|
|
// Customer.nationalCode is required and unique. The expert's own code
|
|
// when they have one, else the mobile — the same fallback
|
|
// admin-customers.service.ts uses when a form omits it.
|
|
nationalCode: admin.salesExpertProfile.nationalCode || admin.mobile,
|
|
// No app password: this account is reached only through the expert's
|
|
// AdminUser session, never by a password login of its own.
|
|
passwordHash: '',
|
|
kind: CustomerKind.APP_SHOPKEEPER,
|
|
goldCreditLimitGrams: admin.salesExpertProfile.gramLevel,
|
|
makingChargeOffsetPercent: admin.wagePercent ?? new Prisma.Decimal(0),
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
await this.prisma.salesExpertProfile.update({
|
|
where: { id: admin.salesExpertProfile.id },
|
|
data: { tradingCustomerId: created.id },
|
|
});
|
|
|
|
this.logger.log(
|
|
`Created trading account ${created.id} for sales expert ${admin.id} on first app sign-in.`,
|
|
);
|
|
return { id: created.id };
|
|
}
|
|
|
|
/**
|
|
* Step 2 — port of ApiMobileController::userVerfiy (henza_server:51-64).
|
|
*
|
|
* Reference: `if (faTOen($request->password) != $customer->send_password) abort(422);`
|
|
* then issue a token and return THAT ONE customer.
|
|
*
|
|
* The parity behaviour is the comparison and the rejection. The attempt
|
|
* counter, the expiry check and the single-use consumption are the D6
|
|
* additions.
|
|
*/
|
|
async verifyOtp(rawMobile: string, rawCode: string, ip: string | undefined) {
|
|
const mobile = faToEn(rawMobile);
|
|
const code = faToEn(rawCode);
|
|
|
|
// One message for every failure mode, so a caller cannot tell "no code was
|
|
// sent" from "wrong code" from "expired". Declared before the master-code
|
|
// branch so that path can reject identically.
|
|
const reject = () => new UnauthorizedException('کد وارد شده صحیح نیست');
|
|
|
|
// D20 — the delivery master code. Checked BEFORE the stored challenge so it
|
|
// works even when the customer never requested a code (no CustomerOtp row
|
|
// exists to compare against). It does not weaken any other rule: the
|
|
// customer must still exist, be active and not archived, and the send
|
|
// throttle on requestOtp is untouched.
|
|
//
|
|
// MUST be removed from the environment before the real release — see D20
|
|
// and DELIVERY.md.
|
|
const master = masterOtpCode();
|
|
if (master !== null && code === master) {
|
|
// D21 — the master code works for a sales expert too, and the expert is
|
|
// checked first for the same reason requestOtp does it.
|
|
const expert = await this.resolveExpertPrincipal(mobile);
|
|
if (expert) {
|
|
this.logger.warn(
|
|
`OTP_MASTER_CODE used to sign in SALES EXPERT ${expert.adminUserId} (mobile ${mobile})`,
|
|
);
|
|
return {
|
|
token: await this.issueExpertToken(expert.adminUserId, mobile),
|
|
customer: { id: expert.tradingCustomerId, mobile },
|
|
};
|
|
}
|
|
|
|
const customer = await this.prisma.customer.findFirst({
|
|
where: { mobile, isActive: true, isArchived: false },
|
|
select: { id: true, mobile: true },
|
|
});
|
|
if (!customer) throw reject();
|
|
|
|
// Audited with the mobile, as D20 requires, so every bypass login is
|
|
// traceable in the server log.
|
|
this.logger.warn(
|
|
`OTP_MASTER_CODE used to sign in customer ${customer.id} (mobile ${customer.mobile})`,
|
|
);
|
|
|
|
return {
|
|
token: await this.issueToken(customer.id, customer.mobile),
|
|
customer: { id: customer.id, mobile: customer.mobile },
|
|
};
|
|
}
|
|
|
|
const challenge = await this.prisma.customerOtp.findFirst({
|
|
where: { mobile, consumedAt: null },
|
|
orderBy: { createdAt: 'desc' },
|
|
include: {
|
|
customer: {
|
|
select: { id: true, mobile: true, isActive: true, isArchived: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!challenge) throw reject();
|
|
if (challenge.expiresAt.getTime() < Date.now()) throw reject();
|
|
|
|
if (challenge.attempts >= MAX_ATTEMPTS) {
|
|
throw new TooManyRequestsException(
|
|
'تعداد تلاشهای ناموفق بیش از حد مجاز است. لطفاً کد جدیدی درخواست کنید.',
|
|
);
|
|
}
|
|
|
|
const ok = await bcrypt.compare(code, challenge.codeHash);
|
|
if (!ok) {
|
|
await this.prisma.customerOtp.update({
|
|
where: { id: challenge.id },
|
|
data: { attempts: { increment: 1 } },
|
|
});
|
|
throw reject();
|
|
}
|
|
|
|
if (!challenge.customer.isActive || challenge.customer.isArchived) {
|
|
throw reject();
|
|
}
|
|
|
|
// Single use: burn the challenge before issuing the token.
|
|
await this.prisma.customerOtp.update({
|
|
where: { id: challenge.id },
|
|
data: { consumedAt: new Date() },
|
|
});
|
|
|
|
// D21 — the challenge hangs off the expert's trading Customer row, so a
|
|
// correct code here may belong to either principal. Resolved from the
|
|
// mobile, never from anything the client sent.
|
|
const expert = await this.resolveExpertPrincipal(mobile);
|
|
if (expert) {
|
|
return {
|
|
token: await this.issueExpertToken(expert.adminUserId, mobile),
|
|
customer: { id: expert.tradingCustomerId, mobile },
|
|
};
|
|
}
|
|
|
|
return {
|
|
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile),
|
|
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* D21 — is this mobile an active sales expert with a usable trading account?
|
|
*
|
|
* Read-only: unlike `resolveExpertTradingAccount`, this never creates
|
|
* anything, because by verify time requestOtp has already done so. Returns
|
|
* null for every ordinary customer, so their path is untouched.
|
|
*/
|
|
private async resolveExpertPrincipal(
|
|
mobile: string,
|
|
): Promise<{ adminUserId: string; tradingCustomerId: string } | null> {
|
|
const admin = await this.prisma.adminUser.findFirst({
|
|
where: { mobile, isActive: true, role: AdminRole.SALES_EXPERT },
|
|
select: {
|
|
id: true,
|
|
salesExpertProfile: {
|
|
select: {
|
|
tradingCustomer: { select: { id: true, isActive: true, isArchived: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const trading = admin?.salesExpertProfile?.tradingCustomer;
|
|
if (!admin || !trading || !trading.isActive || trading.isArchived) return null;
|
|
return { adminUserId: admin.id, tradingCustomerId: trading.id };
|
|
}
|
|
|
|
/**
|
|
* Sign a SALES EXPERT app token.
|
|
*
|
|
* `sub` is the AdminUser id — the durable identity — not the trading
|
|
* Customer id, so the token survives anything that changes the trading row
|
|
* and the role check can be re-applied on every request.
|
|
*
|
|
* Same secret and lifetime as a customer token; only `typ` differs. It is
|
|
* NOT an admin-panel token: JwtStrategy requires the admin type and refuses
|
|
* this one on every admin route.
|
|
*/
|
|
private issueExpertToken(adminUserId: string, mobile: string): Promise<string> {
|
|
const payload: CustomerJwtPayload = {
|
|
sub: adminUserId,
|
|
mobile,
|
|
typ: EXPERT_TOKEN_TYPE,
|
|
};
|
|
return this.jwt.signAsync(payload, {
|
|
secret: customerJwtSecret(),
|
|
expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sign a customer JWT. Shared by the normal verify path and the D20 master
|
|
* code so both issue exactly the same token — the bypass grants no extra
|
|
* privilege and no longer lifetime.
|
|
*
|
|
* Reference reused api_token forever once set (:60-62); a fresh token is
|
|
* issued on every verify here (T1).
|
|
*/
|
|
private issueToken(customerId: string, mobile: string): Promise<string> {
|
|
const payload: CustomerJwtPayload = {
|
|
sub: customerId,
|
|
mobile,
|
|
typ: CUSTOMER_TOKEN_TYPE,
|
|
};
|
|
return this.jwt.signAsync(payload, {
|
|
secret: customerJwtSecret(),
|
|
// jsonwebtoken types expiresIn as a template-literal union, which a plain
|
|
// string from process.env does not satisfy — same cast AuthModule uses.
|
|
expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Per-mobile and per-IP send throttle.
|
|
*
|
|
* Counted off the CustomerOtp rows rather than an in-memory map, so the limit
|
|
* survives a restart and holds across replicas. The reference had no limit:
|
|
* userAuth could be called in a loop, each call sending a real (billed) SMS.
|
|
*/
|
|
private async assertSendAllowed(mobile: string, ip: string | undefined) {
|
|
const since = new Date(Date.now() - SEND_WINDOW_MS);
|
|
|
|
const lastToMobile = await this.prisma.customerOtp.findFirst({
|
|
where: { mobile },
|
|
orderBy: { createdAt: 'desc' },
|
|
select: { createdAt: true },
|
|
});
|
|
if (lastToMobile && Date.now() - lastToMobile.createdAt.getTime() < RESEND_COOLDOWN_MS) {
|
|
const wait = Math.ceil(
|
|
(RESEND_COOLDOWN_MS - (Date.now() - lastToMobile.createdAt.getTime())) / 1000,
|
|
);
|
|
throw new TooManyRequestsException(`لطفاً ${wait} ثانیه دیگر تلاش کنید`);
|
|
}
|
|
|
|
const sentToMobile = await this.prisma.customerOtp.count({
|
|
where: { mobile, createdAt: { gte: since } },
|
|
});
|
|
if (sentToMobile >= MAX_SENDS_PER_MOBILE) {
|
|
throw new TooManyRequestsException(
|
|
'تعداد درخواستهای کد بیش از حد مجاز است. لطفاً بعداً تلاش کنید.',
|
|
);
|
|
}
|
|
|
|
if (ip) {
|
|
const sentFromIp = await this.prisma.customerOtp.count({
|
|
where: { requestIp: ip, createdAt: { gte: since } },
|
|
});
|
|
if (sentFromIp >= MAX_SENDS_PER_IP) {
|
|
throw new TooManyRequestsException(
|
|
'تعداد درخواستهای کد بیش از حد مجاز است. لطفاً بعداً تلاش کنید.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hands the code to the SMS gateway (SmsService -> Trez, per Sms.php:57-82).
|
|
*
|
|
* When no credentials are configured, SmsService does not send and the code
|
|
* is logged instead — development only; production refuses to boot without
|
|
* credentials (assertSmsConfigured in main.ts).
|
|
*
|
|
* A failed send does NOT throw. The reference ignored Sms::Send's result, so
|
|
* a gateway outage never blocked the flow that triggered it; the customer
|
|
* simply retries, and the throttle in assertSendAllowed still applies.
|
|
*/
|
|
private async deliver(mobile: string, code: string) {
|
|
// BYTE-IDENTICAL to the reference (ApiMobileController.php:47). The
|
|
// leading "<#> code is <CODE>" line and the trailing "aMo6iFyUQaY" are the
|
|
// Android SMS Retriever app hash — change either and autofill stops
|
|
// working on every installed client. Do not "tidy" this string.
|
|
const smsText =
|
|
`<#> code is ${code}\n` +
|
|
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد ${code} می باشد\n` +
|
|
`aMo6iFyUQaY`;
|
|
|
|
// persist:false — the real text carries the live code, and the admin SMS
|
|
// grid lists and searches `body` (SEC-11). The redacted audit row below
|
|
// takes its place.
|
|
await this.sms.send(mobile, smsText, { persist: false });
|
|
|
|
// Only outside production, and only when no gateway is configured, so a
|
|
// deployment can never print live codes into its logs.
|
|
if (process.env.NODE_ENV !== 'production' && !smsConfigured()) {
|
|
this.logger.log(`[dev] OTP for ${mobile}: ${code}`);
|
|
}
|
|
|
|
// An audit row, NOT the message body.
|
|
//
|
|
// SmsMessage.body is listed and full-text searchable on the admin panel's
|
|
// SMS grid (admin-misc.service.ts:136 allows `body`, :135 searches it), so
|
|
// persisting the real text would show every live OTP to any operator
|
|
// holding the `sms` rid — and the whole point of hashing codeHash is that
|
|
// the code exists nowhere in readable form. The code is redacted here.
|
|
await this.prisma.smsMessage.create({
|
|
data: {
|
|
audience: MessageAudience.SINGLE_NUMBER,
|
|
recipients: mobile,
|
|
body: 'کد یکبار مصرف ورود (متن پیام ثبت نمیشود)',
|
|
sentCount: 1,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Customer tokens are signed with their own secret so an admin token can never
|
|
* validate as a customer token, or the reverse, even if the `typ` check were
|
|
* ever removed (T1).
|
|
*/
|
|
export function customerJwtSecret(): string {
|
|
return (
|
|
envOrNull('JWT_CUSTOMER_SECRET') ??
|
|
envOrNull('JWT_SECRET') ??
|
|
'dev-only-insecure-customer-secret'
|
|
);
|
|
}
|