OTP overhaul: test-user bypass + hash-at-rest + Fazpass integration

- Test-OTP bypass allowlist for Apple reviewers / QA: phone-scoped static OTPs
  managed in CC (Settings → Test OTP Bypass), bcrypt-hashed on save, kill-switch
  toggle, per-entry expires_at. New `otp_requests` columns (is_bypass, code_hash)
  + DB CHECK enforcing bypass-row shape.
- Hash-at-rest for stub OTPs: replaced plaintext `<ref>:<code>` storage with
  bcrypt(code_hash); reference goes to fazpass_reference alone. Verify routes on
  sovereign is_bypass flag, defers code_hash-NULL rows to Fazpass.
- Fazpass integration (gated by FAZPASS_ENABLED env, default off): new
  fazpass.service.js calling /v1/otp/{request,verify}; distinct errors for wrong
  OTP (CODE_MISMATCH 401) vs provider outage (OTP_PROVIDER_FAILED 502).
- Removed redundant Free Trial CC section (was a back-compat shim for the same
  pricing_promotions row as "Diskon Sesi Pertama") + unused alias in
  pricing.service.js.

208 tests green (34 new for OTP + Fazpass). Fazpass API + dashboard PDFs added
at project root for reference (docs are auth-gated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 22:39:34 +08:00
parent 3a0cdf5c4e
commit 6fd98ca99c
15 changed files with 1958 additions and 158 deletions

View File

@@ -424,7 +424,217 @@
"Bash(tee /tmp/playwright-debug.log)", "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(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(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 '</dev/tcp/127.0.0.1/7777')",
"Bash(echo \"WSL 127.0.0.1:7777 -> $\\(timeout 1 bash -c '</dev/tcp/127.0.0.1/7777' 2>&1 && echo OPEN || echo CLOSED\\)\")",
"Bash(timeout 1 bash -c '</dev/tcp/127.0.0.1/8765')",
"Bash(echo \"WSL 127.0.0.1:8765 -> $\\(timeout 1 bash -c '</dev/tcp/127.0.0.1/8765' 2>&1 && echo OPEN || echo CLOSED\\)\")",
"Bash(timeout 1 bash -c '</dev/tcp/172.22.240.1/7777')",
"Bash(awk '{print $4}')",
"Bash(awk '/\\(node --watch|flutter_tools\\\\.snapshot run -d|wsl_tcp_relay\\)/ && !/awk/ {printf \"PID %s: %s\\\\n\", $2, substr\\($0, index\\($0, $8\\)\\)}')",
"Bash(cat)",
"Bash(node /tmp/list-pending.mjs)",
"Bash(node _tmp-list-pending.mjs)",
"Bash(rm _tmp-list-pending.mjs)",
"Bash(node _tmp-check.mjs)",
"Bash(node _tmp-mitras.mjs)",
"Bash(kill %1)",
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -d -t 800 '*:E' flutter:V)",
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 shell \"ps -A | grep -iE 'halobestie|mitra' \")",
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 shell \"pm list packages 2>/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 '</dev/tcp/172.22.240.1/5037')",
"Bash(ip -4 addr show eth0)",
"Bash(timeout 3 bash -c '</dev/tcp/127.0.0.1/5037')",
"Bash(powershell.exe -NoProfile -Command \"adb.exe start-server\")",
"Bash(timeout 2 bash -c '</dev/tcp/172.22.240.1/__TRACKED_VAR__')",
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-01_returning_lama_online.yaml)",
"Bash(xargs -I {} sh -c 'echo \"=== {} ===\" && grep -o \"\\\\\"text\\\\\":\\\\\"[^\\\\\"]*\\\\\"\" {} | sort -u | head -30')",
"Bash(env -u ADB_SERVER_SOCKET adb -s emulator-5554 install -r build/app/outputs/flutter-apk/app-debug.apk)",
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-04_returning_baru_blast.yaml)",
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-03_returning_lama_offline_tanya_admin.yaml)",
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml)",
"Bash(sed -i 's|\\\\${output.MITRA_NAME}|${output.MITRA_NAME_RE}|g' client_app/.maestro/flows/ts-0*.yaml *)",
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml)",
"Bash(grep -v \"^--$\\\\|kotlin\\\\|graalvm\\\\|jvm\")",
"Bash(kill 130976)",
"Bash(echo \"restarted pid $!\")",
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml)",
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone log --oneline -10)",
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff --stat)",
"Bash(git -c user.email=ramadhan.sjamsani@gmail.com -c 'user.name=Ramadhan Sjamsani' commit -m ' *)",
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone status --short)",
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone log --oneline -3)",
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml)",
"Bash(kill 133811)",
"Bash(python3 .dev/wsl_tcp_relay.py --watch-adb 172.22.240.1)",
"Bash(grep -rEn \"INSERT INTO mitras|phone_number.*[0-9]{8,}\" src/db/migrations/)",
"Bash(ps -eo pid,cmd)",
"Bash(awk '/inet / {print $2}')",
"Bash(powershell.exe -NoProfile -Command \"netsh interface portproxy show v4tov4\")",
"Bash(tee /tmp/confirm.json)",
"Bash(adb -s emulator-5554 shell pm path com.halobestie.client.client_app)",
"Bash(python3 .dev/wsl_tcp_relay.py 5037 172.22.240.1 5037)",
"Bash(env -u ADB_SERVER_SOCKET adb devices)",
"Bash(adb -s emulator-5554 exec-out screencap -p)",
"Bash(adb -s emulator-5554 shell dumpsys activity activities)",
"Bash(adb -s emulator-5554 logcat -d -t '60.0')",
"Bash(dart format *)",
"Bash(adb -s emulator-5554 install -r build/app/outputs/flutter-apk/app-debug.apk)",
"Bash(adb -s emulator-5554 shell am force-stop com.halobestie.client.client_app)",
"Bash(adb -s emulator-5554 shell monkey -p com.halobestie.client.client_app -c android.intent.category.LAUNCHER 1)",
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test /tmp/verify_register_overflow.yaml)",
"Bash(adb -s emulator-5554 shell input tap 800 1530)",
"Bash(adb -s emulator-5554 shell wm size)",
"Bash(adb -s emulator-5554 logcat -d -t '30.0')",
"Bash(adb -s emulator-5554 shell input tap 720 1450)",
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/verify_register_overflow.yaml)",
"Bash(adb -s emulator-5554 shell \"run-as com.halobestie.client.client_app cat /data/data/com.halobestie.client.client_app/shared_prefs/FlutterSharedPreferences.xml\")"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/home/rama/workspaces/workspace-claude/halobestie-clone/backend/src", "/home/rama/workspaces/workspace-claude/halobestie-clone/backend/src",
@@ -434,7 +644,8 @@
"/proc/5649/fd", "/proc/5649/fd",
"/home/rama/.android/avd/Medium_Phone.avd", "/home/rama/.android/avd/Medium_Phone.avd",
"/tmp", "/tmp",
"/home/rama/.android/avd" "/home/rama/.android/avd",
"/home/ramad/workspace/halobestie/halobestie-clone/client_app/.maestro/flows"
] ]
} }
} }

