diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index fa3caca1b..d50ca0780 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -160,6 +160,19 @@ export type EventMap = { 'puter.signup.validate': { allow: boolean; email?: string; + /** + * The address in the same canonical form the `email.validate` hook was + * given (`cleanEmail`), so a handler can correlate the two hooks on one + * key. Aliases (`a+tag@outlook.com`, `a.b@icloud.com`) differ from + * `email` here. + */ + clean_email?: string; + /** + * True for a temp (frictionless, no-password) signup. Those carry a + * synthetic `@gmail.com` and never reach email validation, so + * a handler must not draw conclusions from `email`. + */ + is_temp?: boolean; ip?: string | null; source?: 'oidc'; req?: unknown; diff --git a/src/backend/clients/prelude/PreludeClient.test.ts b/src/backend/clients/prelude/PreludeClient.test.ts index d8356d1c9..cfb828807 100644 --- a/src/backend/clients/prelude/PreludeClient.test.ts +++ b/src/backend/clients/prelude/PreludeClient.test.ts @@ -30,7 +30,7 @@ describe('PreludeClient', () => { expect(makeClient().isConfigured()).toBe(false); }); - describe('isCountrySupported (€0.07 cap)', () => { + describe('isCountrySupported (€0.20 cap)', () => { const client = makeClient('sk_test'); it('allows revenue markets up to the cap (incl. the priciest)', () => { @@ -40,6 +40,17 @@ describe('PreludeClient', () => { expect(client.isCountrySupported('us')).toBe(true); // case-insensitive }); + it('allows the markets the old €0.07 cap excluded', () => { + // Raising the cap to €0.20 brought ~89 countries into the phone + // gate. These pin the new range so a future change to the default + // has to say so out loud. + expect(client.isCountrySupported('UA')).toBe(true); // €0.0940 + expect(client.isCountrySupported('PH')).toBe(true); // €0.1237 + expect(client.isCountrySupported('EG')).toBe(true); // €0.1561 + expect(client.isCountrySupported('NG')).toBe(true); // €0.1980 + expect(client.isCountrySupported('WS')).toBe(true); // €0.2000, at the cap + }); + 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 @@ -63,9 +74,7 @@ describe('PreludeClient', () => { }); it('createVerification POSTs the phone target + ip signal with bearer auth', async () => { - fetchMock.mockResolvedValue( - okJson({ id: 'vrf_1', status: 'success' }), - ); + fetchMock.mockResolvedValue(okJson({ id: 'vrf_1', status: 'success' })); const client = makeClient('sk_test'); const res = await client.createVerification('+14155550123', { @@ -81,7 +90,11 @@ describe('PreludeClient', () => { target: { type: 'phone_number', value: '+14155550123' }, // Defaults to RCS (cheaper); Prelude falls back to SMS. locale is // hardcoded to en-US so the message text is always English. - options: { code_size: 6, preferred_channel: 'rcs', locale: 'en-US' }, + options: { + code_size: 6, + preferred_channel: 'rcs', + locale: 'en-US', + }, signals: { ip: '203.0.113.7' }, }); }); @@ -196,9 +209,9 @@ describe('PreludeClient', () => { it('throws (does not call fetch) when not configured', async () => { const client = makeClient(); - await expect( - client.createVerification('+14155550123'), - ).rejects.toThrow(/not configured/i); + await expect(client.createVerification('+14155550123')).rejects.toThrow( + /not configured/i, + ); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/src/backend/clients/prelude/PreludeClient.ts b/src/backend/clients/prelude/PreludeClient.ts index 3e3ec50e6..3dbd81741 100644 --- a/src/backend/clients/prelude/PreludeClient.ts +++ b/src/backend/clients/prelude/PreludeClient.ts @@ -50,12 +50,15 @@ export type PreludeChannel = 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 - * 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`. + * this — or that have no SMS channel — are not offered phone verification. + * Raised from €0.07 to €0.20: the old cap sat only marginally above the + * priciest revenue markets (Germany €0.0598, Saudi Arabia €0.0638), so any rate + * drift silently withdrew the phone gate from a real market — and the abuse + * harness now routes far more legitimate signups into the SMS band, where an + * unavailable gate is a dead end rather than a mild inconvenience. Override + * per-deployment with `config.prelude.maxSmsCostEur`. */ -const DEFAULT_MAX_SMS_COST_EUR = 0.07; +const DEFAULT_MAX_SMS_COST_EUR = 0.2; /** Status returned by Prelude when creating/retrying a verification. */ export type PreludeCreateStatus = diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index a358a45b2..7aa915bc4 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -862,6 +862,15 @@ export class AuthController extends PuterController { req.socket?.remoteAddress || null) as string | null, email: body.email, + // The same canonical form `email.validate` was given, so a check + // in the abuse harness can look up the verdict that hook cached + // for this address. Without it an alias (`a+tag@outlook.com`, + // `a.b@icloud.com`) reaches the two hooks under two different keys. + clean_email: cleanEmail(body.email), + // Temp signups carry a synthetic `@gmail.com` and skip + // #validateEmail entirely, so an email check must know not to + // reason about the address at all. + is_temp, allow: true, no_temp_user: false, requires_email_confirmation: false, diff --git a/src/backend/services/auth/OIDCService.ts b/src/backend/services/auth/OIDCService.ts index e7a38c244..f13ec8f82 100644 --- a/src/backend/services/auth/OIDCService.ts +++ b/src/backend/services/auth/OIDCService.ts @@ -534,6 +534,12 @@ export class OIDCService extends PuterService { null, user_agent: req?.headers?.['user-agent'] ?? null, email, + // See the same field in AuthController: the canonical form + // `email.validate` is given, so the abuse harness can find the + // verdict that hook cached. + clean_email: cleanEmail(email), + // OIDC signups are never temp users. + is_temp: false, allow: true, no_temp_user: false, requires_email_confirmation: false, @@ -547,25 +553,13 @@ export class OIDCService extends PuterService { // Request Code so support can look the decision up. trail_id: undefined as string | undefined, }; - try { - await this.clients.event?.emitAndWait( - 'puter.signup.validate', - validateEvent, - {}, - ); - } catch (e) { - console.warn('[oidc] validate hook failed:', e); - } - if (!validateEvent.allow) { - return { - success: false, - error: validateEvent.message ?? 'Signup blocked', - code: validateEvent.code ?? 'signup_blocked', - requestCode: validateEvent.trail_id, - }; - } - - // Email validation — mirrors AuthController#validateEmail. + // Email validation — mirrors AuthController#validateEmail, and runs + // BEFORE the signup harness for the same reason it does there: the + // address verdict is an input to the reputation decision. The abuse + // extension's `email.validate` handler caches its Kickbox verdict and + // its `emailQuality` check reads that cache under + // `puter.signup.validate`, so emitting these two in the other order + // silently drops the email signal from every OIDC signup. if (isBlockedEmail(email, this.config.blockedEmailDomains)) { return { success: false, @@ -595,6 +589,24 @@ export class OIDCService extends PuterService { }; } + try { + await this.clients.event?.emitAndWait( + 'puter.signup.validate', + validateEvent, + {}, + ); + } catch (e) { + console.warn('[oidc] validate hook failed:', e); + } + if (!validateEvent.allow) { + return { + success: false, + error: validateEvent.message ?? 'Signup blocked', + code: validateEvent.code ?? 'signup_blocked', + requestCode: validateEvent.trail_id, + }; + } + const cfg = this.config as { always_require_phone_verification?: boolean; always_require_card_verification?: boolean;