mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
fix: clean up email validation
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user