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

@@ -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`
// OTP requests (Fazpass reference + rate-limit tracking)
//
// Storage shape rationale:
// - is_bypass : explicit intent flag — true only when a row was created by
// the test-OTP-bypass allowlist (phone-scoped static OTP for
// App Store reviewers). Verify routes on this flag, NOT on
// the mere presence of code_hash.
// - code_hash : bcrypt hash of the OTP code, present whenever the backend
// owns verification (stub-mode rows + bypass rows). NULL when
// Fazpass owns verification (post-cutover, non-bypass rows).
// - CHECK constraint: bypass rows MUST have code_hash and MUST NOT carry a
// Fazpass reference — physically prevents a bypass row from
// ever falling into the Fazpass-verify path.
await sql`
CREATE TABLE IF NOT EXISTS otp_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -414,12 +426,36 @@ const migrate = async () => {
attempts INT NOT NULL DEFAULT 0,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
expires_at TIMESTAMPTZ NOT NULL,
is_bypass BOOLEAN NOT NULL DEFAULT FALSE,
code_hash VARCHAR(255)
)
`
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_phone_created ON otp_requests (phone, created_at)`
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_ip_created ON otp_requests (ip_address, created_at)`
// Idempotent ALTERs for DBs created before is_bypass/code_hash were added.
await sql`
ALTER TABLE otp_requests
ADD COLUMN IF NOT EXISTS is_bypass BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS code_hash VARCHAR(255)
`
// Drop-then-add lets us tighten the invariant later without writing a v2.
// The constraint is defense-in-depth alongside the verifyOtp branching: even
// if app code regressed, the DB refuses to insert a corrupt bypass row.
await sql`ALTER TABLE otp_requests DROP CONSTRAINT IF EXISTS otp_requests_bypass_shape`
await sql`
ALTER TABLE otp_requests
ADD CONSTRAINT otp_requests_bypass_shape CHECK (
is_bypass = FALSE OR (
is_bypass = TRUE
AND code_hash IS NOT NULL
AND fazpass_reference IS NULL
)
)
`
// Auth-related app_config defaults
await sql`
INSERT INTO app_config (key, value) VALUES