From dcd5f09d6aa30c96e2dbfc250d868103a861ca98 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 24 Sep 2026 03:36:35 +0200 Subject: [PATCH] feat(api): add env toggles for automatic phone flagging (#2927) --- fluxer_api/src/api/Config.ts | 4 + fluxer_api/src/api/auth/AuthRegistration.ts | 6 +- .../api/auth/tests/DeferredPhoneGate.test.ts | 56 +++++++++++- fluxer_api/src/api/config/APIConfig.ts | 4 + .../member/GuildMemberOperationsService.ts | 8 ++ fluxer_api/src/api/risk/AbusePolicy.ts | 42 +++++++++ .../api/risk/__tests__/AbusePolicy.test.ts | 86 +++++++++++++++++++ .../src/api/rpc/RpcSessionStartService.ts | 13 ++- fluxer_api/src/api/user/UserHelpers.test.ts | 15 ++++ fluxer_api/src/api/user/UserHelpers.ts | 2 +- .../services/UserAccountRequestService.ts | 8 +- .../content/docs/operator/configuration.mdx | 8 ++ packages/config/src/ConfigLoader.ts | 4 + packages/config/src/MasterConfig.ts | 4 + .../config/src/__tests__/ConfigLoader.test.ts | 6 ++ .../src/config_loader/EnvironmentOverrides.ts | 8 ++ 16 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 fluxer_api/src/api/risk/__tests__/AbusePolicy.test.ts diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index aa7851252..1d9b6a02e 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -476,6 +476,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { }, abusePolicy: { inboundPhoneCountryCodes: master.instance.abuse_policy.inbound_phone_country_codes, + phoneFlagging: { + enabled: master.instance.abuse_policy.phone_flagging.enabled, + exemptCountryCodes: master.instance.abuse_policy.phone_flagging.exempt_country_codes, + }, phoneVerification: { inboundRequiredPrefixes: master.instance.abuse_policy.phone_verification.inbound_required_prefixes, }, diff --git a/fluxer_api/src/api/auth/AuthRegistration.ts b/fluxer_api/src/api/auth/AuthRegistration.ts index 5525260f7..9f519fddf 100644 --- a/fluxer_api/src/api/auth/AuthRegistration.ts +++ b/fluxer_api/src/api/auth/AuthRegistration.ts @@ -23,7 +23,7 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {User} from '@app/api/models/User'; import {UserSettings} from '@app/api/models/UserSettings'; -import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy'; +import {countryRequiresInboundPhoneVerification, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy'; import { type IAccountPolicyEvaluator, isAssessmentThresholdAuditEvent, @@ -362,7 +362,9 @@ export async function register( action: riskResult.recommendedAction, }, }); - const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits); + const combinedFlags = await deferPhoneFlagsUntilCommunityJoin( + await stripDisallowedPhoneFlags(policyDecision.flagBits, async () => countryCode), + ); const createdAt = new Date(); const riskContext = deriveLatestRiskContext({ userId: userId.toString(), diff --git a/fluxer_api/src/api/auth/tests/DeferredPhoneGate.test.ts b/fluxer_api/src/api/auth/tests/DeferredPhoneGate.test.ts index 45332e3c6..6d3f6efa7 100644 --- a/fluxer_api/src/api/auth/tests/DeferredPhoneGate.test.ts +++ b/fluxer_api/src/api/auth/tests/DeferredPhoneGate.test.ts @@ -9,6 +9,7 @@ import { loginAccount, registerUser, } from '@app/api/auth/tests/AuthTestUtils'; +import {Config} from '@app/api/Config'; import {setInjectedRegistrationRiskEvaluator} from '@app/api/middleware/ServiceMiddleware'; import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; import { @@ -33,7 +34,7 @@ import { SuspiciousActivityFlags, } from '@fluxer/constants/src/UserConstants'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; -import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator { return { @@ -241,6 +242,59 @@ describe('Deferred phone verification gate', () => { expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0); }); + describe('with phone flagging disabled', () => { + const originalPhoneFlagging = {...Config.abusePolicy.phoneFlagging}; + afterEach(() => { + Config.abusePolicy.phoneFlagging = originalPhoneFlagging; + }); + + it('sets no phone requirement and no deferral at registration', async () => { + await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true}); + Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []}; + setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70)); + const registration = await registerUser(harness, { + email: createUniqueEmail('flagging-off'), + username: createUniqueUsername('flagging_off'), + global_name: 'Flagging Off', + password: 'StrongPassword!123', + date_of_birth: '2000-01-01', + consent: true, + }); + const flags = await readFlags(registration.user_id); + expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).toBe(0); + expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0); + }); + + it('keeps an existing deferral dormant on a qualifying join', async () => { + await getInstanceConfigRepository().setInstancePolicyConfig({ + deferred_phone_gate_enabled: true, + deferred_phone_gate_member_threshold: 1, + deferred_phone_gate_window_hours: 24, + }); + const {inviteCode} = await createGuildWithInvite(harness); + const filler = await createTestAccount(harness); + await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute(); + setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70)); + const registration = await registerUser(harness, { + email: createUniqueEmail('flagging-off-join'), + username: createUniqueUsername('flagging_off_join'), + global_name: 'Flagging Off Join', + password: 'StrongPassword!123', + date_of_birth: '2000-01-01', + consent: true, + }); + setInjectedRegistrationRiskEvaluator(undefined); + expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0); + + Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []}; + await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute(); + + const flags = await readFlags(registration.user_id); + expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0); + expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0); + }); + }); + describe('phone gate escape', () => { async function configurePhoneGate( overrides: { diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index ad7b248cc..e2a93f835 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -336,6 +336,10 @@ export interface APIConfig { }; abusePolicy: { inboundPhoneCountryCodes: Array; + phoneFlagging: { + enabled: boolean; + exemptCountryCodes: Array; + }; phoneVerification: { inboundRequiredPrefixes: Array; }; diff --git a/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts b/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts index 72c2e77ad..a019b883b 100644 --- a/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts +++ b/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts @@ -3,6 +3,7 @@ import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils'; import type {GuildID, InviteCode, RoleID, UserID} from '@app/api/BrandedTypes'; import {createChannelID, createRoleID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; import {assertMutableUserId} from '@app/api/constants/Core'; import type {GuildMemberRow} from '@app/api/database/types/GuildTypes'; @@ -421,6 +422,13 @@ export class GuildMemberOperationsService { memberCount: guild.memberCount, accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(), }; + if ( + !Config.abusePolicy.phoneFlagging.enabled && + (getEffectiveSuspiciousFlags(user) & PHONE_REQUIREMENT_FLAGS) === 0 + ) { + Logger.info(logContext, 'deferred_phone_gate.skipped_phone_flagging_disabled'); + return; + } if (status !== 'ok') { const undeferredFlags = getEffectiveSuspiciousFlags({ ...user, diff --git a/fluxer_api/src/api/risk/AbusePolicy.ts b/fluxer_api/src/api/risk/AbusePolicy.ts index 269fb5a7b..751998ec2 100644 --- a/fluxer_api/src/api/risk/AbusePolicy.ts +++ b/fluxer_api/src/api/risk/AbusePolicy.ts @@ -1,6 +1,29 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Config} from '@app/api/Config'; +import {PHONE_REQUIREMENT_FLAGS, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants'; + +const EMAIL_ONLY_EQUIVALENTS: ReadonlyArray = [ + [SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL], + [SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL], + [ + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE, + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL, + ], + [ + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE, + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL, + ], +]; + +const PHONE_OFFERING_FLAGS = EMAIL_ONLY_EQUIVALENTS.reduce((mask, [either]) => mask | either, PHONE_REQUIREMENT_FLAGS); + +function withoutPhoneOfferingFlags(flagBits: number): number { + return EMAIL_ONLY_EQUIVALENTS.reduce( + (next, [either, emailOnly]) => ((flagBits & either) !== 0 ? next | emailOnly : next), + flagBits & ~PHONE_OFFERING_FLAGS, + ); +} function normalizeCountryCode(countryCode: string | null | undefined): string | null { const trimmed = countryCode?.trim(); @@ -17,6 +40,25 @@ export function countryRequiresInboundPhoneVerification(countryCode: string | nu return configuredCountrySet(Config.abusePolicy.inboundPhoneCountryCodes).has(normalized); } +export function phoneFlaggingAllowedForCountry(countryCode: string | null | undefined): boolean { + const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging; + if (!enabled) return false; + const normalized = normalizeCountryCode(countryCode); + if (!normalized) return true; + return !configuredCountrySet(exemptCountryCodes).has(normalized); +} + +export async function stripDisallowedPhoneFlags( + flagBits: number, + resolveCountryCode: () => Promise, +): Promise { + if ((flagBits & PHONE_OFFERING_FLAGS) === 0) return flagBits; + const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging; + if (enabled && exemptCountryCodes.length === 0) return flagBits; + if (enabled && phoneFlaggingAllowedForCountry(await resolveCountryCode())) return flagBits; + return withoutPhoneOfferingFlags(flagBits); +} + export function phoneRequiresInboundVerification( phone: string, prefixes: ReadonlyArray = Config.abusePolicy.phoneVerification.inboundRequiredPrefixes, diff --git a/fluxer_api/src/api/risk/__tests__/AbusePolicy.test.ts b/fluxer_api/src/api/risk/__tests__/AbusePolicy.test.ts new file mode 100644 index 000000000..5fa6c8788 --- /dev/null +++ b/fluxer_api/src/api/risk/__tests__/AbusePolicy.test.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import {phoneFlaggingAllowedForCountry, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy'; +import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const PHONE_AND_EMAIL = + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | + SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | + SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION; + +describe('phone flagging policy', () => { + const original = {...Config.abusePolicy.phoneFlagging}; + + beforeEach(() => { + Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: []}; + }); + + afterEach(() => { + Config.abusePolicy.phoneFlagging = original; + }); + + it('keeps phone flags by default without resolving the country', async () => { + const resolveCountryCode = vi.fn(async () => 'NG'); + expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe(PHONE_AND_EMAIL); + expect(resolveCountryCode).not.toHaveBeenCalled(); + expect(phoneFlaggingAllowedForCountry('NG')).toBe(true); + }); + + it('strips only phone flags when disabled', async () => { + Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []}; + const resolveCountryCode = vi.fn(async () => 'NG'); + expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe( + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, + ); + expect(resolveCountryCode).not.toHaveBeenCalled(); + expect(phoneFlaggingAllowedForCountry('NG')).toBe(false); + expect(phoneFlaggingAllowedForCountry(null)).toBe(false); + }); + + it('strips phone flags for exempt countries only', async () => { + Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: [' br', 'PT']}; + expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'BR')).toBe( + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, + ); + expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'ng')).toBe(PHONE_AND_EMAIL); + expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => null)).toBe(PHONE_AND_EMAIL); + expect(phoneFlaggingAllowedForCountry('pt')).toBe(false); + expect(phoneFlaggingAllowedForCountry('NG')).toBe(true); + }); + + it('replaces email or phone flags with their email only equivalent', async () => { + Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []}; + expect( + await stripDisallowedPhoneFlags( + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE | + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE, + async () => null, + ), + ).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL); + expect( + await stripDisallowedPhoneFlags( + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE | + SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION, + async () => null, + ), + ).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL); + Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']}; + expect( + await stripDisallowedPhoneFlags( + SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE, + async () => 'BR', + ), + ).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL); + }); + + it('skips the country lookup when no phone flags are present', async () => { + Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']}; + const resolveCountryCode = vi.fn(async () => 'BR'); + expect(await stripDisallowedPhoneFlags(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, resolveCountryCode)).toBe( + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, + ); + expect(resolveCountryCode).not.toHaveBeenCalled(); + }); +}); diff --git a/fluxer_api/src/api/rpc/RpcSessionStartService.ts b/fluxer_api/src/api/rpc/RpcSessionStartService.ts index 98bf14069..7e70314d9 100644 --- a/fluxer_api/src/api/rpc/RpcSessionStartService.ts +++ b/fluxer_api/src/api/rpc/RpcSessionStartService.ts @@ -10,7 +10,7 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import {Logger} from '@app/api/Logger'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {User} from '@app/api/models/User'; -import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy'; +import {countryRequiresInboundPhoneVerification, phoneFlaggingAllowedForCountry} from '@app/api/risk/AbusePolicy'; import { createRpcTimingNode, RpcTimingRecorder, @@ -311,6 +311,17 @@ export class RpcSessionStartService { ) { return null; } + if ( + !timeRpcStepSync(timingSteps, 'check_phone_flagging_allowed', () => + phoneFlaggingAllowedForCountry(geoipCountryIso), + ) + ) { + Logger.info( + {userId: user.id.toString(), countryIso: geoipCountryIso}, + 'Skipping configured-country inbound phone requirement: phone flagging disabled for this country', + ); + return null; + } if ( timeRpcStepSync(timingSteps, 'check_not_suspicious_flag', () => (user.flags & UserFlags.NOT_SUSPICIOUS) !== 0n) ) { diff --git a/fluxer_api/src/api/user/UserHelpers.test.ts b/fluxer_api/src/api/user/UserHelpers.test.ts index 80dc9ae82..66de2226d 100644 --- a/fluxer_api/src/api/user/UserHelpers.test.ts +++ b/fluxer_api/src/api/user/UserHelpers.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {Config} from '@app/api/Config'; import type {User} from '@app/api/models/User'; import {setInjectedAccountPolicyEvaluator} from '@app/api/risk/AccountPolicyService'; import {setCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache'; @@ -52,6 +53,20 @@ describe('deferred phone gate marker', () => { }); expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']); }); + it('keeps a deferral suppressed when the gate reads off but phone flagging is disabled', () => { + setCachedDeferredPhoneGateEnabled(false); + const original = {...Config.abusePolicy.phoneFlagging}; + Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []}; + try { + const user = createUser({ + suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN, + }); + expect(getRequiredActions(user)).toEqual([]); + expect(getEffectiveSuspiciousFlags(user)).toBe(0); + } finally { + Config.abusePolicy.phoneFlagging = original; + } + }); it('suppresses a deferred phone requirement so the account is not locked out', () => { const user = createUser({ suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN, diff --git a/fluxer_api/src/api/user/UserHelpers.ts b/fluxer_api/src/api/user/UserHelpers.ts index 37b2fe17a..9f503798b 100644 --- a/fluxer_api/src/api/user/UserHelpers.ts +++ b/fluxer_api/src/api/user/UserHelpers.ts @@ -134,7 +134,7 @@ function suppressDeferredPhoneFlags(rawFlags: number): number { if ((rawFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0) { return rawFlags; } - if (getCachedDeferredPhoneGateEnabled() === false) { + if (getCachedDeferredPhoneGateEnabled() === false && Config.abusePolicy.phoneFlagging.enabled) { return rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN; } return rawFlags & ~DEFERRABLE_PHONE_FLAGS; diff --git a/fluxer_api/src/api/user/services/UserAccountRequestService.ts b/fluxer_api/src/api/user/services/UserAccountRequestService.ts index dfb1a86bf..e5dcc5c1f 100644 --- a/fluxer_api/src/api/user/services/UserAccountRequestService.ts +++ b/fluxer_api/src/api/user/services/UserAccountRequestService.ts @@ -12,6 +12,7 @@ import {Logger} from '@app/api/Logger'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {AuthSession} from '@app/api/models/AuthSession'; import type {User} from '@app/api/models/User'; +import {stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy'; import {createAccountPolicyContactContext, type IAccountPolicyEvaluator} from '@app/api/risk/AccountPolicyEvaluator'; import type {IRegistrationEventsRepository} from '@app/api/risk/adapters/VelocityAdapter'; import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository'; @@ -42,6 +43,7 @@ import { mapUserToPrivateResponse, mapUserToProfileResponse, } from '@app/api/user/UserMappers'; +import {lookupGeoip} from '@app/api/utils/IpUtils'; import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, imposePhoneRequirements} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils'; @@ -302,7 +304,11 @@ export class UserAccountRequestService { action: emailSetRecommendedAction, }, }); - nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyDecision.flagBits); + const policyFlagBits = await stripDisallowedPhoneFlags( + policyDecision.flagBits, + async () => (await lookupGeoip(request)).countryCode, + ); + nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyFlagBits); if (nextSuspiciousFlags !== currentSuspiciousFlags) { user = await this.userRepository.patchUpsert( user.id, diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index 5aeef8e61..35140c40e 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -991,6 +991,14 @@ No default. The account risk policy. JSON. Malformed JSON fails startup, and an Default empty. Allowed inbound phone countries. Comma separated, passed through unvalidated. +#### `FLUXER_ABUSE_PHONE_FLAGGING_ENABLED` + +Default `true`. Automatic phone requirements. With this off, registration, setting an email and gateway session start never add a requirement that offers phone verification. An email or phone requirement becomes its email only form. A deferred phone requirement stays dormant on a community join. Requirements already on an account and flags set by an Admin are untouched. + +#### `FLUXER_ABUSE_PHONE_FLAGGING_EXEMPT_COUNTRY_CODES` + +Default empty. Countries that never get an automatic phone requirement, matched against the request's GeoIP country. An email or phone requirement becomes its email only form. Comma separated, unvalidated. + #### `FLUXER_ABUSE_PHONE_INBOUND_REQUIRED_PREFIXES` Default empty. Required inbound prefixes. Comma separated. diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 91f8867fd..05dd14612 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -277,6 +277,10 @@ function defaultConfig(): MasterConfig { }, abuse_policy: { inbound_phone_country_codes: [], + phone_flagging: { + enabled: true, + exempt_country_codes: [], + }, phone_verification: { inbound_required_prefixes: [], }, diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 629eabf3e..7a007e56a 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -341,6 +341,10 @@ export interface MasterConfig { }; abuse_policy: { inbound_phone_country_codes: Array; + phone_flagging: { + enabled: boolean; + exempt_country_codes: Array; + }; phone_verification: { inbound_required_prefixes: Array; }; diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index 17d8e188f..aac4eb5a7 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -464,6 +464,8 @@ describe('ConfigLoader', () => { FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: 'https://status.example/history', FLUXER_INSTANCE_SETUP_CONFIGURED: 'true', FLUXER_ABUSE_INBOUND_PHONE_COUNTRY_CODES: 'AA,BB', + FLUXER_ABUSE_PHONE_FLAGGING_ENABLED: 'false', + FLUXER_ABUSE_PHONE_FLAGGING_EXEMPT_COUNTRY_CODES: 'CC,DD', FLUXER_ABUSE_PHONE_INBOUND_REQUIRED_PREFIXES: '+101,+202', FLUXER_ABUSE_DIRECT_CONTACT_SPAM_ENABLED: 'true', FLUXER_ABUSE_DIRECT_CONTACT_SPAM_COUNTRY_CODES: 'AA,BB', @@ -492,6 +494,10 @@ describe('ConfigLoader', () => { expect(config.instance.setup.configured).toBe(true); expect(config.instance.abuse_policy).toEqual({ inbound_phone_country_codes: ['AA', 'BB'], + phone_flagging: { + enabled: false, + exempt_country_codes: ['CC', 'DD'], + }, phone_verification: { inbound_required_prefixes: ['+101', '+202'], }, diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index 21b46b0f1..94f15a651 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -369,6 +369,14 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { path: ['instance', 'abuse_policy', 'inbound_phone_country_codes'], parse: parseCsv, }, + FLUXER_ABUSE_PHONE_FLAGGING_ENABLED: { + path: ['instance', 'abuse_policy', 'phone_flagging', 'enabled'], + parse: parseBoolean, + }, + FLUXER_ABUSE_PHONE_FLAGGING_EXEMPT_COUNTRY_CODES: { + path: ['instance', 'abuse_policy', 'phone_flagging', 'exempt_country_codes'], + parse: parseCsv, + }, FLUXER_ABUSE_PHONE_INBOUND_REQUIRED_PREFIXES: { path: ['instance', 'abuse_policy', 'phone_verification', 'inbound_required_prefixes'], parse: parseCsv,