diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a906d69..6c6adf0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -424,7 +424,217 @@ "Bash(tee /tmp/playwright-debug.log)", "Bash(curl -s -i -X OPTIONS http://localhost:3001/internal/config/payment-session-timeout -H 'Origin: http://localhost:5173' -H 'Access-Control-Request-Method: PATCH' -H 'Access-Control-Request-Headers: authorization,content-type')", "Bash(tee /tmp/playwright-run-6.log)", - "Bash(kill 882584)" + "Bash(kill 882584)", + "Bash(adb version *)", + "Bash(adb connect *)", + "Bash(sudo apt-get update -qq)", + "Bash(sudo apt-get install -y android-tools-adb)", + "Read(//home/ramad/flutter/bin/**)", + "Read(//home/ramad/development/flutter/bin/**)", + "Read(//snap/bin/**)", + "Read(//home/ramad/**)", + "Read(//mnt/c/src/flutter/bin/**)", + "Read(//mnt/c/flutter/bin/**)", + "Read(//mnt/c/Users/ramad/flutter/bin/**)", + "Read(//mnt/c/Users/ramad/**)", + "Read(//mnt/c/dev/flutter/**)", + "Bash(grep -E '^\\\\..*env|env$')", + "Bash(curl -fsSL https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json)", + "Bash(python3 -c \"import sys, json; d=json.load\\(sys.stdin\\); stable=d['current_release']['stable']; rel=next\\(r for r in d['releases'] if r['hash']==stable\\); print\\(rel['version']\\); print\\('https://storage.googleapis.com/' + d['base_url'].split\\('//'\\)[-1].split\\('storage.googleapis.com/'\\)[-1] + '/' + rel['archive']\\)\")", + "Bash(curl -fL -o flutter_linux.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.41.9-stable.tar.xz)", + "Bash(command -v node)", + "Bash(command -v npm)", + "Read(//usr/local/bin/**)", + "Bash(tar xf *)", + "Bash(export PATH=\"$HOME/flutter/bin:$PATH\")", + "Bash(~/flutter/bin/flutter --version)", + "Bash(~/flutter/bin/flutter doctor *)", + "Bash(mkdir -p ~/Android/Sdk/cmdline-tools)", + "Bash(curl -fL -o cmdline-tools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip)", + "Bash(unzip -q cmdline-tools.zip -d /tmp/cmdline-extract)", + "Bash(mv /tmp/cmdline-extract/cmdline-tools ~/Android/Sdk/cmdline-tools/latest)", + "Bash(python3 -m zipfile -e cmdline-tools.zip /tmp/cmdline-extract)", + "Bash(command -v java)", + "Bash(java -version)", + "Bash(chmod +x ~/Android/Sdk/cmdline-tools/latest/bin/*)", + "Bash(export ANDROID_HOME=\"$HOME/Android/Sdk\")", + "Bash(export PATH=\"$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH\")", + "Bash(sdkmanager --version)", + "Bash(sdkmanager --licenses)", + "Bash(sdkmanager \"platform-tools\" \"platforms;android-36\" \"build-tools;36.0.0\")", + "Bash(export PATH=\"$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/flutter/bin:$PATH\")", + "Bash(adb -s 192.168.88.247:5555 reverse tcp:3000 tcp:3000)", + "Bash(adb -s 192.168.88.247:5555 reverse --list)", + "Bash(adb reverse *)", + "Bash(adb kill-server *)", + "Bash(adb start-server *)", + "Bash(/usr/bin/adb version *)", + "Bash(~/Android/Sdk/platform-tools/adb version *)", + "Bash(export PATH=\"$HOME/Android/Sdk/platform-tools:$PATH\")", + "Bash(echo \"exit=$?\")", + "Bash(adb -t 1 reverse tcp:3000 tcp:3000)", + "Bash(adb shell *)", + "Bash(curl *)", + "Bash(awk '{print $1}')", + "Bash(ip route *)", + "Bash(awk '/default/ {print $3}')", + "Bash(export ADB_SERVER_SOCKET=tcp:172.22.240.1:5037)", + "Read(//etc/**)", + "Read(//mnt/wsl/**)", + "Bash(ip -br addr)", + "Bash(awk '{printf \"%s %s %s %s %s %s\\\\n\", $2, $8, $9, $10, $11, $12}')", + "Bash(adb forward *)", + "Bash(kill 3639 3663)", + "Bash(adb -s emulator-5554 forward --remove-all)", + "Bash(adb -s emulator-5556 forward --remove-all)", + "Bash(timeout 1 bash -c ' $\\(timeout 1 bash -c '&1 && echo OPEN || echo CLOSED\\)\")", + "Bash(timeout 1 bash -c ' $\\(timeout 1 bash -c '&1 && echo OPEN || echo CLOSED\\)\")", + "Bash(timeout 1 bash -c '/dev/null | grep -iE 'halo|mitra'\")", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 shell \"ps -A 2>/dev/null | grep -iE 'halo|mitra'\")", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -d -b crash -t 200)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -d -t 3000)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -d -t 5000)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -c)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat flutter:V AndroidRuntime:E '*:S')", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -d)", + "Bash(awk '{ts=$0; sub\\(/.*\"time\":/,\"\",ts\\); sub\\(/,.*/,\"\",ts\\); url=$0; sub\\(/.*\"url\":\"/,\"\",url\\); sub\\(/\".*/,\"\",url\\); print ts, url}')", + "Bash(awk -F'\"time\":|,\"pid\"|\"url\":\"|\"host\"' '{print $2, $4}')", + "Bash(awk -F'\"time\":|\"url\":\"|\"' '{print $2, $4}')", + "Bash(node --input-type=module -e ' *)", + "Bash(npx vitest *)", + "Bash(node --env-file=/home/ramad/workspace/halobestie/halobestie-clone/backend/.env -e ' *)", + "Bash(node -e \"require\\('dotenv'\\).config\\(\\); const jwt = require\\('jsonwebtoken'\\); const { randomUUID } = require\\('crypto'\\); console.log\\(jwt.sign\\({ user_type: 'customer', session_id: randomUUID\\(\\) }, process.env.AUTH_JWT_SECRET, { algorithm: 'HS256', expiresIn: 3600, subject: '10ebeb45-7e77-45e7-8177-d5db62539cce' }\\)\\)\")", + "Bash(tee /tmp/pricing-before.json)", + "Bash(tee /tmp/pricing-after.json)", + "Bash(node -e \"require\\('dotenv'\\).config\\(\\); const jwt = require\\('jsonwebtoken'\\); const { randomUUID } = require\\('crypto'\\); console.log\\(jwt.sign\\({ user_type: 'cc_user', session_id: randomUUID\\(\\) }, process.env.AUTH_JWT_SECRET, { algorithm: 'HS256', expiresIn: 3600, subject: '54d90715-d456-4bbe-a31d-a9ae4839b379' }\\)\\)\")", + "Bash(python3 -m json.tool)", + "Bash(node --check src/pages/settings/SettingsPage.jsx)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone status)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone log --oneline -5)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone remote -v)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff --stat backend/package-lock.json control_center/package-lock.json)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff backend/package.json control_center/package.json)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff backend/package-lock.json)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone commit -m ' *)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone log -1 --pretty=format:\"%an <%ae>\")", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone config --local --list)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone config --global --list)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone -c 'user.name=ramadhan sjamsani' -c user.email=ramadhan.sjamsani@gmail.com commit -m ' *)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone push origin master)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 npm run dev)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 python3 .dev/wsl_tcp_relay.py --watch-adb 172.22.240.1)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb devices)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 timeout 15 adb devices)", + "Bash(ip addr *)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 flutter run -d emulator-5554 --dart-define=API_BASE_URL=http://192.168.88.247:3000)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 flutter run -d emulator-5556 --dart-define=API_BASE_URL=http://192.168.88.247:3000)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 timeout 10 adb -s emulator-5556 shell getprop sys.boot_completed)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 timeout 10 adb -s emulator-5556 shell pm list packages com.halobestie)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 timeout 5 adb -s emulator-5556 shell \"ps -A | grep -E 'install|pm '\")", + "Bash(kill 23571)", + "Bash(ls ~/.maestro/bin/ 2>/dev/null; ls /opt/maestro/bin 2>/dev/null; find ~ -maxdepth 5 -name \"maestro\" -type f -executable 2>/dev/null | head -5; find /usr -maxdepth 5 -name \"maestro\" -type f -executable 2>/dev/null | head -5)", + "Read(//usr/**)", + "Bash(bash)", + "Bash(wget --no-verbose -O /tmp/maestro.zip \"https://github.com/mobile-dev-inc/maestro/releases/latest/download/maestro.zip\")", + "Bash(openssl version *)", + "Bash(mkdir -p ~/.maestro)", + "Bash(unzip -qo /tmp/maestro.zip -d ~/.maestro/tmp)", + "Bash(mv ~/.maestro/tmp/maestro/* ~/.maestro/)", + "Bash(~/.maestro/bin/maestro --version)", + "Bash(export PATH=\"$HOME/.maestro/bin:$PATH\")", + "Bash(export MAESTRO_CLI_NO_ANALYTICS=1)", + "Bash(maestro --device emulator-5554 test client_app/.maestro/flows/10_returning_repays.yaml)", + "Bash(~/.maestro/bin/maestro test *)", + "Bash(~/.maestro/bin/maestro --help)", + "Bash(~/.maestro/bin/maestro list-devices *)", + "Bash(python3 .dev/wsl_tcp_relay.py 5555 172.22.240.1 5555)", + "Bash(python3 .dev/wsl_tcp_relay.py 5557 172.22.240.1 5557)", + "Bash(timeout 5 bash -c 'echo \"test\" | nc -v 127.0.0.1 5555')", + "Bash(timeout 5 bash -c 'echo \"test\" | nc -v 172.22.240.1 5555')", + "Bash(timeout 30 ~/.maestro/bin/maestro --udid emulator-5554 list-devices)", + "Bash(timeout 5 bash -c 'exec 3<>/dev/tcp/172.22.240.1/5555 && echo OK_5555')", + "Bash(timeout 5 bash -c 'exec 3<>/dev/tcp/172.22.240.1/5557 && echo OK_5557')", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 ~/.maestro/bin/maestro test client_app/.maestro/flows/10_returning_repays.yaml)", + "Bash(python3 -c \"import json,sys; d=json.load\\(open\\('/home/ramad/.maestro/tests/2026-05-17_000444/commands-\\(10_returning_repays.yaml\\).json'\\)\\); print\\(json.dumps\\(d.get\\('commands',[d]\\)[-1] if isinstance\\(d, dict\\) else d[-1], indent=2\\)[:3000]\\)\")", + "Bash(timeout 30 ~/.maestro/bin/maestro hierarchy)", + "Bash(python3 -c ' *)", + "Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 timeout 5 adb devices)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff --stat requirement/phase4-customer-flow.md)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff requirement/phase4-customer-flow.md)", + "Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff backend/src/routes/internal/_test.routes.js)", + "Bash(awk '{print $3}')", + "Bash(kill 89692)", + "Bash(echo \"started pid $!\")", + "Bash(PGPASSWORD=postgres psql -h localhost -U postgres -d halobestie -c \"SELECT mitra_id, is_online, last_heartbeat_at FROM mitra_online_status WHERE is_online = true ORDER BY last_heartbeat_at DESC;\")", + "Bash(~/.maestro/bin/maestro --device emulator-5554 test client_app/.maestro/flows/ts-01_returning_lama_online.yaml)", + "Bash(~/.maestro/bin/maestro --udid 127.0.0.1:5554 test client_app/.maestro/flows/ts-01_returning_lama_online.yaml)", + "Bash(~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-01_returning_lama_online.yaml)", + "Bash(nohup python3 .dev/wsl_tcp_relay.py 5037 172.22.240.1 5037)", + "Bash(echo \"relay started pid $!\")", + "Bash(unset ADB_SERVER_SOCKET)", + "Bash(pkill -f \"maestro\")", + "Bash(kill 96710)", + "Bash(timeout 3 bash -c ' { 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 diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js index 5cb581b..1c44f35 100644 --- a/backend/src/routes/internal/config.routes.js +++ b/backend/src/routes/internal/config.routes.js @@ -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')], diff --git a/backend/src/routes/public/client.auth.routes.js b/backend/src/routes/public/client.auth.routes.js index 26bac15..7c39744 100644 --- a/backend/src/routes/public/client.auth.routes.js +++ b/backend/src/routes/public/client.auth.routes.js @@ -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, diff --git a/backend/src/routes/public/mitra.auth.routes.js b/backend/src/routes/public/mitra.auth.routes.js index 15ba831..e62feab 100644 --- a/backend/src/routes/public/mitra.auth.routes.js +++ b/backend/src/routes/public/mitra.auth.routes.js @@ -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, diff --git a/backend/src/services/config.service.js b/backend/src/services/config.service.js index fa6e7a3..ef44c2d 100644 --- a/backend/src/services/config.service.js +++ b/backend/src/services/config.service.js @@ -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 } +} diff --git a/backend/src/services/fazpass.service.js b/backend/src/services/fazpass.service.js new file mode 100644 index 0000000..8bd00f0 --- /dev/null +++ b/backend/src/services/fazpass.service.js @@ -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 . 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, + } +} diff --git a/backend/src/services/otp.service.js b/backend/src/services/otp.service.js index 91e7a54..97a3604 100644 --- a/backend/src/services/otp.service.js +++ b/backend/src/services/otp.service.js @@ -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 ":") 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 ":" - 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) diff --git a/backend/src/services/pricing.service.js b/backend/src/services/pricing.service.js index b804ce4..2716ac0 100644 --- a/backend/src/services/pricing.service.js +++ b/backend/src/services/pricing.service.js @@ -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). diff --git a/backend/test/services/fazpass.service.test.js b/backend/test/services/fazpass.service.test.js new file mode 100644 index 0000000..5e19f1b --- /dev/null +++ b/backend/test/services/fazpass.service.test.js @@ -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' }) + }) +}) diff --git a/backend/test/services/otp.service.test.js b/backend/test/services/otp.service.test.js new file mode 100644 index 0000000..822084f --- /dev/null +++ b/backend/test/services/otp.service.test.js @@ -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() + }) +}) diff --git a/control_center/src/pages/settings/SettingsPage.jsx b/control_center/src/pages/settings/SettingsPage.jsx index 0797270..d3ad2ed 100644 --- a/control_center/src/pages/settings/SettingsPage.jsx +++ b/control_center/src/pages/settings/SettingsPage.jsx @@ -23,17 +23,6 @@ const updateMaxCustomersConfig = async (max_customers_per_mitra) => { return res.data.data } -// Phase 3 config fetchers -const fetchFreeTrialConfig = async () => { - const res = await apiClient.get('/internal/config/free-trial') - return res.data.data -} - -const updateFreeTrialConfig = async (data) => { - const res = await apiClient.patch('/internal/config/free-trial', data) - return res.data.data -} - const fetchExtensionTimeoutConfig = async () => { const res = await apiClient.get('/internal/config/extension-timeout') return res.data.data @@ -160,6 +149,30 @@ const updateSupportHandles = async (patch) => { return res.data.data } +// Test OTP bypass allowlist — phone-scoped static OTPs for Apple reviewers / QA. +// Backend rejects requestOtp() to Fazpass for these phones; plaintext OTP is +// bcrypt-hashed on save and never readable after. +const fetchTestOtpBypass = async () => { + const res = await apiClient.get('/internal/config/test-otp-bypass') + return res.data.data +} +const setTestOtpBypassEnabled = async (enabled) => { + const res = await apiClient.patch('/internal/config/test-otp-bypass/enabled', { enabled }) + return res.data.data +} +const addTestOtpBypassEntry = async (body) => { + const res = await apiClient.post('/internal/config/test-otp-bypass/entries', body) + return res.data.data +} +const updateTestOtpBypassEntry = async ({ id, ...patch }) => { + const res = await apiClient.patch(`/internal/config/test-otp-bypass/entries/${id}`, patch) + return res.data.data +} +const deleteTestOtpBypassEntry = async (id) => { + const res = await apiClient.delete(`/internal/config/test-otp-bypass/entries/${id}`) + return res.data.data +} + export default function SettingsPage() { const queryClient = useQueryClient() const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig }) @@ -179,16 +192,6 @@ export default function SettingsPage() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }), }) - // Phase 3: Free Trial - const { data: ftData, isLoading: ftLoading } = useQuery({ - queryKey: ['config-free-trial'], - queryFn: fetchFreeTrialConfig, - }) - const ftMutation = useMutation({ - mutationFn: updateFreeTrialConfig, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-free-trial'] }), - }) - // Phase 3: Extension Timeout const { data: etData, isLoading: etLoading } = useQuery({ queryKey: ['config-extension-timeout'], @@ -331,10 +334,21 @@ export default function SettingsPage() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-support-handles'] }), }) + // Test OTP bypass allowlist. Single query, four mutations (enable + CRUD). + const { data: tobData, isLoading: tobLoading } = useQuery({ + queryKey: ['config-test-otp-bypass'], + queryFn: fetchTestOtpBypass, + }) + const invalidateTob = () => queryClient.invalidateQueries({ queryKey: ['config-test-otp-bypass'] }) + const tobEnabledMutation = useMutation({ mutationFn: setTestOtpBypassEnabled, onSuccess: invalidateTob }) + const tobAddMutation = useMutation({ mutationFn: addTestOtpBypassEntry, onSuccess: invalidateTob }) + const tobUpdateMutation = useMutation({ mutationFn: updateTestOtpBypassEntry, onSuccess: invalidateTob }) + const tobDeleteMutation = useMutation({ mutationFn: deleteTestOtpBypassEntry, onSuccess: invalidateTob }) + if ( - isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading || + isLoading || maxLoading || etLoading || eeLoading || mpLoading || senLoading || pbtLoading || pstLoading || rctLoading || edaLoading || - fsdLoading || ptLoading || shLoading + fsdLoading || ptLoading || shLoading || tobLoading ) return
Loading...
return ( @@ -376,36 +390,6 @@ export default function SettingsPage() { {maxMutation.isError &&

Gagal menyimpan.

} -
-

Free Trial

-

Aktifkan free trial untuk customer baru yang belum pernah bertransaksi.

- -
- - { - const val = parseInt(e.target.value, 10) - if (val >= 1) ftMutation.mutate({ duration_minutes: val }) - }} - disabled={ftMutation.isPending} - style={{ width: 80 }} - /> - menit -
- {ftMutation.isError &&

Gagal menyimpan.

} -
-

Extension Timeout

Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.

@@ -645,6 +629,15 @@ export default function SettingsPage() { ))}
+ {/* Test OTP bypass — Apple reviewer / QA static OTP allowlist */} + + {/* Phase 4: Support handles */}

