OTP overhaul: test-user bypass + hash-at-rest + Fazpass integration

- Test-OTP bypass allowlist for Apple reviewers / QA: phone-scoped static OTPs
  managed in CC (Settings → Test OTP Bypass), bcrypt-hashed on save, kill-switch
  toggle, per-entry expires_at. New `otp_requests` columns (is_bypass, code_hash)
  + DB CHECK enforcing bypass-row shape.
- Hash-at-rest for stub OTPs: replaced plaintext `<ref>:<code>` storage with
  bcrypt(code_hash); reference goes to fazpass_reference alone. Verify routes on
  sovereign is_bypass flag, defers code_hash-NULL rows to Fazpass.
- Fazpass integration (gated by FAZPASS_ENABLED env, default off): new
  fazpass.service.js calling /v1/otp/{request,verify}; distinct errors for wrong
  OTP (CODE_MISMATCH 401) vs provider outage (OTP_PROVIDER_FAILED 502).
- Removed redundant Free Trial CC section (was a back-compat shim for the same
  pricing_promotions row as "Diskon Sesi Pertama") + unused alias in
  pricing.service.js.

208 tests green (34 new for OTP + Fazpass). Fazpass API + dashboard PDFs added
at project root for reference (docs are auth-gated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 22:39:34 +08:00
parent 3a0cdf5c4e
commit 6fd98ca99c
15 changed files with 1958 additions and 158 deletions

View File

@@ -19,10 +19,21 @@ AUTH_JWT_SECRET=replace-with-strong-random-32+char-secret
ACCESS_TOKEN_TTL_SECONDS=3600
REFRESH_TOKEN_TTL_DAYS=30
# Fazpass (OTP provider — TBD real values once docs are available)
FAZPASS_API_KEY=
FAZPASS_BASE_URL=
FAZPASS_WEBHOOK_SECRET=
# --- Fazpass (OTP provider) ---
#
# When FAZPASS_ENABLED=true, requestOtp() calls Fazpass /v1/otp/request and
# verifyOtp() calls Fazpass /v1/otp/verify. When false, the in-process stub
# generates + verifies codes locally (dev/test default).
#
# Single merchant key authenticates the account; single gateway key selects
# the (channel + provider) tuple configured in dashboard → Integration → Add
# Gateway. The client-supplied `channel` in /otp/request becomes informational
# only when Fazpass is live — the gateway decides which channel actually fires.
FAZPASS_ENABLED=false
FAZPASS_BASE_URL=https://api.fazpass.com
FAZPASS_MERCHANT_KEY=
FAZPASS_GATEWAY_KEY=
FAZPASS_TIMEOUT_MS=10000
# Google OAuth — comma-separated list of valid audience client IDs (Android, iOS).
GOOGLE_OAUTH_CLIENT_IDS=

View File

@@ -403,6 +403,18 @@ const migrate = async () => {
await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at) WHERE revoked_at IS NULL`
// OTP requests (Fazpass reference + rate-limit tracking)
//
// Storage shape rationale:
// - is_bypass : explicit intent flag — true only when a row was created by
// the test-OTP-bypass allowlist (phone-scoped static OTP for
// App Store reviewers). Verify routes on this flag, NOT on
// the mere presence of code_hash.
// - code_hash : bcrypt hash of the OTP code, present whenever the backend
// owns verification (stub-mode rows + bypass rows). NULL when
// Fazpass owns verification (post-cutover, non-bypass rows).
// - CHECK constraint: bypass rows MUST have code_hash and MUST NOT carry a
// Fazpass reference — physically prevents a bypass row from
// ever falling into the Fazpass-verify path.
await sql`
CREATE TABLE IF NOT EXISTS otp_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -414,12 +426,36 @@ const migrate = async () => {
attempts INT NOT NULL DEFAULT 0,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
expires_at TIMESTAMPTZ NOT NULL,
is_bypass BOOLEAN NOT NULL DEFAULT FALSE,
code_hash VARCHAR(255)
)
`
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_phone_created ON otp_requests (phone, created_at)`
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_ip_created ON otp_requests (ip_address, created_at)`
// Idempotent ALTERs for DBs created before is_bypass/code_hash were added.
await sql`
ALTER TABLE otp_requests
ADD COLUMN IF NOT EXISTS is_bypass BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS code_hash VARCHAR(255)
`
// Drop-then-add lets us tighten the invariant later without writing a v2.
// The constraint is defense-in-depth alongside the verifyOtp branching: even
// if app code regressed, the DB refuses to insert a corrupt bypass row.
await sql`ALTER TABLE otp_requests DROP CONSTRAINT IF EXISTS otp_requests_bypass_shape`
await sql`
ALTER TABLE otp_requests
ADD CONSTRAINT otp_requests_bypass_shape CHECK (
is_bypass = FALSE OR (
is_bypass = TRUE
AND code_hash IS NOT NULL
AND fazpass_reference IS NULL
)
)
`
// Auth-related app_config defaults
await sql`
INSERT INTO app_config (key, value) VALUES

View File

@@ -6,7 +6,6 @@ import { getDb } from '../../db/client.js'
import {
getAnonymityConfig, setAnonymityConfig,
getMaxCustomersPerMitra, setMaxCustomersPerMitra,
getFreeTrialConfig, setFreeTrialConfig,
getExtensionTimeoutConfig, setExtensionTimeoutConfig,
getEarlyEndConfig, setEarlyEndConfig,
getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds,
@@ -16,6 +15,8 @@ import {
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
getSupportHandles, setSupportHandles,
getTestOtpBypass, setTestOtpBypassEnabled, addTestOtpBypassEntry,
updateTestOtpBypassEntry, deleteTestOtpBypassEntry,
} from '../../services/config.service.js'
const sql = getDb()
@@ -111,22 +112,6 @@ export const internalConfigRoutes = async (app) => {
return reply.send({ success: true, data: config })
})
// --- Phase 3: Free Trial ---
app.get('/free-trial', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const config = await getFreeTrialConfig()
return reply.send({ success: true, data: config })
})
app.patch('/free-trial', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { enabled, duration_minutes } = request.body ?? {}
const config = await setFreeTrialConfig({ enabled, duration_minutes })
return reply.send({ success: true, data: config })
})
// --- Phase 3: Extension Timeout ---
app.get('/extension-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
@@ -735,6 +720,102 @@ export const internalConfigRoutes = async (app) => {
return reply.send({ success: true, data: updated })
})
// --- Test OTP bypass allowlist ---
//
// Phone-scoped static-OTP entries for Apple App Store reviewers / pre-launch
// QA. See config.service.js for the storage shape and security rationale.
// Writes publish 'config:invalidate' so peer instances drop any future cache;
// today every read hits the DB, so this is mostly future-proofing.
const sendError = (reply, err) => {
const status = err.statusCode || 500
const payload = {
success: false,
error: {
code: err.code || 'INTERNAL',
message: err.message,
...(err.field && { field: err.field }),
},
}
return reply.code(status).send(payload)
}
app.get('/test-otp-bypass', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => {
return reply.send({ success: true, data: await getTestOtpBypass() })
})
app.patch('/test-otp-bypass/enabled', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { enabled } = request.body ?? {}
try {
const data = await setTestOtpBypassEnabled(enabled)
await publishConfigInvalidate('test_otp_bypass')
return reply.send({ success: true, data })
} catch (err) {
return sendError(reply, err)
}
})
app.post('/test-otp-bypass/entries', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { phone, otp, user_type, label, expires_at } = request.body ?? {}
try {
const entry = await addTestOtpBypassEntry({ phone, otp, user_type, label, expires_at })
await publishConfigInvalidate('test_otp_bypass')
request.log.info({
event: 'test_otp_bypass.entry_created',
entry_id: entry.id,
label: entry.label,
phone_last4: entry.phone.slice(-4),
user_type: entry.user_type,
actor_cc_user_id: request.auth.userId,
}, 'test OTP bypass entry created')
return reply.code(201).send({ success: true, data: entry })
} catch (err) {
return sendError(reply, err)
}
})
app.patch('/test-otp-bypass/entries/:id', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { id } = request.params
try {
const entry = await updateTestOtpBypassEntry(id, request.body ?? {})
await publishConfigInvalidate('test_otp_bypass')
request.log.info({
event: 'test_otp_bypass.entry_updated',
entry_id: entry.id,
actor_cc_user_id: request.auth.userId,
}, 'test OTP bypass entry updated')
return reply.send({ success: true, data: entry })
} catch (err) {
return sendError(reply, err)
}
})
app.delete('/test-otp-bypass/entries/:id', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { id } = request.params
try {
const result = await deleteTestOtpBypassEntry(id)
await publishConfigInvalidate('test_otp_bypass')
request.log.info({
event: 'test_otp_bypass.entry_deleted',
entry_id: id,
actor_cc_user_id: request.auth.userId,
}, 'test OTP bypass entry deleted')
return reply.send({ success: true, data: result })
} catch (err) {
return sendError(reply, err)
}
})
// --- Phase 4: Support handles ---
app.get('/support-handles', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],

