fix: clean up email validation

This commit is contained in:
Daniel Salazar
2026-08-21 02:50:23 -04:00
parent f0cd251626
commit 30d52a7d7c
5 changed files with 82 additions and 32 deletions
+13
View File
@@ -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 `<username>@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;
@@ -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();
});
+8 -5
View File
@@ -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 =
@@ -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 `<username>@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,
+31 -19
View File
@@ -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;