feat: add logs for failed sms (#3318)

This commit is contained in:
Daniel Salazar
2026-06-30 06:50:47 -07:00
committed by GitHub
parent c0c2f35266
commit f4c3cbd53d
4 changed files with 143 additions and 18 deletions
@@ -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' }));
+90 -14
View File
@@ -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
@@ -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;
+1
View File
@@ -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',