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:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user