diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 9ea325463b..dde0d523da 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -1864,6 +1864,48 @@ describe('AuthController.handleSendConfirmPhone validation', () => { ); }); + it('attaches a support error_id to a Prelude block', 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, + fields: { error_id: expect.any(String) }, + }); + }, + ); + }); + + it('attaches a support error_id when the Prelude request throws', async () => { + const { actor } = await makeUserAndActor(); + await withPrelude( + stubPrelude({ + createVerification: vi.fn(async () => { + throw new Error('network down'); + }), + }), + async () => { + await expect( + controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ + statusCode: 502, + fields: { error_id: expect.any(String) }, + }); + }, + ); + }); + it('forwards ip, device fingerprint, and user-agent to Prelude as signals', async () => { const { actor } = await makeUserAndActor(); const createVerification = vi.fn(async () => ({ status: 'success' })); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index f2d4a2caea..ee71d01179 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -24,6 +24,7 @@ import { v4 as uuidv4 } from 'uuid'; import validator from 'validator'; import { Controller, Get, Post } from '../../core/http/decorators.js'; import { HttpError } from '../../core/http/HttpError.js'; +import type { HttpErrorOptions } from '../../core/http/HttpError.js'; import { antiCsrf } from '../../core/http/middleware/antiCsrf.js'; import { generateCaptcha } from '../../core/http/middleware/captcha.js'; import { checkRateLimit } from '../../core/http/middleware/rateLimit.js'; @@ -1018,6 +1019,43 @@ export class AuthController extends PuterController { // -- Phone verification (SMS via Prelude) ------------------------ + /** + * Build the error thrown when a verification SMS can't be sent (a delivery + * failure, or a refused/blocked send). Mints a short `error_id`, writes a + * single greppable line tying that id to the real reason (so support can + * look it up in CloudWatch with the id the user quotes), and returns the + * `HttpError` with the id attached as `error_id` for the GUI to surface. + * The phone number is deliberately omitted from the log line (PII); the + * user + country are enough to correlate. + */ + private smsSendError( + statusCode: number, + clientMessage: string, + reason: string, + ctx: { + userId?: number; + userUid?: string; + country?: string; + detail?: unknown; + }, + options: HttpErrorOptions = {}, + ): HttpError { + const errorId = uuidv4(); + const detail = + ctx.detail instanceof Error ? ctx.detail.message : ctx.detail; + console.warn( + `[send-confirm-phone] send_failed error_id=${errorId} ` + + `reason=${reason} status=${statusCode} ` + + `user_id=${ctx.userId ?? ''} user_uid=${ctx.userUid ?? ''} ` + + `country=${ctx.country ?? ''}` + + (detail ? ` detail=${JSON.stringify(String(detail))}` : ''), + ); + return new HttpError(statusCode, clientMessage, { + ...options, + fields: { ...options.fields, error_id: errorId }, + }); + } + @Post('/send-confirm-phone', { subdomain: ['api', ''], requireUserActor: true, @@ -1042,9 +1080,13 @@ export class AuthController extends PuterController { legacyCode: 'account_suspended', }); if (!this.clients.prelude?.isConfigured()) - throw new HttpError(503, 'Phone verification is unavailable.', { - legacyCode: 'service_unavailable' as never, - }); + throw this.smsSendError( + 503, + 'Phone verification is unavailable.', + 'prelude_not_configured', + { userId: user.id, userUid: user.uuid }, + { 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. @@ -1073,9 +1115,15 @@ export class AuthController extends PuterController { // (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( + throw this.smsSendError( 400, 'Phone verification is not available for this country.', + 'country_not_supported', + { + userId: user.id, + userUid: user.uuid, + country: parsed.country, + }, { legacyCode: 'phone_country_not_supported' as never }, ); @@ -1110,9 +1158,15 @@ export class AuthController extends PuterController { // its meaning lives in the extension (which sets it) and the GUI (which // displays it), so no abuse semantics leak into the OSS repo. if (abuseCheck.allowed === false) - throw new HttpError( + throw this.smsSendError( 429, 'Phone verification is unavailable for this number right now.', + `not_allowed:${abuseCheck.reason ?? 'unspecified'}`, + { + userId: user.id, + userUid: user.uuid, + country: parsed.country, + }, { legacyCode: 'phone_verification_unavailable' as never, fields: abuseCheck.reason @@ -1135,10 +1189,18 @@ export class AuthController extends PuterController { expireAt: Math.floor(Date.now() / 1000) + 60 * 60, }); } catch (e) { - console.warn('[send-confirm-phone] pending-store failed:', e); - throw new HttpError(503, 'Could not start phone verification.', { - legacyCode: 'service_unavailable' as never, - }); + throw this.smsSendError( + 503, + 'Could not start phone verification.', + 'pending_store_failed', + { + userId: user.id, + userUid: user.uuid, + country: parsed.country, + detail: e, + }, + { legacyCode: 'service_unavailable' as never }, + ); } const ip = req.ip || req.socket?.remoteAddress || undefined; @@ -1161,18 +1223,32 @@ export class AuthController extends PuterController { result.status === 'blocked' || result.status === 'shadow_blocked' ) { - throw new HttpError( + throw this.smsSendError( 429, 'Phone verification is temporarily unavailable for this number.', + `prelude_${result.status}`, + { + userId: user.id, + userUid: user.uuid, + country: parsed.country, + }, { 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, - }); + throw this.smsSendError( + 502, + 'Could not send verification code.', + 'prelude_request_failed', + { + userId: user.id, + userUid: user.uuid, + country: parsed.country, + detail: e, + }, + { legacyCode: 'upstream_error' as never }, + ); } // Tell the abuse extension a code was actually sent, so it can bump its diff --git a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js index 35b4c7466c..fbe97a2eca 100644 --- a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js +++ b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js @@ -763,11 +763,17 @@ function UIWindowPhoneVerificationRequired(options) { }, error: function (xhr) { const reason = xhr.responseJSON?.reason; - showError( + let msg = SEND_REASON_MESSAGES[reason] ?? - xhr.responseJSON?.error ?? - T.could_not_send, - ); + xhr.responseJSON?.error ?? + T.could_not_send; + // Append the support reference id the backend minted for + // this failure (it logged the real reason against it), so + // the user can quote it when they email support. + const errorId = xhr.responseJSON?.error_id; + if (errorId) + msg += ' ' + i18n('phone_error_reference', { id: errorId }); + showError(msg); }, complete: function () { is_sending = false; diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 6d330071cd..b1159eb198 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -240,6 +240,7 @@ const en = { phone_enter_valid: 'Please enter a valid phone number.', phone_invalid_code: 'Invalid verification code.', phone_could_not_send: 'Could not send a code to that number.', + phone_error_reference: 'If this keeps happening, email support@puter.com and include this code: {{id}}', phone_could_not_verify: 'Could not verify code.', phone_search_countries: 'Search countries', phone_no_matches: 'No matches',