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

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