Support Handles (Tanya Admin)

@@ -1107,3 +1100,293 @@ function FirstSessionDiscountSection({ data, mutation, toast, onDismissToast })
) } + +// ============================================================================ +// Test OTP Bypass — Apple-reviewer / QA static OTP allowlist +// ============================================================================ +// +// SECURITY-SENSITIVE: any phone in this list authenticates with a static OTP +// and never receives an SMS. Backend bcrypt-hashes the plaintext on save and +// never returns it again — to rotate an OTP, edit the entry and set a new one. +// +// The kill-switch toggle disables ALL entries instantly without touching the +// list, useful for incidents. Per-entry expires_at provides automatic disable. + +const formatExpiresAt = (iso) => { + if (!iso) return '—' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + return d.toLocaleString('id-ID', { dateStyle: 'short', timeStyle: 'short' }) +} + +const toDatetimeLocal = (iso) => { + // wants "YYYY-MM-DDTHH:mm" in local time. + if (!iso) return '' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + const pad = (n) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +const fromDatetimeLocal = (s) => { + // Convert local-tz "YYYY-MM-DDTHH:mm" back to ISO. new Date() handles it. + if (!s) return null + const d = new Date(s) + return Number.isNaN(d.getTime()) ? null : d.toISOString() +} + +const sectionErrorText = (err) => + err?.response?.data?.error?.message || err?.message || 'Gagal menyimpan.' + +function TestOtpBypassSection({ data, enabledMutation, addMutation, updateMutation, deleteMutation }) { + const [showAdd, setShowAdd] = useState(false) + const [editingId, setEditingId] = useState(null) + const entries = data?.entries ?? [] + const isExpired = (iso) => { + const t = new Date(iso).getTime() + return Number.isFinite(t) && t <= Date.now() + } + + return ( +
+

