diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..4f2fa3032 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +FOLLOW ./AGENTS.md diff --git a/src/backend/clients/prelude/PreludeClient.test.ts b/src/backend/clients/prelude/PreludeClient.test.ts index b6edd66dc..d8356d1c9 100644 --- a/src/backend/clients/prelude/PreludeClient.test.ts +++ b/src/backend/clients/prelude/PreludeClient.test.ts @@ -86,6 +86,21 @@ describe('PreludeClient', () => { }); }); + it('returns the delivery channel sequence Prelude reports', async () => { + fetchMock.mockResolvedValue( + okJson({ + id: 'vrf_1', + status: 'success', + channels: ['whatsapp', 'sms'], + }), + ); + const client = makeClient('sk_test'); + + const res = await client.createVerification('+14155550123'); + + expect(res.channels).toEqual(['whatsapp', 'sms']); + }); + it('forwards device_id and user_agent signals when supplied', async () => { fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' })); const client = makeClient('sk_test'); diff --git a/src/backend/clients/prelude/PreludeClient.ts b/src/backend/clients/prelude/PreludeClient.ts index c59a0b272..1ece88b55 100644 --- a/src/backend/clients/prelude/PreludeClient.ts +++ b/src/backend/clients/prelude/PreludeClient.ts @@ -42,6 +42,12 @@ export type PreludeChannel = | 'viber' | 'zalo' | 'telegram'; +/** + * Channels Prelude reports in a verification's `channels` array — the ordered + * delivery sequence, first entry first. Superset of `PreludeChannel`: 'silent' + * and 'voice' can appear as delivery methods but can't be preferred. + */ +export type PreludeDeliveryChannel = PreludeChannel | 'silent' | 'voice'; /** * 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 @@ -117,18 +123,6 @@ export class PreludeClient extends PuterClient { 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 more we forward, the - * better Prelude's fraud model scores the attempt (per their docs IP + - * device + platform are the highest-value). We pass what the backend - * already has: the request `ip`, the client `device_id` (ThumbmarkJS - * hash), and the `user_agent` (Prelude infers platform/model/OS from it). - * `dispatch_id` carries the richer browser signals gathered by Prelude's - * frontend JS Signals SDK; it's a top-level field in the request, not part - * of the `signals` object, so it's pulled out below. - */ async createVerification( target: string, signals: { @@ -137,7 +131,11 @@ export class PreludeClient extends PuterClient { user_agent?: string; dispatch_id?: string; } = {}, - ): Promise<{ id?: string; status: PreludeCreateStatus }> { + ): Promise<{ + id?: string; + status: PreludeCreateStatus; + channels?: PreludeDeliveryChannel[]; + }> { // Match the 6-box code UI (UIWindowPhoneVerificationRequired). Without // code_size Prelude uses the dashboard default (4). preferred_channel // prioritizes RCS (cheaper); Prelude falls back to SMS when unavailable. @@ -171,6 +169,7 @@ export class PreludeClient extends PuterClient { return this.#post('/verification', body) as Promise<{ id?: string; status: PreludeCreateStatus; + channels?: PreludeDeliveryChannel[]; }>; } diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 1ee529cb6..bb97e29d9 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -2047,6 +2047,38 @@ describe('AuthController.handleSendConfirmPhone validation', () => { user_agent: undefined, }); }); + + it('returns the delivery channel Prelude picked so the client can point at the right app', async () => { + const { actor } = await makeUserAndActor(); + await withPrelude( + stubPrelude({ + createVerification: vi.fn(async () => ({ + status: 'success', + channels: ['whatsapp', 'sms'], + })), + }), + async () => { + const res = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + res, + ); + expect(res.body).toMatchObject({ channel: 'whatsapp' }); + }, + ); + }); + + it('omits `channel` when Prelude reports no delivery sequence', async () => { + const { actor } = await makeUserAndActor(); + await withPrelude(stubPrelude(), async () => { + const res = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + res, + ); + expect(res.body).not.toHaveProperty('channel'); + }); + }); }); describe('AuthController phone verification — staging & reuse', () => { diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index dafb5f064..b7b3b79a3 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -1358,6 +1358,10 @@ export class AuthController extends PuterController { typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : undefined; + // First entry of Prelude's delivery sequence — where the code actually + // went. Returned to the client so it can point the user at the right + // app (e.g. "check WhatsApp" instead of "check your texts"). + let deliveryChannel: string | undefined; try { const result = await this.clients.prelude.createVerification( parsed.e164, @@ -1368,6 +1372,7 @@ export class AuthController extends PuterController { dispatch_id: dispatchId, }, ); + deliveryChannel = result.channels?.[0]; // Prelude rejected the attempt as abusive — surface as rate-limit. if ( result.status === 'blocked' || @@ -1422,7 +1427,10 @@ export class AuthController extends PuterController { } catch { // ignore — best-effort velocity signal } - res.json(fallbackAvailable ? { card_fallback_available: true } : {}); + res.json({ + ...fallbackFields, + ...(deliveryChannel ? { channel: deliveryChannel } : {}), + }); } @Post('/confirm-phone', { diff --git a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js index fbe97a2ec..59467d9f6 100644 --- a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js +++ b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js @@ -110,6 +110,7 @@ function UIWindowPhoneVerificationRequired(options) { no_matches: i18n('phone_no_matches'), select_country: i18n('phone_select_country'), code_sent_to: i18n('phone_code_sent_to'), + code_sent_whatsapp: i18n('phone_code_sent_whatsapp'), suggested: i18n('phone_suggested'), all_countries: i18n('phone_all_countries'), }; @@ -382,7 +383,9 @@ function UIWindowPhoneVerificationRequired(options) { // -- Step 2: 6-digit code (hidden until a code is sent) -- h += '