refactor(api): emit moderation events and apply account actions (#3031)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 12:24:15 +02:00
1 parent c488906131
commit 364084c819
385 files changed
+8994 -22221

No files matched your search

+33 -169
View File
@@ -4,10 +4,12 @@ import type {ApiContext} from '@app/api/ApiContext';
import * as AuthPassword from '@app/api/auth/AuthPassword';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import type {IRegistrationRiskEvaluator} from '@app/api/auth/services/IRegistrationRiskEvaluator';
import {assertEmailNotBlocklisted} from '@app/api/auth/EmailBlocklist';
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {APIConfig} from '@app/api/config/APIConfig';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import {isBlockedEmailDomain} from '@app/api/infrastructure/activity/SharedLists';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
import {
@@ -23,17 +25,6 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {UserSettings} from '@app/api/models/UserSettings';
import {countryRequiresInboundPhoneVerification, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
import {
type IAccountPolicyEvaluator,
isAssessmentThresholdAuditEvent,
normalizePolicyContactDomain,
} from '@app/api/risk/AccountPolicyEvaluator';
import type {IRegistrationEventsRepository} from '@app/api/risk/adapters/VelocityAdapter';
import {deferPhoneFlagsUntilCommunityJoin} from '@app/api/risk/DeferredPhoneGate';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
import type {IRiskAssessmentRepository} from '@app/api/risk/RiskAssessmentRepository';
import {deriveLatestRiskContext} from '@app/api/risk/RiskHistoryContext';
import * as AgeUtils from '@app/api/utils/AgeUtils';
import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils';
import {lookupGeoip} from '@app/api/utils/IpUtils';
@@ -41,6 +32,7 @@ import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {getRegionalMinimumAge} from '@fluxer/constants/src/RegionalMinimumAge';
import {ProfileFieldPrivacyFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
@@ -54,8 +46,6 @@ import {parseAcceptLanguage} from '@pkgs/locale/src/LocaleService';
import {types} from 'cassandra-driver';
import {ms} from 'itty-time';
const DEFAULT_MINIMUM_AGE = 13;
function parseDobLocalDate(dateOfBirth: string): types.LocalDate {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateOfBirth);
if (!match) {
@@ -83,13 +73,6 @@ export interface RegistrationDependencies {
singleCommunityService: SingleCommunityService;
discriminatorService: IDiscriminatorService;
kvActivityTracker: KVActivityTracker;
registrationRiskEvaluator: IRegistrationRiskEvaluator;
accountPolicyEvaluator: IAccountPolicyEvaluator;
isEmailDomainSuspicious: (domain: string) => Promise<boolean>;
isEmailDomainDisposable: (domain: string) => Promise<boolean>;
registrationEventsRepository: IRegistrationEventsRepository;
riskAssessmentRepository: IRiskAssessmentRepository;
riskHistoryRepository: Pick<IRiskHistoryRepository, 'upsertLatestContext' | 'recordOutcomeForUser'>;
}
interface RegistrationTokenResult {
@@ -114,20 +97,8 @@ export async function register(
{data, request, requestCache}: RegisterParams,
): Promise<RegisterResult> {
const {users, snowflake, emailDnsValidation, config} = ctx.services;
const {
inviteService,
instanceConfigRepository,
singleCommunityService,
discriminatorService,
kvActivityTracker,
registrationRiskEvaluator,
accountPolicyEvaluator,
isEmailDomainSuspicious,
isEmailDomainDisposable,
registrationEventsRepository,
riskAssessmentRepository,
riskHistoryRepository,
} = deps;
const {inviteService, instanceConfigRepository, singleCommunityService, discriminatorService, kvActivityTracker} =
deps;
const appPublicConfig = await instanceConfigRepository.getAppPublicConfig();
const emailEnabled = await instanceConfigRepository.isEmailEnabled();
const requiresTermsConsent = shouldRequireHostedLegalConsent(config) || appPublicConfig.legal.terms_url !== null;
@@ -152,7 +123,7 @@ export async function register(
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT);
}
dateOfBirth = parseDobLocalDate(dateOfBirthInput);
const minAge = accountPolicyEvaluator.getMinimumAgeForRegion(countryCode, DEFAULT_MINIMUM_AGE);
const minAge = getRegionalMinimumAge(countryCode);
if (!AuthUtility.validateAge(ctx, {dateOfBirth: dateOfBirthInput, minAge})) {
throw InputValidationError.fromCode('date_of_birth', ValidationErrorCodes.MUST_BE_MINIMUM_AGE, {minAge});
}
@@ -163,28 +134,17 @@ export async function register(
}
const rawEmail = data.email ?? null;
const emailKey = rawEmail ? rawEmail.toLowerCase() : null;
const userAgent = request.headers.get('user-agent');
const enforceRateLimits = !config.dev.relaxRegistrationRateLimits;
await enforceRegistrationRateLimits(ctx, {enforceRateLimits, clientIp, emailKey});
let contactDomain: string | null = null;
let contactDomainAdminListed = false;
let contactDomainDisposable = false;
let contactDomainBlocked = false;
let contactDomainStepUpRequired = false;
if (rawEmail) {
contactDomain = normalizePolicyContactDomain(extractEmailDomain(rawEmail));
if (isBlockedEmailDomain(extractEmailDomain(rawEmail))) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
}
await assertEmailNotBlocklisted(rawEmail, 'email');
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(rawEmail);
if (!hasValidDns) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
}
contactDomainBlocked = accountPolicyEvaluator.isBlockedRegistrationEmailDomain(contactDomain);
if (contactDomainBlocked) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
}
[contactDomainAdminListed, contactDomainDisposable] = contactDomain
? await Promise.all([isEmailDomainSuspicious(contactDomain), isEmailDomainDisposable(contactDomain)])
: [false, false];
contactDomainStepUpRequired = contactDomainBlocked || contactDomainAdminListed || contactDomainDisposable;
const emailTaken = await users.findByEmail(rawEmail);
if (emailTaken) throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
}
@@ -330,108 +290,25 @@ export async function register(
void kvActivityTracker.updateActivity(user.id, now).catch((error: unknown) => {
Logger.warn({error, userId: user.id}, 'Failed to update real-time user activity');
});
const isUnclaimed = !rawEmail;
const usernameIsUserChosen = data.username != null || data.global_name != null;
const riskResult = await registrationRiskEvaluator.evaluate({
email: rawEmail,
clientIp,
locale: userLocale,
timezone: null,
userAgent,
username,
globalName: data.global_name ?? null,
usernameIsUserChosen,
isUnclaimed,
});
const policyDecision = accountPolicyEvaluator.evaluate({
contact: {
value: rawEmail,
domain: contactDomain,
domainAdminListed: contactDomainAdminListed,
domainDisposable: contactDomainDisposable,
domainBlocked: contactDomainBlocked,
domainStepUpRequired: contactDomainStepUpRequired,
await emitActivity(
'registration',
user.id.toString(),
{
user_id: user.id.toString(),
method: data.password ? 'password' : rawEmail ? 'other' : 'unclaimed',
email: rawEmail,
username,
username_user_chosen: data.username != null || data.global_name != null,
global_name: data.global_name ?? null,
locale: userLocale,
timezone: null,
invite_code: data.invite_code?.trim() || null,
suspicious_flags: user.suspiciousActivityFlags ?? 0,
flags: user.flags.toString(),
},
region: {
code: countryCode,
stepUpRequired: countryRequiresInboundPhoneVerification(countryCode),
},
assessment: {
raw: riskResult.assessment,
level: riskResult.level,
action: riskResult.recommendedAction,
},
});
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(
await stripDisallowedPhoneFlags(policyDecision.flagBits, async () => countryCode),
null,
user.id.toString(),
);
const createdAt = new Date();
const riskContext = deriveLatestRiskContext({
userId: userId.toString(),
email: rawEmail ?? null,
clientIp,
asn: riskResult.assessment.signals.geoIpAsn?.asn ?? null,
updatedAt: createdAt,
});
if (combinedFlags !== 0) {
user = await users.patchUpsert(user.id, {suspicious_activity_flags: combinedFlags}, user.toRow());
}
registrationEventsRepository
.recordEvent({
userId: userId.toString(),
email: rawEmail ?? null,
emailDomain: riskContext.emailDomain,
ip: clientIp,
locale: userLocale,
createdAt,
})
.catch((error) => {
Logger.warn(
{userId: userId.toString(), error},
'[AuthRegistration] Failed to record registration velocity event',
);
});
(async () => {
try {
await riskHistoryRepository.upsertLatestContext(riskContext);
if (policyDecision.riskHistoryOutcomeCodes.length > 0) {
await riskHistoryRepository.recordOutcomeForUser({
userId: userId.toString(),
occurredAt: createdAt,
source: 'registration_risk',
outcomeCodes: [...policyDecision.riskHistoryOutcomeCodes],
});
}
} catch (error) {
Logger.warn({userId: userId.toString(), error}, '[AuthRegistration] Failed to persist direct risk history');
}
})();
riskAssessmentRepository
.recordAssessment({
userId,
ip: clientIp,
email: rawEmail ?? null,
locale: userLocale,
assessment: riskResult.assessment,
})
.catch((error) => {
Logger.warn({userId: userId.toString(), error}, '[AuthRegistration] Failed to persist risk assessment');
});
for (const event of policyDecision.auditEvents) {
if (isAssessmentThresholdAuditEvent(event)) {
Logger.warn(
{
userId: userId.toString(),
email: rawEmail,
ip: clientIp,
score: riskResult.assessment.riskScore,
reasoning: riskResult.assessment.reasoning,
policyRuleId: event.ruleId,
},
'[AuthRegistration] Account policy emitted assessment threshold notice',
);
}
}
if (rawEmail && emailEnabled) await maybeSendVerificationEmail(ctx, {user, email: rawEmail});
await users.createAuthorizedIp(userId, clientIp);
if (registrationAccess.pendingApproval) {
@@ -440,24 +317,11 @@ export async function register(
user_id: user.id.toString(),
};
}
if (policyDecision.inviteAutoJoinEnabled) {
await maybeAutoJoinInvite(inviteService, {
userId,
inviteCode: data.invite_code || config.instance.autoJoinInviteCode,
requestCache,
});
} else {
Logger.info(
{
userId: userId.toString(),
riskLevel: riskResult.level,
riskScore: riskResult.assessment.riskScore,
inviteCode: data.invite_code,
reason: policyDecision.inviteAutoJoinSkipReason,
},
'[AuthRegistration] Skipping invite auto-join because account policy disabled it',
);
}
await maybeAutoJoinInvite(inviteService, {
userId,
inviteCode: data.invite_code || config.instance.autoJoinInviteCode,
requestCache,
});
await singleCommunityService.joinStockCommunity(userId, requestCache);
const [token] = await AuthSession.createAuthSession(ctx, {
user,