From cd800148d5bcdb297f3bd42d44718341b35bf5ed Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Mon, 15 Jun 2026 09:16:00 -0700 Subject: [PATCH] feat: optional additional verification gates (#3262) * phone number verificiation initial * finish phone verification * feat: add card gate for signups * chore: npm * fix: modal order and wording * fix:wording --------- Co-authored-by: Neal Shah --- extensions/whoami.ts | 3 + package-lock.json | 221 ++-- package.json | 1 + .../clients/database/SqliteDatabaseClient.ts | 2 + .../database/migrations/mysql/mysql_mig_1.sql | 9 +- .../migrations/mysql/mysql_mig_13.sql | 47 + .../migrations/mysql/mysql_mig_14.sql | 26 + .../migrations/postgres/postgres_mig_2.sql | 26 + .../migrations/postgres/postgres_mig_3.sql | 24 + .../sqlite/0058_add_phone_verification.sql | 25 + .../sqlite/0059_add_card_verification.sql | 23 + src/backend/clients/event/types.ts | 41 + src/backend/clients/index.ts | 2 + .../clients/prelude/PreludeClient.test.ts | 141 +++ src/backend/clients/prelude/PreludeClient.ts | 185 ++++ src/backend/clients/prelude/countries.ts | 952 ++++++++++++++++++ .../controllers/auth/AuthController.test.ts | 616 +++++++++++- .../controllers/auth/AuthController.ts | 452 +++++++++ src/backend/services/auth/AuthService.ts | 5 + src/backend/stores/user/UserStore.ts | 31 +- src/backend/types.ts | 41 + src/backend/util/phone.test.ts | 60 ++ src/backend/util/phone.ts | 68 ++ src/gui/src/UI/UIDesktop.js | 20 + .../UI/UIWindowCardVerificationRequired.js | 319 ++++++ .../UI/UIWindowPhoneVerificationRequired.js | 383 +++++++ src/gui/src/UI/UIWindowSignup.js | 49 +- src/gui/src/initgui.js | 68 ++ 28 files changed, 3723 insertions(+), 117 deletions(-) create mode 100644 src/backend/clients/database/migrations/mysql/mysql_mig_13.sql create mode 100644 src/backend/clients/database/migrations/mysql/mysql_mig_14.sql create mode 100644 src/backend/clients/database/migrations/postgres/postgres_mig_2.sql create mode 100644 src/backend/clients/database/migrations/postgres/postgres_mig_3.sql create mode 100644 src/backend/clients/database/migrations/sqlite/0058_add_phone_verification.sql create mode 100644 src/backend/clients/database/migrations/sqlite/0059_add_card_verification.sql create mode 100644 src/backend/clients/prelude/PreludeClient.test.ts create mode 100644 src/backend/clients/prelude/PreludeClient.ts create mode 100644 src/backend/clients/prelude/countries.ts create mode 100644 src/backend/util/phone.test.ts create mode 100644 src/backend/util/phone.ts create mode 100644 src/gui/src/UI/UIWindowCardVerificationRequired.js create mode 100644 src/gui/src/UI/UIWindowPhoneVerificationRequired.js diff --git a/extensions/whoami.ts b/extensions/whoami.ts index cb5394f31..64752fcc2 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -72,6 +72,9 @@ export const handleWhoami = async ( unconfirmed_email: user.email, email_confirmed: user.email_confirmed || user.username === 'admin', requires_email_confirmation: user.requires_email_confirmation, + phone: user.phone, + requires_phone_verification: user.requires_phone_verification, + requires_card_verification: user.requires_card_verification, desktop_bg_url: user.desktop_bg_url, desktop_bg_color: user.desktop_bg_color, desktop_bg_fit: user.desktop_bg_fit, diff --git a/package-lock.json b/package-lock.json index 4af7cda03..2347c1510 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "ai": "^6.0.73", "dedent": "^1.5.3", "javascript-time-ago": "^2.5.11", + "libphonenumber-js": "1.13.6", "open": "^10.1.0" }, "devDependencies": { @@ -1858,9 +1859,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -1875,9 +1876,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -1892,9 +1893,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -1909,9 +1910,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -1926,7 +1927,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -1941,9 +1944,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -1958,9 +1961,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -1975,9 +1978,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -1992,9 +1995,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -2009,9 +2012,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -2026,9 +2029,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -2043,9 +2046,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -2060,9 +2063,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -2077,9 +2080,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -2094,9 +2097,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -2111,9 +2114,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -2128,9 +2131,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -2145,9 +2148,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -2162,9 +2165,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -2179,9 +2182,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -2196,9 +2199,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -2213,9 +2216,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -2230,9 +2233,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -2247,9 +2250,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -2264,9 +2267,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -2281,9 +2284,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -2681,7 +2684,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.3", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -9457,7 +9462,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.0", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -9468,32 +9475,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -11858,6 +11865,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.6.tgz", + "integrity": "sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q==", + "license": "MIT" + }, "node_modules/license-check-and-add": { "version": "4.0.5", "dev": true, diff --git a/package.json b/package.json index ef950c633..280b35e50 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "ai": "^6.0.73", "dedent": "^1.5.3", "javascript-time-ago": "^2.5.11", + "libphonenumber-js": "1.13.6", "open": "^10.1.0" }, "engines": { diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index 5604e05af..d6108b447 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -86,6 +86,8 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [50, ['0055_username_nocase_unique.sql']], [51, ['0056_sessions_kind_worker.sql']], [52, ['0057_add_user_reputation.sql']], + [53, ['0058_add_phone_verification.sql']], + [54, ['0059_add_card_verification.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_1.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_1.sql index 19d1ed9a2..d9306cbdb 100644 --- a/src/backend/clients/database/migrations/mysql/mysql_mig_1.sql +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_1.sql @@ -24,7 +24,9 @@ -- -- --- Idempotent column-ensure helper (used by CALLs below; dropped at end of file) +-- Idempotent column-ensure helper. Used by the CALLs below AND by later +-- migration files, which CALL it as a one-line idempotent column add. Left +-- resident (not dropped at end of file) so it outlives this migration. -- DROP PROCEDURE IF EXISTS _puter_add_col; @@ -1333,7 +1335,10 @@ CALL _puter_add_col('user_update_audit', 'new_username', '`new_username` varchar CALL _puter_add_col('user_update_audit', 'reason', '`reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL'); -DROP PROCEDURE IF EXISTS _puter_add_col; +-- _puter_add_col is intentionally NOT dropped here: later migration files CALL +-- it as a one-line idempotent column add, and migrations replay on every boot +-- with no applied-state tracking, so the helper must outlive this file. The +-- DROP-before-CREATE at the top keeps this migration itself replay-safe. /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_13.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_13.sql new file mode 100644 index 000000000..08028d029 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_13.sql @@ -0,0 +1,47 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the +-- E.164 number collected during verification (indexed like `email`); +-- `requires_phone_verification` gates account use for low-reputation signups +-- (not indexed, mirroring `requires_email_confirmation`). +-- +-- Idempotent: column adds use _puter_add_col (defined in mig_1, which leaves it +-- resident for later migrations); the index add is guarded against +-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely. + +CALL _puter_add_col('user', 'phone', '`phone` varchar(20) DEFAULT NULL'); +CALL _puter_add_col('user', 'requires_phone_verification', '`requires_phone_verification` tinyint(1) NOT NULL DEFAULT ''0'''); + +DROP PROCEDURE IF EXISTS _puter_add_user_phone_index; +DELIMITER // +CREATE PROCEDURE _puter_add_user_phone_index() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user' + AND INDEX_NAME = 'idx_user_phone' + ) THEN + ALTER TABLE `user` ADD INDEX `idx_user_phone` (`phone`); + END IF; +END// +DELIMITER ; + +CALL _puter_add_user_phone_index(); + +DROP PROCEDURE IF EXISTS _puter_add_user_phone_index; diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_14.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_14.sql new file mode 100644 index 000000000..d1b0c90f7 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_14.sql @@ -0,0 +1,26 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Credit-card verification column. Mirrors SQLite migration 0059. +-- `requires_card_verification` gates account use for low-reputation signups; +-- the card itself never touches our DB, so this is the only column (not +-- indexed, mirroring `requires_phone_verification`). +-- +-- Idempotent: the column add uses _puter_add_col (from mig_1) so the +-- directory replays safely. + +CALL _puter_add_col('user', 'requires_card_verification', '`requires_card_verification` tinyint(1) NOT NULL DEFAULT ''0'''); diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_2.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_2.sql new file mode 100644 index 000000000..f4f153969 --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_2.sql @@ -0,0 +1,26 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the +-- E.164 number collected during verification (indexed like `email`); +-- `requires_phone_verification` gates account use for low-reputation signups +-- (not indexed, mirroring `requires_email_confirmation`). +-- Idempotent via IF NOT EXISTS. + +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS phone varchar(20); +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_phone_verification boolean NOT NULL DEFAULT FALSE; +CREATE INDEX IF NOT EXISTS idx_user_phone ON "user" (phone); diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_3.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_3.sql new file mode 100644 index 000000000..c66715771 --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_3.sql @@ -0,0 +1,24 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Credit-card verification column. Mirrors SQLite migration 0059. +-- `requires_card_verification` gates account use for low-reputation signups; +-- the card itself never touches our DB, so this is the only column (not +-- indexed, mirroring `requires_phone_verification`). +-- Idempotent via IF NOT EXISTS. + +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_card_verification boolean NOT NULL DEFAULT FALSE; diff --git a/src/backend/clients/database/migrations/sqlite/0058_add_phone_verification.sql b/src/backend/clients/database/migrations/sqlite/0058_add_phone_verification.sql new file mode 100644 index 000000000..f7df34969 --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0058_add_phone_verification.sql @@ -0,0 +1,25 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- SMS phone verification. `phone` holds the E.164 number collected during +-- verification (indexed like `email`). `requires_phone_verification` gates +-- account use until verified — the abuse v2 harness sets it for low-reputation +-- signups instead of blocking them. (Not indexed, mirroring +-- `requires_email_confirmation`.) +ALTER TABLE `user` ADD COLUMN `phone` varchar(20) DEFAULT NULL; +ALTER TABLE `user` ADD COLUMN `requires_phone_verification` tinyint(1) NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS idx_user_phone ON `user` (`phone`); diff --git a/src/backend/clients/database/migrations/sqlite/0059_add_card_verification.sql b/src/backend/clients/database/migrations/sqlite/0059_add_card_verification.sql new file mode 100644 index 000000000..1f3d255bc --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0059_add_card_verification.sql @@ -0,0 +1,23 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Credit-card verification ($0 authorization handled by a payments +-- extension). `requires_card_verification` gates account use until verified — +-- the abuse v2 harness sets it for low-reputation signups instead of blocking +-- them. The card itself never touches our DB, so this is the only column. +-- (Not indexed, mirroring `requires_phone_verification`.) +ALTER TABLE `user` ADD COLUMN `requires_card_verification` tinyint(1) NOT NULL DEFAULT 0; diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index 37902fc18..17035a659 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -134,6 +134,10 @@ export type EventMap = { /** Device signals forwarded verbatim from the signup request body. */ fingerprint?: string | null; dfp_telemetry_id?: string | null; + /** Set by the abuse harness — require SMS phone verification post-signup. */ + requires_phone_verification?: boolean; + /** Set by the abuse harness — require card verification post-signup. */ + requires_card_verification?: boolean; [key: string]: unknown; }; 'puter.signup.success': { @@ -164,6 +168,43 @@ export type EventMap = { user_uid: string; email: string; }; + 'user.phone-verified': { + user_id: number; + user_uid: string; + phone: string; + }; + // Card verification is pure mechanism here — a payments extension fills + // these in (emitted via `emitAndWait`). `enabled` stays null when no + // extension is installed; the extension always sets it (true/false) so + // the endpoints can distinguish "disabled" from "not installed". + 'puter.card-verification.setup': { + user_id: number; + user_uid: string; + ip?: string | null; + enabled: boolean | null; + client_secret: string | null; + publishable_key: string | null; + [key: string]: unknown; + }; + 'puter.card-verification.confirm': { + user_id: number; + user_uid: string; + setup_intent_id: string; + enabled: boolean | null; + verified: boolean; + reason: string | null; + fingerprint: string | null; + funding: string | null; + country: string | null; + [key: string]: unknown; + }; + 'user.card-verified': { + user_id: number; + user_uid: string; + fingerprint: string | null; + funding: string | null; + country: string | null; + }; 'user.username-changed': { user_id: number; old_username: string; diff --git a/src/backend/clients/index.ts b/src/backend/clients/index.ts index edb4f0cbb..12b5ae80c 100644 --- a/src/backend/clients/index.ts +++ b/src/backend/clients/index.ts @@ -24,6 +24,7 @@ import { EventClient } from './event/EventClient'; import { DDBClient } from './dynamodb/DDBClient'; import { RedisClient } from './redis/RedisClient'; import { S3Client } from './s3/S3Client'; +import { PreludeClient } from './prelude/PreludeClient'; import type { IPuterClientRegistry } from './types'; export const puterClients = { @@ -34,4 +35,5 @@ export const puterClients = { dynamo: DDBClient, redis: RedisClient, s3: S3Client, + prelude: PreludeClient, } satisfies IPuterClientRegistry; diff --git a/src/backend/clients/prelude/PreludeClient.test.ts b/src/backend/clients/prelude/PreludeClient.test.ts new file mode 100644 index 000000000..5cecb08d8 --- /dev/null +++ b/src/backend/clients/prelude/PreludeClient.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PreludeClient } from './PreludeClient'; +import type { IConfig } from '../../types'; + +const makeClient = (apiKey?: string) => + new PreludeClient({ + prelude: apiKey ? { apiKey } : undefined, + } as unknown as IConfig); + +const okJson = (body: unknown) => + ({ + ok: true, + status: 200, + json: async () => body, + }) as unknown as Response; + +describe('PreludeClient', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('isConfigured reflects whether an apiKey is set', () => { + expect(makeClient('sk_test').isConfigured()).toBe(true); + expect(makeClient().isConfigured()).toBe(false); + }); + + describe('isCountrySupported (€0.07 cap)', () => { + const client = makeClient('sk_test'); + + it('allows revenue markets up to the cap (incl. the priciest)', () => { + expect(client.isCountrySupported('US')).toBe(true); // €0.0043 + expect(client.isCountrySupported('DE')).toBe(true); // €0.0598 + expect(client.isCountrySupported('SA')).toBe(true); // €0.0638 + expect(client.isCountrySupported('us')).toBe(true); // case-insensitive + }); + + it('rejects countries above the cap, with no SMS, or unknown', () => { + expect(client.isCountrySupported('PK')).toBe(false); // €0.3548 + expect(client.isCountrySupported('ID')).toBe(false); // €0.2430 + expect(client.isCountrySupported('LI')).toBe(false); // null (no SMS) + expect(client.isCountrySupported('ZZ')).toBe(false); // unknown + expect(client.isCountrySupported(undefined)).toBe(false); + }); + + it('honors a configured maxSmsCostEur override', () => { + const strict = new PreludeClient({ + prelude: { apiKey: 'sk', maxSmsCostEur: 0.01 }, + } as unknown as IConfig); + expect(strict.isCountrySupported('US')).toBe(true); // €0.0043 + expect(strict.isCountrySupported('DE')).toBe(false); // €0.0598 > 0.01 + + const loose = new PreludeClient({ + prelude: { apiKey: 'sk', maxSmsCostEur: 0.5 }, + } as unknown as IConfig); + expect(loose.isCountrySupported('PK')).toBe(true); // €0.3548 <= 0.5 + }); + }); + + it('createVerification POSTs the phone target + ip signal with bearer auth', async () => { + fetchMock.mockResolvedValue( + okJson({ id: 'vrf_1', status: 'success' }), + ); + const client = makeClient('sk_test'); + + const res = await client.createVerification('+14155550123', { + ip: '203.0.113.7', + }); + + expect(res).toEqual({ id: 'vrf_1', status: 'success' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.prelude.dev/v2/verification'); + expect(init.method).toBe('POST'); + expect(init.headers.Authorization).toBe('Bearer sk_test'); + expect(JSON.parse(init.body)).toEqual({ + target: { type: 'phone_number', value: '+14155550123' }, + options: { code_size: 6 }, + signals: { ip: '203.0.113.7' }, + }); + }); + + it('includes a configured template_id + sender_id in the options', async () => { + fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' })); + const client = new PreludeClient({ + prelude: { + apiKey: 'sk_test', + templateId: 'tmpl_puter', + senderId: 'Puter', + }, + } as unknown as IConfig); + + await client.createVerification('+14155550123'); + + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(init.body).options).toEqual({ + code_size: 6, + template_id: 'tmpl_puter', + sender_id: 'Puter', + }); + }); + + it('checkVerification POSTs target + code and returns the status', async () => { + fetchMock.mockResolvedValue(okJson({ status: 'success' })); + const client = makeClient('sk_test'); + + const res = await client.checkVerification('+14155550123', '123456'); + + expect(res).toEqual({ status: 'success' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.prelude.dev/v2/verification/check'); + expect(JSON.parse(init.body)).toEqual({ + target: { type: 'phone_number', value: '+14155550123' }, + code: '123456', + }); + }); + + it('throws (does not call fetch) when not configured', async () => { + const client = makeClient(); + await expect( + client.createVerification('+14155550123'), + ).rejects.toThrow(/not configured/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws on a non-2xx Prelude response', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ message: 'bad target' }), + } as unknown as Response); + + await expect( + makeClient('sk_test').checkVerification('+1', 'x'), + ).rejects.toThrow(/Prelude .* failed: 400/); + }); +}); diff --git a/src/backend/clients/prelude/PreludeClient.ts b/src/backend/clients/prelude/PreludeClient.ts new file mode 100644 index 000000000..b6e58253e --- /dev/null +++ b/src/backend/clients/prelude/PreludeClient.ts @@ -0,0 +1,185 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { IConfig } from '../../types'; +import { PuterClient } from '../types'; +import { COUNTRY_SMS_PRICES } from './countries.js'; + +const PRELUDE_API_BASE = 'https://api.prelude.dev/v2'; +const REQUEST_TIMEOUT_MS = 8000; +/** OTP length — matches the 6-box code UI. Prelude allows 4–8. */ +const PRELUDE_CODE_SIZE = 6; +/** + * Default per-SMS cost ceiling (EUR). Countries whose Prelude SMS rate exceeds + * this — or that have no SMS channel — are not offered phone verification. The + * cap covers every realistic revenue market (priciest are Germany €0.0598 and + * Saudi Arabia €0.0638) while excluding the expensive, high-fraud long tail. + * Override per-deployment with `config.prelude.maxSmsCostEur`. + */ +const DEFAULT_MAX_SMS_COST_EUR = 0.07; + +/** Status returned by Prelude when creating/retrying a verification. */ +export type PreludeCreateStatus = + | 'success' + | 'retry' + | 'challenged' + | 'blocked' + | 'shadow_blocked'; + +/** Status returned by Prelude when checking a code. */ +export type PreludeCheckStatus = + | 'success' + | 'failure' + | 'expired_or_not_found' + | 'transaction_missing' + | 'transaction_mismatch'; + +/** + * Prelude Verify v2 client (https://docs.prelude.so/verify/v2). Sends and checks + * SMS one-time codes — Prelude generates, delivers, and validates the code, so + * we never store one ourselves. No-ops with a warning when no API key is + * configured (so dev environments don't crash); `isConfigured()` lets callers + * surface a clean "phone verification unavailable" error instead. + */ +export class PreludeClient extends PuterClient { + private apiKey: string | null = null; + + constructor(config: IConfig) { + super(config); + this.apiKey = config.prelude?.apiKey ?? null; + } + + override onServerStart(): void { + if (!this.apiKey) { + console.warn( + '[prelude] no apiKey configured — SMS phone verification is disabled', + ); + } + } + + /** True when an API key is configured and verification can be attempted. */ + isConfigured(): boolean { + return !!this.apiKey; + } + + /** ISO region used to parse local-format numbers (config-driven). */ + get defaultCountry(): string | undefined { + return this.config.prelude?.defaultCountry; + } + + /** Per-SMS cost ceiling in EUR (config override or the default cap). */ + get maxSmsCostEur(): number { + return this.config.prelude?.maxSmsCostEur ?? DEFAULT_MAX_SMS_COST_EUR; + } + + /** + * Whether SMS verification should be offered for a country. False when the + * country is unknown, has no SMS channel, or its rate exceeds the cost cap. + * @param iso ISO-3166 alpha-2 (e.g. 'US') — from the parsed phone number. + */ + isCountrySupported(iso: string | undefined): boolean { + if (!iso) return false; // couldn't determine country → can't price it + const price = COUNTRY_SMS_PRICES[iso.toUpperCase()]; + if (!price || price.sms == null) return false; + return price.sms <= this.maxSmsCostEur; + } + + /** + * Create (or retry) a verification: Prelude sends an OTP to `target`. + * @param target E.164 phone number, e.g. "+14155550123". + * @param signals Optional anti-fraud signals (the signup IP). + */ + async createVerification( + target: string, + signals: { ip?: string } = {}, + ): Promise<{ id?: string; status: PreludeCreateStatus }> { + // Match the 6-box code UI (UIWindowPhoneVerificationRequired). Without + // code_size Prelude uses the dashboard default (4). + const options: Record = { + code_size: PRELUDE_CODE_SIZE, + }; + // Branding lives in the Prelude dashboard (the message text is a + // template); these just select a Puter-branded template / sender when + // configured. See IPreludeConfig. + const { templateId, senderId } = this.config.prelude ?? {}; + if (templateId) options.template_id = templateId; + if (senderId) options.sender_id = senderId; + + const body: Record = { + target: { type: 'phone_number', value: target }, + options, + }; + if (signals.ip) body.signals = { ip: signals.ip }; + return this.#post('/verification', body) as Promise<{ + id?: string; + status: PreludeCreateStatus; + }>; + } + + /** + * Check a code the user entered against the active verification for `target`. + * @returns `{ status }` — `'success'` means verified. + */ + async checkVerification( + target: string, + code: string, + ): Promise<{ status: PreludeCheckStatus }> { + return this.#post('/verification/check', { + target: { type: 'phone_number', value: target }, + code, + }) as Promise<{ status: PreludeCheckStatus }>; + } + + async #post( + path: string, + body: Record, + ): Promise> { + if (!this.apiKey) { + throw new Error('Prelude is not configured'); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const res = await fetch(`${PRELUDE_API_BASE}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const json = (await res.json().catch(() => ({}))) as Record< + string, + unknown + >; + if (!res.ok) { + throw new Error( + `Prelude ${path} failed: ${res.status} ${ + (json as { message?: string })?.message ?? '' + }`.trim(), + ); + } + return json; + } finally { + clearTimeout(timer); + } + } +} diff --git a/src/backend/clients/prelude/countries.ts b/src/backend/clients/prelude/countries.ts new file mode 100644 index 000000000..388be9688 --- /dev/null +++ b/src/backend/clients/prelude/countries.ts @@ -0,0 +1,952 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** Per-country Prelude SMS pricing, hardcoded from the Prelude global price + * sheet. `sms` is the average cost in EUR, or null when SMS is unavailable + * there. Used to cap phone verification to affordable countries (see + * PreludeClient.isCountrySupported). Regenerate from the price sheet when + * Prelude updates rates. */ +export interface CountryPrice { + name: string; + /** Prelude SMS average in EUR, or null when SMS is not available. */ + sms: number | null; +} + +export const COUNTRY_SMS_PRICES: Record = { + AD: { + name: 'Andorra', + sms: 0.0515, + }, + AE: { + name: 'United Arab Emirates', + sms: 0.0145, + }, + AF: { + name: 'Afghanistan', + sms: 0.2409, + }, + AG: { + name: 'Antigua and Barbuda', + sms: 0.0755, + }, + AI: { + name: 'Anguilla', + sms: 0.0765, + }, + AL: { + name: 'Albania', + sms: 0.0345, + }, + AM: { + name: 'Armenia', + sms: 0.118, + }, + AN: { + name: 'Netherlands Antilles', + sms: null, + }, + AO: { + name: 'Angola', + sms: 0.0347, + }, + AR: { + name: 'Argentina', + sms: 0.0614, + }, + AS: { + name: 'American Samoa', + sms: 0.077, + }, + AT: { + name: 'Austria', + sms: 0.014, + }, + AU: { + name: 'Australia', + sms: 0.0091, + }, + AW: { + name: 'Aruba', + sms: 0.0617, + }, + AZ: { + name: 'Azerbaijan', + sms: 0.0969, + }, + BA: { + name: 'Bosnia and Herzegovina', + sms: 0.0801, + }, + BB: { + name: 'Barbados', + sms: 0.2021, + }, + BD: { + name: 'Bangladesh', + sms: 0.1844, + }, + BE: { + name: 'Belgium', + sms: 0.0297, + }, + BF: { + name: 'Burkina Faso', + sms: 0.063, + }, + BG: { + name: 'Bulgaria', + sms: 0.0799, + }, + BH: { + name: 'Bahrain', + sms: 0.014, + }, + BI: { + name: 'Burundi', + sms: 0.217, + }, + BJ: { + name: 'Benin', + sms: 0.13, + }, + BM: { + name: 'Bermuda', + sms: 0.21, + }, + BN: { + name: 'Brunei', + sms: 0.035, + }, + BO: { + name: 'Bolivia', + sms: 0.0708, + }, + BQ: { + name: 'Caribbean Netherlands', + sms: 0.0565, + }, + BR: { + name: 'Brazil', + sms: 0.0086, + }, + BS: { + name: 'Bahamas', + sms: 0.0672, + }, + BT: { + name: 'Bhutan', + sms: null, + }, + BW: { + name: 'Botswana', + sms: 0.0235, + }, + BY: { + name: 'Belarus', + sms: 0.118, + }, + BZ: { + name: 'Belize', + sms: 0.1529, + }, + CA: { + name: 'Canada', + sms: 0.0052, + }, + CD: { + name: 'Congo RDC', + sms: 0.1112, + }, + CF: { + name: 'Central African Republic', + sms: 0.2, + }, + CG: { + name: 'Congo', + sms: 0.2507, + }, + CH: { + name: 'Switzerland', + sms: 0.0163, + }, + CI: { + name: "Cote d'Ivoire", + sms: 0.19, + }, + CK: { + name: 'Cook Islands', + sms: 0.1041, + }, + CL: { + name: 'Chile', + sms: 0.0027, + }, + CM: { + name: 'Cameroon', + sms: 0.1504, + }, + CN: { + name: 'China', + sms: 0.0053, + }, + CO: { + name: 'Colombia', + sms: 0.0008, + }, + CR: { + name: 'Costa Rica', + sms: 0.0045, + }, + CU: { + name: 'Cuba', + sms: 0.0512, + }, + CV: { + name: 'Cabo Verde', + sms: 0.2031, + }, + CW: { + name: 'Curacao', + sms: 0.0138, + }, + CY: { + name: 'Northern Cyprus', + sms: 0.0065, + }, + CZ: { + name: 'Czech Republic', + sms: 0.0299, + }, + DE: { + name: 'Germany', + sms: 0.0598, + }, + DJ: { + name: 'Djibouti', + sms: 0.0897, + }, + DK: { + name: 'Denmark', + sms: 0.0301, + }, + DM: { + name: 'Dominica', + sms: 0.0824, + }, + DO: { + name: 'Dominican Republic', + sms: 0.0351, + }, + DZ: { + name: 'Algeria', + sms: 0.196, + }, + EC: { + name: 'Ecuador', + sms: 0.1041, + }, + EE: { + name: 'Estonia', + sms: 0.0234, + }, + EG: { + name: 'Egypt', + sms: 0.1561, + }, + ER: { + name: 'Eritrea', + sms: 0.0659, + }, + ES: { + name: 'Spain', + sms: 0.0193, + }, + ET: { + name: 'Ethiopia', + sms: 0.2741, + }, + FI: { + name: 'Finland', + sms: 0.043, + }, + FJ: { + name: 'Fiji', + sms: 0.069, + }, + FK: { + name: 'Falkland Islands', + sms: 0.0713, + }, + FM: { + name: 'Micronesia', + sms: 0.0118, + }, + FO: { + name: 'Faroe Islands', + sms: 0.0319, + }, + FR: { + name: 'France', + sms: 0.03, + }, + GA: { + name: 'Gabon', + sms: 0.15, + }, + GB: { + name: 'United Kingdom', + sms: 0.026, + }, + GD: { + name: 'Grenada', + sms: null, + }, + GE: { + name: 'Georgia', + sms: 0.0788, + }, + GF: { + name: 'French Guiana', + sms: 0.05, + }, + GG: { + name: 'Guernsey', + sms: 0.025, + }, + GH: { + name: 'Ghana', + sms: 0.15, + }, + GI: { + name: 'Gibraltar', + sms: 0.0134, + }, + GL: { + name: 'Greenland', + sms: 0.0048, + }, + GM: { + name: 'Gambia', + sms: 0.1283, + }, + GN: { + name: 'Guinea', + sms: 0.2, + }, + GP: { + name: 'Guadeloupe', + sms: 0.0455, + }, + GQ: { + name: 'Equatorial Guinea', + sms: 0.099, + }, + GR: { + name: 'Greece', + sms: 0.0296, + }, + GT: { + name: 'Guatemala', + sms: 0.1202, + }, + GU: { + name: 'Guam', + sms: 0.02, + }, + GW: { + name: 'Guinea-Bissau', + sms: 0.1566, + }, + GY: { + name: 'Guyana', + sms: 0.2178, + }, + HK: { + name: 'Hong Kong', + sms: 0.038, + }, + HN: { + name: 'Honduras', + sms: 0.162, + }, + HR: { + name: 'Croatia', + sms: 0.03, + }, + HT: { + name: 'Haiti', + sms: 0.09, + }, + HU: { + name: 'Hungary', + sms: 0.029, + }, + ID: { + name: 'Indonesia', + sms: 0.243, + }, + IE: { + name: 'Ireland', + sms: 0.0305, + }, + IL: { + name: 'Israel', + sms: 0.01, + }, + IM: { + name: 'Isle of Man', + sms: 0.0385, + }, + IN: { + name: 'India', + sms: 0.0375, + }, + IQ: { + name: 'Iraq', + sms: 0.151, + }, + IR: { + name: 'Iran', + sms: 0.14, + }, + IS: { + name: 'Iceland', + sms: 0.0493, + }, + IT: { + name: 'Italy', + sms: 0.0245, + }, + JE: { + name: 'Jersey', + sms: 0.025, + }, + JM: { + name: 'Jamaica', + sms: 0.1525, + }, + JO: { + name: 'Jordan', + sms: 0.2094, + }, + JP: { + name: 'Japan', + sms: 0.017, + }, + KE: { + name: 'Kenya', + sms: 0.125, + }, + KG: { + name: 'Kyrgyzstan', + sms: 0.15, + }, + KH: { + name: 'Cambodia', + sms: 0.1529, + }, + KI: { + name: 'Kiribati', + sms: 0.025, + }, + KM: { + name: 'Comoros', + sms: 0.15, + }, + KN: { + name: 'Saint Kitts and Nevis', + sms: 0.1295, + }, + KR: { + name: 'South Korea', + sms: 0.006, + }, + KW: { + name: 'Kuwait', + sms: 0.145, + }, + KY: { + name: 'Cayman Islands', + sms: 0.2172, + }, + KZ: { + name: 'Kazakhstan', + sms: 0.202, + }, + LA: { + name: 'Laos', + sms: 0.15, + }, + LB: { + name: 'Lebanon', + sms: 0.153, + }, + LC: { + name: 'Saint Lucia', + sms: 0.0892, + }, + LI: { + name: 'Liechtenstein', + sms: null, + }, + LK: { + name: 'Sri Lanka', + sms: 0.3593, + }, + LR: { + name: 'Liberia', + sms: 0.1448, + }, + LS: { + name: 'Lesotho', + sms: 0.0301, + }, + LT: { + name: 'Lithuania', + sms: 0.0261, + }, + LU: { + name: 'Luxembourg', + sms: 0.034, + }, + LV: { + name: 'Latvia', + sms: 0.028, + }, + LY: { + name: 'Libya', + sms: 0.1898, + }, + MA: { + name: 'Morocco', + sms: 0.105, + }, + MC: { + name: 'Monaco', + sms: 0.1219, + }, + MD: { + name: 'Moldova', + sms: 0.065, + }, + ME: { + name: 'Montenegro', + sms: 0.07, + }, + MG: { + name: 'Madagascar', + sms: 0.24, + }, + MH: { + name: '', + sms: 0.032, + }, + MK: { + name: 'Macedonia', + sms: 0.0096, + }, + ML: { + name: 'Mali', + sms: 0.136, + }, + MM: { + name: 'Myanmar', + sms: 0.3037, + }, + MN: { + name: 'Mongolia', + sms: 0.183, + }, + MO: { + name: 'Macao', + sms: 0.005, + }, + MP: { + name: 'Northern Mariana Islands', + sms: 0.0669, + }, + MQ: { + name: 'Martinique', + sms: 0.0455, + }, + MR: { + name: 'Mauritania', + sms: 0.161, + }, + MS: { + name: 'Montserrat', + sms: 0.0725, + }, + MT: { + name: 'Malta', + sms: 0.0343, + }, + MU: { + name: 'Mauritius', + sms: 0.1431, + }, + MV: { + name: 'Maldives', + sms: 0.145, + }, + MW: { + name: 'Malawi', + sms: 0.19, + }, + MX: { + name: 'Mexico', + sms: 0.0021, + }, + MY: { + name: 'Malaysia', + sms: 0.08, + }, + MZ: { + name: 'Mozambique', + sms: 0.2266, + }, + NA: { + name: 'Namibia', + sms: 0.0173, + }, + NC: { + name: 'New Caledonia', + sms: 0.052, + }, + NE: { + name: 'Niger', + sms: 0.143, + }, + NG: { + name: 'Nigeria', + sms: 0.198, + }, + NI: { + name: 'Nicaragua', + sms: 0.1033, + }, + NL: { + name: 'Netherlands', + sms: 0.046, + }, + NO: { + name: 'Norway', + sms: 0.03, + }, + NP: { + name: 'Nepal', + sms: 0.1645, + }, + NR: { + name: 'Nauru', + sms: null, + }, + NU: { + name: 'Niue', + sms: null, + }, + NZ: { + name: 'New Zealand', + sms: 0.0372, + }, + OM: { + name: 'Oman', + sms: 0.0713, + }, + PA: { + name: 'Panama', + sms: 0.07, + }, + PE: { + name: 'Peru', + sms: 0.13, + }, + PF: { + name: 'French Polynesia', + sms: 0.0518, + }, + PG: { + name: 'Papua New Guinea', + sms: 0.15, + }, + PH: { + name: 'Philippines', + sms: 0.1237, + }, + PK: { + name: 'Pakistan', + sms: 0.3548, + }, + PL: { + name: 'Poland', + sms: 0.011, + }, + PM: { + name: 'Saint Pierre and Miquelon', + sms: 0.0905, + }, + PR: { + name: 'Puerto Rico', + sms: 0.02, + }, + PS: { + name: 'Palestine', + sms: 0.2541, + }, + PT: { + name: 'Portugal', + sms: 0.009, + }, + PW: { + name: 'Palau', + sms: null, + }, + PY: { + name: 'Paraguay', + sms: 0.0236, + }, + QA: { + name: 'Qatar', + sms: 0.1393, + }, + RE: { + name: 'Reunion', + sms: 0.034, + }, + RO: { + name: 'Romania', + sms: 0.0255, + }, + RS: { + name: 'Serbia', + sms: 0.1935, + }, + RU: { + name: 'Russia', + sms: 0.2028, + }, + RW: { + name: 'Rwanda', + sms: 0.1177, + }, + SA: { + name: 'Saudi Arabia', + sms: 0.0638, + }, + SB: { + name: 'Solomon Islands', + sms: 0.0357, + }, + SC: { + name: 'Seychelles', + sms: 0.0404, + }, + SD: { + name: 'Sudan', + sms: 0.224, + }, + SE: { + name: 'Sweden', + sms: 0.026, + }, + SG: { + name: 'Singapore', + sms: 0.0256, + }, + SI: { + name: 'Slovenia', + sms: 0.1, + }, + SK: { + name: 'Slovakia', + sms: 0.0245, + }, + SL: { + name: 'Sierra Leone', + sms: 0.2322, + }, + SM: { + name: 'San Marino', + sms: null, + }, + SN: { + name: 'Senegal', + sms: 0.1151, + }, + SO: { + name: 'Somalia', + sms: 0.06, + }, + SR: { + name: 'Suriname', + sms: 0.119, + }, + SS: { + name: 'South Sudan', + sms: 0.15, + }, + ST: { + name: 'Sao Tome and Principe', + sms: 0.0133, + }, + SV: { + name: 'El Salvador', + sms: 0.06, + }, + SX: { + name: 'Sint Maarten', + sms: 0.0623, + }, + SY: { + name: 'Syria', + sms: 0.221, + }, + SZ: { + name: 'Eswatini', + sms: 0.12, + }, + TC: { + name: 'Turks and Caicos Islands', + sms: null, + }, + TD: { + name: 'Chad', + sms: 0.1711, + }, + TG: { + name: 'Togo', + sms: 0.1806, + }, + TH: { + name: 'Thailand', + sms: 0.003, + }, + TJ: { + name: 'Tajikistan', + sms: 0.2626, + }, + TL: { + name: 'Timor-Leste', + sms: 0.0675, + }, + TM: { + name: 'Turkmenistan', + sms: 0.1677, + }, + TN: { + name: 'Tunisia', + sms: 0.225, + }, + TO: { + name: 'Tonga', + sms: 0.0902, + }, + TR: { + name: 'Turkey', + sms: 0.0008, + }, + TT: { + name: 'Trinidad and Tobago', + sms: 0.1556, + }, + TW: { + name: 'Taiwan', + sms: 0.0165, + }, + TZ: { + name: 'Tanzania', + sms: 0.2753, + }, + UA: { + name: 'Ukraine', + sms: 0.094, + }, + UG: { + name: 'Uganda', + sms: 0.1707, + }, + US: { + name: 'United States', + sms: 0.0043, + }, + UY: { + name: 'Uruguay', + sms: 0.0174, + }, + UZ: { + name: 'Uzbekistan', + sms: 0.292, + }, + VC: { + name: 'Saint Vincent and The Grenadines', + sms: 0.1216, + }, + VE: { + name: 'Venezuela', + sms: 0.0427, + }, + VG: { + name: 'British Virgin Islands', + sms: 0.083, + }, + VI: { + name: 'Virgin Island', + sms: 0.0017, + }, + VN: { + name: 'Vietnam', + sms: 0.0885, + }, + VU: { + name: 'Vanuatu', + sms: 0.1347, + }, + WF: { + name: 'Wallis and Futuna', + sms: 0.09, + }, + WS: { + name: 'Samoa', + sms: 0.2, + }, + XK: { + name: 'Kosovo', + sms: 0.1394, + }, + YE: { + name: 'Yemen', + sms: 0.1486, + }, + YT: { + name: 'Mayotte', + sms: 0.06, + }, + ZA: { + name: 'South Africa', + sms: 0.039, + }, + ZM: { + name: 'Zambia', + sms: 0.21, + }, + ZW: { + name: 'Zimbabwe', + sms: 0.13, + }, +}; diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index b89abce7c..bfd9cae71 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -32,7 +32,7 @@ import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { EventClient } from '../../clients/event/EventClient.js'; import type { Actor } from '../../core/actor.js'; import { runWithContext } from '../../core/context.js'; @@ -76,6 +76,23 @@ let signupValidateOverride: SignupValidateOverride | null = null; const heardSignupSuccess: Array> = []; const heardUserDelete: Array> = []; +// Card verification is pure mechanism in core: a payments extension fills in the +// event fields via emitAndWait. These overrides let a test stand in for that +// extension, using the same shared-listener pattern as the signup validate +// override above (EventClient has no off()). null override => no extension. +type CardSetupOverride = (data: { + enabled: boolean | null; + client_secret: string | null; + publishable_key: string | null; +}) => void; +type CardConfirmOverride = (data: { + enabled: boolean | null; + verified: boolean; + reason: string | null; +}) => void; +let cardSetupOverride: CardSetupOverride | null = null; +let cardConfirmOverride: CardConfirmOverride | null = null; + const installSharedListeners = () => { eventClient.on('puter.signup.validate', (_k: unknown, data: unknown) => { if (signupValidateOverride) { @@ -90,6 +107,22 @@ const installSharedListeners = () => { eventClient.on('user.delete', (_k: unknown, data: unknown) => { heardUserDelete.push(data as Record); }); + eventClient.on( + 'puter.card-verification.setup', + (_k: unknown, data: unknown) => { + if (cardSetupOverride) { + cardSetupOverride(data as Parameters[0]); + } + }, + ); + eventClient.on( + 'puter.card-verification.confirm', + (_k: unknown, data: unknown) => { + if (cardConfirmOverride) { + cardConfirmOverride(data as Parameters[0]); + } + }, + ); }; const withSignupValidateOverride = async ( @@ -104,6 +137,30 @@ const withSignupValidateOverride = async ( } }; +const withCardSetupOverride = async ( + override: CardSetupOverride, + fn: () => Promise, +): Promise => { + cardSetupOverride = override; + try { + return await fn(); + } finally { + cardSetupOverride = null; + } +}; + +const withCardConfirmOverride = async ( + override: CardConfirmOverride, + fn: () => Promise, +): Promise => { + cardConfirmOverride = override; + try { + return await fn(); + } finally { + cardConfirmOverride = null; + } +}; + // ── Synthetic req/res helpers ─────────────────────────────────────── interface MockRes { @@ -328,6 +385,35 @@ describe('AuthController.handleSignup', () => { ).toBe(true); }); + it('forces phone verification on every signup when always_require_phone_verification is set', async () => { + const cfg = (controller as { config: Record }).config; + const prev = cfg.always_require_phone_verification; + cfg.always_require_phone_verification = true; + try { + const username = `s_${uniq()}`; + const req = makeReq({ + username, + email: `${username}@test.local`, + password: 'correct-horse-battery', + }); + const res = makeRes(); + + await controller.handleSignup(req, res); + + // The login envelope flags the gate so the GUI shows the dialog … + const body = res.body as { + user: { requires_phone_verification?: number | boolean }; + }; + expect(body.user.requires_phone_verification).toBeTruthy(); + + // … and it's persisted so the gate survives re-login. + const persisted = await server.stores.user.getByUsername(username); + expect(persisted!.requires_phone_verification).toBe(true); + } finally { + cfg.always_require_phone_verification = prev; + } + }); + it('rejects a duplicate username with 400', async () => { const username = `s_${uniq()}`; // Seed first. @@ -1723,6 +1809,534 @@ describe('AuthController.handleSendConfirmEmail', () => { }); }); +describe('AuthController.handleSendConfirmPhone attempt cap', () => { + it('allows two code sends, then hard-blocks the third (429)', async () => { + const { actor } = await makeUserAndActor(); + // Stub the Prelude client: report configured + supported, and a + // successful send — so we exercise the KV-backed attempt cap, not the + // network or the country gate. + const ctrl = controller as { clients: { prelude: unknown } }; + const realPrelude = ctrl.clients.prelude; + const createVerification = vi.fn(async () => ({ status: 'success' })); + ctrl.clients.prelude = { + isConfigured: () => true, + isCountrySupported: () => true, + defaultCountry: 'US', + createVerification, + }; + try { + const send = () => + controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + makeRes(), + ); + await send(); // 1st — allowed + await send(); // 2nd — allowed + expect(createVerification).toHaveBeenCalledTimes(2); + // 3rd — over the lifetime cap → 429, and no SMS dispatched. + await expect(send()).rejects.toMatchObject({ statusCode: 429 }); + expect(createVerification).toHaveBeenCalledTimes(2); + } finally { + ctrl.clients.prelude = realPrelude; + } + }); + + it('does not burn an attempt when the send fails upstream', async () => { + const { actor } = await makeUserAndActor(); + const ctrl = controller as { clients: { prelude: unknown } }; + const realPrelude = ctrl.clients.prelude; + // First call throws (upstream error), second succeeds. + const createVerification = vi + .fn() + .mockRejectedValueOnce(new Error('prelude down')) + .mockResolvedValue({ status: 'success' }); + ctrl.clients.prelude = { + isConfigured: () => true, + isCountrySupported: () => true, + defaultCountry: 'US', + createVerification, + }; + try { + const send = () => + controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + makeRes(), + ); + // Upstream failure → 502, attempt NOT counted. + await expect(send()).rejects.toMatchObject({ statusCode: 502 }); + // Two real sends still available afterwards. + await send(); + await send(); + await expect(send()).rejects.toMatchObject({ statusCode: 429 }); + } finally { + ctrl.clients.prelude = realPrelude; + } + }); +}); + +describe('AuthController.handleSendConfirmPhone validation', () => { + // Stub the Prelude client (a real external boundary) so we exercise the + // controller's validation branches, not the network. Override per case. + const stubPrelude = (over: Record = {}) => ({ + isConfigured: () => true, + isCountrySupported: () => true, + defaultCountry: 'US', + createVerification: vi.fn(async () => ({ status: 'success' })), + ...over, + }); + const withPrelude = async ( + prelude: unknown, + fn: () => Promise, + ): Promise => { + const ctrl = controller as { clients: { prelude: unknown } }; + const real = ctrl.clients.prelude; + ctrl.clients.prelude = prelude; + try { + await fn(); + } finally { + ctrl.clients.prelude = real; + } + }; + + it('throws 400 for an unparseable phone number', async () => { + const { actor } = await makeUserAndActor(); + await withPrelude(stubPrelude(), async () => { + await expect( + controller.handleSendConfirmPhone( + makeReq({ phone: 'not a phone' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + it('throws 400 (and sends nothing) when the country is over the cost cap', async () => { + const { actor } = await makeUserAndActor(); + const createVerification = vi.fn(async () => ({ status: 'success' })); + await withPrelude( + stubPrelude({ isCountrySupported: () => false, createVerification }), + async () => { + await expect( + controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(createVerification).not.toHaveBeenCalled(); + }, + ); + }); + + it('throws 503 when Prelude is not configured', async () => { + const { actor } = await makeUserAndActor(); + await withPrelude( + stubPrelude({ isConfigured: () => false }), + async () => { + await expect( + controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 503 }); + }, + ); + }); + + it('surfaces a Prelude block as 429', async () => { + const { actor } = await makeUserAndActor(); + await withPrelude( + stubPrelude({ + createVerification: vi.fn(async () => ({ status: 'blocked' })), + }), + async () => { + await expect( + controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 429 }); + }, + ); + }); +}); + +describe('AuthController.handleConfirmPhone', () => { + const stubPrelude = (over: Record = {}) => ({ + isConfigured: () => true, + checkVerification: vi.fn(async () => ({ status: 'success' })), + ...over, + }); + const withPrelude = async ( + prelude: unknown, + fn: () => Promise, + ): Promise => { + const ctrl = controller as { clients: { prelude: unknown } }; + const real = ctrl.clients.prelude; + ctrl.clients.prelude = prelude; + try { + await fn(); + } finally { + ctrl.clients.prelude = real; + } + }; + + it('throws 400 when code is missing', async () => { + const { actor } = await makeUserAndActor({ + requires_phone_verification: 1, + phone: '+14155550123', + }); + await expect( + controller.handleConfirmPhone(makeReq({}, { actor }), makeRes()), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('short-circuits to verified when the gate is not set (no Prelude call)', async () => { + const { actor } = await makeUserAndActor(); + const checkVerification = vi.fn(); + await withPrelude(stubPrelude({ checkVerification }), async () => { + const res = makeRes(); + await controller.handleConfirmPhone( + makeReq({ code: '123456' }, { actor }), + res, + ); + expect(res.body).toMatchObject({ phone_verified: true }); + expect(checkVerification).not.toHaveBeenCalled(); + }); + }); + + it('throws 400 when the gate is set but no phone is on file', async () => { + const { actor } = await makeUserAndActor({ + requires_phone_verification: 1, + }); + await expect( + controller.handleConfirmPhone( + makeReq({ code: '123456' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('throws 503 when the gate is set but Prelude is not configured', async () => { + const { actor } = await makeUserAndActor({ + requires_phone_verification: 1, + phone: '+14155550123', + }); + await withPrelude( + stubPrelude({ isConfigured: () => false }), + async () => { + await expect( + controller.handleConfirmPhone( + makeReq({ code: '123456' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 503 }); + }, + ); + }); + + it('returns phone_verified:false on a wrong code without clearing the gate', async () => { + const { user, actor } = await makeUserAndActor({ + requires_phone_verification: 1, + phone: '+14155550123', + }); + await withPrelude( + stubPrelude({ + checkVerification: vi.fn(async () => ({ status: 'failure' })), + }), + async () => { + const res = makeRes(); + await controller.handleConfirmPhone( + makeReq({ code: '000000' }, { actor }), + res, + ); + expect(res.body).toMatchObject({ phone_verified: false }); + const after = await server.stores.user.getById(user.id, { + force: true, + }); + expect(after!.requires_phone_verification).toBe(true); + }, + ); + }); + + it('clears the gate on a correct code and echoes the socket id', async () => { + const { user, actor } = await makeUserAndActor({ + requires_phone_verification: 1, + phone: '+14155550123', + }); + await withPrelude( + stubPrelude({ + checkVerification: vi.fn(async () => ({ status: 'success' })), + }), + async () => { + const res = makeRes(); + await controller.handleConfirmPhone( + makeReq( + { code: '123456', original_client_socket_id: 'sock_1' }, + { actor }, + ), + res, + ); + expect(res.body).toMatchObject({ + phone_verified: true, + original_client_socket_id: 'sock_1', + }); + const after = await server.stores.user.getById(user.id, { + force: true, + }); + expect(after!.requires_phone_verification).toBe(false); + }, + ); + }); + + it('throws 502 when the upstream check fails', async () => { + const { actor } = await makeUserAndActor({ + requires_phone_verification: 1, + phone: '+14155550123', + }); + await withPrelude( + stubPrelude({ + checkVerification: vi.fn(async () => { + throw new Error('prelude down'); + }), + }), + async () => { + await expect( + controller.handleConfirmPhone( + makeReq({ code: '123456' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 502 }); + }, + ); + }); +}); + +describe('AuthController.handleCardVerificationSetup', () => { + it('short-circuits to verified when the gate is not set', async () => { + const { actor } = await makeUserAndActor(); + const res = makeRes(); + await controller.handleCardVerificationSetup( + makeReq({}, { actor }), + res, + ); + expect(res.body).toMatchObject({ card_verified: true }); + }); + + it('throws 403 when the account is suspended', async () => { + const { actor } = await makeUserAndActor({ + requires_card_verification: 1, + suspended: 1, + }); + await expect( + controller.handleCardVerificationSetup( + makeReq({}, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('throws 409 when phone verification must be completed first', async () => { + const { actor } = await makeUserAndActor({ + requires_card_verification: 1, + requires_phone_verification: 1, + phone: '+14155550123', + }); + await expect( + controller.handleCardVerificationSetup( + makeReq({}, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 409 }); + }); + + it('throws 503 when no payments extension is listening', async () => { + const { actor } = await makeUserAndActor({ + requires_card_verification: 1, + }); + await expect( + controller.handleCardVerificationSetup( + makeReq({}, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 503 }); + }); + + it('clears the gate when the extension reports the feature disabled', async () => { + const { user, actor } = await makeUserAndActor({ + requires_card_verification: 1, + }); + const res = makeRes(); + await withCardSetupOverride( + (data) => { + data.enabled = false; + }, + () => + controller.handleCardVerificationSetup( + makeReq({}, { actor }), + res, + ), + ); + expect(res.body).toMatchObject({ card_verified: true, disabled: true }); + const after = await server.stores.user.getById(user.id, { + force: true, + }); + expect(after!.requires_card_verification).toBe(false); + }); + + it('returns the provider credentials on success', async () => { + const { actor } = await makeUserAndActor({ + requires_card_verification: 1, + }); + const res = makeRes(); + await withCardSetupOverride( + (data) => { + data.enabled = true; + data.client_secret = 'seti_secret'; + data.publishable_key = 'pk_test'; + }, + () => + controller.handleCardVerificationSetup( + makeReq({}, { actor }), + res, + ), + ); + expect(res.body).toEqual({ + client_secret: 'seti_secret', + publishable_key: 'pk_test', + }); + }); +}); + +describe('AuthController.handleCardVerificationConfirm', () => { + it('throws 400 for a missing or invalid setup_intent_id', async () => { + const { actor } = await makeUserAndActor({ + requires_card_verification: 1, + }); + for (const bad of [undefined, '', 123, 'x'.repeat(256)]) { + await expect( + controller.handleCardVerificationConfirm( + makeReq({ setup_intent_id: bad }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + } + }); + + it('short-circuits to verified when the gate is not set', async () => { + const { actor } = await makeUserAndActor(); + const res = makeRes(); + await controller.handleCardVerificationConfirm( + makeReq({ setup_intent_id: 'seti_1' }, { actor }), + res, + ); + expect(res.body).toMatchObject({ card_verified: true }); + }); + + it('throws 409 when phone verification must be completed first', async () => { + const { actor } = await makeUserAndActor({ + requires_card_verification: 1, + requires_phone_verification: 1, + phone: '+14155550123', + }); + await expect( + controller.handleCardVerificationConfirm( + makeReq({ setup_intent_id: 'seti_1' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 409 }); + }); + + it('throws 503 when no payments extension is listening', async () => { + const { actor } = await makeUserAndActor({ + requires_card_verification: 1, + }); + await expect( + controller.handleCardVerificationConfirm( + makeReq({ setup_intent_id: 'seti_1' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 503 }); + }); + + it('clears the gate when the extension reports the feature disabled', async () => { + const { user, actor } = await makeUserAndActor({ + requires_card_verification: 1, + }); + const res = makeRes(); + await withCardConfirmOverride( + (data) => { + data.enabled = false; + }, + () => + controller.handleCardVerificationConfirm( + makeReq({ setup_intent_id: 'seti_1' }, { actor }), + res, + ), + ); + expect(res.body).toMatchObject({ card_verified: true, disabled: true }); + const after = await server.stores.user.getById(user.id, { + force: true, + }); + expect(after!.requires_card_verification).toBe(false); + }); + + it('returns card_verified:false with a reason when not verified', async () => { + const { user, actor } = await makeUserAndActor({ + requires_card_verification: 1, + }); + const res = makeRes(); + await withCardConfirmOverride( + (data) => { + data.enabled = true; + data.verified = false; + data.reason = 'prepaid_not_allowed'; + }, + () => + controller.handleCardVerificationConfirm( + makeReq({ setup_intent_id: 'seti_1' }, { actor }), + res, + ), + ); + expect(res.body).toMatchObject({ + card_verified: false, + reason: 'prepaid_not_allowed', + }); + const after = await server.stores.user.getById(user.id, { + force: true, + }); + expect(after!.requires_card_verification).toBe(true); + }); + + it('clears the gate when the extension verifies the card', async () => { + const { user, actor } = await makeUserAndActor({ + requires_card_verification: 1, + }); + const res = makeRes(); + await withCardConfirmOverride( + (data) => { + data.enabled = true; + data.verified = true; + }, + () => + controller.handleCardVerificationConfirm( + makeReq( + { + setup_intent_id: 'seti_1', + original_client_socket_id: 'sock_1', + }, + { actor }, + ), + res, + ), + ); + expect(res.body).toMatchObject({ card_verified: true }); + const after = await server.stores.user.getById(user.id, { + force: true, + }); + expect(after!.requires_card_verification).toBe(false); + }); +}); + describe('AuthController.handleConfirmEmail', () => { it('throws 400 when code is missing', async () => { const { actor } = await makeUserAndActor(); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index ec90f6fa4..d89b4b95e 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -47,6 +47,7 @@ import { } from '../../services/auth/OTPUtil.js'; import { sessionCookieFlags } from '../../util/cookieFlags.js'; import { cleanEmail, isBlockedEmail } from '../../util/email.js'; +import { parsePhone } from '../../util/phone.js'; import { generate_identifier } from '../../util/identifier.js'; import { getTaskbarItems } from '../../util/taskbarItems.js'; import { @@ -593,6 +594,13 @@ export class AuthController extends PuterController { allow: true, no_temp_user: false, requires_email_confirmation: false, + // Set by the abuse harness for low-reputation signups: the account is + // created + logged in but gated behind SMS phone verification (in + // addition to email confirmation) instead of being blocked. + requires_phone_verification: false, + // Same idea, one rung up the ladder: gate the account behind + // credit-card verification (a $0 auth handled by an extension). + requires_card_verification: false, message: null, code: null, user_agent: req?.headers?.['user-agent'] ?? null, @@ -637,6 +645,17 @@ export class AuthController extends PuterController { const force_email_confirmation = Boolean( validateEvent.requires_email_confirmation, ); + const force_phone_verification = + Boolean(validateEvent.requires_phone_verification) || + // Test/QA switch: force the SMS gate on every signup regardless of + // reputation (see config.always_require_phone_verification). + Boolean(this.config.always_require_phone_verification); + const force_card_verification = Boolean( + validateEvent.requires_card_verification || + // Test/QA switch: force the card gate on every signup regardless of + // reputation (see config.always_require_card_verification). + this.config.always_require_card_verification, + ); // Prepare shared fields const user_uuid = uuidv4(); @@ -671,6 +690,14 @@ export class AuthController extends PuterController { ...(validateEvent.reputation != null ? { reputation: validateEvent.reputation } : {}), + // Carry the phone gate onto the claimed account when required. + ...(force_phone_verification + ? { requires_phone_verification: 1 } + : {}), + // Likewise for the card gate. + ...(force_card_verification + ? { requires_card_verification: 1 } + : {}), }); // Move from temp group to regular user group @@ -730,6 +757,10 @@ export class AuthController extends PuterController { referrer: req.body.referrer ?? null, last_activity_ts: signupSqlTs, reputation: validateEvent.reputation, + // Phone collected later in the verification dialog (null now). + phone: null, + requires_phone_verification: force_phone_verification, + requires_card_verification: force_card_verification, } as never); // Add to default group @@ -1005,6 +1036,421 @@ export class AuthController extends PuterController { res.json({ email_confirmed: true, original_client_socket_id }); } + // -- Phone verification (SMS via Prelude) ------------------------ + + @Post('/send-confirm-phone', { + subdomain: ['api', ''], + requireUserActor: true, + allowUnconfirmed: true, + rateLimit: { + scope: 'send-confirm-phone', + limit: 10, + window: 60 * 60_000, + key: 'user', + }, + }) + async handleSendConfirmPhone(req: Request, res: Response): Promise { + const user = await this.stores.user.getById(req.actor!.user.id!, { + force: true, + }); + if (!user) + throw new HttpError(404, 'User not found.', { + legacyCode: 'user_not_found' as never, + }); + if (user.suspended) + throw new HttpError(403, 'Account suspended.', { + legacyCode: 'account_suspended', + }); + if (!this.clients.prelude?.isConfigured()) + throw new HttpError(503, 'Phone verification is unavailable.', { + legacyCode: 'service_unavailable' as never, + }); + + // Parse to E.164 (Prelude's required form + the stored form) and the + // country, so we can apply the per-country cost cap. + const parsed = parsePhone( + req.body?.phone, + this.clients.prelude.defaultCountry, + ); + if (!parsed) + throw new HttpError(400, 'Invalid phone number.', { + legacyCode: 'bad_request', + }); + + // Cost cap: skip countries with no SMS channel or rates above the cap + // (see PreludeClient / countries.ts). Avoids paying exorbitant per-SMS + // rates in low-revenue, high-fraud geographies. + if (!this.clients.prelude.isCountrySupported(parsed.country)) + throw new HttpError( + 400, + 'Phone verification is not available for this country.', + { legacyCode: 'phone_country_not_supported' as never }, + ); + + // Hard lifetime cap: at most MAX_PHONE_VERIFY_SENDS SMS codes per user, + // tracked in KV so it survives logout / re-login (unlike the per-window + // rate limit above). Once exhausted the user can't request another code + // — and so can't clear the gate. KV read failures fail open: this is + // abuse/cost control, not a security boundary, so a KV blip must not + // lock signups out. Only successful sends are counted (incremented + // below), so a bad number / upstream error doesn't burn an attempt. + const MAX_PHONE_VERIFY_SENDS = 2; + const attemptsKey = `phone-verify-attempts:${user.id}`; + let priorAttempts = 0; + try { + const { res } = await this.stores.kv.get({ key: attemptsKey }); + if (typeof res === 'number') priorAttempts = res; + } catch (e) { + console.warn('[send-confirm-phone] attempt-count read failed:', e); + } + if (priorAttempts >= MAX_PHONE_VERIFY_SENDS) + throw new HttpError( + 429, + 'You have used all of your phone verification attempts.', + { legacyCode: 'phone_verify_attempts_exhausted' as never }, + ); + + await this.stores.user.update(user.id, { phone: parsed.e164 }); + + const ip = req.ip || req.socket?.remoteAddress || undefined; + try { + const result = await this.clients.prelude.createVerification( + parsed.e164, + { ip }, + ); + // Prelude rejected the attempt as abusive — surface as rate-limit. + if ( + result.status === 'blocked' || + result.status === 'shadow_blocked' + ) { + throw new HttpError( + 429, + 'Phone verification is temporarily unavailable for this number.', + { legacyCode: 'too_many_requests' as never }, + ); + } + } catch (e) { + if (e instanceof HttpError) throw e; + console.warn('[send-confirm-phone] createVerification failed:', e); + throw new HttpError(502, 'Could not send verification code.', { + legacyCode: 'upstream_error' as never, + }); + } + + // Count the successful send against the lifetime cap (best-effort; a KV + // write failure shouldn't fail a code that was already sent). ~30d TTL + // so abandoned counters self-clean rather than living forever. + try { + await this.stores.kv.set({ + key: attemptsKey, + value: priorAttempts + 1, + expireAt: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, + }); + } catch (e) { + console.warn('[send-confirm-phone] attempt-count write failed:', e); + } + res.json({}); + } + + @Post('/confirm-phone', { + subdomain: ['api', ''], + requireUserActor: true, + allowUnconfirmed: true, + rateLimit: { + scope: 'confirm-phone', + limit: 10, + window: 10 * 60_000, + key: 'user', + }, + }) + async handleConfirmPhone(req: Request, res: Response): Promise { + const { code, original_client_socket_id } = req.body ?? {}; + if (!code) + throw new HttpError(400, 'Missing `code`.', { + legacyCode: 'bad_request', + }); + + const user = await this.stores.user.getById(req.actor!.user.id!, { + force: true, + }); + if (!user) + throw new HttpError(404, 'User not found.', { + legacyCode: 'not_found', + }); + if (!user.requires_phone_verification) { + res.json({ phone_verified: true, original_client_socket_id }); + return; + } + if (!user.phone) + throw new HttpError( + 400, + 'No phone number on file. Request a code first.', + { legacyCode: 'bad_request' }, + ); + if (!this.clients.prelude?.isConfigured()) + throw new HttpError(503, 'Phone verification is unavailable.', { + legacyCode: 'service_unavailable' as never, + }); + + let status; + try { + ({ status } = await this.clients.prelude.checkVerification( + user.phone, + String(code), + )); + } catch (e) { + console.warn('[confirm-phone] checkVerification failed:', e); + throw new HttpError(502, 'Could not verify code.', { + legacyCode: 'upstream_error' as never, + }); + } + + if (status !== 'success') { + res.json({ phone_verified: false, original_client_socket_id }); + return; + } + + await this.stores.user.update(user.id, { + requires_phone_verification: 0, + }); + + try { + this.clients.event?.emit( + 'user.phone-verified' as never, + { + user_id: user.id, + user_uid: user.uuid, + phone: user.phone, + } as never, + {}, + ); + } catch { + // ignore — event is a side-channel signal, not load-bearing + } + // Notify other tabs/devices for this user so they refresh + drop the gate. + try { + await this.services.socket?.send( + { room: user.id }, + 'user.phone_verified', + { original_client_socket_id }, + ); + } catch { + // ignore — best-effort + } + + res.json({ phone_verified: true, original_client_socket_id }); + } + + // -- Card verification ($0 auth via a payments extension) -------- + + /** + * Start card verification for the calling user. Pure mechanism: the + * endpoint emits `puter.card-verification.setup` and a payments + * extension fills in the client credentials — the OSS backend holds + * no provider knowledge or config. Phone verification (when required) + * must be completed first; the ordering is enforced here so a client + * can't skip the cheaper gate. + */ + @Post('/card-verification/setup', { + subdomain: ['api', ''], + requireUserActor: true, + allowUnconfirmed: true, + rateLimit: { + scope: 'card-verification-setup', + limit: 5, + window: 60 * 60_000, + key: 'user', + }, + }) + async handleCardVerificationSetup( + req: Request, + res: Response, + ): Promise { + const user = await this.stores.user.getById(req.actor!.user.id!, { + force: true, + }); + if (!user) + throw new HttpError(404, 'User not found.', { + legacyCode: 'user_not_found' as never, + }); + if (user.suspended) + throw new HttpError(403, 'Account suspended.', { + legacyCode: 'account_suspended', + }); + if (!user.requires_card_verification) { + res.json({ card_verified: true }); + return; + } + if (user.requires_phone_verification) + throw new HttpError( + 409, + 'Phone verification must be completed first.', + { legacyCode: 'conflict' }, + ); + + // `enabled` stays null when no extension is listening; an installed + // extension always sets it (true/false) before doing any work. + const setupEvent = { + user_id: user.id, + user_uid: user.uuid, + ip: (req.ip || req.socket?.remoteAddress || null) as string | null, + enabled: null as boolean | null, + client_secret: null as string | null, + publishable_key: null as string | null, + }; + try { + await this.clients.event?.emitAndWait( + 'puter.card-verification.setup', + setupEvent, + {}, + ); + } catch (e) { + console.warn('[card-verification/setup] setup hook failed:', e); + } + + // Kill switch: the extension reports the feature disabled — unstick + // any user still carrying the flag instead of dead-ending them. + if (setupEvent.enabled === false) { + await this.stores.user.update(user.id, { + requires_card_verification: 0, + }); + res.json({ card_verified: true, disabled: true }); + return; + } + if (!setupEvent.client_secret || !setupEvent.publishable_key) + throw new HttpError(503, 'Card verification is not available.', { + legacyCode: 'service_unavailable' as never, + }); + + res.json({ + client_secret: setupEvent.client_secret, + publishable_key: setupEvent.publishable_key, + }); + } + + /** + * Complete card verification. The client confirms the setup intent with + * the payment provider directly, then posts the resulting id here; the + * payments extension checks it (and applies its own abuse limits) via + * `puter.card-verification.confirm`. On success the gate clears exactly + * like `/confirm-phone` clears the phone gate. + */ + @Post('/card-verification/confirm', { + subdomain: ['api', ''], + requireUserActor: true, + allowUnconfirmed: true, + rateLimit: { + scope: 'card-verification-confirm', + limit: 10, + window: 10 * 60_000, + key: 'user', + }, + }) + async handleCardVerificationConfirm( + req: Request, + res: Response, + ): Promise { + const { setup_intent_id, original_client_socket_id } = req.body ?? {}; + if ( + typeof setup_intent_id !== 'string' || + setup_intent_id.length === 0 || + setup_intent_id.length > 255 + ) + throw new HttpError(400, 'Invalid `setup_intent_id`.', { + legacyCode: 'bad_request', + }); + + const user = await this.stores.user.getById(req.actor!.user.id!, { + force: true, + }); + if (!user) + throw new HttpError(404, 'User not found.', { + legacyCode: 'not_found', + }); + if (!user.requires_card_verification) { + res.json({ card_verified: true }); + return; + } + if (user.requires_phone_verification) + throw new HttpError( + 409, + 'Phone verification must be completed first.', + { legacyCode: 'conflict' }, + ); + + const confirmEvent = { + user_id: user.id, + user_uid: user.uuid, + setup_intent_id, + enabled: null as boolean | null, + verified: false, + reason: null as string | null, + fingerprint: null as string | null, + funding: null as string | null, + country: null as string | null, + }; + try { + await this.clients.event?.emitAndWait( + 'puter.card-verification.confirm', + confirmEvent, + {}, + ); + } catch (e) { + console.warn('[card-verification/confirm] confirm hook failed:', e); + } + + // Kill switch — same semantics as /card-verification/setup. + if (confirmEvent.enabled === false) { + await this.stores.user.update(user.id, { + requires_card_verification: 0, + }); + res.json({ card_verified: true, disabled: true }); + return; + } + // No extension listening — nothing could have verified anything. + if (confirmEvent.enabled === null) + throw new HttpError(503, 'Card verification is not available.', { + legacyCode: 'service_unavailable' as never, + }); + + if (confirmEvent.verified !== true) { + res.json({ card_verified: false, reason: confirmEvent.reason }); + return; + } + + await this.stores.user.update(user.id, { + requires_card_verification: 0, + }); + + try { + this.clients.event?.emit( + 'user.card-verified' as never, + { + user_id: user.id, + user_uid: user.uuid, + fingerprint: confirmEvent.fingerprint, + funding: confirmEvent.funding, + country: confirmEvent.country, + } as never, + {}, + ); + } catch { + // ignore — event is a side-channel signal, not load-bearing + } + // Notify other tabs/devices for this user so they refresh + drop the gate. + try { + await this.services.socket?.send( + { room: user.id }, + 'user.card_verified', + { original_client_socket_id }, + ); + } catch { + // ignore — best-effort + } + + res.json({ card_verified: true }); + } + // -- Password recovery ------------------------------------------- @Post('/send-pass-recovery-email', { @@ -3133,6 +3579,9 @@ export class AuthController extends PuterController { password?: string | null; email_confirmed?: number | boolean; requires_email_confirmation?: number | boolean; + phone?: string | null; + requires_phone_verification?: number | boolean; + requires_card_verification?: number | boolean; }, ): Promise { const meta = { @@ -3181,6 +3630,9 @@ export class AuthController extends PuterController { email: user.email, email_confirmed: user.email_confirmed, requires_email_confirmation: user.requires_email_confirmation, + phone: user.phone, + requires_phone_verification: user.requires_phone_verification, + requires_card_verification: user.requires_card_verification, is_temp: user.password === null && user.email === null, taskbar_items, }, diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 302be5a2e..ffb11ee96 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -1919,6 +1919,11 @@ export class AuthService extends PuterService { email_confirmed: user.email_confirmed ?? false, requires_email_confirmation: user.requires_email_confirmation ?? false, + phone: user.phone ?? null, + requires_phone_verification: + user.requires_phone_verification ?? false, + requires_card_verification: + user.requires_card_verification ?? false, }; } diff --git a/src/backend/stores/user/UserStore.ts b/src/backend/stores/user/UserStore.ts index 70f93fef7..7e66760d5 100644 --- a/src/backend/stores/user/UserStore.ts +++ b/src/backend/stores/user/UserStore.ts @@ -45,6 +45,12 @@ export interface UserRow { metadata?: Record; /** Abuse v2 reputation score recorded at signup (DB default 100). */ reputation?: number; + /** E.164 phone number collected during SMS verification. */ + phone?: string | null; + /** True while the account must complete SMS phone verification before use. */ + requires_phone_verification?: boolean; + /** True while the account must complete credit-card verification before use. */ + requires_card_verification?: boolean; password?: string; [k: string]: unknown; } @@ -86,10 +92,13 @@ const LATIN1_USER_COLUMNS: ReadonlySet = new Set([ 'email', 'username', 'clean_email', + 'phone', ]); const USER_BOOLEAN_COLUMNS: ReadonlySet = new Set([ 'requires_email_confirmation', 'email_confirmed', + 'requires_phone_verification', + 'requires_card_verification', 'dev_approved_for_incentive_program', 'dev_joined_incentive_program', 'suspended', @@ -327,6 +336,9 @@ export class UserStore extends PuterStore { referrer?: string | null; last_activity_ts?: string | null; reputation?: number | null; + phone?: string | null; + requires_phone_verification?: boolean; + requires_card_verification?: boolean; }): Promise { assertLatin1Writable(fields as Record); const result = await this.clients.db.write( @@ -348,8 +360,11 @@ export class UserStore extends PuterStore { signup_server, referrer, last_activity_ts, - reputation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${this.clients.db.returningIdClause()}`, + reputation, + phone, + requires_phone_verification, + requires_card_verification) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${this.clients.db.returningIdClause()}`, [ fields.username, fields.email, @@ -374,6 +389,13 @@ export class UserStore extends PuterStore { fields.last_activity_ts ?? null, // Default matches the DB column default + v2's STARTING_REPUTATION. fields.reputation ?? 100, + fields.phone ?? null, + this.clients.db.booleanValue( + Boolean(fields.requires_phone_verification), + ), + this.clients.db.booleanValue( + Boolean(fields.requires_card_verification), + ), ], ); @@ -583,6 +605,11 @@ export class UserStore extends PuterStore { requires_email_confirmation: asBool( rest.requires_email_confirmation, ), + requires_phone_verification: asBool( + rest.requires_phone_verification, + ), + requires_card_verification: asBool(rest.requires_card_verification), + phone: rest.phone == null ? null : String(rest.phone), reputation: rest.reputation == null ? undefined : Number(rest.reputation), metadata, diff --git a/src/backend/types.ts b/src/backend/types.ts index 1e2c67970..9f1e0e476 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -112,6 +112,31 @@ export interface IEmailConfig { [key: string]: unknown; } +/** Prelude (https://prelude.so) Verify v2 — SMS phone verification provider. */ +export interface IPreludeConfig { + /** Prelude v2 API key (sent as `Authorization: Bearer `). */ + apiKey?: string; + /** Default region for parsing local-format phone numbers (e.g. 'US'). */ + defaultCountry?: string; + /** + * Per-SMS cost ceiling in EUR. + */ + maxSmsCostEur?: number; + /** + * Verification template id (from the Prelude dashboard) that controls the + * SMS wording — e.g. a "Your Puter verification code is {{code}}" template. + * The message text itself is authored in Prelude, not here; this just + * selects it. Omit to use the dashboard default. + */ + templateId?: string; + /** + * Alphanumeric Sender ID to brand who the SMS is "from" (e.g. "Puter"). + * Must be pre-enabled by Prelude and isn't supported by all carriers/regions + * (notably US long/short codes). Omit to use Prelude's default sender. + */ + senderId?: string; +} + /** * S3-compatible bucket the thumbnails extension uses for storing generated * thumbnails. When unset, the extension falls back to the main `S3Client` @@ -526,6 +551,20 @@ interface IConfigOptional { allow_system_login: boolean; /** Reject auth-gated routes unless the user has confirmed their email. */ strict_email_verification_required: boolean; + /** + * Force SMS phone verification on every new signup, regardless of abuse + * reputation. Off by default; mainly a test/QA switch so the phone gate can + * be exercised on demand (it otherwise only triggers for low-reputation + * signups). Requires `prelude.apiKey` to actually deliver codes. + */ + always_require_phone_verification: boolean; + /** + * Force credit-card verification on every new signup, regardless of abuse + * reputation. Off by default; mainly a test/QA switch so the card gate can + * be exercised on demand (it otherwise only triggers for low-reputation + * signups). Requires a payments extension to actually run the $0 auth. + */ + always_require_card_verification: boolean; /** Captcha configuration. */ captcha: { enabled: boolean; difficulty?: 'easy' | 'medium' | 'hard' }; /** OIDC / OAuth2 providers (google + custom). */ @@ -569,6 +608,8 @@ interface IConfigOptional { redis: IRedisConfig; pager: IPagerConfig; email: IEmailConfig; + /** Optional — only set when SMS phone verification (Prelude) is wired in. */ + prelude: IPreludeConfig; /** Optional — only set when a ClickHouse analytics client is wired in. */ clickhouse?: IClickhouseConfig; cf_file_cache: ICfFileCacheConfig; diff --git a/src/backend/util/phone.test.ts b/src/backend/util/phone.test.ts new file mode 100644 index 000000000..d1dc18b7c --- /dev/null +++ b/src/backend/util/phone.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { parsePhone, sanitizePhone } from './phone'; + +describe('sanitizePhone', () => { + it('normalizes a valid E.164 number unchanged', () => { + expect(sanitizePhone('+14155550123')).toBe('+14155550123'); + }); + + it('normalizes messy but valid input to E.164', () => { + expect(sanitizePhone('+1 (415) 555-0123')).toBe('+14155550123'); + expect(sanitizePhone(' +44 20 7946 0958 ')).toBe('+442079460958'); + }); + + it('parses local format using the default country', () => { + expect(sanitizePhone('(415) 555-0123', 'US')).toBe('+14155550123'); + }); + + it('rejects local format with no default country (ambiguous)', () => { + expect(sanitizePhone('4155550123')).toBeNull(); + }); + + it('rejects invalid / too-short / non-numeric input', () => { + expect(sanitizePhone('+1 555')).toBeNull(); + expect(sanitizePhone('not a phone')).toBeNull(); + expect(sanitizePhone('')).toBeNull(); + expect(sanitizePhone(' ')).toBeNull(); + }); + + it('rejects non-string input', () => { + expect(sanitizePhone(undefined)).toBeNull(); + expect(sanitizePhone(null)).toBeNull(); + expect(sanitizePhone(14155550123)).toBeNull(); + }); +}); + +describe('parsePhone', () => { + it('returns E.164 + ISO country for a valid number', () => { + expect(parsePhone('+14155550123')).toEqual({ + e164: '+14155550123', + country: 'US', + }); + expect(parsePhone('+442079460958')).toEqual({ + e164: '+442079460958', + country: 'GB', + }); + }); + + it('infers country from the default region for local format', () => { + expect(parsePhone('(415) 555-0123', 'US')).toEqual({ + e164: '+14155550123', + country: 'US', + }); + }); + + it('returns null for invalid input', () => { + expect(parsePhone('not a phone')).toBeNull(); + expect(parsePhone('')).toBeNull(); + expect(parsePhone(undefined)).toBeNull(); + }); +}); diff --git a/src/backend/util/phone.ts b/src/backend/util/phone.ts new file mode 100644 index 000000000..8c803c9a3 --- /dev/null +++ b/src/backend/util/phone.ts @@ -0,0 +1,68 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + parsePhoneNumberFromString, + type CountryCode, +} from 'libphonenumber-js'; + +export interface ParsedPhone { + /** E.164 form, e.g. "+14155550123" — the format Prelude requires. */ + e164: string; + /** ISO 3166-1 alpha-2 region (e.g. 'US'), or undefined if undetermined. */ + country?: string; +} + +/** + * Parse + validate raw user-entered phone input, returning its E.164 form and + * country. Returns `null` when the input isn't a valid number — callers should + * treat that as a 400 (bad phone). `defaultCountry` lets a local-format number + * (no "+") be interpreted (e.g. "(415) 555-0123" with 'US'). + * + * @param input Raw phone string from the client. + * @param defaultCountry ISO 3166-1 alpha-2 region for local-format numbers. + */ +export function parsePhone( + input: unknown, + defaultCountry?: string, +): ParsedPhone | null { + if (typeof input !== 'string') return null; + const trimmed = input.trim(); + if (!trimmed) return null; + + const parsed = parsePhoneNumberFromString( + trimmed, + defaultCountry as CountryCode | undefined, + ); + if (!parsed || !parsed.isValid()) return null; + + return { e164: parsed.number, country: parsed.country }; +} + +/** + * Sanitize raw user-entered phone input to a validated E.164 string, or `null` + * if invalid. Thin wrapper over {@link parsePhone} for callers that only need + * the number. + */ +export function sanitizePhone( + input: unknown, + defaultCountry?: string, +): string | null { + return parsePhone(input, defaultCountry)?.e164 ?? null; +} diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index 549cc89d5..d4631643e 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -505,6 +505,26 @@ async function UIDesktop (options) { window.refresh_user_data(window.auth_token); }); + window.socket.on('user.phone_verified', (msg) => { + // don't update if this is the original client that initiated the action + if ( msg.original_client_socket_id === window.socket.id ) + { + return; + } + + window.refresh_user_data(window.auth_token); + }); + + window.socket.on('user.card_verified', (msg) => { + // don't update if this is the original client that initiated the action + if ( msg.original_client_socket_id === window.socket.id ) + { + return; + } + + window.refresh_user_data(window.auth_token); + }); + window.socket.on('user.email_changed', (msg) => { // don't update if this is the original client that initiated the action if ( msg.original_client_socket_id === window.socket.id ) diff --git a/src/gui/src/UI/UIWindowCardVerificationRequired.js b/src/gui/src/UI/UIWindowCardVerificationRequired.js new file mode 100644 index 000000000..534c2c9d9 --- /dev/null +++ b/src/gui/src/UI/UIWindowCardVerificationRequired.js @@ -0,0 +1,319 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import UIWindow from './UIWindow.js'; + +// Credit-card verification dialog (Stripe SetupIntent). Three states in one window: +// 1. Loading → POST /card-verification/setup returns a client_secret and a +// publishable key (or short-circuits when the user is already verified). +// 2. Card entry → a Stripe Payment Element; stripe.confirmSetup() runs the +// SetupIntent client-side, then POST /card-verification/confirm validates +// it server-side (card fingerprint reuse, etc.). +// 3. Unavailable → setup failed; retry and log-out affordances, not a dead end. +// Stripe.js is loaded lazily from the CDN only when this dialog actually opens. +// Used as a hard gate for low-reputation signups (after phone verification), so +// by default it has no close button. + +const STRIPE_JS_URL = 'https://js.stripe.com/v3/'; + +let stripe_js_promise = null; +const loadStripeJs = () => { + if ( window.Stripe ) return Promise.resolve(); + if ( ! stripe_js_promise ) { + stripe_js_promise = window.loadScript(STRIPE_JS_URL); + stripe_js_promise.catch(error => { + // Don't cache the failure — the retry button re-attempts the load. + stripe_js_promise = null; + console.debug('Stripe.js unavailable:', error); + }); + } + return stripe_js_promise; +}; + +function UIWindowCardVerificationRequired (options) { + return new Promise(async (resolve) => { + options = options ?? {}; + options.window_options = options.window_options ?? {}; + let is_setting_up = false; + let is_submitting = false; + let stripe = null; + let elements = null; + let payment_element = null; + // Set once stripe.confirmSetup() succeeds client-side; lets a retry + // skip straight to the server confirmation if that call failed. + let confirmed_setup_intent_id = null; + + const spinner = 'circle anim'; + const dark_spinner = 'circle anim'; + const verify_btn_txt = 'Verify Card'; + const retry_btn_txt = 'Try Again'; + + let h = ''; + if ( options.show_close_button !== false ) { + h += '
×
'; + } + h += '
'; + h += ``; + h += '

Verify Your Card

'; + h += '
'; + + // -- Loading: setup call in flight, Stripe.js loading -- + h += `
${dark_spinner}
`; + + // -- Card entry: Stripe Payment Element (hidden until setup succeeds) -- + h += ''; + + // -- Unavailable: setup failed (hidden unless it does) -- + h += ''; + + if ( options.logout_in_footer ) { + h += '
'; + h += `${i18n('log_out')}`; + h += '
'; + } + h += '
'; + + const el_window = await UIWindow({ + title: null, + icon: null, + uid: null, + is_dir: false, + body_content: h, + has_head: false, + selectable_body: false, + draggable_body: true, + allow_context_menu: false, + is_draggable: options.is_draggable ?? true, + is_droppable: false, + is_resizable: false, + stay_on_top: options.stay_on_top ?? false, + allow_native_ctxmenu: true, + allow_user_select: true, + backdrop: true, + close_on_backdrop_click: false, + width: 390, + dominant: true, + ...options.window_options, + window_class: 'window-card-verification', + window_css: { + height: 'initial', + // Dominant windows pin to 15vh from the top; with a tall Stripe + // iframe that drops the log-out below the viewport. Sit higher so + // the dialog always ends above the browser's bottom edge. + top: '5vh', + }, + body_css: { + // border-box so max-height includes the padding — keeps the math + // exact: 5vh top + 85vh body = 90vh, always within the viewport. + 'box-sizing': 'border-box', + padding: '30px', + width: 'initial', + height: 'initial', + // The Stripe Payment Element can run tall; cap the dialog to the + // viewport and let the body scroll instead of overflowing it. + 'max-height': '85vh', + 'overflow-y': 'auto', + 'background-color': 'rgb(247 251 255)', + 'backdrop-filter': 'blur(3px)', + }, + }); + + const showError = (msg) => { + $(el_window).find('.error').html(html_encode(msg)).fadeIn(); + }; + const clearError = () => { + $(el_window).find('.error').hide(); + }; + const showStep = (name) => { + $(el_window).find('.card-step').hide(); + $(el_window).find(`.card-step-${name}`).show(); + }; + + const finish = () => { + $(el_window).close(); + window.refresh_user_data(window.auth_token); + resolve(true); + }; + + const mountPaymentElement = async (publishable_key, client_secret) => { + await loadStripeJs(); + stripe = window.Stripe(publishable_key); + elements = stripe.elements({ clientSecret: client_secret }); + if ( payment_element ) { + payment_element.destroy(); + } + payment_element = elements.create('payment'); + payment_element.mount($(el_window).find('.card-payment-element').get(0)); + }; + + // -- Setup: fetch a client_secret and mount the Payment Element. Also + // the restart path after a rejected card (a succeeded SetupIntent is + // spent, so trying another card needs a fresh client_secret). -- + const startSetup = (setup_options = {}) => { + if ( is_setting_up ) return; + is_setting_up = true; + // After a rejection the error explains why the flow restarted, so + // keep it on screen through the new setup call. + if ( ! setup_options.keep_error ) clearError(); + showStep('loading'); + + $.ajax({ + url: `${window.api_origin}/card-verification/setup`, + type: 'POST', + async: true, + contentType: 'application/json', + headers: { 'Authorization': `Bearer ${window.auth_token}` }, + statusCode: { 401: (xhr) => window.handle401(xhr) }, + success: async function (res) { + // Already verified, or the feature was disabled server-side + // (kill switch) — either way the gate is satisfied. + if ( res.card_verified ) { + finish(); + return; + } + confirmed_setup_intent_id = null; + try { + await mountPaymentElement(res.publishable_key, res.client_secret); + $(el_window) + .find('.card-verify-btn') + .prop('disabled', false) + .html(verify_btn_txt); + showStep('form'); + } catch (e) { + console.debug('Could not mount the payment element:', e); + showStep('unavailable'); + } + }, + error: function (xhr) { + if ( xhr.responseJSON?.error ) { + showError(xhr.responseJSON.error); + } + showStep('unavailable'); + }, + complete: function () { + is_setting_up = false; + }, + }); + }; + + $(el_window).find('.card-retry-btn').on('click', function () { + startSetup(); + }); + + // -- Confirm: run the SetupIntent client-side, then verify it server-side -- + $(el_window).find('.card-verify-btn').on('click submit', async function (e) { + e.preventDefault(); + e.stopPropagation(); + + if ( is_submitting ) return; + is_submitting = true; + clearError(); + $(el_window).find('.card-verify-btn').prop('disabled', true).html(spinner); + + // Skipped when a previous attempt already confirmed the SetupIntent + // but the server call failed — retrying re-uses the confirmed intent. + if ( ! confirmed_setup_intent_id ) { + let result; + try { + result = await stripe.confirmSetup({ + elements, + redirect: 'if_required', + }); + } catch (error) { + result = { error }; + } + if ( result.error ) { + showError(result.error.message ?? 'Could not verify your card.'); + $(el_window) + .find('.card-verify-btn') + .prop('disabled', false) + .html(verify_btn_txt); + is_submitting = false; + return; + } + confirmed_setup_intent_id = result.setupIntent.id; + } + + $.ajax({ + url: `${window.api_origin}/card-verification/confirm`, + type: 'POST', + data: JSON.stringify({ setup_intent_id: confirmed_setup_intent_id }), + async: true, + contentType: 'application/json', + headers: { 'Authorization': `Bearer ${window.auth_token}` }, + statusCode: { 401: (xhr) => window.handle401(xhr) }, + success: function (res) { + if ( res.card_verified ) { + finish(); + return; + } + // Rejected. The succeeded SetupIntent is spent, so restart + // with a fresh setup call to let the user try another card. + confirmed_setup_intent_id = null; + if ( res.reason === 'card_already_used' ) { + showError('This card has already been used to verify other accounts. Please try a different card.'); + } else { + showError('We couldn\'t verify this card. Please try a different card.'); + } + startSetup({ keep_error: true }); + }, + error: function (xhr) { + // Transient failure — the SetupIntent already succeeded + // client-side, so keep it and let the user retry the + // server confirmation. + showError(xhr.responseJSON?.error ?? 'Could not verify your card. Please try again.'); + $(el_window) + .find('.card-verify-btn') + .prop('disabled', false) + .html(verify_btn_txt); + }, + complete: function () { + is_submitting = false; + }, + }); + }); + + // logout + $(el_window).find('.card-log-out').on('click', function () { + window.logout(); + $(el_window).close(); + }); + + startSetup(); + }); +} + +def(UIWindowCardVerificationRequired, 'ui.UIConfirmCard'); + +export default UIWindowCardVerificationRequired; diff --git a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js new file mode 100644 index 000000000..e8cdf53a7 --- /dev/null +++ b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js @@ -0,0 +1,383 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import UIWindow from './UIWindow.js'; + +// SMS phone verification dialog. Two steps in one window: +// 1. Enter a phone number → POST /send-confirm-phone (Prelude sends an SMS). +// 2. Enter the 6-digit code → POST /confirm-phone (Prelude validates it). +// The 6-digit code UX mirrors UIWindowEmailConfirmationRequired.js. Used as a +// hard gate for low-reputation signups, so by default it has no close button. +function UIWindowPhoneVerificationRequired (options) { + return new Promise(async (resolve) => { + options = options ?? {}; + options.window_options = options.window_options ?? {}; + let final_code = ''; + let is_checking_code = false; + let is_sending = false; + + const spinner = 'circle anim'; + const send_btn_txt = 'Send Code'; + const verify_btn_txt = 'Verify Phone'; + + const phoneIcon = + ''; + + let h = ''; + // Scoped styling for this dialog. + h += ``; + if ( options.show_close_button !== false ) { + h += '
×
'; + } + h += '
'; + h += `
${phoneIcon}
`; + h += '

Verify your phone number

'; + + // -- Step 1: phone number -- + h += '
'; + h += '

We\'ll text you a verification code to confirm it\'s really you.

'; + // Offer a friendly human fallback so verification is never a dead end — + // worded as help, not as an accusation. + h += `

Need help? Email hi@puter.com and we'll help you finish creating your account.

`; + h += '
'; + h += ''; + h += ''; + h += ``; + if ( options.logout_in_footer ) { + h += ``; + } + h += '
'; + + // -- Step 2: 6-digit code (hidden until a code is sent) -- + h += ''; + h += '
'; + + const el_window = await UIWindow({ + title: null, + icon: null, + uid: null, + is_dir: false, + body_content: h, + has_head: false, + selectable_body: false, + draggable_body: true, + allow_context_menu: false, + is_draggable: options.is_draggable ?? true, + is_droppable: false, + is_resizable: false, + stay_on_top: options.stay_on_top ?? false, + allow_native_ctxmenu: true, + allow_user_select: true, + backdrop: true, + close_on_backdrop_click: false, + width: 390, + dominant: true, + ...options.window_options, + onAppend: function (el_window) { + $(el_window).find('.phone-input').first().focus(); + }, + window_class: 'window-confirm-phone-using-code', + window_css: { + height: 'initial', + }, + body_css: { + padding: '30px', + width: 'initial', + height: 'initial', + 'background-color': 'rgb(247 251 255)', + 'backdrop-filter': 'blur(3px)', + }, + }); + + const showError = (msg) => { + $(el_window).find('.error').html(html_encode(msg)).fadeIn(); + }; + const clearError = () => { + $(el_window).find('.error').hide(); + }; + + // -- Step 1: send the code -- + const sendCode = () => { + if ( is_sending ) return; + clearError(); + const phone = $(el_window).find('.phone-input').val(); + if ( !phone || phone.trim().length < 5 ) { + showError('Please enter a valid phone number.'); + return; + } + is_sending = true; + $(el_window).find('.phone-send-btn').prop('disabled', true).html(spinner); + + $.ajax({ + url: `${window.api_origin}/send-confirm-phone`, + type: 'POST', + data: JSON.stringify({ phone }), + async: true, + contentType: 'application/json', + headers: { 'Authorization': `Bearer ${window.auth_token}` }, + statusCode: { 401: (xhr) => window.handle401(xhr) }, + success: function () { + // Advance to the code-entry step. + $(el_window).find('.phone-target').text(phone); + $(el_window).find('.phone-step-1').hide(); + $(el_window).find('.phone-step-2').show(); + $(el_window).find('.digit-input').first().focus(); + }, + error: function (xhr) { + showError( + xhr.responseJSON?.error ?? + 'Could not send a code to that number.', + ); + }, + complete: function () { + is_sending = false; + $(el_window) + .find('.phone-send-btn') + .prop('disabled', false) + .html(send_btn_txt); + }, + }); + }; + + $(el_window).find('.phone-send-btn').on('click submit', function (e) { + e.preventDefault(); + e.stopPropagation(); + sendCode(); + }); + + // Re-send / change number on the code step. + $(el_window).find('.phone-resend-code').on('click', function () { + sendCode(); + }); + $(el_window).find('.phone-change-number').on('click', function () { + clearError(); + $(el_window).find('.phone-step-2').hide(); + $(el_window).find('.phone-step-1').show(); + $(el_window).find('.phone-input').focus(); + }); + + // -- Step 2: verify the code -- + $(el_window).find('.phone-verify-btn').on('click submit', function (e) { + e.preventDefault(); + e.stopPropagation(); + + $(el_window).find('.phone-verify-btn').prop('disabled', true); + $(el_window).find('.digit-input').prop('disabled', true); + clearError(); + + if ( is_checking_code ) return; + is_checking_code = true; + + $(el_window).find('.phone-verify-btn').html(spinner); + + setTimeout(() => { + $.ajax({ + url: `${window.api_origin}/confirm-phone`, + type: 'POST', + data: JSON.stringify({ code: final_code }), + async: true, + contentType: 'application/json', + headers: { 'Authorization': `Bearer ${window.auth_token}` }, + statusCode: { 401: (xhr) => window.handle401(xhr) }, + success: function (res) { + if ( res.phone_verified ) { + $(el_window).close(); + window.refresh_user_data(window.auth_token); + resolve(true); + } else { + showError('Invalid verification code.'); + $(el_window).find('.digit-input').val(''); + $(el_window).find('.digit-input').first().focus(); + $(el_window) + .find('.phone-verify-btn') + .prop('disabled', false) + .html(verify_btn_txt); + $(el_window) + .find('.digit-input') + .prop('disabled', false); + } + }, + error: function (xhr) { + showError( + xhr.responseJSON?.error ?? 'Could not verify code.', + ); + $(el_window).find('.digit-input').val(''); + $(el_window).find('.digit-input').first().focus(); + $(el_window) + .find('.phone-verify-btn') + .prop('disabled', false) + .html(verify_btn_txt); + $(el_window).find('.digit-input').prop('disabled', false); + }, + complete: function () { + is_checking_code = false; + }, + }); + }, 1000); + }); + + // logout + $(el_window).find('.phone-log-out').on('click', function () { + window.logout(); + $(el_window).close(); + }); + + // -- 6-digit input handling (mirrors the email confirmation dialog) -- + const numberCodeForm = el_window.querySelector('[data-number-code-form]'); + const numberCodeInputs = [ + ...numberCodeForm.querySelectorAll('[data-number-code-input]'), + ]; + + numberCodeForm.addEventListener('input', ({ target }) => { + if ( !target.value.length ) { + return (target.value = null); + } + const inputLength = target.value.length; + let currentIndex = Number(target.dataset.numberCodeInput); + if ( inputLength === 2 ) { + const inputValues = target.value.split(''); + target.value = inputValues[0]; + } else if ( inputLength > 1 ) { + const inputValues = target.value.split(''); + inputValues.forEach((value, valueIndex) => { + const nextValueIndex = currentIndex + valueIndex; + if ( nextValueIndex >= numberCodeInputs.length ) { + return; + } + numberCodeInputs[nextValueIndex].value = value; + }); + currentIndex += inputValues.length - 2; + } + + const nextIndex = currentIndex + 1; + if ( nextIndex < numberCodeInputs.length ) { + numberCodeInputs[nextIndex].focus(); + } + + final_code = ''; + for ( let i = 0; i < numberCodeInputs.length; i++ ) { + final_code += numberCodeInputs[i].value; + } + if ( final_code.length === 6 ) { + $(el_window).find('.phone-verify-btn').prop('disabled', false); + $(el_window).find('.digit-input').prop('disabled', false); + $(el_window).find('.phone-verify-btn').trigger('click'); + } + }); + + numberCodeForm.addEventListener('keydown', (e) => { + const { code, target } = e; + const currentIndex = Number(target.dataset.numberCodeInput); + const previousIndex = currentIndex - 1; + const nextIndex = currentIndex + 1; + const hasPreviousIndex = previousIndex >= 0; + const hasNextIndex = nextIndex <= numberCodeInputs.length - 1; + + switch ( code ) { + case 'ArrowLeft': + case 'ArrowUp': + if ( hasPreviousIndex ) numberCodeInputs[previousIndex].focus(); + e.preventDefault(); + break; + case 'ArrowRight': + case 'ArrowDown': + if ( hasNextIndex ) numberCodeInputs[nextIndex].focus(); + e.preventDefault(); + break; + case 'Backspace': + if ( !e.target.value.length && hasPreviousIndex ) { + numberCodeInputs[previousIndex].value = null; + numberCodeInputs[previousIndex].focus(); + } + break; + default: + break; + } + }); + }); +} + +def(UIWindowPhoneVerificationRequired, 'ui.UIConfirmPhone'); + +export default UIWindowPhoneVerificationRequired; diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 392d09780..a151d929f 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -20,6 +20,8 @@ import check_password_strength from '../helpers/check_password_strength.js'; import UIWindow from './UIWindow.js'; import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js'; +import UIWindowPhoneVerificationRequired from './UIWindowPhoneVerificationRequired.js'; +import UIWindowCardVerificationRequired from './UIWindowCardVerificationRequired.js'; import UIWindowLogin from './UIWindowLogin.js'; function UIWindowSignup (options) { @@ -396,15 +398,46 @@ function UIWindowSignup (options) { // either options.redirect_url or the current page const redirectUrl = options.redirect_url || '/'; window.location.replace(redirectUrl); - } else if ( options.send_confirmation_code || data.user?.requires_email_confirmation ) { + } else if ( data.user?.requires_phone_verification || data.user?.requires_card_verification || options.send_confirmation_code || data.user?.requires_email_confirmation ) { $(el_window).close(); - let is_verified = await UIWindowEmailConfirmationRequired({ - stay_on_top: true, - has_head: true, - reload_on_success: options.reload_on_success, - window_options: options.window_options ?? {}, - }); - resolve(is_verified); + // Low-reputation signups must clear every flagged gate. + // Phone (SMS) and email come first; the card gate only + // shows once those are cleared. + if ( data.user?.requires_phone_verification ) { + let phone_ok = false; + do { + phone_ok = await UIWindowPhoneVerificationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: true, + window_options: options.window_options ?? {}, + }); + } + while ( !phone_ok ); + } + let email_verified = true; + if ( options.send_confirmation_code || data.user?.requires_email_confirmation ) { + email_verified = await UIWindowEmailConfirmationRequired({ + stay_on_top: true, + has_head: true, + reload_on_success: options.reload_on_success, + window_options: options.window_options ?? {}, + }); + } + // Card verification is the last gate. + if ( data.user?.requires_card_verification ) { + let card_ok = false; + do { + card_ok = await UIWindowCardVerificationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: true, + window_options: options.window_options ?? {}, + }); + } + while ( !card_ok ); + } + resolve(email_verified); } else { resolve(true); } diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index ef70a5832..a50fd2e71 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -26,6 +26,8 @@ import UIWindowAuthMe from './UI/UIWindowAuthMe.js'; import UIWindowChangeUsername from './UI/UIWindowChangeUsername.js'; import UIWindowCopyToken from './UI/UIWindowCopyToken.js'; import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js'; +import UIWindowPhoneVerificationRequired from './UI/UIWindowPhoneVerificationRequired.js'; +import UIWindowCardVerificationRequired from './UI/UIWindowCardVerificationRequired.js'; import UIWindowLogin from './UI/UIWindowLogin.js'; import UIWindowLoginInProgress from './UI/UIWindowLoginInProgress.js'; import UIWindowNewPassword from './UI/UIWindowNewPassword.js'; @@ -665,6 +667,21 @@ window.initgui = async function (options) { } if ( whoami ) { + // is phone verification required? (hard gate for low-rep signups) + if ( whoami.requires_phone_verification ) { + let is_verified; + do { + is_verified = await UIWindowPhoneVerificationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: false, + window_options: { + is_draggable: false, + }, + }); + } + while ( !is_verified ); + } if ( whoami.requires_email_confirmation ) { let is_verified; do { @@ -679,6 +696,22 @@ window.initgui = async function (options) { } while ( !is_verified ); } + // Card verification is the last gate: only show it once the email and + // phone (SMS) gates are cleared, since those show up first. + if ( whoami.requires_card_verification ) { + let is_verified; + do { + is_verified = await UIWindowCardVerificationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: false, + window_options: { + is_draggable: false, + }, + }); + } + while ( !is_verified ); + } // if user is logging in using an auth token that means it's not their first ever visit to Puter.com // it might be their first visit to Puter on this specific device but it's not their first time ever visiting Puter. window.first_visit_ever = false; @@ -833,6 +866,23 @@ window.initgui = async function (options) { } // update local user data if ( whoami ) { + // is phone verification required? (hard gate for low-rep signups) + if ( whoami.requires_phone_verification ) { + let is_verified; + do { + is_verified = await UIWindowPhoneVerificationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: false, + logout_in_footer: true, + window_options: { + is_draggable: false, + cover_page: window.is_embedded, + }, + }); + } + while ( !is_verified ); + } // is email confirmation required? if ( whoami.requires_email_confirmation ) { let is_verified; @@ -850,6 +900,24 @@ window.initgui = async function (options) { } while ( !is_verified ); } + // Card verification is the last gate: only show it once the email and + // phone (SMS) gates are cleared, since those show up first. + if ( whoami.requires_card_verification ) { + let is_verified; + do { + is_verified = await UIWindowCardVerificationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: false, + logout_in_footer: true, + window_options: { + is_draggable: false, + cover_page: window.is_embedded, + }, + }); + } + while ( !is_verified ); + } await window.update_auth_data(whoami.token || window.auth_token, whoami); // -------------------------------------------------------------------------------------