BIN
Fazpass API Docpdf.pdf Normal file

Binary file not shown.

BIN
Fazpass Dashboard.pdf Normal file

Binary file not shown.

View File

@@ -19,10 +19,21 @@ AUTH_JWT_SECRET=replace-with-strong-random-32+char-secret
ACCESS_TOKEN_TTL_SECONDS=3600 ACCESS_TOKEN_TTL_SECONDS=3600
REFRESH_TOKEN_TTL_DAYS=30 REFRESH_TOKEN_TTL_DAYS=30
# Fazpass (OTP provider — TBD real values once docs are available) # --- Fazpass (OTP provider) ---
FAZPASS_API_KEY= #
FAZPASS_BASE_URL= # When FAZPASS_ENABLED=true, requestOtp() calls Fazpass /v1/otp/request and
FAZPASS_WEBHOOK_SECRET= # verifyOtp() calls Fazpass /v1/otp/verify. When false, the in-process stub
# generates + verifies codes locally (dev/test default).
#
# Single merchant key authenticates the account; single gateway key selects
# the (channel + provider) tuple configured in dashboard → Integration → Add
# Gateway. The client-supplied `channel` in /otp/request becomes informational
# only when Fazpass is live — the gateway decides which channel actually fires.
FAZPASS_ENABLED=false
FAZPASS_BASE_URL=https://api.fazpass.com
FAZPASS_MERCHANT_KEY=
FAZPASS_GATEWAY_KEY=
FAZPASS_TIMEOUT_MS=10000
# Google OAuth — comma-separated list of valid audience client IDs (Android, iOS). # Google OAuth — comma-separated list of valid audience client IDs (Android, iOS).
GOOGLE_OAUTH_CLIENT_IDS= GOOGLE_OAUTH_CLIENT_IDS=

