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

@@ -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,