diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 08fe3d374..3410244e2 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -12573,6 +12573,15 @@ "bluesky": {"type": "boolean"} }, "required": ["gif", "youtube", "bluesky"] + }, + "deferred_phone_gate": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "window_hours": {"type": "number"}, + "member_threshold": {"type": "number"} + }, + "required": ["enabled", "window_hours", "member_threshold"] } }, "required": [ @@ -12583,7 +12592,8 @@ "premium_mode", "services", "services_resolved", - "services_available" + "services_available", + "deferred_phone_gate" ] }, "integrations": { @@ -13110,6 +13120,21 @@ "youtube_enabled": {"nullable": true, "type": "boolean"}, "bluesky_enabled": {"nullable": true, "type": "boolean"} } + }, + "deferred_phone_gate": { + "nullable": true, + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "window_hours": {"type": "number", "maximum": 8760, "minimum": 0, "exclusiveMinimum": true}, + "member_threshold": { + "type": "integer", + "maximum": 1000000, + "format": "int32", + "minimum": 0, + "exclusiveMinimum": true + } + } } } } @@ -14879,6 +14904,10 @@ "premium_grace_ends_at": {"nullable": true, "type": "string"}, "premium_lifetime_sequence": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, "suspicious_activity_flags": {"$ref": "#/components/schemas/SuspiciousActivityFlags"}, + "phone_verification_deferred": { + "type": "boolean", + "description": "Whether a stored phone requirement is deferred until the user joins a discoverable or large community" + }, "temp_banned_until": {"nullable": true, "type": "string"}, "pending_deletion_at": {"nullable": true, "type": "string"}, "pending_bulk_message_deletion_at": {"nullable": true, "type": "string"}, @@ -14919,6 +14948,7 @@ "premium_grace_ends_at", "premium_lifetime_sequence", "suspicious_activity_flags", + "phone_verification_deferred", "temp_banned_until", "pending_deletion_at", "pending_bulk_message_deletion_at", diff --git a/fluxer_admin/src/api/generated.rs b/fluxer_admin/src/api/generated.rs index 1b62ecec5..dbe3b6e33 100644 --- a/fluxer_admin/src/api/generated.rs +++ b/fluxer_admin/src/api/generated.rs @@ -69,6 +69,7 @@ mod tests { "premium_grace_ends_at": null, "premium_lifetime_sequence": null, "suspicious_activity_flags": 0, + "phone_verification_deferred": false, "temp_banned_until": null, "pending_deletion_at": null, "pending_bulk_message_deletion_at": null, diff --git a/fluxer_admin/src/api/types/common.rs b/fluxer_admin/src/api/types/common.rs index 4b915b210..c25dd4421 100644 --- a/fluxer_admin/src/api/types/common.rs +++ b/fluxer_admin/src/api/types/common.rs @@ -102,6 +102,8 @@ pub struct AdminUser { #[serde(default)] pub suspicious_activity_flags: i32, #[serde(default)] + pub phone_verification_deferred: bool, + #[serde(default)] pub has_totp: bool, #[serde(default)] pub authenticator_types: Vec, diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index 7b3d0c4fc..aebe32e0a 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -37,6 +37,28 @@ pub struct InstancePolicyResponse { pub services_resolved: InstanceServicesResolved, #[serde(default)] pub services_available: InstanceServicesAvailable, + #[serde(default)] + pub deferred_phone_gate: DeferredPhoneGateResponse, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DeferredPhoneGateResponse { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub window_hours: f64, + #[serde(default)] + pub member_threshold: i64, +} + +impl Default for DeferredPhoneGateResponse { + fn default() -> Self { + Self { + enabled: true, + window_hours: 6.0, + member_threshold: 50, + } + } } impl Default for InstancePolicyResponse { @@ -50,6 +72,7 @@ impl Default for InstancePolicyResponse { services: InstanceServicesOverrides::default(), services_resolved: InstanceServicesResolved::default(), services_available: InstanceServicesAvailable::default(), + deferred_phone_gate: DeferredPhoneGateResponse::default(), } } } @@ -516,6 +539,18 @@ pub struct InstancePolicyUpdateRequest { pub premium_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub services: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deferred_phone_gate: Option, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct DeferredPhoneGateUpdateRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub window_hours: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub member_threshold: Option, } #[derive(Clone, Debug, Default, Serialize)] diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 8009a7575..647c32dd2 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -7,7 +7,7 @@ use crate::{ AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest, - GatewayRolloutConfigUpdateRequest, GatewayRolloutMode, + DeferredPhoneGateUpdateRequest, GatewayRolloutConfigUpdateRequest, GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest, @@ -547,6 +547,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest { _ => None, }; let services = build_services_update(form); + let deferred_phone_gate = build_deferred_phone_gate_update(form); InstanceConfigUpdateRequest { gateway_rollout: None, registration: None, @@ -558,12 +559,37 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest { direct_messages_disabled, premium_mode, services, + deferred_phone_gate, }), integrations: None, media: None, } } +fn build_deferred_phone_gate_update( + form: &MultiValueForm, +) -> Option { + let enabled = form + .first("policy_deferred_phone_gate_enabled") + .map(|value| value == "true"); + let window_hours = form + .first("policy_deferred_phone_gate_window_hours") + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0.0); + let member_threshold = form + .first("policy_deferred_phone_gate_member_threshold") + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0); + if enabled.is_none() && window_hours.is_none() && member_threshold.is_none() { + return None; + } + Some(DeferredPhoneGateUpdateRequest { + enabled, + window_hours, + member_threshold, + }) +} + fn build_services_update(form: &MultiValueForm) -> Option { let parse_tristate = |key: &str| match form.first(key) { Some("inherit") => Some(None), @@ -712,6 +738,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest { direct_messages_disabled: None, premium_mode: None, services: None, + deferred_phone_gate: None, }), integrations: None, media: None, diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index 7144aa3b2..2ab5bf307 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -191,6 +191,7 @@ fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyRe (single_community_form(base, csrf_token, policy)) (direct_messages_form(base, csrf_token, policy)) (premium_mode_form(base, csrf_token, policy)) + (deferred_phone_gate_form(base, csrf_token, policy)) (services_form(base, csrf_token, policy)) } }, @@ -283,6 +284,57 @@ fn direct_messages_form(base: &str, csrf_token: &str, policy: &InstancePolicyRes } } +fn deferred_phone_gate_form( + base: &str, + csrf_token: &str, + policy: &InstancePolicyResponse, +) -> Markup { + let gate = &policy.deferred_phone_gate; + let status = if gate.enabled { + ("Enabled", BadgeVariant::Success) + } else { + ("Disabled", BadgeVariant::Default) + }; + html! { + div class="space-y-4 border-t border-neutral-200 pt-6" { + div class="flex flex-wrap items-center gap-2" { + h3 class="text-sm font-semibold text-neutral-900" { "Deferred phone verification" } + (badge(status.0, status.1)) + } + p class="text-sm text-neutral-500" { + "When enabled, a phone requirement raised at registration is held back and only \ + applied if the account joins a discoverable community, or one above the member \ + threshold, within the window. Accounts that wait out the window are not challenged. \ + Inbound-SMS requirements are never deferred." + } + form method="post" action={(base) "/instance-config?action=update_policy"} { + (csrf_input(csrf_token)) + div class="space-y-4" { + (select_input("policy_deferred_phone_gate_enabled", "Deferred phone verification", &[ + ("true", "Enabled"), + ("false", "Disabled"), + ], if gate.enabled { "true" } else { "false" })) + (text_input( + "policy_deferred_phone_gate_window_hours", + "Window (hours)", + &gate.window_hours.to_string(), + "6", + )) + (text_input( + "policy_deferred_phone_gate_member_threshold", + "Member threshold", + &gate.member_threshold.to_string(), + "50", + )) + (form_actions(html! { + (submit_button("Save deferred phone verification")) + })) + } + } + } + } +} + fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup { html! { div class="space-y-4 border-t border-neutral-200 pt-6" { diff --git a/fluxer_admin/src/templates/pages/user_detail_tabs/overview.rs b/fluxer_admin/src/templates/pages/user_detail_tabs/overview.rs index e905c4347..1442dafde 100644 --- a/fluxer_admin/src/templates/pages/user_detail_tabs/overview.rs +++ b/fluxer_admin/src/templates/pages/user_detail_tabs/overview.rs @@ -291,6 +291,11 @@ fn flags_card( can_update_suspicious, Some(acl::USER_UPDATE_SUSPICIOUS_ACTIVITY), )) + @if user.phone_verification_deferred { + p class="text-sm text-amber-700 dark:text-amber-400" { + "Phone verification is deferred: the requirement above is stored but not enforced until this user joins a discoverable or large community within the deferral window." + } + } } } } diff --git a/fluxer_admin/tests/htmx_acceptance.rs b/fluxer_admin/tests/htmx_acceptance.rs index 5153a66e3..00856036d 100644 --- a/fluxer_admin/tests/htmx_acceptance.rs +++ b/fluxer_admin/tests/htmx_acceptance.rs @@ -898,6 +898,7 @@ fn user(id: &str, username: &str) -> Value { "premium_grace_ends_at": null, "premium_lifetime_sequence": null, "suspicious_activity_flags": 0, + "phone_verification_deferred": false, "has_totp": false, "authenticator_types": [], "has_verified_phone": false, diff --git a/fluxer_admin/tests/parity/fixtures/api/admin_user_me.json b/fluxer_admin/tests/parity/fixtures/api/admin_user_me.json index a7e121bbb..2416dbd43 100644 --- a/fluxer_admin/tests/parity/fixtures/api/admin_user_me.json +++ b/fluxer_admin/tests/parity/fixtures/api/admin_user_me.json @@ -25,6 +25,7 @@ "premium_grace_ends_at": null, "premium_lifetime_sequence": null, "suspicious_activity_flags": 0, + "phone_verification_deferred": false, "temp_banned_until": null, "pending_deletion_at": null, "pending_bulk_message_deletion_at": null, diff --git a/fluxer_admin/tests/parity/fixtures/api/lookup_user.json b/fluxer_admin/tests/parity/fixtures/api/lookup_user.json index b94938f08..214cac527 100644 --- a/fluxer_admin/tests/parity/fixtures/api/lookup_user.json +++ b/fluxer_admin/tests/parity/fixtures/api/lookup_user.json @@ -26,6 +26,7 @@ "premium_grace_ends_at": null, "premium_lifetime_sequence": null, "suspicious_activity_flags": 0, + "phone_verification_deferred": false, "temp_banned_until": null, "pending_deletion_at": null, "pending_bulk_message_deletion_at": null, diff --git a/fluxer_admin/tests/parity/fixtures/api/search_users.json b/fluxer_admin/tests/parity/fixtures/api/search_users.json index 816ba41e8..004ee4fd7 100644 --- a/fluxer_admin/tests/parity/fixtures/api/search_users.json +++ b/fluxer_admin/tests/parity/fixtures/api/search_users.json @@ -26,6 +26,7 @@ "premium_grace_ends_at": null, "premium_lifetime_sequence": null, "suspicious_activity_flags": 0, + "phone_verification_deferred": false, "temp_banned_until": null, "pending_deletion_at": null, "pending_bulk_message_deletion_at": null, diff --git a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts index 590025114..21ad533a5 100644 --- a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts @@ -100,6 +100,11 @@ async function buildInstanceConfigResponse(): Promise { youtube_enabled: policy.youtube_enabled, bluesky_enabled: policy.bluesky_enabled, }, + deferred_phone_gate: { + enabled: policy.deferred_phone_gate_enabled, + window_hours: policy.deferred_phone_gate_window_hours, + member_threshold: policy.deferred_phone_gate_member_threshold, + }, services_resolved: resolvedServices, services_available: { gif: integrations.gif.effective_available, @@ -591,6 +596,17 @@ async function applyInstancePolicyUpdate( patch.bluesky_enabled = policy.services.bluesky_enabled ?? null; } } + if (policy.deferred_phone_gate) { + if (policy.deferred_phone_gate.enabled !== undefined) { + patch.deferred_phone_gate_enabled = policy.deferred_phone_gate.enabled; + } + if (policy.deferred_phone_gate.window_hours !== undefined) { + patch.deferred_phone_gate_window_hours = policy.deferred_phone_gate.window_hours; + } + if (policy.deferred_phone_gate.member_threshold !== undefined) { + patch.deferred_phone_gate_member_threshold = policy.deferred_phone_gate.member_threshold; + } + } if (Object.keys(patch).length > 0) { await instanceConfigRepository.setInstancePolicyConfig(patch); } diff --git a/fluxer_api/src/api/admin/models/UserTypes.ts b/fluxer_api/src/api/admin/models/UserTypes.ts index b6686a3eb..fb67528f1 100644 --- a/fluxer_api/src/api/admin/models/UserTypes.ts +++ b/fluxer_api/src/api/admin/models/UserTypes.ts @@ -2,6 +2,7 @@ import dns from 'node:dns'; import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {DEFERRED_PHONE_ON_COMMUNITY_JOIN} from '@fluxer/constants/src/UserConstants'; import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas'; import type {ICacheService} from '@pkgs/cache/src/ICacheService'; import {formatGeoipLocation} from '@pkgs/geoip/src/GeoipLookup'; @@ -82,6 +83,7 @@ export async function mapUserToAdminResponse( premium_grace_ends_at: user.premiumGraceEndsAt?.toISOString() ?? null, premium_lifetime_sequence: user.premiumLifetimeSequence ?? null, suspicious_activity_flags: user.suspiciousActivityFlags, + phone_verification_deferred: ((user.suspiciousActivityFlags ?? 0) & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0, temp_banned_until: user.tempBannedUntil?.toISOString() ?? null, pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null, pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null, diff --git a/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts b/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts index a72485a5f..a3563a853 100644 --- a/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts +++ b/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts @@ -2,7 +2,15 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; -import {SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants'; +import { + ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS, + ALL_SUSPICIOUS_ACTIVITY_FLAGS, + DEFERRABLE_PHONE_FLAGS, + DEFERRED_PHONE_ON_COMMUNITY_JOIN, + imposePhoneRequirements, + SuspiciousActivityFlags, + UserFlags, +} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; @@ -31,6 +39,7 @@ import * as AuthMfa from '../../auth/AuthMfa'; import * as AuthSession from '../../auth/AuthSession'; import * as AuthUtility from '../../auth/AuthUtility'; import {createPasswordResetToken, createUserID, type UserID} from '../../BrandedTypes'; +import type {UserRow} from '../../database/types/UserTypes'; import {Logger} from '../../Logger'; import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository'; import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes'; @@ -406,11 +415,14 @@ export class AdminUserSecurityService { if (!user) { throw new UnknownUserError(); } - const updatedUser = await userRepository.patchUpsert( - userId, - {has_verified_phone: data.has_verified_phone}, - user.toRow(), - ); + const phonePatch: Partial = {has_verified_phone: data.has_verified_phone}; + if (data.has_verified_phone) { + const clearedFlags = (user.suspiciousActivityFlags ?? 0) & ~ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS; + if (clearedFlags !== (user.suspiciousActivityFlags ?? 0)) { + phonePatch.suspicious_activity_flags = clearedFlags; + } + } + const updatedUser = await userRepository.patchUpsert(userId, phonePatch, user.toRow()); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser}); await auditService.createAuditLog({ adminUserId, @@ -418,7 +430,15 @@ export class AdminUserSecurityService { targetId: BigInt(userId), action: 'update_has_verified_phone', auditLogReason, - metadata: new Map([['has_verified_phone', String(data.has_verified_phone)]]), + metadata: new Map( + phonePatch.suspicious_activity_flags === undefined + ? [['has_verified_phone', String(data.has_verified_phone)]] + : [ + ['has_verified_phone', String(data.has_verified_phone)], + ['suspicious_activity_flags_before', String(user.suspiciousActivityFlags ?? 0)], + ['suspicious_activity_flags_after', String(phonePatch.suspicious_activity_flags)], + ], + ), }); return { user: await mapUserToAdminResponse(updatedUser, cacheService, acls), @@ -438,15 +458,24 @@ export class AdminUserSecurityService { if (!user) { throw new UnknownUserError(); } + const currentFlags = user.suspiciousActivityFlags ?? 0; + const keepsDeferral = + (currentFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0 && + (data.flags & DEFERRABLE_PHONE_FLAGS) !== 0 && + (data.flags & DEFERRABLE_PHONE_FLAGS) === (currentFlags & DEFERRABLE_PHONE_FLAGS); + const newFlags = keepsDeferral ? data.flags | DEFERRED_PHONE_ON_COMMUNITY_JOIN : data.flags; const updatedUser = await userRepository.patchUpsert( userId, { - suspicious_activity_flags: data.flags, + suspicious_activity_flags: newFlags, }, user.toRow(), ); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); - if ((user.suspiciousActivityFlags ?? 0) !== data.flags && data.flags !== 0) { + if ( + (currentFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== (newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) && + (newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== 0 + ) { await this.recordRiskOutcomes(userId, ['challenged'], 'admin_update_suspicious_activity_flags'); } await auditService.createAuditLog({ @@ -600,7 +629,7 @@ export class AdminUserSecurityService { throw new UnknownUserError(); } const currentFlags = user.suspiciousActivityFlags ?? 0; - const newFlags = (currentFlags | addMask) & ~removeMask; + const newFlags = imposePhoneRequirements(currentFlags, addMask) & ~removeMask; const updatedUser = await userRepository.patchUpsert( userId, {suspicious_activity_flags: newFlags}, diff --git a/fluxer_api/src/api/admin/services/guild/AdminGuildMembershipService.ts b/fluxer_api/src/api/admin/services/guild/AdminGuildMembershipService.ts index 2a11797e5..0da0ba810 100644 --- a/fluxer_api/src/api/admin/services/guild/AdminGuildMembershipService.ts +++ b/fluxer_api/src/api/admin/services/guild/AdminGuildMembershipService.ts @@ -43,6 +43,7 @@ export class AdminGuildMembershipService { throw new UnknownUserError(); } await guildService.members.addUserToGuild({ + skipRiskGate: true, userId, guildId, sendJoinMessage: true, @@ -83,6 +84,7 @@ export class AdminGuildMembershipService { try { const userId = createUserID(userIdBigInt); await guildService.members.addUserToGuild({ + skipRiskGate: true, userId, guildId, sendJoinMessage: false, diff --git a/fluxer_api/src/api/auth/AuthRegistration.ts b/fluxer_api/src/api/auth/AuthRegistration.ts index bd35ba863..b0ca2b30c 100644 --- a/fluxer_api/src/api/auth/AuthRegistration.ts +++ b/fluxer_api/src/api/auth/AuthRegistration.ts @@ -39,6 +39,7 @@ import { normalizePolicyContactDomain, } from '../risk/AccountPolicyEvaluator'; import type {IRegistrationEventsRepository} from '../risk/adapters/VelocityAdapter'; +import {deferPhoneFlagsUntilCommunityJoin} from '../risk/DeferredPhoneGate'; import type {IRiskHistoryRepository} from '../risk/HistoricalOutcomeRepository'; import type {IRiskAssessmentRepository} from '../risk/RiskAssessmentRepository'; import {deriveLatestRiskContext} from '../risk/RiskHistoryContext'; @@ -334,7 +335,7 @@ export async function register( action: riskResult.recommendedAction, }, }); - const combinedFlags = policyDecision.flagBits; + const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits); 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 new file mode 100644 index 000000000..7c71beef7 --- /dev/null +++ b/fluxer_api/src/api/auth/tests/DeferredPhoneGate.test.ts @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants'; +import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import {setInjectedRegistrationRiskEvaluator} from '../../middleware/ServiceMiddleware'; +import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons'; +import { + RecommendedAction, + RiskConfidence, + RiskDecisionMethod, + RiskLevel, + type RiskLevel as RiskLevelType, +} from '../../risk/RiskTypes'; +import type {ApiTestHarness} from '../../test/ApiTestHarness'; +import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; +import type {IRegistrationRiskEvaluator} from '../services/IRegistrationRiskEvaluator'; +import { + createAuthHarness, + createTestAccount, + createUniqueEmail, + createUniqueUsername, + loginAccount, + registerUser, +} from './AuthTestUtils'; + +function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator { + return { + async evaluate() { + return { + level, + recommendedAction: RecommendedAction.RequireOutboundPhone, + assessment: { + suspicious: true, + level, + confidence: RiskConfidence.High, + riskScore, + reasoning: 'deferred phone gate test', + recommendedAction: RecommendedAction.RequireOutboundPhone, + method: RiskDecisionMethod.Noop, + modelUsed: 'test', + rounds: 0, + elapsedMs: 0, + signals: {}, + }, + }; + }, + }; +} + +async function createGuildWithInvite(harness: ApiTestHarness): Promise<{guildId: string; inviteCode: string}> { + let owner = await createTestAccount(harness); + await createBuilderWithoutAuth(harness) + .post(`/test/users/${owner.userId}/acls`) + .body({acls: ['*']}) + .expect(200) + .execute(); + owner = await loginAccount(harness, owner); + const guild = await createBuilder(harness, owner.token) + .post('/guilds') + .body({name: `PhoneGate-${Date.now()}`}) + .execute(); + const invite = await createBuilder<{code: string}>(harness, owner.token) + .post(`/channels/${guild.system_channel_id}`.concat('/invites')) + .body({max_uses: 0, max_age: 0, unique: false, temporary: false}) + .execute(); + return {guildId: guild.id, inviteCode: invite.code}; +} + +async function readFlags(userId: string): Promise { + const {UserRepository} = await import('../../user/repositories/UserRepository'); + const {createUserID} = await import('../../BrandedTypes'); + const user = await new UserRepository().findUnique(createUserID(BigInt(userId))); + return user?.suspiciousActivityFlags ?? 0; +} + +describe('Deferred phone verification gate', () => { + let harness: ApiTestHarness; + beforeAll(async () => { + harness = await createAuthHarness(); + }); + beforeEach(async () => { + setInjectedRegistrationRiskEvaluator(undefined); + await harness.reset(); + }); + afterAll(async () => { + setInjectedRegistrationRiskEvaluator(undefined); + await harness?.shutdown(); + }); + + it('applies the phone requirement immediately while the gate is off', async () => { + await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: false}); + setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70)); + const registration = await registerUser(harness, { + email: createUniqueEmail('gate-off'), + username: createUniqueUsername('gate_off'), + global_name: 'Gate 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).not.toBe(0); + expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0); + }); + + it('defers the phone requirement at registration while the gate is on', async () => { + await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true}); + setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70)); + const registration = await registerUser(harness, { + email: createUniqueEmail('gate-on'), + username: createUniqueUsername('gate_on'), + global_name: 'Gate On', + password: 'StrongPassword!123', + date_of_birth: '2000-01-01', + consent: true, + }); + const flags = await readFlags(registration.user_id); + expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0); + expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0); + const me = await createBuilder<{required_actions: Array}>(harness, registration.token) + .get('/users/@me') + .expect(200) + .execute(); + expect(me.required_actions ?? []).toEqual([]); + }); + + it('lets a deferred account join a small guild without being challenged', async () => { + await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true}); + const {guildId, inviteCode} = await createGuildWithInvite(harness); + setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70)); + const registration = await registerUser(harness, { + email: createUniqueEmail('gate-small'), + username: createUniqueUsername('gate_small'), + global_name: 'Gate Small', + password: 'StrongPassword!123', + date_of_birth: '2000-01-01', + consent: true, + }); + setInjectedRegistrationRiskEvaluator(undefined); + 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(guildId).toBeTruthy(); + }); + + it('does not defer the inbound-SMS tier, which stays enforced from registration', async () => { + await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true}); + setInjectedRegistrationRiskEvaluator({ + async evaluate() { + return { + level: RiskLevel.VeryHigh, + recommendedAction: RecommendedAction.RequireInboundPhone, + assessment: { + suspicious: true, + level: RiskLevel.VeryHigh, + confidence: RiskConfidence.High, + riskScore: 90, + reasoning: 'inbound tier', + recommendedAction: RecommendedAction.RequireInboundPhone, + method: RiskDecisionMethod.Noop, + modelUsed: 'test', + rounds: 0, + elapsedMs: 0, + signals: {}, + }, + }; + }, + }); + const registration = await registerUser(harness, { + email: createUniqueEmail('gate-inbound'), + username: createUniqueUsername('gate_inbound'), + global_name: 'Gate Inbound', + password: 'StrongPassword!123', + date_of_birth: '2000-01-01', + consent: true, + }); + const flags = await readFlags(registration.user_id); + expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0); + expect(flags & SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION).not.toBe(0); + }); + + it('promotes the requirement and refuses the join on a qualifying guild inside the window', 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('gate-qualifying'), + username: createUniqueUsername('gate_qualifying'), + global_name: 'Gate Qualifying', + 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); + + await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(403).execute(); + + const flags = await readFlags(registration.user_id); + expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0); + expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0); + }); +}); diff --git a/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts b/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts index bfb0e8d5d..b3e0ed6f4 100644 --- a/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts @@ -104,6 +104,7 @@ export function GuildDiscoveryController(app: HonoApp) { app.post( '/discovery/guilds/:guild_id/join', RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN), + LoginRequired, DefaultUserOnly, Validator('param', GuildIdParam), OpenAPI({ diff --git a/fluxer_api/src/api/guild/services/GuildMemberService.ts b/fluxer_api/src/api/guild/services/GuildMemberService.ts index efb392ece..8bd2b9b7b 100644 --- a/fluxer_api/src/api/guild/services/GuildMemberService.ts +++ b/fluxer_api/src/api/guild/services/GuildMemberService.ts @@ -319,6 +319,7 @@ export class GuildMemberService { sendJoinMessage?: boolean; skipGuildLimitCheck?: boolean; skipBanCheck?: boolean; + skipRiskGate?: boolean; isTemporary?: boolean; joinSourceType?: JoinSourceType; sourceInviteCode?: InviteCode; diff --git a/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts b/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts index 905af8fb4..0c670a782 100644 --- a/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts +++ b/fluxer_api/src/api/guild/services/member/GuildMemberOperationsService.ts @@ -2,10 +2,17 @@ import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; -import {type JoinSourceType, JoinSourceTypes, SystemChannelFlags} from '@fluxer/constants/src/GuildConstants'; +import { + GuildFeatures, + type JoinSourceType, + JoinSourceTypes, + SystemChannelFlags, +} from '@fluxer/constants/src/GuildConstants'; import { DEFAULT_GUILD_FOLDER_ICON, + DEFERRED_PHONE_ON_COMMUNITY_JOIN, type MentionReplyPreference, + PHONE_REQUIREMENT_FLAGS, UserNotificationSettings, } from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; @@ -17,10 +24,12 @@ import {MaxGuildsError} from '@fluxer/errors/src/domains/guild/MaxGuildsError'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError'; import {CommunicationDisabledError} from '@fluxer/errors/src/domains/moderation/CommunicationDisabledError'; +import {AccountSuspiciousActivityError} from '@fluxer/errors/src/domains/user/AccountSuspiciousActivityError'; import {UserNotInVoiceError} from '@fluxer/errors/src/domains/user/UserNotInVoiceError'; import {DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {GuildMemberUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas'; +import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake'; import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService'; import {ms} from 'itty-time'; import {requireEmailVerified} from '../../../auth/EmailVerificationUtils'; @@ -38,13 +47,24 @@ import {resolveLimitSafe} from '../../../limits/LimitConfigUtils'; import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder'; import {profileSubstringBlocklistCache} from '../../../middleware/ProfileSubstringBlocklistCache'; import type {RequestCache} from '../../../middleware/RequestCacheMiddleware'; +import type {Guild} from '../../../models/Guild'; import type {GuildMember} from '../../../models/GuildMember'; import type {User} from '../../../models/User'; import type {UserGuildSettings} from '../../../models/UserGuildSettings'; import type {UserSettings} from '../../../models/UserSettings'; +import { + DEFAULT_PHONE_GATE_MEMBER_THRESHOLD, + evaluateDeferredPhoneGate, + getDeferredPhoneGateConfig, + guildTriggersPhoneGate, +} from '../../../risk/DeferredPhoneGate'; import type {IUserRepository} from '../../../user/IUserRepository'; -import {isProfileSubstringExempt} from '../../../user/UserHelpers'; -import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '../../../user/UserMappers'; +import {getEffectiveSuspiciousFlags, isProfileSubstringExempt} from '../../../user/UserHelpers'; +import { + mapUserGuildSettingsToResponse, + mapUserSettingsToResponse, + mapUserToPrivateResponse, +} from '../../../user/UserMappers'; import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '../../../user/utils/GuildFolderUtils'; import type {GuildAuditLogService} from '../../GuildAuditLogService'; import type {GuildAuditLogChange} from '../../GuildAuditLogTypes'; @@ -374,6 +394,68 @@ export class GuildMemberOperationsService { await this.gatewayService.leaveGuild({userId: targetId, guildId}); } + private async applyDeferredPhoneGate(user: User, guild: Guild): Promise { + if (user.hasVerifiedPhone) { + return; + } + const rawFlags = user.suspiciousActivityFlags ?? 0; + if ((rawFlags & (DEFERRED_PHONE_ON_COMMUNITY_JOIN | PHONE_REQUIREMENT_FLAGS)) === 0) { + return; + } + const {status, config} = await getDeferredPhoneGateConfig(); + const logContext = { + userId: user.id.toString(), + guildId: guild.id.toString(), + discoverable: guild.features.has(GuildFeatures.DISCOVERABLE), + memberCount: guild.memberCount, + accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(), + }; + if (status !== 'ok') { + const undeferredFlags = getEffectiveSuspiciousFlags({ + ...user, + suspiciousActivityFlags: rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN, + } as User); + if ( + (undeferredFlags & PHONE_REQUIREMENT_FLAGS) === 0 || + !guildTriggersPhoneGate(guild, DEFAULT_PHONE_GATE_MEMBER_THRESHOLD) + ) { + return; + } + Logger.info(logContext, `deferred_phone_gate.enforced_while_${status}`); + throw new AccountSuspiciousActivityError(undeferredFlags); + } + const liveFlags = getEffectiveSuspiciousFlags(user); + if ((liveFlags & PHONE_REQUIREMENT_FLAGS) !== 0) { + if (!guildTriggersPhoneGate(guild, config.memberThreshold)) { + return; + } + Logger.info(logContext, 'deferred_phone_gate.blocked_unsatisfied_phone_requirement'); + throw new AccountSuspiciousActivityError(liveFlags); + } + const outcome = evaluateDeferredPhoneGate(user, guild, config, Date.now()); + if (!outcome.applies) { + Logger.info(logContext, `deferred_phone_gate.skipped_${outcome.reason}`); + return; + } + const promotedFlags = getEffectiveSuspiciousFlags({...user, suspiciousActivityFlags: outcome.flags} as User); + if (promotedFlags === 0) { + Logger.info(logContext, 'deferred_phone_gate.skipped_unenforceable'); + return; + } + const updatedUser = await this.userRepository.patchUpsert( + user.id, + {suspicious_activity_flags: outcome.flags}, + user.toRow(), + ); + await this.gatewayService.dispatchPresence({ + userId: user.id, + event: 'USER_UPDATE', + data: mapUserToPrivateResponse(updatedUser), + }); + Logger.info(logContext, 'deferred_phone_gate.applied'); + throw new AccountSuspiciousActivityError(promotedFlags); + } + async addUserToGuild( params: { userId: UserID; @@ -381,6 +463,7 @@ export class GuildMemberOperationsService { sendJoinMessage?: boolean; skipGuildLimitCheck?: boolean; skipBanCheck?: boolean; + skipRiskGate?: boolean; isTemporary?: boolean; joinSourceType?: JoinSourceType; sourceInviteCode?: InviteCode; @@ -398,6 +481,7 @@ export class GuildMemberOperationsService { sendJoinMessage = true, skipGuildLimitCheck = false, skipBanCheck = false, + skipRiskGate = false, isTemporary = false, joinSourceType = JoinSourceTypes.INSTANT_INVITE, sourceInviteCode = null, @@ -418,6 +502,9 @@ export class GuildMemberOperationsService { if (!skipGuildLimitCheck) { await this.enforceGuildLimit(user, userGuildsCount); } + if (!skipRiskGate && !user.isBot) { + await this.applyDeferredPhoneGate(user, guild); + } const maxGuildMembers = resolveMaxGuildMembersLimit({ guildFeatures: guild.features, snapshot: this.limitConfigService.getConfigSnapshot(), diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.ts index f11c6499b..7826b9abb 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.ts @@ -13,6 +13,7 @@ import {sanitizeLimitConfigForInstance} from '../constants/LimitConfig'; import {fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution'; import type {InstanceConfigurationRow} from '../database/types/InstanceConfigTypes'; import {Logger} from '../Logger'; +import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache'; import {InstanceConfiguration} from '../Tables'; import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '../utils/AttachmentDecay'; import {isJsonRecord, parseJsonArray, parseJsonRecord} from '../utils/JsonBoundaryUtils'; @@ -84,6 +85,9 @@ export interface InstancePolicyConfig { gif_enabled: boolean | null; youtube_enabled: boolean | null; bluesky_enabled: boolean | null; + deferred_phone_gate_enabled: boolean; + deferred_phone_gate_window_hours: number; + deferred_phone_gate_member_threshold: number; } interface InstanceCommunityPublicConfig { @@ -402,6 +406,9 @@ const DEFAULT_INSTANCE_POLICY_CONFIG: InstancePolicyConfig = { gif_enabled: null, youtube_enabled: null, bluesky_enabled: null, + deferred_phone_gate_enabled: false, + deferred_phone_gate_window_hours: 6, + deferred_phone_gate_member_threshold: 50, }; function isPremiumMode(value: unknown): value is InstancePremiumMode { @@ -412,6 +419,13 @@ function normalizeNullableBoolean(value: unknown): boolean | null { return typeof value === 'boolean' ? value : null; } +function normalizePositiveNumber(value: unknown, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return fallback; + } + return value; +} + function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig { if (!isJsonRecord(value)) { return {...DEFAULT_INSTANCE_POLICY_CONFIG}; @@ -425,6 +439,15 @@ function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig { gif_enabled: normalizeNullableBoolean(value.gif_enabled), youtube_enabled: normalizeNullableBoolean(value.youtube_enabled), bluesky_enabled: normalizeNullableBoolean(value.bluesky_enabled), + deferred_phone_gate_enabled: value.deferred_phone_gate_enabled === true, + deferred_phone_gate_window_hours: normalizePositiveNumber( + value.deferred_phone_gate_window_hours, + DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_window_hours, + ), + deferred_phone_gate_member_threshold: normalizePositiveNumber( + value.deferred_phone_gate_member_threshold, + DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_member_threshold, + ), }; } @@ -913,12 +936,18 @@ export class InstanceConfigRepository { this.refreshRequested = false; this.configCache = await this.fetchAllConfigsFromDatabase(); } while (this.refreshRequested); + this.syncDeferredPhoneGateCache(this.configCache.get(INSTANCE_POLICY_CONFIG_KEY) ?? null); })().finally(() => { this.refreshPromise = null; }); await this.refreshPromise; } + private syncDeferredPhoneGateCache(raw: string | null): void { + const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG}; + setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy)); + } + private updateCachedConfigs(entries: Array<[string, string]>): void { if (!this.configCache) { return; @@ -1079,16 +1108,16 @@ export class InstanceConfigRepository { async getInstancePolicyConfig(): Promise { const raw = await this.getConfig(INSTANCE_POLICY_CONFIG_KEY); - if (!raw) { - return {...DEFAULT_INSTANCE_POLICY_CONFIG}; - } - return normalizeInstancePolicyConfig(parseJsonRecord(raw)); + const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG}; + setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy)); + return policy; } async setInstancePolicyConfig(config: Partial): Promise { const current = await this.getInstancePolicyConfig(); const next = normalizeInstancePolicyConfig({...current, ...config}); await this.setConfig(INSTANCE_POLICY_CONFIG_KEY, JSON.stringify(next)); + setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(next)); return next; } diff --git a/fluxer_api/src/api/instance/SingleCommunityService.ts b/fluxer_api/src/api/instance/SingleCommunityService.ts index c11e37fb0..5948d0e5c 100644 --- a/fluxer_api/src/api/instance/SingleCommunityService.ts +++ b/fluxer_api/src/api/instance/SingleCommunityService.ts @@ -35,6 +35,7 @@ export class SingleCommunityService { } try { await this.guildMemberService.addUserToGuild({ + skipRiskGate: true, userId, guildId, skipGuildLimitCheck: true, diff --git a/fluxer_api/src/api/limits/LimitConfigService.ts b/fluxer_api/src/api/limits/LimitConfigService.ts index fbf51efb9..0ff8f0815 100644 --- a/fluxer_api/src/api/limits/LimitConfigService.ts +++ b/fluxer_api/src/api/limits/LimitConfigService.ts @@ -69,7 +69,8 @@ export class LimitConfigService { } async refreshCache(): Promise { - this.premiumMode = (await this.repository.getInstancePolicyConfig()).premium_mode; + const policyConfig = await this.repository.getInstancePolicyConfig(); + this.premiumMode = policyConfig.premium_mode; setCachedInstancePremiumMode(this.premiumMode); const currentHash = computeDefaultsHash(); const lockToken = await this.cacheService.acquireLock(LIMIT_CONFIG_REFRESH_LOCK_KEY, 10); diff --git a/fluxer_api/src/api/oauth/OAuth2RequestService.ts b/fluxer_api/src/api/oauth/OAuth2RequestService.ts index fcf71e6a3..b23db8cf7 100644 --- a/fluxer_api/src/api/oauth/OAuth2RequestService.ts +++ b/fluxer_api/src/api/oauth/OAuth2RequestService.ts @@ -273,6 +273,7 @@ export class OAuth2RequestService { } } await this.guildService.members.addUserToGuild({ + skipRiskGate: true, userId: botUserId, guildId, skipGuildLimitCheck: true, diff --git a/fluxer_api/src/api/risk/DeferredPhoneGate.test.ts b/fluxer_api/src/api/risk/DeferredPhoneGate.test.ts new file mode 100644 index 000000000..d257e9a33 --- /dev/null +++ b/fluxer_api/src/api/risk/DeferredPhoneGate.test.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; +import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants'; +import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake'; +import {ms} from 'itty-time'; +import {describe, expect, it} from 'vitest'; +import type {Guild} from '../models/Guild'; +import type {User} from '../models/User'; +import {type DeferredPhoneGateConfig, evaluateDeferredPhoneGate, guildTriggersPhoneGate} from './DeferredPhoneGate'; +import {resolveDeferredPhoneGateEnabled} from './DeferredPhoneGateCache'; + +const CONFIG: DeferredPhoneGateConfig = { + enabled: true, + windowMs: 6 * ms('1 hour'), + memberThreshold: 50, +}; + +const USER_SNOWFLAKE = 1485046297690587136n; +const REGISTERED_AT = snowflakeToDate(USER_SNOWFLAKE).getTime(); + +function createUser(overrides: Partial> = {}): User { + return { + id: USER_SNOWFLAKE, + hasVerifiedPhone: false, + suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN, + ...overrides, + } as unknown as User; +} + +function createGuild(overrides: {discoverable?: boolean; memberCount?: number} = {}): Guild { + return { + id: 1n, + features: new Set(overrides.discoverable ? [GuildFeatures.DISCOVERABLE] : []), + memberCount: overrides.memberCount ?? 10, + } as unknown as Guild; +} + +describe('evaluateDeferredPhoneGate', () => { + it('applies to a discoverable guild inside the window, promoting the real phone flags', () => { + const outcome = evaluateDeferredPhoneGate( + createUser(), + createGuild({discoverable: true, memberCount: 3}), + CONFIG, + REGISTERED_AT + ms('1 hour'), + ); + expect(outcome.applies).toBe(true); + if (!outcome.applies) return; + expect(outcome.flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0); + expect(outcome.flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0); + }); + + it('applies to a large non-discoverable guild inside the window', () => { + const outcome = evaluateDeferredPhoneGate( + createUser(), + createGuild({memberCount: 51}), + CONFIG, + REGISTERED_AT + ms('1 hour'), + ); + expect(outcome.applies).toBe(true); + }); + + it('does not apply to a small non-discoverable guild', () => { + const outcome = evaluateDeferredPhoneGate( + createUser(), + createGuild({memberCount: 50}), + CONFIG, + REGISTERED_AT + ms('1 hour'), + ); + expect(outcome).toEqual({applies: false, reason: 'guild_below_threshold'}); + }); + + it('applies on the last millisecond inside the window', () => { + const outcome = evaluateDeferredPhoneGate( + createUser(), + createGuild({discoverable: true}), + CONFIG, + REGISTERED_AT + CONFIG.windowMs - 1, + ); + expect(outcome.applies).toBe(true); + }); + + it('does not gate once the window has elapsed, and mutates nothing', () => { + const outcome = evaluateDeferredPhoneGate( + createUser(), + createGuild({discoverable: true}), + CONFIG, + REGISTERED_AT + CONFIG.windowMs, + ); + expect(outcome).toEqual({applies: false, reason: 'outside_window'}); + }); + it('is inert while the gate is disabled, even on a qualifying guild', () => { + const outcome = evaluateDeferredPhoneGate( + createUser(), + createGuild({discoverable: true}), + {...CONFIG, enabled: false}, + REGISTERED_AT + ms('1 hour'), + ); + expect(outcome).toEqual({applies: false, reason: 'gate_disabled'}); + }); + + it('does not apply to a user who already has a verified phone', () => { + const outcome = evaluateDeferredPhoneGate( + createUser({hasVerifiedPhone: true}), + createGuild({discoverable: true}), + CONFIG, + REGISTERED_AT + ms('1 hour'), + ); + expect(outcome).toEqual({applies: false, reason: 'already_verified'}); + }); + + it('preserves non-phone requirements when promoting', () => { + const outcome = evaluateDeferredPhoneGate( + createUser({ + suspiciousActivityFlags: + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | + SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION | + DEFERRED_PHONE_ON_COMMUNITY_JOIN, + }), + createGuild({discoverable: true}), + CONFIG, + REGISTERED_AT + ms('1 hour'), + ); + expect(outcome.applies).toBe(true); + if (!outcome.applies) return; + expect(outcome.flags).toBe( + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION, + ); + }); +}); + +describe('resolveDeferredPhoneGateEnabled', () => { + it('is on only when the tunable is set and the instance is not single-community', () => { + expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: true, single_community_enabled: false})).toBe( + true, + ); + expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: true, single_community_enabled: true})).toBe( + false, + ); + expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: false, single_community_enabled: false})).toBe( + false, + ); + }); +}); + +describe('guildTriggersPhoneGate', () => { + it('qualifies a discoverable guild regardless of size', () => { + expect(guildTriggersPhoneGate(createGuild({discoverable: true, memberCount: 1}), 50)).toBe(true); + }); + it('qualifies a guild strictly above the member threshold', () => { + expect(guildTriggersPhoneGate(createGuild({memberCount: 51}), 50)).toBe(true); + expect(guildTriggersPhoneGate(createGuild({memberCount: 50}), 50)).toBe(false); + }); +}); diff --git a/fluxer_api/src/api/risk/DeferredPhoneGate.ts b/fluxer_api/src/api/risk/DeferredPhoneGate.ts new file mode 100644 index 000000000..feb4c55f8 --- /dev/null +++ b/fluxer_api/src/api/risk/DeferredPhoneGate.ts @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; +import { + DEFERRABLE_PHONE_FLAGS, + DEFERRED_PHONE_ON_COMMUNITY_JOIN, + NEVER_DEFERRABLE_PHONE_FLAGS, +} from '@fluxer/constants/src/UserConstants'; +import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake'; +import {ms} from 'itty-time'; +import {Logger} from '../Logger'; +import {getInstanceConfigRepository} from '../middleware/ServiceSingletons'; +import type {Guild} from '../models/Guild'; +import type {User} from '../models/User'; +import {resolveDeferredPhoneGateEnabled} from './DeferredPhoneGateCache'; + +export interface DeferredPhoneGateConfig { + enabled: boolean; + windowMs: number; + memberThreshold: number; +} + +export const DEFAULT_PHONE_GATE_MEMBER_THRESHOLD = 50; + +const DISABLED_CONFIG: DeferredPhoneGateConfig = { + enabled: false, + windowMs: Number.POSITIVE_INFINITY, + memberThreshold: Number.POSITIVE_INFINITY, +}; + +type DeferredPhoneGateConfigResult = + | {status: 'ok'; config: DeferredPhoneGateConfig} + | {status: 'disabled'; config: DeferredPhoneGateConfig} + | {status: 'unreadable'; config: DeferredPhoneGateConfig}; + +export async function getDeferredPhoneGateConfig(): Promise { + try { + const policy = await getInstanceConfigRepository().getInstancePolicyConfig(); + if (!resolveDeferredPhoneGateEnabled(policy)) { + return {status: 'disabled', config: DISABLED_CONFIG}; + } + return { + status: 'ok', + config: { + enabled: true, + windowMs: policy.deferred_phone_gate_window_hours * ms('1 hour'), + memberThreshold: policy.deferred_phone_gate_member_threshold, + }, + }; + } catch (error) { + Logger.warn({error}, 'Failed to read deferred phone gate config'); + return {status: 'unreadable', config: DISABLED_CONFIG}; + } +} + +export async function deferPhoneFlagsUntilCommunityJoin(flagBits: number): Promise { + if ((flagBits & DEFERRABLE_PHONE_FLAGS) === 0 || (flagBits & NEVER_DEFERRABLE_PHONE_FLAGS) !== 0) { + return flagBits; + } + const {status} = await getDeferredPhoneGateConfig(); + if (status !== 'ok') { + return flagBits; + } + return flagBits | DEFERRED_PHONE_ON_COMMUNITY_JOIN; +} + +export function guildTriggersPhoneGate(guild: Guild, memberThreshold: number): boolean { + return guild.features.has(GuildFeatures.DISCOVERABLE) || guild.memberCount > memberThreshold; +} + +type DeferredPhoneGateOutcome = + | {applies: false; reason: 'gate_disabled' | 'already_verified' | 'guild_below_threshold' | 'outside_window'} + | {applies: true; flags: number}; + +export function evaluateDeferredPhoneGate( + user: User, + guild: Guild, + config: DeferredPhoneGateConfig, + now: number, +): DeferredPhoneGateOutcome { + if (!config.enabled) { + return {applies: false, reason: 'gate_disabled'}; + } + if (user.hasVerifiedPhone) { + return {applies: false, reason: 'already_verified'}; + } + if (!guildTriggersPhoneGate(guild, config.memberThreshold)) { + return {applies: false, reason: 'guild_below_threshold'}; + } + if (now - snowflakeToDate(BigInt(user.id)).getTime() >= config.windowMs) { + return {applies: false, reason: 'outside_window'}; + } + return {applies: true, flags: (user.suspiciousActivityFlags ?? 0) & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN}; +} diff --git a/fluxer_api/src/api/risk/DeferredPhoneGateCache.ts b/fluxer_api/src/api/risk/DeferredPhoneGateCache.ts new file mode 100644 index 000000000..5b785b5a8 --- /dev/null +++ b/fluxer_api/src/api/risk/DeferredPhoneGateCache.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +let cachedEnabled = false; + +export function resolveDeferredPhoneGateEnabled(policy: { + deferred_phone_gate_enabled: boolean; + single_community_enabled: boolean; +}): boolean { + return policy.deferred_phone_gate_enabled && !policy.single_community_enabled; +} + +export function getCachedDeferredPhoneGateEnabled(): boolean { + return cachedEnabled; +} + +export function setCachedDeferredPhoneGateEnabled(enabled: boolean): void { + cachedEnabled = enabled; +} diff --git a/fluxer_api/src/api/rpc/RpcSessionStartService.ts b/fluxer_api/src/api/rpc/RpcSessionStartService.ts index eda49aa5a..df42da1ca 100644 --- a/fluxer_api/src/api/rpc/RpcSessionStartService.ts +++ b/fluxer_api/src/api/rpc/RpcSessionStartService.ts @@ -1,6 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PremiumFlags, SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants'; +import { + DEFERRED_PHONE_ON_COMMUNITY_JOIN, + imposePhoneRequirements, + PremiumFlags, + SuspiciousActivityFlags, + UserFlags, +} from '@fluxer/constants/src/UserConstants'; import type {RpcSessionTimings} from '@fluxer/schema/src/domains/rpc/RpcSchemas'; import {Config} from '../Config'; import type {UserRow} from '../database/types/UserTypes'; @@ -370,12 +376,14 @@ export class RpcSessionStartService { timeRpcStepSync( timingSteps, 'check_required_inbound_phone_flags_already_set', - () => (user.suspiciousActivityFlags & requiredFlags) === requiredFlags, + () => + (user.suspiciousActivityFlags & requiredFlags) === requiredFlags && + (user.suspiciousActivityFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0, ) ) { return null; } - const newFlags = user.suspiciousActivityFlags | requiredFlags; + const newFlags = imposePhoneRequirements(user.suspiciousActivityFlags, requiredFlags); try { const updatedUser = await timeRpcStep(timingSteps, 'persist_inbound_phone_requirement', async () => this.deps.userRepository.patchUpsert(user.id, {suspicious_activity_flags: newFlags}, user.toRow()), diff --git a/fluxer_api/src/api/stripe/services/StripePremiumService.ts b/fluxer_api/src/api/stripe/services/StripePremiumService.ts index 8a65c4956..555211614 100644 --- a/fluxer_api/src/api/stripe/services/StripePremiumService.ts +++ b/fluxer_api/src/api/stripe/services/StripePremiumService.ts @@ -296,6 +296,7 @@ export class StripePremiumService { const existingMember = await this.guildRepository.getMember(visionariesGuildId, userId); if (!existingMember) { await this.guildService.members.addUserToGuild({ + skipRiskGate: true, userId, guildId: visionariesGuildId, sendJoinMessage: true, diff --git a/fluxer_api/src/api/user/UserHelpers.test.ts b/fluxer_api/src/api/user/UserHelpers.test.ts index 0ffae28d3..f14bf3cdb 100644 --- a/fluxer_api/src/api/user/UserHelpers.test.ts +++ b/fluxer_api/src/api/user/UserHelpers.test.ts @@ -1,9 +1,16 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PremiumFlags, SuspiciousActivityFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import { + DEFERRED_PHONE_ON_COMMUNITY_JOIN, + imposePhoneRequirements, + PremiumFlags, + SuspiciousActivityFlags, + UserPremiumTypes, +} from '@fluxer/constants/src/UserConstants'; import {afterEach, beforeEach, describe, expect, it} from 'vitest'; import type {User} from '../models/User'; import {setInjectedAccountPolicyEvaluator} from '../risk/AccountPolicyService'; +import {setCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache'; import { createCurrentBehaviorTestAccountPolicyEvaluator, TEST_POLICY_CONTACT_DOMAIN, @@ -23,6 +30,88 @@ function createUser( } as User; } +describe('deferred phone gate marker', () => { + beforeEach(() => { + setInjectedAccountPolicyEvaluator(createCurrentBehaviorTestAccountPolicyEvaluator()); + setCachedDeferredPhoneGateEnabled(true); + }); + afterEach(() => { + setInjectedAccountPolicyEvaluator(undefined); + setCachedDeferredPhoneGateEnabled(false); + }); + it('does not suppress anything until a policy read has proven the gate is on', () => { + setCachedDeferredPhoneGateEnabled(false); + const user = createUser({ + suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN, + }); + expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']); + }); + 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, + }); + expect(getRequiredActions(user)).toEqual([]); + expect(getEffectiveSuspiciousFlags(user)).toBe(0); + }); + it('never lets an inbound-SMS requirement be suppressed, since that tier is never deferred', () => { + const user = createUser({ + suspiciousActivityFlags: + SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION | DEFERRED_PHONE_ON_COMMUNITY_JOIN, + }); + expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE', 'REQUIRE_INBOUND_PHONE_VERIFICATION']); + }); + it('keeps non-phone requirements active while a phone requirement is deferred', () => { + const user = createUser({ + suspiciousActivityFlags: + SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | + SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | + DEFERRED_PHONE_ON_COMMUNITY_JOIN, + }); + expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_EMAIL']); + expect(getEffectiveSuspiciousFlags(user)).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL); + }); + it('applies the phone requirement in full once the marker is cleared', () => { + const user = createUser({ + suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE, + }); + expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']); + expect(getEffectiveSuspiciousFlags(user)).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE); + }); + it('leaves an account carrying only the marker completely unrestricted', () => { + const user = createUser({suspiciousActivityFlags: DEFERRED_PHONE_ON_COMMUNITY_JOIN}); + expect(getRequiredActions(user)).toEqual([]); + expect(getEffectiveSuspiciousFlags(user)).toBe(0); + }); + it('re-arms stored phone requirements as soon as the gate is switched off', () => { + const user = createUser({ + suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN, + }); + expect(getRequiredActions(user)).toEqual([]); + setCachedDeferredPhoneGateEnabled(false); + expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']); + }); + it('stops suppressing once another subsystem imposes the phone requirement directly', () => { + const deferred = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN; + const imposed = imposePhoneRequirements(deferred, SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE); + expect(imposed & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0); + const user = createUser({suspiciousActivityFlags: imposed}); + expect(getRequiredActions(user)).toEqual(['REQUIRE_REVERIFIED_PHONE']); + }); + it('keeps the marker when a non-phone requirement is imposed', () => { + const deferred = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN; + const imposed = imposePhoneRequirements(deferred, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL); + expect(imposed & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0); + expect(getRequiredActions(createUser({suspiciousActivityFlags: imposed}))).toEqual(['REQUIRE_VERIFIED_EMAIL']); + }); + it('yields no enforceable requirement for an account without an email, so the gate must not promote it', () => { + const user = createUser({ + email: null, + suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE, + }); + expect(getEffectiveSuspiciousFlags(user)).toBe(0); + }); +}); + describe('getRequiredActions', () => { beforeEach(() => { setInjectedAccountPolicyEvaluator(createCurrentBehaviorTestAccountPolicyEvaluator()); diff --git a/fluxer_api/src/api/user/UserHelpers.ts b/fluxer_api/src/api/user/UserHelpers.ts index aebf6a5de..38987eb8f 100644 --- a/fluxer_api/src/api/user/UserHelpers.ts +++ b/fluxer_api/src/api/user/UserHelpers.ts @@ -1,6 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PremiumFlags, SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants'; +import { + DEFERRABLE_PHONE_FLAGS, + DEFERRED_PHONE_ON_COMMUNITY_JOIN, + PremiumFlags, + SuspiciousActivityFlags, + UserFlags, +} from '@fluxer/constants/src/UserConstants'; import type {RequiredAction} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {ms} from 'itty-time'; import {Config} from '../Config'; @@ -8,6 +14,7 @@ import type {UserRow} from '../database/types/UserTypes'; import {getCachedInstancePremiumMode} from '../limits/InstancePremiumModeCache'; import type {User} from '../models/User'; import {accountPolicyContactHasCapability} from '../risk/AccountPolicyService'; +import {getCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache'; type ClauseAction = Exclude; type VerificationChannel = 'email' | 'phone'; @@ -123,8 +130,18 @@ function getRequiredActionSortIndex(action: RequiredAction): number { return index === -1 ? REQUIRED_ACTION_ORDER.length : index; } +function suppressDeferredPhoneFlags(rawFlags: number): number { + if ((rawFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0) { + return rawFlags; + } + if (!getCachedDeferredPhoneGateEnabled()) { + return rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN; + } + return rawFlags & ~DEFERRABLE_PHONE_FLAGS; +} + export function getRequiredActions(user: User): ReadonlyArray { - const flags = user.suspiciousActivityFlags ?? 0; + const flags = suppressDeferredPhoneFlags(user.suspiciousActivityFlags ?? 0); if (flags === 0) { return []; } diff --git a/fluxer_api/src/api/user/services/UserAccountRequestService.ts b/fluxer_api/src/api/user/services/UserAccountRequestService.ts index c1d3725c1..b3655dfb8 100644 --- a/fluxer_api/src/api/user/services/UserAccountRequestService.ts +++ b/fluxer_api/src/api/user/services/UserAccountRequestService.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +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'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; @@ -299,7 +300,7 @@ export class UserAccountRequestService { action: emailSetRecommendedAction, }, }); - nextSuspiciousFlags |= policyDecision.flagBits; + nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyDecision.flagBits); if (nextSuspiciousFlags !== currentSuspiciousFlags) { user = await this.userRepository.patchUpsert( user.id, @@ -550,7 +551,7 @@ export class UserAccountRequestService { } private shouldSkipFollowupRiskChecks(user: User): boolean { - return user.hasEverPurchased || user.suspiciousActivityFlags === 0; + return user.hasEverPurchased || ((user.suspiciousActivityFlags ?? 0) & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0; } private enforceUserAccess(user: User): void { diff --git a/fluxer_api/src/api/worker/tasks/admin_bulk/BulkAddGuildMembers.ts b/fluxer_api/src/api/worker/tasks/admin_bulk/BulkAddGuildMembers.ts index 01e4ba66b..9211dc5f1 100644 --- a/fluxer_api/src/api/worker/tasks/admin_bulk/BulkAddGuildMembers.ts +++ b/fluxer_api/src/api/worker/tasks/admin_bulk/BulkAddGuildMembers.ts @@ -41,6 +41,7 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => { const userId = createUserID(userIdBigInt); try { await deps.guildService.members.addUserToGuild({ + skipRiskGate: true, userId, guildId, sendJoinMessage: false, diff --git a/fluxer_api/src/api/worker/tasks/admin_bulk/BulkUpdateSuspiciousActivityFlags.ts b/fluxer_api/src/api/worker/tasks/admin_bulk/BulkUpdateSuspiciousActivityFlags.ts index 87167cd78..63fd396b4 100644 --- a/fluxer_api/src/api/worker/tasks/admin_bulk/BulkUpdateSuspiciousActivityFlags.ts +++ b/fluxer_api/src/api/worker/tasks/admin_bulk/BulkUpdateSuspiciousActivityFlags.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants'; +import {imposePhoneRequirements, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants'; import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask'; import {AdminAuditService} from '../../../admin/services/AdminAuditService'; @@ -58,7 +58,7 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => { const user = await deps.userRepository.findUnique(userId); if (!user) throw new Error('user_not_found'); const currentFlags = user.suspiciousActivityFlags ?? 0; - const newFlags = (currentFlags | addMask) & ~removeMask; + const newFlags = imposePhoneRequirements(currentFlags, addMask) & ~removeMask; const updatedUser = await deps.userRepository.patchUpsert( userId, {suspicious_activity_flags: newFlags}, diff --git a/fluxer_app/src/features/discovery/commands/DiscoveryJoinCommands.tsx b/fluxer_app/src/features/discovery/commands/DiscoveryJoinCommands.tsx index 07b215402..e0a0a306e 100644 --- a/fluxer_app/src/features/discovery/commands/DiscoveryJoinCommands.tsx +++ b/fluxer_app/src/features/discovery/commands/DiscoveryJoinCommands.tsx @@ -7,6 +7,7 @@ import * as NavigationCommands from '@app/features/navigation/commands/Navigatio import {failureCode} from '@app/features/platform/utils/ResponseInspection'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; +import Users from '@app/features/user/state/Users'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import {msg} from '@lingui/core/macro'; @@ -100,7 +101,11 @@ function resolveJoinGuildErrorContent(code: string | undefined): {title: string; } function showJoinGuildErrorModal(error: unknown): void { - const {title, message} = resolveJoinGuildErrorContent(failureCode(error)); + const code = failureCode(error); + if (code === APIErrorCodes.ACCOUNT_SUSPICIOUS_ACTIVITY && (Users.currentUser?.requiredActions?.length ?? 0) > 0) { + return; + } + const {title, message} = resolveJoinGuildErrorContent(code); ModalCommands.push( modal(() => ), ); diff --git a/fluxer_app/src/features/invite/commands/InviteCommands.tsx b/fluxer_app/src/features/invite/commands/InviteCommands.tsx index 962c8b0fc..569da3061 100644 --- a/fluxer_app/src/features/invite/commands/InviteCommands.tsx +++ b/fluxer_app/src/features/invite/commands/InviteCommands.tsx @@ -174,6 +174,12 @@ function showGuildInviteAcceptFailure( responseErr: HttpError | null, ): void { const isRaidDetected = guildInviteFeatures(invite).includes(GuildFeatures.RAID_DETECTED); + if ( + errorCode === APIErrorCodes.ACCOUNT_SUSPICIOUS_ACTIVITY && + (Users.currentUser?.requiredActions?.length ?? 0) > 0 + ) { + return; + } if (errorCode === APIErrorCodes.INVITES_DISABLED) { ModalCommands.push( modal(() => ( diff --git a/packages/constants/src/UserConstants.ts b/packages/constants/src/UserConstants.ts index b09e13cea..1abb6052d 100644 --- a/packages/constants/src/UserConstants.ts +++ b/packages/constants/src/UserConstants.ts @@ -195,7 +195,28 @@ export const SuspiciousActivityFlagsDescriptions: Record mask | flag, + 0, +); +export const DEFERRED_PHONE_ON_COMMUNITY_JOIN = 1 << 16; +export const DEFERRABLE_PHONE_FLAGS = + SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE; +export const NEVER_DEFERRABLE_PHONE_FLAGS = SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION; +export const PHONE_REQUIREMENT_FLAGS = DEFERRABLE_PHONE_FLAGS | NEVER_DEFERRABLE_PHONE_FLAGS; +export function imposePhoneRequirements(currentFlags: number, addedFlags: number): number { + const nextFlags = currentFlags | addedFlags; + if ((addedFlags & DEFERRABLE_PHONE_FLAGS) === 0) { + return nextFlags; + } + return nextFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN; +} +export const ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS = + DEFERRED_PHONE_ON_COMMUNITY_JOIN | + SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | + SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION; export const PHONE_ADD_CLEARABLE_FLAGS = + DEFERRED_PHONE_ON_COMMUNITY_JOIN | SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE | SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE | diff --git a/packages/schema/src/domains/admin/AdminSchemas.ts b/packages/schema/src/domains/admin/AdminSchemas.ts index 59b08c0af..e4f1d96d5 100644 --- a/packages/schema/src/domains/admin/AdminSchemas.ts +++ b/packages/schema/src/domains/admin/AdminSchemas.ts @@ -527,6 +527,11 @@ const InstancePolicyResponse = z.object({ youtube: z.boolean(), bluesky: z.boolean(), }), + deferred_phone_gate: z.object({ + enabled: z.boolean(), + window_hours: z.number(), + member_threshold: z.number(), + }), }); const CaptchaProviderSchema = z.enum(['hcaptcha', 'turnstile', 'none']); @@ -735,6 +740,13 @@ export const InstanceConfigUpdateRequest = z.object({ bluesky_enabled: z.boolean().nullish(), }) .nullish(), + deferred_phone_gate: z + .object({ + enabled: z.boolean().optional(), + window_hours: z.number().positive().max(8760).optional(), + member_threshold: z.number().int().positive().max(1_000_000).optional(), + }) + .nullish(), }) .nullish(), }); diff --git a/packages/schema/src/domains/admin/AdminUserSchemas.ts b/packages/schema/src/domains/admin/AdminUserSchemas.ts index 7d748382b..9bc13103d 100644 --- a/packages/schema/src/domains/admin/AdminUserSchemas.ts +++ b/packages/schema/src/domains/admin/AdminUserSchemas.ts @@ -57,6 +57,9 @@ export const UserAdminResponseSchema = z.object({ 'Suspicious activity indicators', 'SuspiciousActivityFlags', ), + phone_verification_deferred: z + .boolean() + .describe('Whether a stored phone requirement is deferred until the user joins a discoverable or large community'), temp_banned_until: z.string().nullable(), pending_deletion_at: z.string().nullable(), pending_bulk_message_deletion_at: z.string().nullable(),