Compare commits

...
40 changed files with 1057 additions and 34 deletions
+31 -1
View File
@@ -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",
+1
View File
@@ -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,
+2
View File
@@ -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<i32>,
@@ -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<PremiumMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<InstanceServicesUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deferred_phone_gate: Option<DeferredPhoneGateUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct DeferredPhoneGateUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub window_hours: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub member_threshold: Option<i64>,
}
#[derive(Clone, Debug, Default, Serialize)]
+28 -1
View File
@@ -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<DeferredPhoneGateUpdateRequest> {
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::<f64>().ok())
.filter(|value| *value > 0.0);
let member_threshold = form
.first("policy_deferred_phone_gate_member_threshold")
.and_then(|value| value.parse::<i64>().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<InstanceServicesUpdateRequest> {
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,
@@ -103,6 +103,7 @@ pub fn instance_config_page(
instance_config.self_hosted,
))
(sso_config_section(base, csrf_token, &instance_config.sso))
(deferred_phone_gate_form(base, csrf_token, &instance_config.policy))
},
))
@if instance_config.self_hosted {
@@ -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" {
@@ -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."
}
}
}
}
}
+1
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -100,6 +100,11 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
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);
}
@@ -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,
@@ -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<UserRow> = {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},
@@ -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,
+2 -1
View File
@@ -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(),
@@ -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<GuildResponse>(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<number> {
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<string>}>(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);
});
});
@@ -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({
@@ -319,6 +319,7 @@ export class GuildMemberService {
sendJoinMessage?: boolean;
skipGuildLimitCheck?: boolean;
skipBanCheck?: boolean;
skipRiskGate?: boolean;
isTemporary?: boolean;
joinSourceType?: JoinSourceType;
sourceInviteCode?: InviteCode;
@@ -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<void> {
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(),
@@ -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<InstancePolicyConfig> {
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<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
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;
}
@@ -35,6 +35,7 @@ export class SingleCommunityService {
}
try {
await this.guildMemberService.addUserToGuild({
skipRiskGate: true,
userId,
guildId,
skipGuildLimitCheck: true,
@@ -69,7 +69,8 @@ export class LimitConfigService {
}
async refreshCache(): Promise<void> {
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);
@@ -273,6 +273,7 @@ export class OAuth2RequestService {
}
}
await this.guildService.members.addUserToGuild({
skipRiskGate: true,
userId: botUserId,
guildId,
skipGuildLimitCheck: true,
@@ -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<Pick<User, 'hasVerifiedPhone' | 'suspiciousActivityFlags'>> = {}): 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);
});
});
@@ -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<DeferredPhoneGateConfigResult> {
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<number> {
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};
}
@@ -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;
}
@@ -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()),
@@ -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,
+90 -1
View File
@@ -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());
+19 -2
View File
@@ -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<RequiredAction, 'REQUIRE_INBOUND_PHONE_VERIFICATION'>;
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<RequiredAction> {
const flags = user.suspiciousActivityFlags ?? 0;
const flags = suppressDeferredPhoneFlags(user.suspiciousActivityFlags ?? 0);
if (flags === 0) {
return [];
}
@@ -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 {
@@ -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,
@@ -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},
@@ -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(() => <GenericErrorModal title={title} message={message} data-flx="discovery.join.generic-error-modal" />),
);
@@ -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(() => (
+57 -2
View File
@@ -117,7 +117,10 @@ pub async fn run(cfg: Config) -> anyhow::Result<()> {
bunny_ip_gate::gate_middleware,
));
}
router = router.layer(middleware::from_fn(add_security_header_middleware));
router = router.layer(middleware::from_fn_with_state(
state.cfg.mode,
add_security_header_middleware,
));
let app = router.with_state(state);
let listener = TcpListener::bind(addr).await?;
info!(%addr, "media proxy listening");
@@ -176,11 +179,16 @@ async fn add_version_header(request: Request<Body>, next: middleware::Next) -> R
}
async fn add_security_header_middleware(
State(mode): State<DeploymentMode>,
request: Request<Body>,
next: middleware::Next,
) -> Response {
let mut response = next.run(request).await;
http_headers::add_security_headers(response.headers_mut());
let headers = response.headers_mut();
http_headers::add_security_headers(headers);
if mode == DeploymentMode::Static {
headers.remove("X-Robots-Tag");
}
response
}
@@ -3074,6 +3082,53 @@ mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD;
async fn robots_header_for(mode: DeploymentMode) -> Option<String> {
use axum::{body::Body, http::Request, routing::get};
let router = Router::new()
.route(
"/probe",
get(|| async {
let mut response = Response::new(Body::empty());
http_headers::add_media_headers(response.headers_mut(), 0, "text/plain", None);
response
}),
)
.layer(middleware::from_fn_with_state(
mode,
add_security_header_middleware,
));
let response = tower::ServiceExt::oneshot(
router,
Request::builder()
.uri("/probe")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response
.headers()
.get("X-Robots-Tag")
.map(|v| v.to_str().unwrap().to_owned())
}
#[tokio::test]
async fn static_mode_does_not_set_robots_tag() {
assert_eq!(robots_header_for(DeploymentMode::Static).await, None);
}
#[tokio::test]
async fn media_and_upload_modes_still_set_robots_tag() {
assert_eq!(
robots_header_for(DeploymentMode::Mp).await.as_deref(),
Some(http_headers::ROBOTS)
);
assert_eq!(
robots_header_for(DeploymentMode::Upload).await.as_deref(),
Some(http_headers::ROBOTS)
);
}
fn upload_relay_test_config(
storage_root: &std::path::Path,
spool_dir: &std::path::Path,
+21
View File
@@ -195,7 +195,28 @@ export const SuspiciousActivityFlagsDescriptions: Record<keyof typeof Suspicious
REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE: 'Requires re-verified email or re-verified phone',
REQUIRE_INBOUND_PHONE_VERIFICATION: 'Requires inbound SMS verification (user must text code to platform number)',
};
export const ALL_SUSPICIOUS_ACTIVITY_FLAGS = Object.values(SuspiciousActivityFlags).reduce(
(mask, flag) => 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 |
@@ -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(),
});
@@ -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(),