Test OTP Bypass (Apple Reviewer / QA)

+

+ Daftar nomor HP yang melewati Fazpass dan login dengan OTP statis. + Untuk reviewer App Store dan tester internal saja —{' '} + siapa pun yang tahu pasangan nomor + OTP bisa login sebagai user ini.{' '} + Jaga daftarnya kecil, tetapkan expires_at, dan hapus segera setelah pemakaian. +

+ + + {enabledMutation.isError && ( +

{sectionErrorText(enabledMutation.error)}

+ )} + +
+

Entri ({entries.length})

+ +
+ + {showAdd && ( + addMutation.mutate(body, { onSuccess: () => setShowAdd(false) })} + onCancel={() => setShowAdd(false)} + isPending={addMutation.isPending} + serverError={addMutation.isError ? sectionErrorText(addMutation.error) : null} + /> + )} + + + + + + + + + + + + + + {entries.length === 0 && ( + + )} + {entries.map((entry) => ( + editingId === entry.id ? ( + updateMutation.mutate( + { id: entry.id, ...patch }, + { onSuccess: () => setEditingId(null) }, + )} + onCancel={() => setEditingId(null)} + isPending={updateMutation.isPending} + serverError={updateMutation.isError ? sectionErrorText(updateMutation.error) : null} + /> + ) : ( + + + + + + + + + ) + ))} + +
PhoneUser typeLabelExpiresOTP hashAksi
Belum ada entri.
{entry.phone}{entry.user_type}{entry.label} + {formatExpiresAt(entry.expires_at)} + {isExpired(entry.expires_at) && ( + EXPIRED + )} + + {entry.otp_hash ? `${entry.otp_hash.slice(0, 12)}…` : '—'} + + + +
+ {deleteMutation.isError && ( +

{sectionErrorText(deleteMutation.error)}

+ )} +
+ ) +} + +function AddTestOtpBypassForm({ onSubmit, onCancel, isPending, serverError }) { + const [phone, setPhone] = useState('+62') + const [userType, setUserType] = useState('customer') + const [otp, setOtp] = useState('') + const [label, setLabel] = useState('') + // Default expiry: 30 days from now. + const defaultExpiry = (() => { + const d = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + return toDatetimeLocal(d.toISOString()) + })() + const [expiresAt, setExpiresAt] = useState(defaultExpiry) + const [localError, setLocalError] = useState(null) + + const submit = (e) => { + e.preventDefault() + if (!/^\+[1-9]\d{6,14}$/.test(phone)) return setLocalError('Phone harus format E.164 (mis. +628...).') + if (!/^\d{4,8}$/.test(otp)) return setLocalError('OTP harus 4-8 digit angka.') + if (label.trim().length === 0) return setLocalError('Label wajib diisi.') + const iso = fromDatetimeLocal(expiresAt) + if (!iso) return setLocalError('Tanggal expires_at tidak valid.') + if (new Date(iso).getTime() <= Date.now()) return setLocalError('expires_at harus di masa depan.') + + setLocalError(null) + onSubmit({ phone, user_type: userType, otp, label: label.trim(), expires_at: iso }) + } + + return ( +
+ + + + + + + + {(localError || serverError) && ( +
+ {localError || serverError} +
+ )} +
+ ) +} + +function EditTestOtpBypassRow({ entry, onSave, onCancel, isPending, serverError }) { + const [label, setLabel] = useState(entry.label) + const [expiresAt, setExpiresAt] = useState(toDatetimeLocal(entry.expires_at)) + const [otp, setOtp] = useState('') // blank = don't rotate + const [localError, setLocalError] = useState(null) + + const submit = () => { + const patch = {} + if (label.trim() !== entry.label) { + if (label.trim().length === 0) return setLocalError('Label tidak boleh kosong.') + patch.label = label.trim() + } + const iso = fromDatetimeLocal(expiresAt) + if (!iso) return setLocalError('Tanggal expires_at tidak valid.') + if (iso !== entry.expires_at) { + if (new Date(iso).getTime() <= Date.now()) return setLocalError('expires_at harus di masa depan.') + patch.expires_at = iso + } + if (otp.length > 0) { + if (!/^\d{4,8}$/.test(otp)) return setLocalError('OTP harus 4-8 digit angka (kosongkan untuk tidak ganti).') + patch.otp = otp + } + if (Object.keys(patch).length === 0) { + onCancel() + return + } + setLocalError(null) + onSave(patch) + } + + return ( + + {entry.phone} + {entry.user_type} + + setLabel(e.target.value)} + style={{ width: 180 }} disabled={isPending} /> + + + setExpiresAt(e.target.value)} + disabled={isPending} /> + + + setOtp(e.target.value.replace(/\D/g, '').slice(0, 8))} + placeholder="Rotate OTP (opsional)" + style={{ width: 140, fontFamily: 'monospace' }} + disabled={isPending} + /> + + + + + {(localError || serverError) && ( +
{localError || serverError}
+ )} + + + ) +}