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:
@@ -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')],
|
||||
|
||||
Reference in New Issue
Block a user