mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(api): defer registration phone verification to community joins (#1709)
This commit is contained in:
1 parent
7e1ca9ed6a
commit
d7b2e67c35
39 files changed
+1000
-32
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in new issue
Block a user