Files
halobestie-clone/backend/src/services/otp.service.js
ramadhan sjamsani a09f37135c Phase 4 checkpoint: chat-screen perf refactor + retryable blast-failure + repo-wide dispose-ref guardrail
Chat-screen performance (customer + mitra):
- Parent screens have zero `ref.watch` — only `ref.listen` for side effects
- Body extracted into its own `ConsumerStatefulWidget`; AppBar parts split
  into narrow `.select` consumers (mode, sensitivity, timer)
- Per-second timer ticks routed to dedicated providers
  (`chatRemainingSecondsProvider` + new `mitraChatRemainingSecondsProvider`)
  so WS `session_tick` frames don't invalidate the rest of the chat state

Dispose-in-ref bug fix:
- `home_screen.dart`, `payment_screen.dart`, `mitra_chat_screen.dart` —
  ref-using cleanup moved from `dispose()` to `deactivate()`. Modern
  Riverpod invalidates `ref` the moment `dispose()` runs; the resulting
  silent error corrupts the widget-tree finalize and the next screen
  appears frozen
- `halo_lints` package added at repo root with `no_ref_in_dispose` rule
  to catch this pattern in CI / IDE analysis
- `custom_lint` activated in both apps' `analysis_options.yaml`
  (was installed but never wired in — also brings `riverpod_lint`'s
  `avoid_ref_inside_state_dispose` online)
- CLAUDE.md Pitfalls section added to client_app + mitra_app

Phase 4 §3 retryable blast-failure (Option A):
- Backend `expirePairingRequest` + all-rejected use
  `recordIntermediateFailure` instead of `failPaymentSession` so the
  payment session stays `confirmed` for re-blast
- WS `pairing_failed` payload carries `is_terminal: false` on the
  retryable paths; client parses the flag and exposes `retryBlast()`
- "Coba cari lagi" CTA on S7 Timeout now re-blasts on the same payment
- Pairing service test updated to reflect the new semantics

Customer waiting-payment screen navigation patch:
- `_navigateTerminal` uses `Future.microtask` + `addPostFrameCallback`
  redundancy after a release-mode bug where polling stopped but
  `context.go` never fired, leaving the screen visually stuck on
  "menunggu pembayaran"

See requirement/resume-2026-05-15.md for next-day pickup checklist
(mitra release rebuild + S21 Ultra install + retest is the gating item).

Bundles unrelated in-flight Phase 4 §2.x work that was already on disk
(ESP screen removal, USP one-time gate scaffolding, bestie-availability
public route, OTP service edits, Maestro flow tweaks) — kept together
to avoid a partial-rebase mess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:12:34 +08:00

213 lines
8.0 KiB
JavaScript

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 "<reference>:<hash>") 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 "<ref>:<code>"
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 }
}