View File

@@ -403,6 +403,18 @@ const migrate = async () => {
await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at) WHERE revoked_at IS NULL` 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) // 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` await sql`
CREATE TABLE IF NOT EXISTS otp_requests ( CREATE TABLE IF NOT EXISTS otp_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -414,12 +426,36 @@ const migrate = async () => {
attempts INT NOT NULL DEFAULT 0, attempts INT NOT NULL DEFAULT 0,
used_at TIMESTAMPTZ, used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 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_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)` 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 // Auth-related app_config defaults
await sql` await sql`
INSERT INTO app_config (key, value) VALUES INSERT INTO app_config (key, value) VALUES

View File

@@ -6,7 +6,6 @@ import { getDb } from '../../db/client.js'
import { import {
getAnonymityConfig, setAnonymityConfig, getAnonymityConfig, setAnonymityConfig,
getMaxCustomersPerMitra, setMaxCustomersPerMitra, getMaxCustomersPerMitra, setMaxCustomersPerMitra,
getFreeTrialConfig, setFreeTrialConfig,
getExtensionTimeoutConfig, setExtensionTimeoutConfig, getExtensionTimeoutConfig, setExtensionTimeoutConfig,
getEarlyEndConfig, setEarlyEndConfig, getEarlyEndConfig, setEarlyEndConfig,
getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds, getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds,
@@ -16,6 +15,8 @@ import {
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout, getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds, getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
getSupportHandles, setSupportHandles, getSupportHandles, setSupportHandles,
getTestOtpBypass, setTestOtpBypassEnabled, addTestOtpBypassEntry,
updateTestOtpBypassEntry, deleteTestOtpBypassEntry,
} from '../../services/config.service.js' } from '../../services/config.service.js'
const sql = getDb() const sql = getDb()
@@ -111,22 +112,6 @@ export const internalConfigRoutes = async (app) => {
return reply.send({ success: true, data: config }) 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 --- // --- Phase 3: Extension Timeout ---
app.get('/extension-timeout', { app.get('/extension-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
@@ -735,6 +720,102 @@ export const internalConfigRoutes = async (app) => {
return reply.send({ success: true, data: updated }) 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 --- // --- Phase 4: Support handles ---
app.get('/support-handles', { app.get('/support-handles', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],

View File

@@ -64,6 +64,7 @@ export const clientAuthRoutes = async (app) => {
userType: UserType.CUSTOMER, userType: UserType.CUSTOMER,
ipAddress: request.ip, ipAddress: request.ip,
channel, channel,
logger: request.log,
}) })
return reply.send({ success: true, data: result }) return reply.send({ success: true, data: result })
} catch (err) { } catch (err) {
@@ -74,7 +75,7 @@ export const clientAuthRoutes = async (app) => {
app.post('/otp/verify', async (request, reply) => { app.post('/otp/verify', async (request, reply) => {
const { otp_request_id, code } = request.body || {} const { otp_request_id, code } = request.body || {}
try { 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) { if (user_type !== UserType.CUSTOMER) {
return reply.code(400).send({ return reply.code(400).send({
success: false, success: false,

View File

@@ -30,6 +30,7 @@ export const mitraAuthRoutes = async (app) => {
userType: UserType.MITRA, userType: UserType.MITRA,
ipAddress: request.ip, ipAddress: request.ip,
channel, channel,
logger: request.log,
}) })
return reply.send({ success: true, data: result }) return reply.send({ success: true, data: result })
} catch (err) { } catch (err) {
@@ -40,7 +41,7 @@ export const mitraAuthRoutes = async (app) => {
app.post('/otp/verify', async (request, reply) => { app.post('/otp/verify', async (request, reply) => {
const { otp_request_id, code } = request.body || {} const { otp_request_id, code } = request.body || {}
try { 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) { if (user_type !== UserType.MITRA) {
return reply.code(400).send({ return reply.code(400).send({
success: false, success: false,

View File

@@ -1,8 +1,16 @@
import bcrypt from 'bcrypt'
import crypto from 'node:crypto'
import { getDb } from '../db/client.js' import { getDb } from '../db/client.js'
import { ExtensionTimeoutAction } from '../constants.js' import { ExtensionTimeoutAction, UserType } from '../constants.js'
const sql = getDb() 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 () => { export const getAnonymityConfig = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'anonymity'` const [row] = await sql`SELECT value FROM app_config WHERE key = 'anonymity'`
return { anonymity_enabled: row?.value?.enabled ?? false } return { anonymity_enabled: row?.value?.enabled ?? false }
@@ -35,49 +43,6 @@ export const setMaxCustomersPerMitra = async (value) => {
return { max_customers_per_mitra: 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 --- // --- Phase 4: Support handles ---
export const getSupportHandles = async () => { export const getSupportHandles = async () => {
@@ -177,6 +142,25 @@ export const getValkeyOnlineMirrorSweepSeconds = () => {
return Number.isFinite(parsed) && parsed >= 30 ? parsed : 300 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 --- // --- Phase 5: Xendit integration ---
// //
// Env-driven (per backend/CLAUDE.md Config-Source Convention). All five values // 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 } 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 }
}

View File

@@ -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 <MERCHANT_KEY>. 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,
}
}

View File

@@ -1,24 +1,32 @@
import crypto from 'node:crypto' import crypto from 'node:crypto'
import bcrypt from 'bcrypt'
import { getDb } from '../db/client.js' 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' import { OtpChannel, UserType } from '../constants.js'
const sql = getDb() const sql = getDb()
const OTP_TTL_MINUTES = 5 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. // In production, Fazpass is the source of truth for the OTP code: the backend
// We will only ever handle a reference ID (string) returned by Fazpass, // never sees the plaintext code. The stub generates a 6-digit code locally,
// never the raw code. For now, we generate a 6-digit code locally and // bcrypt-hashes it into otp_requests.code_hash, and ships the plaintext only
// store its bcrypt hash in the metadata field of otp_requests via // to in-memory (for /peek-otp dev convenience) and to the dev console log.
// fazpass_reference (reused as "<reference>:<hash>") so the stub can
// round-trip without schema changes.
// //
// When real docs arrive, replace fazpassSendStub + fazpassVerifyStub // When real docs arrive: replace fazpassSendStub with a real HTTP call, and
// with real HTTP calls and drop the local code generation. // 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 = () => { const generate6DigitCode = () => {
@@ -47,10 +55,6 @@ const fazpassSendStub = async ({ phone, channel }) => {
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`) console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
return { reference, channel_used: channel, code } // `code` only present in stub 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 { 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 }. * Start an OTP flow. Returns { otp_request_id, channel_used, expires_at }.
* Does NOT return the code to the caller. * 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)) { if (!phone || !/^\+[1-9]\d{6,14}$/.test(phone)) {
throw new OtpError('Invalid phone format (E.164 expected)', 'PHONE_INVALID', 422) 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() const limits = await getOtpRateLimits()
await checkRateLimits({ phone, ipAddress, limits }) 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 // Live Fazpass path. Provider owns the code AND verification — we only
// after a colon so the verify stub can compare. Real Fazpass flow will NOT store // hold the reference. code_hash MUST stay NULL so verifyOtp's branching
// the code; Fazpass itself holds it. This line is the main place to change // routes this row to Fazpass (the DB CHECK constraint also relies on the
// when switching to real Fazpass. // is_bypass=false shape we set here).
const storedReference = code ? `${reference}:${code}` : reference 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` 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 ( VALUES (
${phone}, ${ipAddress ?? null}, ${userType}, ${storedReference}, ${channel_used}, ${phone}, ${ipAddress ?? null}, ${userType}, ${reference}, ${channel_used},
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
FALSE, ${codeHash}
) )
RETURNING id, expires_at 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. * Verify an OTP code. Returns { phone, user_type } on success.
* Throws OtpError on failure. * 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)) { if (typeof code !== 'string' || !/^\d{4,8}$/.test(code)) {
throw new OtpError('Invalid code format', 'CODE_INVALID', 422) throw new OtpError('Invalid code format', 'CODE_INVALID', 422)
} }
@@ -179,7 +256,8 @@ export const verifyOtp = async ({ otpRequestId, code }) => {
const limits = await getOtpRateLimits() const limits = await getOtpRateLimits()
const [row] = await sql` 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 FROM otp_requests
WHERE id = ${otpRequestId} 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}` await sql`UPDATE otp_requests SET attempts = attempts + 1 WHERE id = ${otpRequestId}`
// Stub: fazpass_reference is stored as "<ref>:<code>" // Verification routing: the is_bypass flag is sovereign — never use the
const [reference, expectedCode] = (row.fazpass_reference || '').split(':') // mere presence of code_hash to decide which verifier runs, because a
const { valid } = await fazpassVerifyStub({ reference, code, expectedCode }) // 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) { if (!valid) {
throw new OtpError('Incorrect code', 'CODE_MISMATCH', 401) throw new OtpError('Incorrect code', 'CODE_MISMATCH', 401)

View File

@@ -203,12 +203,6 @@ export const getExtensionPriceTiers = async (customerId) => {
// ---- Back-compat aliases (will be removed after Phase 4 frontend cutover) ---- // ---- 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. * @deprecated Tiers now live in `chat`/`call` groups; callers should pick one.
* Returns chat tiers in the legacy shape (single array, no group wrapper). * Returns chat tiers in the legacy shape (single array, no group wrapper).

View File

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

View File

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

View File

@@ -23,17 +23,6 @@ const updateMaxCustomersConfig = async (max_customers_per_mitra) => {
return res.data.data 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 fetchExtensionTimeoutConfig = async () => {
const res = await apiClient.get('/internal/config/extension-timeout') const res = await apiClient.get('/internal/config/extension-timeout')
return res.data.data return res.data.data
@@ -160,6 +149,30 @@ const updateSupportHandles = async (patch) => {
return res.data.data 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() { export default function SettingsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig }) const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
@@ -179,16 +192,6 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }), 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 // Phase 3: Extension Timeout
const { data: etData, isLoading: etLoading } = useQuery({ const { data: etData, isLoading: etLoading } = useQuery({
queryKey: ['config-extension-timeout'], queryKey: ['config-extension-timeout'],
@@ -331,10 +334,21 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-support-handles'] }), 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 ( if (
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading || isLoading || maxLoading || etLoading || eeLoading || mpLoading || senLoading ||
pbtLoading || pstLoading || rctLoading || edaLoading || pbtLoading || pstLoading || rctLoading || edaLoading ||
fsdLoading || ptLoading || shLoading fsdLoading || ptLoading || shLoading || tobLoading
) return <div>Loading...</div> ) return <div>Loading...</div>
return ( return (
@@ -376,36 +390,6 @@ export default function SettingsPage() {
{maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>} {maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section> </section>
<section style={{ marginBottom: 24 }}>
<h2>Free Trial</h2>
<p>Aktifkan free trial untuk customer baru yang belum pernah bertransaksi.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={ftData?.enabled ?? false}
onChange={e => ftMutation.mutate({ enabled: e.target.checked })}
disabled={ftMutation.isPending}
/>
Aktifkan Free Trial
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label>Durasi:</label>
<input
type="number"
min="1"
value={ftData?.duration_minutes ?? 5}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 1) ftMutation.mutate({ duration_minutes: val })
}}
disabled={ftMutation.isPending}
style={{ width: 80 }}
/>
<span>menit</span>
</div>
{ftMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}> <section style={{ marginBottom: 24 }}>
<h2>Extension Timeout</h2> <h2>Extension Timeout</h2>
<p>Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.</p> <p>Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.</p>
@@ -645,6 +629,15 @@ export default function SettingsPage() {
))} ))}
</section> </section>
{/* Test OTP bypass — Apple reviewer / QA static OTP allowlist */}
<TestOtpBypassSection
data={tobData}
enabledMutation={tobEnabledMutation}
addMutation={tobAddMutation}
updateMutation={tobUpdateMutation}
deleteMutation={tobDeleteMutation}
/>
{/* Phase 4: Support handles */} {/* Phase 4: Support handles */}
<section style={{ marginBottom: 24 }}> <section style={{ marginBottom: 24 }}>
<h2>Support Handles (Tanya Admin)</h2> <h2>Support Handles (Tanya Admin)</h2>
@@ -1107,3 +1100,293 @@ function FirstSessionDiscountSection({ data, mutation, toast, onDismissToast })
</section> </section>
) )
} }
// ============================================================================
// 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) => {
// <input type="datetime-local"> 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 (
<section style={{ marginBottom: 24, border: '1px solid #f0c36d', padding: 12, background: '#fff8e8' }}>
<h2 style={{ marginTop: 0 }}>Test OTP Bypass (Apple Reviewer / QA)</h2>
<p style={{ fontSize: 13, color: '#664' }}>
Daftar nomor HP yang melewati Fazpass dan login dengan OTP statis.
Untuk reviewer App Store dan tester internal saja {' '}
<strong>siapa pun yang tahu pasangan nomor + OTP bisa login sebagai user ini.</strong>{' '}
Jaga daftarnya kecil, tetapkan <em>expires_at</em>, dan hapus segera setelah pemakaian.
</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<input
type="checkbox"
checked={data?.enabled ?? false}
onChange={e => enabledMutation.mutate(e.target.checked)}
disabled={enabledMutation.isPending}
/>
<strong>Aktifkan bypass</strong> (kill switch global matikan untuk menonaktifkan semua entri sekaligus)
</label>
{enabledMutation.isError && (
<p style={{ color: 'red', fontSize: 12 }}>{sectionErrorText(enabledMutation.error)}</p>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h3 style={{ margin: 0, fontSize: 15 }}>Entri ({entries.length})</h3>
<button type="button" onClick={() => setShowAdd(s => !s)} disabled={addMutation.isPending}>
{showAdd ? 'Batal tambah' : '+ Tambah entri'}
</button>
</div>
{showAdd && (
<AddTestOtpBypassForm
onSubmit={(body) => addMutation.mutate(body, { onSuccess: () => setShowAdd(false) })}
onCancel={() => setShowAdd(false)}
isPending={addMutation.isPending}
serverError={addMutation.isError ? sectionErrorText(addMutation.error) : null}
/>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, marginTop: 8 }}>
<thead>
<tr style={{ background: '#f7f7f7', textAlign: 'left' }}>
<th style={th}>Phone</th>
<th style={th}>User type</th>
<th style={th}>Label</th>
<th style={th}>Expires</th>
<th style={th}>OTP hash</th>
<th style={th}>Aksi</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && (
<tr><td colSpan={6} style={{ ...td, color: '#999', fontStyle: 'italic' }}>Belum ada entri.</td></tr>
)}
{entries.map((entry) => (
editingId === entry.id ? (
<EditTestOtpBypassRow
key={entry.id}
entry={entry}
onSave={(patch) => updateMutation.mutate(
{ id: entry.id, ...patch },
{ onSuccess: () => setEditingId(null) },
)}
onCancel={() => setEditingId(null)}
isPending={updateMutation.isPending}
serverError={updateMutation.isError ? sectionErrorText(updateMutation.error) : null}
/>
) : (
<tr key={entry.id} style={{ opacity: isExpired(entry.expires_at) ? 0.5 : 1 }}>
<td style={td}>{entry.phone}</td>
<td style={td}>{entry.user_type}</td>
<td style={td}>{entry.label}</td>
<td style={td}>
{formatExpiresAt(entry.expires_at)}
{isExpired(entry.expires_at) && (
<span style={{ marginLeft: 6, color: '#a00', fontWeight: 'bold' }}>EXPIRED</span>
)}
</td>
<td style={{ ...td, fontFamily: 'monospace', fontSize: 11, color: '#888' }}>
{entry.otp_hash ? `${entry.otp_hash.slice(0, 12)}` : '—'}
</td>
<td style={td}>
<button type="button" onClick={() => setEditingId(entry.id)} style={{ marginRight: 4 }}>
Edit
</button>
<button
type="button"
onClick={() => {
if (!window.confirm(`Hapus entri "${entry.label}" (${entry.phone})?`)) return
deleteMutation.mutate(entry.id)
}}
disabled={deleteMutation.isPending}
style={{ color: '#a00' }}
>
Hapus
</button>
</td>
</tr>
)
))}
</tbody>
</table>
{deleteMutation.isError && (
<p style={{ color: 'red', fontSize: 12 }}>{sectionErrorText(deleteMutation.error)}</p>
)}
</section>
)
}
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 (
<form onSubmit={submit} style={{
padding: 10, marginBottom: 8, background: '#f0f7ff', border: '1px solid #cde',
display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end',
}}>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
Phone (E.164)
<input type="text" value={phone} onChange={e => setPhone(e.target.value.trim())}
style={{ width: 180 }} disabled={isPending} placeholder="+628111111111" required />
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
User type
<select value={userType} onChange={e => setUserType(e.target.value)}
disabled={isPending} style={{ width: 110 }}>
<option value="customer">customer</option>
<option value="mitra">mitra</option>
</select>
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
OTP (4-8 digit)
<input type="text" inputMode="numeric" value={otp}
onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 8))}
style={{ width: 110, fontFamily: 'monospace' }} disabled={isPending} required />
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
Label
<input type="text" value={label} onChange={e => setLabel(e.target.value)}
style={{ width: 200 }} disabled={isPending} placeholder="Apple Reviewer #1" required />
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
Expires at
<input type="datetime-local" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
disabled={isPending} required />
</label>
<button type="submit" disabled={isPending}>{isPending ? '...' : 'Tambah'}</button>
<button type="button" onClick={onCancel} disabled={isPending}>Batal</button>
{(localError || serverError) && (
<div style={{ color: 'red', fontSize: 12, width: '100%' }}>
{localError || serverError}
</div>
)}
</form>
)
}
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 (
<tr style={{ background: '#fffdf3' }}>
<td style={td}>{entry.phone}</td>
<td style={td}>{entry.user_type}</td>
<td style={td}>
<input type="text" value={label} onChange={e => setLabel(e.target.value)}
style={{ width: 180 }} disabled={isPending} />
</td>
<td style={td}>
<input type="datetime-local" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
disabled={isPending} />
</td>
<td style={td}>
<input
type="text" inputMode="numeric" value={otp}
onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder="Rotate OTP (opsional)"
style={{ width: 140, fontFamily: 'monospace' }}
disabled={isPending}
/>
</td>
<td style={td}>
<button type="button" onClick={submit} disabled={isPending} style={{ marginRight: 4 }}>
{isPending ? '...' : 'Simpan'}
</button>
<button type="button" onClick={onCancel} disabled={isPending}>Batal</button>
{(localError || serverError) && (
<div style={{ color: 'red', fontSize: 11, marginTop: 2 }}>{localError || serverError}</div>
)}
</td>
</tr>
)
}