import crypto from 'node:crypto' import { getDb } from '../db/client.js' import { getOtpRateLimits } from './config.service.js' import { OtpChannel, UserType } from '../constants.js' const sql = getDb() const OTP_TTL_MINUTES = 5 // ------------------------------------------------------------------- // ⚠ Fazpass integration — STUB until real API docs are obtained. // // In production, Fazpass is the source of truth for the OTP code. // We will only ever handle a reference ID (string) returned by Fazpass, // never the raw code. For now, we generate a 6-digit code locally and // store its bcrypt hash in the metadata field of otp_requests via // fazpass_reference (reused as ":") so the stub can // round-trip without schema changes. // // When real docs arrive, replace fazpassSendStub + fazpassVerifyStub // with real HTTP calls and drop the local code generation. // ------------------------------------------------------------------- const generate6DigitCode = () => { // Dev escape hatch: when OTP_STATIC_CODE is set (6 digits), every stub OTP // returns this exact value. Lets manual testers skip the peek round-trip. // Leave unset in production — real Fazpass owns the code there. const staticCode = process.env.OTP_STATIC_CODE if (staticCode && /^\d{6}$/.test(staticCode)) return staticCode // Avoid Math.random for OTP generation — use crypto.randomInt return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0') } // Dev-only in-memory cache of latest stub OTP per phone, read by the // /internal/_test/peek-otp endpoint to make Maestro flows deterministic // without baking test phone numbers into production code paths. const stubOtpByPhone = new Map() export const peekStubOtp = (phone) => stubOtpByPhone.get(phone) ?? null const fazpassSendStub = async ({ phone, channel }) => { const reference = `stub_${crypto.randomUUID()}` const code = generate6DigitCode() stubOtpByPhone.set(phone, { code, reference, channel, generated_at: new Date().toISOString() }) // Log the code so developers can read it during dev testing. // eslint-disable-next-line no-console console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`) return { reference, channel_used: channel, code } // `code` only present in stub } const fazpassVerifyStub = async ({ reference, code, expectedCode }) => { return { valid: code === expectedCode } } // ------------------------------------------------------------------- export class OtpError extends Error { constructor(message, code, statusCode, details = null) { super(message) this.code = code this.statusCode = statusCode this.details = details } } // Returns seconds until the oldest of N most-recent matching requests falls // out of the 1-hour rolling window — i.e. when the next slot opens up. const computeRetryAfterFromRollingWindow = async (whereClauseFragment) => { const [row] = await whereClauseFragment if (!row) return null const oldestTs = new Date(row.created_at).getTime() const slotOpensAt = oldestTs + 60 * 60 * 1000 return Math.max(1, Math.ceil((slotOpensAt - Date.now()) / 1000)) } const checkRateLimits = async ({ phone, ipAddress, limits }) => { // Resend cooldown const [lastRow] = await sql` SELECT created_at FROM otp_requests WHERE phone = ${phone} ORDER BY created_at DESC LIMIT 1 ` if (lastRow) { const elapsed = (Date.now() - new Date(lastRow.created_at).getTime()) / 1000 if (elapsed < limits.resend_cooldown_seconds) { const retryAfter = Math.ceil(limits.resend_cooldown_seconds - elapsed) throw new OtpError( `Please wait ${retryAfter}s before requesting another OTP`, 'OTP_COOLDOWN', 429, { retry_after_seconds: retryAfter }, ) } } // Per-phone hourly limit const [{ phone_count }] = await sql` SELECT COUNT(*)::int AS phone_count FROM otp_requests WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour' ` if (phone_count >= limits.max_per_phone_per_hour) { const retryAfter = await computeRetryAfterFromRollingWindow(sql` SELECT created_at FROM otp_requests WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour' ORDER BY created_at ASC LIMIT 1 `) throw new OtpError( 'Too many OTP requests for this number', 'OTP_RATE_LIMIT_PHONE', 429, retryAfter ? { retry_after_seconds: retryAfter } : null, ) } // Per-IP hourly limit (only if ip provided) if (ipAddress) { const [{ ip_count }] = await sql` SELECT COUNT(*)::int AS ip_count FROM otp_requests WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour' ` if (ip_count >= limits.max_per_ip_per_hour) { const retryAfter = await computeRetryAfterFromRollingWindow(sql` SELECT created_at FROM otp_requests WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour' ORDER BY created_at ASC LIMIT 1 `) throw new OtpError( 'Too many OTP requests from this network', 'OTP_RATE_LIMIT_IP', 429, retryAfter ? { retry_after_seconds: retryAfter } : null, ) } } } /** * Start an OTP flow. Returns { otp_request_id, channel_used, expires_at }. * Does NOT return the code to the caller. */ export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChannel.WHATSAPP }) => { if (!phone || !/^\+[1-9]\d{6,14}$/.test(phone)) { throw new OtpError('Invalid phone format (E.164 expected)', 'PHONE_INVALID', 422) } if (userType !== UserType.CUSTOMER && userType !== UserType.MITRA) { throw new OtpError('Invalid user type', 'USER_TYPE_INVALID', 400) } const limits = await getOtpRateLimits() await checkRateLimits({ phone, ipAddress, limits }) const { reference, channel_used, code } = await fazpassSendStub({ phone, channel }) // Store the reference. In stub mode, we also store the expected code appended // after a colon so the verify stub can compare. Real Fazpass flow will NOT store // the code; Fazpass itself holds it. This line is the main place to change // when switching to real Fazpass. const storedReference = code ? `${reference}:${code}` : reference const [row] = await sql` INSERT INTO otp_requests (phone, ip_address, user_type, fazpass_reference, channel, expires_at) VALUES ( ${phone}, ${ipAddress ?? null}, ${userType}, ${storedReference}, ${channel_used}, NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval ) RETURNING id, expires_at ` return { otp_request_id: row.id, channel_used, expires_at: row.expires_at, } } /** * Verify an OTP code. Returns { phone, user_type } on success. * Throws OtpError on failure. */ export const verifyOtp = async ({ otpRequestId, code }) => { if (typeof code !== 'string' || !/^\d{4,8}$/.test(code)) { throw new OtpError('Invalid code format', 'CODE_INVALID', 422) } const limits = await getOtpRateLimits() const [row] = await sql` SELECT id, phone, user_type, fazpass_reference, attempts, used_at, expires_at FROM otp_requests WHERE id = ${otpRequestId} ` if (!row) { throw new OtpError('OTP request not found', 'OTP_NOT_FOUND', 404) } if (row.used_at) { throw new OtpError('OTP already used', 'OTP_USED', 409) } if (new Date(row.expires_at) <= new Date()) { throw new OtpError('OTP expired', 'OTP_EXPIRED', 410) } if (row.attempts >= limits.verify_max_attempts) { throw new OtpError('Too many verification attempts; request a new OTP', 'OTP_ATTEMPTS_EXCEEDED', 429) } await sql`UPDATE otp_requests SET attempts = attempts + 1 WHERE id = ${otpRequestId}` // Stub: fazpass_reference is stored as ":" const [reference, expectedCode] = (row.fazpass_reference || '').split(':') const { valid } = await fazpassVerifyStub({ reference, code, expectedCode }) if (!valid) { throw new OtpError('Incorrect code', 'CODE_MISMATCH', 401) } await sql`UPDATE otp_requests SET used_at = NOW() WHERE id = ${otpRequestId}` return { phone: row.phone, user_type: row.user_type } }