View File

@@ -64,6 +64,7 @@ export const clientAuthRoutes = async (app) => {
userType: UserType.CUSTOMER,
ipAddress: request.ip,
channel,
logger: request.log,
})
return reply.send({ success: true, data: result })
} catch (err) {
@@ -74,7 +75,7 @@ export const clientAuthRoutes = async (app) => {
app.post('/otp/verify', async (request, reply) => {
const { otp_request_id, code } = request.body || {}
try {
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code })
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code, logger: request.log })
if (user_type !== UserType.CUSTOMER) {
return reply.code(400).send({
success: false,

View File

@@ -30,6 +30,7 @@ export const mitraAuthRoutes = async (app) => {
userType: UserType.MITRA,
ipAddress: request.ip,
channel,
logger: request.log,
})
return reply.send({ success: true, data: result })
} catch (err) {
@@ -40,7 +41,7 @@ export const mitraAuthRoutes = async (app) => {
app.post('/otp/verify', async (request, reply) => {
const { otp_request_id, code } = request.body || {}
try {
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code })
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code, logger: request.log })
if (user_type !== UserType.MITRA) {
return reply.code(400).send({
success: false,

View File

@@ -1,8 +1,16 @@
import bcrypt from 'bcrypt'
import crypto from 'node:crypto'
import { getDb } from '../db/client.js'
import { ExtensionTimeoutAction } from '../constants.js'
import { ExtensionTimeoutAction, UserType } from '../constants.js'
const sql = getDb()
// bcrypt cost for the per-entry static OTP. Same rationale as
// otp.service.js OTP_BCRYPT_COST — 10 keeps the verify SLA tight without
// meaningfully reducing protection (OTPs are 6 digits; cost mostly buys time
// against an offline DB-dump brute force, which the 5-min TTL already bounds).
const TEST_OTP_BYPASS_BCRYPT_COST = 10
export const getAnonymityConfig = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'anonymity'`
return { anonymity_enabled: row?.value?.enabled ?? false }
@@ -35,49 +43,6 @@ export const setMaxCustomersPerMitra = async (value) => {
return { max_customers_per_mitra: value }
}
// --- Phase 4: First-session discount config (back-compat shim) ---
//
// The canonical source of truth for the first-session discount lives in the
// `pricing_promotions` table (eligibility = 'first_session'). The CC settings
// page still calls `/internal/config/free-trial`, which exposes a slim
// {enabled, duration_minutes} view — kept as a back-compat shim until the CC
// UI is migrated to the richer /internal/config/first-session-discount handler.
// Reads and writes go directly against `pricing_promotions` so operator edits
// stay in sync with the customer-facing pricing payload.
//
// The legacy `first_session_discount_*` keys in `app_config` were retired in
// Stage 5 (deleted by migrate.js) — do NOT reintroduce them.
export const getFreeTrialConfig = async () => {
const [row] = await sql`
SELECT enabled, duration_minutes FROM pricing_promotions
WHERE eligibility = 'first_session'
`
return {
enabled: row?.enabled ?? true,
duration_minutes: row?.duration_minutes ?? 12,
}
}
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
// Build a sparse UPDATE so undefined fields are left alone (matches the prior
// semantics where missing patch fields were no-ops). Use COALESCE on each
// column with the sentinel-when-undefined pattern; postgres.js parameterizes
// null/undefined identically, so we branch on which fields the caller sent.
if (enabled === undefined && duration_minutes === undefined) {
return getFreeTrialConfig()
}
await sql`
UPDATE pricing_promotions
SET enabled = ${enabled === undefined ? sql`enabled` : enabled},
duration_minutes = ${duration_minutes === undefined ? sql`duration_minutes` : duration_minutes},
updated_at = NOW()
WHERE eligibility = 'first_session'
`
return getFreeTrialConfig()
}
// --- Phase 4: Support handles ---
export const getSupportHandles = async () => {
@@ -177,6 +142,25 @@ export const getValkeyOnlineMirrorSweepSeconds = () => {
return Number.isFinite(parsed) && parsed >= 30 ? parsed : 300
}
// --- Fazpass (OTP provider) ---
//
// Env-driven per backend/CLAUDE.md Config-Source Convention. Read at call
// time (not module load) so test setups can inject via vi.stubEnv. When
// `enabled` is true, otp.service.js routes both /request and /verify through
// Fazpass; when false, the in-process stub plays the role of provider.
export const getFazpassConfig = () => {
const rawTimeout = Number.parseInt(process.env.FAZPASS_TIMEOUT_MS ?? '', 10)
// Trim — dotenv preserves leading whitespace after `=` and a stray space
// would corrupt the `Authorization: Bearer …` header silently.
return {
enabled: process.env.FAZPASS_ENABLED === 'true',
baseUrl: (process.env.FAZPASS_BASE_URL || 'https://api.fazpass.com').trim(),
merchantKey: (process.env.FAZPASS_MERCHANT_KEY ?? '').trim(),
gatewayKey: (process.env.FAZPASS_GATEWAY_KEY ?? '').trim(),
timeoutMs: Number.isFinite(rawTimeout) && rawTimeout >= 1000 ? rawTimeout : 10_000,
}
}
// --- Phase 5: Xendit integration ---
//
// Env-driven (per backend/CLAUDE.md Config-Source Convention). All five values
@@ -390,3 +374,242 @@ export const setPairingBlastTimeoutSeconds = async (value) => {
`
return { pairing_blast_timeout_seconds: value }
}
// --- Test OTP bypass allowlist ---
//
// Phone-scoped static-OTP allowlist for Apple App Store reviewers and similar
// pre-launch QA. When the phone in requestOtp() matches an entry here, the
// backend skips Fazpass entirely and plants the entry's pre-hashed OTP into
// otp_requests so the existing verify path works unchanged.
//
// Storage shape:
// {
// enabled: boolean, // global kill switch
// entries: [
// {
// id: uuid,
// phone: "+E.164",
// user_type: "client" | "mitra",
// otp_hash: "$2b$10$...", // bcrypt; plaintext NEVER stored
// label: "Apple Reviewer #1",
// expires_at: "ISO-8601", // per-entry auto-disable
// created_at: "ISO-8601",
// },
// ...
// ],
// }
//
// Plaintext OTP is accepted by setTestOtpBypass at write time, bcrypt-hashed
// before persisting, and is never readable again — list/get returns hashes
// only, callers re-create entries to rotate the secret.
const TEST_OTP_BYPASS_KEY = 'test_otp_bypass'
const PHONE_E164_RE = /^\+[1-9]\d{6,14}$/
const STATIC_OTP_RE = /^\d{4,8}$/
const isValidIsoDate = (v) => {
if (typeof v !== 'string') return false
const d = new Date(v)
return !Number.isNaN(d.getTime())
}
const sanitizeEntry = (entry) => ({
id: entry.id,
phone: entry.phone,
user_type: entry.user_type,
label: entry.label,
// otp_hash is intentionally returned so the CC can show "hash on file" but
// never the plaintext. We could redact further if the CC ever leaks logs.
otp_hash: entry.otp_hash,
expires_at: entry.expires_at,
created_at: entry.created_at,
})
const loadRawBypass = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = ${TEST_OTP_BYPASS_KEY}`
const value = row?.value ?? { enabled: false, entries: [] }
return {
enabled: value.enabled === true,
entries: Array.isArray(value.entries) ? value.entries : [],
}
}
const persistBypass = async ({ enabled, entries }) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES (${TEST_OTP_BYPASS_KEY}, ${sql.json({ enabled, entries })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
export const getTestOtpBypass = async () => {
const raw = await loadRawBypass()
return {
enabled: raw.enabled,
entries: raw.entries.map(sanitizeEntry),
}
}
/**
* Hot-path matcher used by requestOtp(). Returns the matching entry (with
* otp_hash) if (kill switch on) + (phone exact match) + (not expired) +
* (user_type matches). Returns null otherwise.
*
* Every call is a fresh DB SELECT — same pattern as the other config getters.
* Cache TBD (see project memory: `config_cache_pending`).
*/
export const getTestOtpBypassMatch = async ({ phone, userType }) => {
const raw = await loadRawBypass()
if (!raw.enabled) return null
const now = Date.now()
for (const entry of raw.entries) {
if (entry.phone !== phone) continue
if (entry.user_type !== userType) continue
if (!entry.expires_at) continue
const exp = new Date(entry.expires_at).getTime()
if (!Number.isFinite(exp) || exp <= now) continue
return entry
}
return null
}
export const setTestOtpBypassEnabled = async (enabled) => {
if (typeof enabled !== 'boolean') {
throw Object.assign(new Error('enabled must be a boolean'), {
code: 'VALIDATION_ERROR', statusCode: 422,
})
}
const raw = await loadRawBypass()
await persistBypass({ ...raw, enabled })
return getTestOtpBypass()
}
/**
* Add an entry. `otp` is plaintext (4-8 digits); we hash before persisting
* and do not return it after. Phone must be E.164. user_type must match the
* UserType enum (client | mitra). expires_at is required and must be in the
* future. Duplicate (phone, user_type) is rejected.
*/
export const addTestOtpBypassEntry = async ({ phone, otp, user_type, label, expires_at }) => {
if (typeof phone !== 'string' || !PHONE_E164_RE.test(phone)) {
throw Object.assign(new Error('phone must be E.164 (e.g. +628...)'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'phone',
})
}
if (typeof otp !== 'string' || !STATIC_OTP_RE.test(otp)) {
throw Object.assign(new Error('otp must be a 4-8 digit numeric string'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'otp',
})
}
if (user_type !== UserType.CUSTOMER && user_type !== UserType.MITRA) {
throw Object.assign(new Error(`user_type must be "${UserType.CUSTOMER}" or "${UserType.MITRA}"`), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'user_type',
})
}
if (typeof label !== 'string' || label.trim().length === 0) {
throw Object.assign(new Error('label is required'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'label',
})
}
if (!isValidIsoDate(expires_at)) {
throw Object.assign(new Error('expires_at must be an ISO-8601 datetime string'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
})
}
if (new Date(expires_at).getTime() <= Date.now()) {
throw Object.assign(new Error('expires_at must be in the future'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
})
}
const raw = await loadRawBypass()
if (raw.entries.some((e) => e.phone === phone && e.user_type === user_type)) {
throw Object.assign(new Error('An entry for this phone + user_type already exists'), {
code: 'DUPLICATE_ENTRY', statusCode: 422, field: 'phone',
})
}
const otpHash = await bcrypt.hash(otp, TEST_OTP_BYPASS_BCRYPT_COST)
const entry = {
id: crypto.randomUUID(),
phone,
user_type,
label: label.trim(),
otp_hash: otpHash,
expires_at: new Date(expires_at).toISOString(),
created_at: new Date().toISOString(),
}
raw.entries.push(entry)
await persistBypass(raw)
return sanitizeEntry(entry)
}
/**
* Patch an entry by id. Supported fields: label, expires_at, otp (plaintext
* → rehashed). Phone and user_type are immutable — delete + re-add to change
* them, so the audit trail stays clean.
*/
export const updateTestOtpBypassEntry = async (id, patch) => {
if (typeof id !== 'string' || id.length === 0) {
throw Object.assign(new Error('id is required'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'id',
})
}
const raw = await loadRawBypass()
const idx = raw.entries.findIndex((e) => e.id === id)
if (idx < 0) {
throw Object.assign(new Error('Entry not found'), {
code: 'NOT_FOUND', statusCode: 404,
})
}
const current = raw.entries[idx]
const next = { ...current }
if (patch.label !== undefined) {
if (typeof patch.label !== 'string' || patch.label.trim().length === 0) {
throw Object.assign(new Error('label must be a non-empty string'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'label',
})
}
next.label = patch.label.trim()
}
if (patch.expires_at !== undefined) {
if (!isValidIsoDate(patch.expires_at)) {
throw Object.assign(new Error('expires_at must be an ISO-8601 datetime string'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
})
}
if (new Date(patch.expires_at).getTime() <= Date.now()) {
throw Object.assign(new Error('expires_at must be in the future'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
})
}
next.expires_at = new Date(patch.expires_at).toISOString()
}
if (patch.otp !== undefined) {
if (typeof patch.otp !== 'string' || !STATIC_OTP_RE.test(patch.otp)) {
throw Object.assign(new Error('otp must be a 4-8 digit numeric string'), {
code: 'VALIDATION_ERROR', statusCode: 422, field: 'otp',
})
}
next.otp_hash = await bcrypt.hash(patch.otp, TEST_OTP_BYPASS_BCRYPT_COST)
}
raw.entries[idx] = next
await persistBypass(raw)
return sanitizeEntry(next)
}
export const deleteTestOtpBypassEntry = async (id) => {
const raw = await loadRawBypass()
const before = raw.entries.length
raw.entries = raw.entries.filter((e) => e.id !== id)
if (raw.entries.length === before) {
throw Object.assign(new Error('Entry not found'), {
code: 'NOT_FOUND', statusCode: 404,
})
}
await persistBypass(raw)
return { deleted: true, id }
}

View File

@@ -0,0 +1,192 @@
// Fazpass OTP provider client.
//
// Two endpoints per Fazpass docs:
// POST /v1/otp/request — Fazpass creates the OTP, masks it in response,
// owns verification (mandatory at provider).
// POST /v1/otp/verify — submit { otp_id, otp } back to provider.
//
// Auth: Authorization: Bearer <MERCHANT_KEY>. Channel selection is via
// `gateway_key` in the body — one gateway per provider (we use a single
// gateway today, so the client-supplied OtpChannel is informational).
//
// Error policy:
// - Transport/timeout failures → throw FazpassError (502 upstream)
// - 4xx with parseable body → throw FazpassError with the body's
// `code` + `message` for log triage
// - 2xx with `status: false` → success-shaped failure path; for
// /request this is a provider reject
// (throws), for /verify this is the
// normal "wrong OTP" (returns {valid:false})
// - 2xx with `status: true` (or undefined on legacy responses)
// → success
//
// We do NOT trust HTTP status alone — Fazpass occasionally returns 200 OK with
// `status: false` for legitimate "wrong code" responses, which must not be
// reported as outages. The verify path differentiates this from a real outage
// by always parsing the body and only escalating to FazpassError if the body
// is unparseable or the http status is non-2xx.
import { getFazpassConfig } from './config.service.js'
export class FazpassError extends Error {
constructor(message, { httpStatus, providerCode, providerMessage, cause } = {}) {
super(message)
this.name = 'FazpassError'
this.code = 'OTP_PROVIDER_FAILED'
this.statusCode = 502
this.httpStatus = httpStatus ?? null
this.providerCode = providerCode ?? null
this.providerMessage = providerMessage ?? null
if (cause) this.cause = cause
}
}
const buildHeaders = (merchantKey) => ({
'Authorization': `Bearer ${merchantKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
})
const parseJsonSafe = async (res) => {
try {
return await res.json()
} catch {
return null
}
}
const postJson = async ({ url, body, headers, timeoutMs, logger }) => {
let res
try {
res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
})
} catch (err) {
// AbortError (timeout) or network failure — log once, then escalate.
logger?.error({ err: { message: err?.message, name: err?.name }, url }, 'Fazpass HTTP error')
throw new FazpassError(`Fazpass request failed: ${err?.message ?? 'unknown error'}`, { cause: err })
}
return res
}
/**
* Request an OTP from Fazpass. Returns { reference, channel_used, provider }.
* Throws FazpassError on transport, non-2xx, or status:false response.
*/
export const fazpassRequestOtp = async ({ phone, logger }) => {
const cfg = getFazpassConfig()
if (!cfg.enabled) {
throw new FazpassError('Fazpass is not enabled (FAZPASS_ENABLED=false)')
}
if (!cfg.merchantKey || !cfg.gatewayKey) {
throw new FazpassError('Fazpass credentials missing — set FAZPASS_MERCHANT_KEY and FAZPASS_GATEWAY_KEY')
}
const url = `${cfg.baseUrl}/v1/otp/request`
const res = await postJson({
url,
headers: buildHeaders(cfg.merchantKey),
body: { phone, gateway_key: cfg.gatewayKey },
timeoutMs: cfg.timeoutMs,
logger,
})
const json = await parseJsonSafe(res)
if (!res.ok) {
logger?.warn({
event: 'fazpass.request.non_2xx',
http_status: res.status,
provider_code: json?.code ?? null,
provider_message: json?.message ?? null,
}, 'Fazpass /request non-2xx')
throw new FazpassError(`Fazpass /request returned HTTP ${res.status}`, {
httpStatus: res.status,
providerCode: json?.code ?? null,
providerMessage: json?.message ?? null,
})
}
if (json?.status !== true || !json?.data?.id) {
logger?.warn({
event: 'fazpass.request.bad_shape',
http_status: res.status,
provider_code: json?.code ?? null,
provider_message: json?.message ?? null,
has_id: !!json?.data?.id,
}, 'Fazpass /request returned status:false or missing id')
throw new FazpassError('Fazpass /request returned a non-success body', {
httpStatus: res.status,
providerCode: json?.code ?? null,
providerMessage: json?.message ?? null,
})
}
return {
reference: json.data.id,
channel_used: json.data.channel ?? null,
provider: json.data.provider ?? null,
}
}
/**
* Verify an OTP via Fazpass. Returns { valid, providerCode, providerMessage }.
* - 2xx + status:true → { valid: true }
* - 2xx + status:false → { valid: false } (normal "wrong code")
* - non-2xx or bad shape → throws FazpassError
*
* The throw policy intentionally separates "wrong code" (a normal UX path,
* returns valid:false) from "provider outage / our state is corrupt" (a 502).
* If Fazpass starts using 4xx for legitimate wrong-code responses we'll need
* to re-classify based on their `code` field — surfaced in the logs.
*/
export const fazpassVerifyOtp = async ({ reference, code, logger }) => {
const cfg = getFazpassConfig()
if (!cfg.enabled) {
throw new FazpassError('Fazpass is not enabled (FAZPASS_ENABLED=false)')
}
if (!cfg.merchantKey) {
throw new FazpassError('Fazpass credentials missing — set FAZPASS_MERCHANT_KEY')
}
const url = `${cfg.baseUrl}/v1/otp/verify`
const res = await postJson({
url,
headers: buildHeaders(cfg.merchantKey),
body: { otp_id: reference, otp: code },
timeoutMs: cfg.timeoutMs,
logger,
})
const json = await parseJsonSafe(res)
if (!res.ok) {
logger?.warn({
event: 'fazpass.verify.non_2xx',
http_status: res.status,
provider_code: json?.code ?? null,
provider_message: json?.message ?? null,
}, 'Fazpass /verify non-2xx')
throw new FazpassError(`Fazpass /verify returned HTTP ${res.status}`, {
httpStatus: res.status,
providerCode: json?.code ?? null,
providerMessage: json?.message ?? null,
})
}
if (json == null || typeof json.status !== 'boolean') {
logger?.warn({
event: 'fazpass.verify.bad_shape',
http_status: res.status,
body_keys: json ? Object.keys(json) : null,
}, 'Fazpass /verify returned malformed body')
throw new FazpassError('Fazpass /verify returned a malformed body', {
httpStatus: res.status,
})
}
return {
valid: json.status === true,
providerCode: json.code ?? null,
providerMessage: json.message ?? null,
}
}

View File

@@ -1,24 +1,32 @@
import crypto from 'node:crypto'
import bcrypt from 'bcrypt'
import { getDb } from '../db/client.js'
import { getOtpRateLimits } from './config.service.js'
import { getOtpRateLimits, getTestOtpBypassMatch, getFazpassConfig } from './config.service.js'
import { fazpassRequestOtp, fazpassVerifyOtp, FazpassError } from './fazpass.service.js'
import { OtpChannel, UserType } from '../constants.js'
const sql = getDb()
const OTP_TTL_MINUTES = 5
// bcrypt cost for OTP codes. Lower than password (12) because OTPs live 5 min
// and the verify call happens once per attempt — total budget ~80ms per verify
// is fine, and the lower cost makes the verify SLA tighter on slow Cloud Run
// cold starts.
const OTP_BCRYPT_COST = 10
// -------------------------------------------------------------------
// Fazpass integration — STUB until real API docs are obtained.
// 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.
// In production, Fazpass is the source of truth for the OTP code: the backend
// never sees the plaintext code. The stub generates a 6-digit code locally,
// bcrypt-hashes it into otp_requests.code_hash, and ships the plaintext only
// to in-memory (for /peek-otp dev convenience) and to the dev console log.
//
// When real docs arrive, replace fazpassSendStub + fazpassVerifyStub
// with real HTTP calls and drop the local code generation.
// When real docs arrive: replace fazpassSendStub with a real HTTP call, and
// stop writing code_hash on the normal path (Fazpass owns verification then).
// The bypass path keeps writing code_hash exactly as it does today — that's
// the only place backend-owned verification survives post-cutover.
// -------------------------------------------------------------------
const generate6DigitCode = () => {
@@ -47,10 +55,6 @@ const fazpassSendStub = async ({ phone, channel }) => {
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 {
@@ -132,7 +136,7 @@ const checkRateLimits = async ({ phone, ipAddress, limits }) => {
* 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 }) => {
export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChannel.WHATSAPP, logger }) => {
if (!phone || !/^\+[1-9]\d{6,14}$/.test(phone)) {
throw new OtpError('Invalid phone format (E.164 expected)', 'PHONE_INVALID', 422)
}
@@ -143,19 +147,92 @@ export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChan
const limits = await getOtpRateLimits()
await checkRateLimits({ phone, ipAddress, limits })
const { reference, channel_used, code } = await fazpassSendStub({ phone, channel })
// Test-user bypass: when this phone is in the CC-managed allowlist,
// plant a pre-hashed static OTP and skip Fazpass entirely. Logged loudly so
// any successful bypass is visible in audit pipelines.
const bypassEntry = await getTestOtpBypassMatch({ phone, userType })
if (bypassEntry) {
const [row] = await sql`
INSERT INTO otp_requests (
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
is_bypass, code_hash
)
VALUES (
${phone}, ${ipAddress ?? null}, ${userType}, NULL, ${channel},
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
TRUE, ${bypassEntry.otp_hash}
)
RETURNING id, expires_at
`
if (logger) {
logger.info({
event: 'test_otp_bypass.request',
otp_request_id: row.id,
label: bypassEntry.label,
phone_last4: phone.slice(-4),
user_type: userType,
}, 'test OTP bypass triggered')
}
return {
otp_request_id: row.id,
channel_used: channel,
expires_at: row.expires_at,
}
}
// 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
// Live Fazpass path. Provider owns the code AND verification — we only
// hold the reference. code_hash MUST stay NULL so verifyOtp's branching
// routes this row to Fazpass (the DB CHECK constraint also relies on the
// is_bypass=false shape we set here).
const fazpass = getFazpassConfig()
if (fazpass.enabled) {
const { reference, channel_used: providerChannel, provider } = await fazpassRequestOtp({
phone, logger,
})
if (logger) {
logger.info({
event: 'fazpass.request.ok',
phone_last4: phone.slice(-4),
provider, provider_channel: providerChannel, requested_channel: channel,
}, 'Fazpass OTP request succeeded')
}
const [row] = await sql`
INSERT INTO otp_requests (
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
is_bypass, code_hash
)
VALUES (
${phone}, ${ipAddress ?? null}, ${userType}, ${reference}, ${channel},
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
FALSE, NULL
)
RETURNING id, expires_at
`
return {
otp_request_id: row.id,
// Echo the client-requested channel for backwards compatibility — apps
// already render this in user-facing strings. Provider's internal
// channel code lives in logs only.
channel_used: channel,
expires_at: row.expires_at,
}
}
// Stub fallback (FAZPASS_ENABLED=false). Generates a local 6-digit code,
// stores its bcrypt hash, and lets the in-memory peek endpoint expose the
// plaintext for Maestro / dev. Removed once Fazpass is the only path.
const { reference, channel_used, code } = await fazpassSendStub({ phone, channel })
const codeHash = await bcrypt.hash(code, OTP_BCRYPT_COST)
const [row] = await sql`
INSERT INTO otp_requests (phone, ip_address, user_type, fazpass_reference, channel, expires_at)
INSERT INTO otp_requests (
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
is_bypass, code_hash
)
VALUES (
${phone}, ${ipAddress ?? null}, ${userType}, ${storedReference}, ${channel_used},
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval
${phone}, ${ipAddress ?? null}, ${userType}, ${reference}, ${channel_used},
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
FALSE, ${codeHash}
)
RETURNING id, expires_at
`
@@ -171,7 +248,7 @@ export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChan
* Verify an OTP code. Returns { phone, user_type } on success.
* Throws OtpError on failure.
*/
export const verifyOtp = async ({ otpRequestId, code }) => {
export const verifyOtp = async ({ otpRequestId, code, logger }) => {
if (typeof code !== 'string' || !/^\d{4,8}$/.test(code)) {
throw new OtpError('Invalid code format', 'CODE_INVALID', 422)
}
@@ -179,7 +256,8 @@ export const verifyOtp = async ({ otpRequestId, code }) => {
const limits = await getOtpRateLimits()
const [row] = await sql`
SELECT id, phone, user_type, fazpass_reference, attempts, used_at, expires_at
SELECT id, phone, user_type, fazpass_reference, attempts, used_at, expires_at,
is_bypass, code_hash
FROM otp_requests
WHERE id = ${otpRequestId}
`
@@ -198,9 +276,86 @@ export const verifyOtp = async ({ otpRequestId, code }) => {
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 })
// Verification routing: the is_bypass flag is sovereign — never use the
// mere presence of code_hash to decide which verifier runs, because a
// bug or errant migration could leave code_hash populated on a normal row.
let valid = false
if (row.is_bypass) {
if (!row.code_hash) {
// DB CHECK constraint should make this impossible, but defend anyway.
if (logger) {
logger.error({ otp_request_id: row.id }, 'bypass row missing code_hash — refusing')
}
throw new OtpError('OTP system error', 'OTP_CORRUPT', 500)
}
valid = await bcrypt.compare(code, row.code_hash)
if (valid && logger) {
logger.info({
event: 'test_otp_bypass.verify_success',
otp_request_id: row.id,
phone_last4: row.phone.slice(-4),
user_type: row.user_type,
}, 'test OTP bypass verified')
}
} else {
// Normal row. Routing depends on which mode wrote it:
// - stub-mode row → code_hash is set, bcrypt-compare locally
// - Fazpass-live row → code_hash is NULL, defer to provider
// Distinguishing by code_hash presence is safe here because the
// is_bypass=true case is already handled above; this branch only sees
// normal rows where the writer's mode is encoded by which fields they
// populated (CHECK constraint ensures bypass rows can't reach here).
if (row.code_hash) {
valid = await bcrypt.compare(code, row.code_hash)
} else {
if (!row.fazpass_reference) {
// Both code_hash AND fazpass_reference are NULL — row is unverifiable
// (a bug, partial write, or someone tampering). Don't fall through to
// "valid by default"; reject and alert.
if (logger) {
logger.error({ otp_request_id: row.id }, 'non-bypass row has no code_hash and no fazpass_reference — unverifiable')
}
throw new OtpError('OTP system error', 'OTP_CORRUPT', 500)
}
try {
const result = await fazpassVerifyOtp({
reference: row.fazpass_reference,
code,
logger,
})
valid = result.valid
if (!valid && logger) {
logger.info({
event: 'fazpass.verify.invalid',
otp_request_id: row.id,
provider_code: result.providerCode,
provider_message: result.providerMessage,
}, 'Fazpass reported invalid OTP — surfacing as CODE_MISMATCH')
}
} catch (err) {
// Provider outage / our state corrupt / Fazpass schema drift.
// Distinct from "wrong code" — preserve attempt increment but throw
// 502 so the client distinguishes "retry the code" from "retry later".
if (err instanceof FazpassError) {
if (logger) {
logger.error({
err: {
message: err.message,
provider_code: err.providerCode,
provider_message: err.providerMessage,
http_status: err.httpStatus,
},
otp_request_id: row.id,
}, 'Fazpass verify failed (provider-side)')
}
throw new OtpError('OTP verification temporarily unavailable', 'OTP_PROVIDER_FAILED', 502, {
provider_code: err.providerCode,
})
}
throw err
}
}
}
if (!valid) {
throw new OtpError('Incorrect code', 'CODE_MISMATCH', 401)

View File

@@ -203,12 +203,6 @@ export const getExtensionPriceTiers = async (customerId) => {
// ---- Back-compat aliases (will be removed after Phase 4 frontend cutover) ----
/**
* @deprecated Use isCustomerEligibleForFirstSessionDiscount.
* Kept so route handlers and migrated services still resolve while we cut over.
*/
export const isCustomerEligibleForFreeTrial = isCustomerEligibleForFirstSessionDiscount
/**
* @deprecated Tiers now live in `chat`/`call` groups; callers should pick one.
* Returns chat tiers in the legacy shape (single array, no group wrapper).

View File

@@ -0,0 +1,176 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
// Imported lazily after env stubs so the config getter reads the test values.
let fazpassRequestOtp
let fazpassVerifyOtp
let FazpassError
const setFazpassEnv = () => {
vi.stubEnv('FAZPASS_ENABLED', 'true')
vi.stubEnv('FAZPASS_BASE_URL', 'https://api.fazpass.example')
vi.stubEnv('FAZPASS_MERCHANT_KEY', 'test-merchant-key')
vi.stubEnv('FAZPASS_GATEWAY_KEY', 'test-gateway-key')
vi.stubEnv('FAZPASS_TIMEOUT_MS', '5000')
}
beforeEach(async () => {
setFazpassEnv()
// Re-import so the module's top-level closures use the stubbed env.
// getFazpassConfig reads process.env at call time so this is mostly a safety
// belt — but it also ensures the test isn't depending on import order.
const mod = await import('../../src/services/fazpass.service.js')
fazpassRequestOtp = mod.fazpassRequestOtp
fazpassVerifyOtp = mod.fazpassVerifyOtp
FazpassError = mod.FazpassError
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
const mockFetch = (impl) => vi.spyOn(globalThis, 'fetch').mockImplementation(impl)
const jsonResponse = (status, body) => new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
describe('fazpassRequestOtp', () => {
it('POSTs phone + gateway_key with Bearer auth and returns reference', async () => {
let captured
mockFetch(async (url, init) => {
captured = { url, init }
return jsonResponse(200, {
status: true,
message: 'Request generated successfully',
code: '2000200',
data: {
id: 'abc-123', otp: 'XXXXXX', otp_length: 6,
channel: 'WA_GENERIC_OTP', provider: 'Fazpass', purpose: 'testing',
},
})
})
const result = await fazpassRequestOtp({ phone: '+628111' })
expect(result).toEqual({
reference: 'abc-123',
channel_used: 'WA_GENERIC_OTP',
provider: 'Fazpass',
})
expect(captured.url).toBe('https://api.fazpass.example/v1/otp/request')
expect(captured.init.method).toBe('POST')
expect(captured.init.headers.Authorization).toBe('Bearer test-merchant-key')
expect(captured.init.headers['Content-Type']).toBe('application/json')
expect(JSON.parse(captured.init.body)).toEqual({
phone: '+628111',
gateway_key: 'test-gateway-key',
})
})
it('throws FazpassError on non-2xx with provider code surfaced', async () => {
mockFetch(async () => jsonResponse(400, { status: false, code: '4000400', message: 'bad gateway_key' }))
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toMatchObject({
code: 'OTP_PROVIDER_FAILED',
statusCode: 502,
httpStatus: 400,
providerCode: '4000400',
providerMessage: 'bad gateway_key',
})
})
it('throws FazpassError when 2xx body has status:false', async () => {
mockFetch(async () => jsonResponse(200, { status: false, code: '5000500', message: 'gateway down' }))
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', providerCode: '5000500' })
})
it('throws FazpassError when 2xx body is missing data.id', async () => {
mockFetch(async () => jsonResponse(200, { status: true, data: { otp: 'XXXXXX' } }))
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toBeInstanceOf(FazpassError)
})
it('throws FazpassError on transport / timeout error', async () => {
mockFetch(async () => { throw new Error('network down') })
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', httpStatus: null })
})
it('throws when FAZPASS_ENABLED is false', async () => {
vi.stubEnv('FAZPASS_ENABLED', 'false')
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
})
it('throws when merchantKey or gatewayKey are blank', async () => {
vi.stubEnv('FAZPASS_MERCHANT_KEY', '')
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
})
})
describe('fazpassVerifyOtp', () => {
it('POSTs otp_id + otp with Bearer auth and returns valid:true on status:true', async () => {
let captured
mockFetch(async (url, init) => {
captured = { url, init }
return jsonResponse(200, {
status: true, message: 'Validate otp successfully', code: '2000200',
})
})
const result = await fazpassVerifyOtp({ reference: 'abc-123', code: '424242' })
expect(result).toEqual({
valid: true,
providerCode: '2000200',
providerMessage: 'Validate otp successfully',
})
expect(captured.url).toBe('https://api.fazpass.example/v1/otp/verify')
expect(JSON.parse(captured.init.body)).toEqual({
otp_id: 'abc-123', otp: '424242',
})
})
it('returns valid:false on 2xx + status:false (the "wrong OTP" path)', async () => {
mockFetch(async () => jsonResponse(200, {
status: false, message: 'Invalid OTP', code: '4000401',
}))
const result = await fazpassVerifyOtp({ reference: 'abc-123', code: '000000' })
expect(result.valid).toBe(false)
expect(result.providerCode).toBe('4000401')
})
it('throws FazpassError on non-2xx (provider outage)', async () => {
mockFetch(async () => jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' }))
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
.rejects.toMatchObject({
code: 'OTP_PROVIDER_FAILED',
httpStatus: 503,
providerCode: '5030503',
})
})
it('throws FazpassError on malformed body (no status field)', async () => {
mockFetch(async () => new Response('not json', { status: 200, headers: { 'Content-Type': 'text/plain' } }))
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
.rejects.toBeInstanceOf(FazpassError)
})
it('throws FazpassError on network error', async () => {
mockFetch(async () => { throw new Error('connection reset') })
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
})
})

View File

@@ -0,0 +1,436 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { randomUUID } from 'node:crypto'
import bcrypt from 'bcrypt'
const { requestOtp, verifyOtp, OtpError } = await import('../../src/services/otp.service.js')
const {
addTestOtpBypassEntry,
setTestOtpBypassEnabled,
getTestOtpBypass,
} = await import('../../src/services/config.service.js')
const { db, resetDb } = await import('../helpers/db.js')
// Unique phone per test so rate-limit (3 per hour per phone) doesn't poison
// tests that reuse otp_requests rows. resetDb() truncates otp_requests but
// keeps the rate-limit guarantee tight regardless.
const uniquePhone = () => {
const digits = randomUUID().replace(/[^0-9]/g, '').slice(0, 10).padEnd(10, '0')
return `+628${digits}`
}
const clearBypassConfig = async () => {
const sql = db()
await sql`DELETE FROM app_config WHERE key = 'test_otp_bypass'`
}
const peekOtpRow = async (id) => {
const sql = db()
const [row] = await sql`
SELECT id, phone, fazpass_reference, is_bypass, code_hash, used_at, expires_at
FROM otp_requests WHERE id = ${id}
`
return row
}
describe('otp.service — hash-at-rest (stub mode)', () => {
beforeEach(async () => {
await resetDb()
await clearBypassConfig()
})
it('stores bcrypt(code_hash) instead of plaintext after requestOtp', async () => {
const phone = uniquePhone()
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.1',
})
const row = await peekOtpRow(otp_request_id)
expect(row).toBeDefined()
expect(row.is_bypass).toBe(false)
expect(row.code_hash).toMatch(/^\$2[aby]\$/) // bcrypt signature
// fazpass_reference holds ONLY the stub reference now — no ":code" suffix.
expect(row.fazpass_reference).toMatch(/^stub_/)
expect(row.fazpass_reference).not.toContain(':')
})
it('verifyOtp succeeds against the same plaintext code (via stub peek)', async () => {
const phone = uniquePhone()
// Pin the stub to a known code so we don't depend on the in-memory Map.
vi.stubEnv('OTP_STATIC_CODE', '424242')
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.1',
})
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '424242' })
expect(result).toEqual({ phone, user_type: 'customer' })
const used = await peekOtpRow(otp_request_id)
expect(used.used_at).not.toBeNull()
vi.unstubAllEnvs()
})
it('verifyOtp rejects a wrong code with CODE_MISMATCH', async () => {
const phone = uniquePhone()
vi.stubEnv('OTP_STATIC_CODE', '111111')
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.1',
})
await expect(verifyOtp({ otpRequestId: otp_request_id, code: '999999' }))
.rejects.toMatchObject({ code: 'CODE_MISMATCH', statusCode: 401 })
vi.unstubAllEnvs()
})
})
describe('otp.service — DB-level CHECK constraint', () => {
beforeEach(async () => {
await resetDb()
await clearBypassConfig()
})
it('rejects an insert with is_bypass=true and code_hash NULL', async () => {
const sql = db()
await expect(sql`
INSERT INTO otp_requests (phone, user_type, channel, expires_at, is_bypass, code_hash)
VALUES ('+628999999991', 'customer', 'whatsapp', NOW() + INTERVAL '5 min', true, NULL)
`).rejects.toMatchObject({ code: '23514' }) // PG check_violation
})
it('rejects an insert with is_bypass=true and fazpass_reference set', async () => {
const sql = db()
await expect(sql`
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
is_bypass, code_hash, fazpass_reference)
VALUES ('+628999999992', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
true, '$2b$10$abcdefghijklmnopqrstuv', 'leak_ref')
`).rejects.toMatchObject({ code: '23514' })
})
it('allows is_bypass=false with code_hash NULL (Fazpass-live shape) at insert time', async () => {
const sql = db()
const [row] = await sql`
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
is_bypass, code_hash, fazpass_reference)
VALUES ('+628999999993', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
false, NULL, 'fazpass_ref_xyz')
RETURNING id
`
expect(row.id).toBeDefined()
})
})
describe('otp.service — verify anomaly refusal', () => {
beforeEach(async () => {
await resetDb()
await clearBypassConfig()
})
it('rejects verify on a row missing BOTH code_hash and fazpass_reference (unverifiable)', async () => {
const sql = db()
const [row] = await sql`
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
is_bypass, code_hash, fazpass_reference)
VALUES ('+628999999994', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
false, NULL, NULL)
RETURNING id
`
await expect(verifyOtp({ otpRequestId: row.id, code: '123456' }))
.rejects.toMatchObject({ code: 'OTP_CORRUPT', statusCode: 500 })
})
it('returns OTP_PROVIDER_FAILED when row has fazpass_reference but Fazpass is disabled', async () => {
const sql = db()
const [row] = await sql`
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
is_bypass, code_hash, fazpass_reference)
VALUES ('+628999999998', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
false, NULL, 'fazpass_ref_xyz')
RETURNING id
`
// FAZPASS_ENABLED is unset/false in tests; fazpassVerifyOtp throws
// FazpassError, which otp.service.js converts to OTP_PROVIDER_FAILED 502.
await expect(verifyOtp({ otpRequestId: row.id, code: '123456' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 })
})
})
describe('otp.service — test OTP bypass allowlist', () => {
beforeEach(async () => {
await resetDb()
await clearBypassConfig()
})
afterEach(async () => {
await clearBypassConfig()
})
it('plants a bypass row that verifies against the configured static OTP', async () => {
const phone = uniquePhone()
await setTestOtpBypassEnabled(true)
await addTestOtpBypassEntry({
phone, otp: '294857', user_type: 'customer', label: 'Apple Reviewer #1',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.2',
})
const row = await peekOtpRow(otp_request_id)
expect(row.is_bypass).toBe(true)
expect(row.fazpass_reference).toBeNull()
expect(row.code_hash).toMatch(/^\$2[aby]\$/)
// Verify against the configured static OTP succeeds.
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '294857' })
expect(result).toEqual({ phone, user_type: 'customer' })
})
it('does not match when user_type differs (same phone for customer + mitra is distinct)', async () => {
const phone = uniquePhone()
await setTestOtpBypassEnabled(true)
await addTestOtpBypassEntry({
phone, otp: '111111', user_type: 'mitra', label: 'Internal QA Mitra',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
// Customer request to the same phone → falls through to stub.
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.3',
})
const row = await peekOtpRow(otp_request_id)
expect(row.is_bypass).toBe(false)
expect(row.fazpass_reference).toMatch(/^stub_/)
})
it('does not match when the entry has expired', async () => {
const phone = uniquePhone()
await setTestOtpBypassEnabled(true)
// addTestOtpBypassEntry refuses past dates, so set a valid future date,
// then manually backdate the entry via SQL — emulating "this entry has
// been sitting in the list for too long".
await addTestOtpBypassEntry({
phone, otp: '222222', user_type: 'customer', label: 'Old Reviewer',
expires_at: new Date(Date.now() + 60_000).toISOString(),
})
const sql = db()
await sql`
UPDATE app_config
SET value = jsonb_set(
value,
'{entries,0,expires_at}',
to_jsonb(${new Date(Date.now() - 60_000).toISOString()}::text)
)
WHERE key = 'test_otp_bypass'
`
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.4',
})
const row = await peekOtpRow(otp_request_id)
expect(row.is_bypass).toBe(false)
})
it('does not match when the global kill switch is off', async () => {
const phone = uniquePhone()
await setTestOtpBypassEnabled(true)
await addTestOtpBypassEntry({
phone, otp: '333333', user_type: 'customer', label: 'Disabled later',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
// Flip the kill switch off — entries remain but no longer match.
await setTestOtpBypassEnabled(false)
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.5',
})
const row = await peekOtpRow(otp_request_id)
expect(row.is_bypass).toBe(false)
})
it('rejects an entry whose plaintext OTP is malformed', async () => {
await expect(addTestOtpBypassEntry({
phone: '+628999999995', otp: 'abc', user_type: 'customer', label: 'Bad OTP',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
})
it('rejects an entry whose expires_at is in the past', async () => {
await expect(addTestOtpBypassEntry({
phone: '+628999999996', otp: '123456', user_type: 'customer', label: 'Stale',
expires_at: new Date(Date.now() - 60_000).toISOString(),
})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
})
it('rejects a duplicate (phone, user_type) entry', async () => {
const phone = uniquePhone()
const future = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
await addTestOtpBypassEntry({
phone, otp: '101010', user_type: 'customer', label: 'First',
expires_at: future,
})
await expect(addTestOtpBypassEntry({
phone, otp: '202020', user_type: 'customer', label: 'Second',
expires_at: future,
})).rejects.toMatchObject({ code: 'DUPLICATE_ENTRY' })
})
// No new tests in this describe — see "Fazpass-live mode" below for the
// request/verify integration coverage.
it('getTestOtpBypass returns the bcrypt hash, not the plaintext OTP', async () => {
const phone = uniquePhone()
await addTestOtpBypassEntry({
phone, otp: '424242', user_type: 'customer', label: 'Apple #1',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
const list = await getTestOtpBypass()
expect(list.entries).toHaveLength(1)
const entry = list.entries[0]
expect(entry.otp_hash).toMatch(/^\$2[aby]\$/)
// Defense-in-depth: serialised object must not contain the plaintext anywhere.
expect(JSON.stringify(entry)).not.toContain('424242')
// And the hash actually matches the plaintext (so verify works downstream).
expect(await bcrypt.compare('424242', entry.otp_hash)).toBe(true)
})
})
describe('otp.service — Fazpass-live mode (FAZPASS_ENABLED=true)', () => {
const setFazpassEnv = () => {
vi.stubEnv('FAZPASS_ENABLED', 'true')
vi.stubEnv('FAZPASS_BASE_URL', 'https://api.fazpass.example')
vi.stubEnv('FAZPASS_MERCHANT_KEY', 'mkey')
vi.stubEnv('FAZPASS_GATEWAY_KEY', 'gkey')
vi.stubEnv('FAZPASS_TIMEOUT_MS', '5000')
}
const mockFetch = (impl) => vi.spyOn(globalThis, 'fetch').mockImplementation(impl)
const jsonResponse = (status, body) => new Response(JSON.stringify(body), {
status, headers: { 'Content-Type': 'application/json' },
})
beforeEach(async () => {
setFazpassEnv()
await resetDb()
await clearBypassConfig()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
it('requestOtp stores fazpass_reference + leaves code_hash NULL when Fazpass returns success', async () => {
mockFetch(async () => jsonResponse(200, {
status: true,
data: {
id: 'fzp-ref-001', otp: 'XXXXXX', otp_length: 6,
channel: 'WA_GENERIC_OTP', provider: 'Fazpass', purpose: 'production',
},
}))
const phone = uniquePhone()
const { otp_request_id, channel_used } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.10', channel: 'whatsapp',
})
expect(channel_used).toBe('whatsapp') // API contract: echoes client-requested channel
const row = await peekOtpRow(otp_request_id)
expect(row.is_bypass).toBe(false)
expect(row.fazpass_reference).toBe('fzp-ref-001')
expect(row.code_hash).toBeNull()
})
it('requestOtp propagates Fazpass error and does NOT insert a row', async () => {
mockFetch(async () => jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' }))
const phone = uniquePhone()
await expect(requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.11', channel: 'whatsapp',
})).rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 })
const sql = db()
const [{ n }] = await sql`SELECT COUNT(*)::int AS n FROM otp_requests WHERE phone = ${phone}`
expect(n).toBe(0)
})
it('verifyOtp delegates to Fazpass and succeeds on status:true', async () => {
// Sequence: 1st fetch = /request, 2nd fetch = /verify.
let call = 0
mockFetch(async (url) => {
call++
if (url.endsWith('/v1/otp/request')) {
return jsonResponse(200, { status: true, data: { id: 'fzp-ref-002' } })
}
if (url.endsWith('/v1/otp/verify')) {
return jsonResponse(200, { status: true, code: '2000200' })
}
throw new Error(`unexpected url ${url}`)
})
const phone = uniquePhone()
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.12', channel: 'whatsapp',
})
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '424242' })
expect(result).toEqual({ phone, user_type: 'customer' })
expect(call).toBe(2)
const used = await peekOtpRow(otp_request_id)
expect(used.used_at).not.toBeNull()
})
it('verifyOtp surfaces wrong OTP as CODE_MISMATCH when Fazpass returns status:false', async () => {
mockFetch(async (url) => {
if (url.endsWith('/v1/otp/request')) {
return jsonResponse(200, { status: true, data: { id: 'fzp-ref-003' } })
}
return jsonResponse(200, { status: false, code: '4000401', message: 'Invalid OTP' })
})
const phone = uniquePhone()
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.13', channel: 'whatsapp',
})
await expect(verifyOtp({ otpRequestId: otp_request_id, code: '000000' }))
.rejects.toMatchObject({ code: 'CODE_MISMATCH', statusCode: 401 })
// Row stays unused — attempts incremented but not marked.
const row = await peekOtpRow(otp_request_id)
expect(row.used_at).toBeNull()
})
it('verifyOtp returns OTP_PROVIDER_FAILED 502 on Fazpass outage (distinct from wrong code)', async () => {
mockFetch(async (url) => {
if (url.endsWith('/v1/otp/request')) {
return jsonResponse(200, { status: true, data: { id: 'fzp-ref-004' } })
}
return jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' })
})
const phone = uniquePhone()
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.14', channel: 'whatsapp',
})
await expect(verifyOtp({ otpRequestId: otp_request_id, code: '424242' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 })
})
it('test-OTP bypass still works even when FAZPASS_ENABLED=true (skips Fazpass entirely)', async () => {
const fetchSpy = mockFetch(async () => {
throw new Error('Fazpass MUST NOT be called for bypass rows')
})
const phone = uniquePhone()
await setTestOtpBypassEnabled(true)
await addTestOtpBypassEntry({
phone, otp: '999000', user_type: 'customer', label: 'Apple #1',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.15', channel: 'whatsapp',
})
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '999000' })
expect(result).toEqual({ phone, user_type: 'customer' })
expect(fetchSpy).not.toHaveBeenCalled()
})
})