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

@@ -10,24 +10,13 @@ import {
BanUrlDomainRequest,
BanUrlRequest,
CheckAvatarHashRequest,
SuspiciousEmailDomainRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {createQueryIntegerType} from '@fluxer/schema/src/primitives/QueryValidators';
import {createStringType, Int32Type, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const AdminBlocklistListType = z
.enum([
'ip',
'email',
'email-domain-suspicious',
'phrase',
'url',
'url-domain',
'file-sha',
'avatar-hash',
'profile-substring',
])
.enum(['ip', 'email', 'phrase', 'url', 'url-domain', 'file-sha', 'avatar-hash', 'profile-substring'])
.describe('The blocklist an entry belongs to');
export type AdminBlocklistListType = z.infer<typeof AdminBlocklistListType>;
@@ -119,7 +108,6 @@ export const AdminBlocklistEntryCreateRequest = z
.union([
BanIpRequest,
BanEmailRequest,
SuspiciousEmailDomainRequest,
BanPhraseRequest,
BanUrlRequest,
BanUrlDomainRequest,
@@ -309,19 +309,6 @@ export const BanEmailRequest = z.object({
export type BanEmailRequest = z.infer<typeof BanEmailRequest>;
export const SuspiciousEmailDomainRequest = z.object({
domain: z
.string()
.min(1)
.max(253)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9\-.]*\.[a-zA-Z]{2,}$/, 'Must be a valid domain name (e.g. example.com)')
.describe(
'Email domain to flag as suspicious (e.g. mail.ru). Registrants from this domain will be required to verify a phone number.',
),
});
export type SuspiciousEmailDomainRequest = z.infer<typeof SuspiciousEmailDomainRequest>;
export const BanPhraseRequest = z.object({
phrase: createStringType(1, 500).describe(
'Phrase to ban. Matching is case-insensitive and also normalizes common bypass tricks such as inserted whitespace, punctuation, invisible characters, and compatibility glyphs.',
@@ -585,11 +572,6 @@ 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 EmailProviderSchema = z.enum(['smtp', 'none']);
@@ -700,13 +682,6 @@ const InstancePolicyUpdateSchema = 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(),
});
export const InstanceConfigUpdateRequest = z.object({
@@ -63,14 +63,20 @@ 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'),
phone_verification_deferred: z.boolean().describe('Whether a stored phone requirement is deferred and not enforced'),
temp_banned_until: z.string().nullable(),
pending_deletion_at: z.string().nullable(),
pending_bulk_message_deletion_at: z.string().nullable(),
deletion_reason_code: Int32Type.nullable(),
deletion_public_reason: z.string().nullable(),
deletion_audit_log_reason: z
.string()
.nullable()
.describe('Private reason recorded with the pending deletion, null without the audit log view permission'),
deletion_scheduled_by: SnowflakeStringType.nullable().describe(
'ID of the account that scheduled the pending deletion, null when it was not recorded',
),
deletion_scheduled_at: z.string().nullable().describe('ISO 8601 timestamp when the pending deletion was scheduled'),
acls: z.array(z.string()).max(ADMIN_ACL_COUNT),
traits: z.array(z.string()).max(100),
has_totp: z.boolean(),
@@ -334,6 +340,12 @@ export const ScheduleAccountDeletionRequest = z.object({
.max(365)
.default(60)
.describe('Number of days until the account is deleted'),
replace_pending_deletion_at: z.iso
.datetime()
.optional()
.describe(
'pending_deletion_at of the deletion this request replaces. Required when a deletion is already scheduled for the account',
),
});
export type ScheduleAccountDeletionRequest = z.infer<typeof ScheduleAccountDeletionRequest>;
@@ -420,7 +432,10 @@ export const BulkUpdateUserFlagsRequest = z.object({
export type BulkUpdateUserFlagsRequest = z.infer<typeof BulkUpdateUserFlagsRequest>;
export const BulkScheduleUserDeletionRequest = ScheduleAccountDeletionRequest.omit({user_id: true}).extend({
export const BulkScheduleUserDeletionRequest = ScheduleAccountDeletionRequest.omit({
user_id: true,
replace_pending_deletion_at: true,
}).extend({
user_ids: z.array(SnowflakeType).max(1000).describe('List of user IDs to schedule deletion for'),
days_until_deletion: ScheduleAccountDeletionRequest.shape.days_until_deletion.describe(
'Number of days until the accounts are deleted',
@@ -629,6 +644,22 @@ export const AdminUserDeletionScheduleRequest = ScheduleAccountDeletionRequest.o
export type AdminUserDeletionScheduleRequest = z.infer<typeof AdminUserDeletionScheduleRequest>;
export const AdminUserDeletionCancelRequest = z.object({
expected_pending_deletion_at: z.iso
.datetime()
.describe('pending_deletion_at of the deletion being cancelled, as shown on the account'),
notify_user: z.boolean().default(false).describe('Whether to email the user that the deletion was cancelled'),
});
export type AdminUserDeletionCancelRequest = z.infer<typeof AdminUserDeletionCancelRequest>;
export const AdminUserBanNoteRequest = z.object({
ban_audit_log_id: SnowflakeType.describe('Audit log entry of the current ban that the note refers to'),
note: createStringType(1, 512).describe('Note to append to the ban. Recorded as the reason of a new audit log entry'),
});
export type AdminUserBanNoteRequest = z.infer<typeof AdminUserBanNoteRequest>;
export const AdminUserAclsRequest = SetUserAclsRequest.omit({user_id: true});
export type AdminUserAclsRequest = z.infer<typeof AdminUserAclsRequest>;
@@ -441,9 +441,7 @@ const PhoneSendVerificationInboundChallengeResponse = z.object({
challenge_code: createStringType(4, 12).describe('The numeric code the user must text to our number'),
our_number: createStringType(4, 32).describe('The Twilio number the user must text the code to (E.164)'),
expires_at: z.iso.datetime().describe('ISO 8601 timestamp when this inbound challenge expires'),
reason: z
.enum(['voip', 'canadian', 'unknown_line_type', 'expensive_destination', 'account_forced', 'behavioural_risk'])
.describe('Why inbound verification is required'),
reason: z.enum(['verification_required']).describe('Always verification_required'),
});
export const PhoneSendVerificationResponse = z.union([
@@ -137,9 +137,7 @@ export const UserPrivateResponse = UserPartialResponse.extend({
force_inbound_phone_verification: z
.boolean()
.optional()
.describe(
'Whether this account is forced through the inbound (expensive-destination) phone verification flow regardless of prefix, for debugging',
),
.describe('Whether this account is forced through the inbound phone verification flow, for debugging'),
password_last_changed_at: z.string().nullable().describe('ISO8601 timestamp of the last password change'),
last_voice_activity_sharing_change_at: z
.string()
@@ -653,13 +651,9 @@ const PhoneGateEscapeGuildResponse = z.object({
});
export const PhoneGateEscapePreviewResponse = z.object({
available: z.boolean().describe('Whether this account can set the deferred phone verification check aside right now'),
guilds: z
.array(PhoneGateEscapeGuildResponse)
.describe('Communities that trigger the phone check and will be left when the escape runs'),
owned_guilds: z
.array(PhoneGateEscapeGuildResponse)
.describe('Communities that trigger the phone check but are owned by this user, so they are kept'),
available: z.boolean().describe('Whether this account can set a due phone verification requirement aside right now'),
guilds: z.array(PhoneGateEscapeGuildResponse).describe('Always empty, the escape leaves no community'),
owned_guilds: z.array(PhoneGateEscapeGuildResponse).describe('Always empty, the escape leaves no community'),
});
export type PhoneGateEscapePreviewResponse = z.infer<typeof PhoneGateEscapePreviewResponse>;