diff --git a/src/backend/stores/user/UserStore.test.ts b/src/backend/stores/user/UserStore.test.ts index 1fc80e87b..5f37d2394 100644 --- a/src/backend/stores/user/UserStore.test.ts +++ b/src/backend/stores/user/UserStore.test.ts @@ -54,4 +54,38 @@ describe('UserStore', () => { expect(typeof cachedUser?.email_confirmed).toBe('boolean'); expect(typeof cachedUser?.requires_email_confirmation).toBe('boolean'); }); + + it('counts other accounts holding the same phone number', async () => { + const phone = `+1415555${Math.floor(1000 + Math.random() * 9000)}`; + const makeUser = async () => { + const username = `up-${Math.random().toString(36).slice(2, 10)}`; + const user = await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + }); + await server.stores.user.update(user.id, { phone }); + return user; + }; + + const first = await makeUser(); + expect( + await server.stores.user.countOthersByPhone(phone, first.id), + ).toBe(0); + + const second = await makeUser(); + expect( + await server.stores.user.countOthersByPhone(phone, first.id), + ).toBe(1); + expect( + await server.stores.user.countOthersByPhone(phone, second.id), + ).toBe(1); + // A caller that isn't among the holders sees both. + expect(await server.stores.user.countOthersByPhone(phone, -1)).toBe(2); + // Unknown number counts zero. + expect( + await server.stores.user.countOthersByPhone('+19995550000', -1), + ).toBe(0); + }); }); diff --git a/src/backend/stores/user/UserStore.ts b/src/backend/stores/user/UserStore.ts index 535189996..efc283be7 100644 --- a/src/backend/stores/user/UserStore.ts +++ b/src/backend/stores/user/UserStore.ts @@ -257,6 +257,29 @@ export class UserStore extends PuterStore { return this.getById(row.id as number); } + /** + * Count accounts other than `excludeUserId` whose confirmed `phone` + * equals this number. Backs the cap on live accounts per phone number + * (the row is deleted with the account, so this only counts accounts + * that still exist — recycling velocity is rate-limited separately by + * the abuse policy's KV log). + * + * Not cached — like `getByCleanEmail`, callers use this for duplicate + * detection at write time, which needs fresh reads. + */ + async countOthersByPhone( + phone: string, + excludeUserId: number, + ): Promise { + if (!phone) return 0; + if (!isStorableAsLatin1(phone)) return 0; + const rows = (await this.clients.db.tryHardRead( + 'SELECT COUNT(*) AS n FROM `user` WHERE `phone` = ? AND `id` != ?', + [phone, excludeUserId], + )) as Array<{ n: number | string }>; + return Number(rows[0]?.n) || 0; + } + /** * Generic property lookup. Fast-path reads redis first (cache is * multi-key — every identifying property points at the same serialized diff --git a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js index 59467d9f6..ee427d1f1 100644 --- a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js +++ b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js @@ -44,6 +44,8 @@ import { const SEND_REASON_MESSAGES = { phone_already_used: 'This phone number has already been used to verify the maximum number of accounts. Please use a different number, or email support@puter.com for help.', + phone_verify_limit: + 'This phone number cannot be used to verify this account. Please use a different number, or email support@puter.com for help.', phone_send_limit: 'This phone number has received too many verification codes recently. Please try again later, or use a different number.', phone_verify_attempts_exhausted: